Merge pull request #4201 from vitorpamplona/claude/kind-keller-itzy63

Add Cordn group messaging UI and runtime implementation
This commit is contained in:
Vitor Pamplona
2026-09-26 13:04:26 -04:00
committed by GitHub
419 changed files with 51174 additions and 1170 deletions
+6 -1
View File
@@ -5,7 +5,12 @@
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`,
`desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
contain implementations of Nostr specifications and utilities to help implement them. Commons stores
contain implementations of Nostr specifications and utilities to help implement them — NIPs under
`nipXX` packages, and whole non-NIP protocol families beside them: `marmot/` (MLS over Nostr,
`mipXX`), `cordn/` (MLS over an MCP coordinator, `specXX`), `contextvm/` (MCP over Nostr, `cepXX`),
`concord/` (`cordXX`), `buzz/`, plus the binding-agnostic RFC 9420 engine in `mls/`. A new protocol
over Nostr belongs here as a package, not as a Gradle module; `quic`/`nestsClient`/`marmotQuic` are
modules because they are transports with no Nostr in them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
App is designed to be mouse first and so uses a completely different screen and navigation
architecture while sharing the back end components with the android counterpart. `cli` ships `amy`,
+21 -1
View File
@@ -37,7 +37,7 @@ 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,
`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, `InsertOutcomeClassificationTest`, …). If a rule here ever contradicts a test,
the test wins — and this file has a bug to fix.
## Kind classes (used throughout)
@@ -189,6 +189,21 @@ back alone and reports `Rejected(reason)`; the rest commit. If the **outer commi
entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned
in input order; OK frames pair by event id, not order.
**STORE-W09 — a failed row is classified against the database, not against the driver's
exception text.** `SQLiteEventStore.classifyRowError` rolls the row's savepoint back and then
asks the connection (which now shows pre-insert state): id already present → `DUPLICATE`;
a stored version that beats this one at the replaceable/addressable coordinate (the exact
complement of the supersession predicate in W01/W02) → `SUPERSEDED`; otherwise `Failed`.
Message text is only a fast path and a fallback for trigger RAISEs (`blocked:`, `not allowed`),
which leave no database-visible trace. This matters because the message is driver-specific —
the bundled JVM driver writes `UNIQUE constraint failed: event_headers.id`, Android's throws an
`android.database.SQLException` with a **null** message — so a text-only classifier answered
`OK false` on Android for events the store already held. Corollary: re-offering a stored
replaceable/addressable event **byte-for-byte** is `DUPLICATE`, not `SUPERSEDED` (it violates
both indexes and only the id answer is driver-independent); a stale *different* version is
still `SUPERSEDED`. Both carry the `duplicate:` prefix, so the relay reply is `OK true` either
way.
---
## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D)
@@ -323,6 +338,11 @@ non-itemizable cases).
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
- 2026-09-18 (pending) W09, W01/W02 — insert-failure classification now queries the database
instead of parsing the driver's exception message (Android's is null, so duplicates were
reported as `Failed`/`OK false`). A byte-for-byte re-offer of a stored replaceable/addressable
event now reports `DUPLICATE` where the JVM driver previously reported `SUPERSEDED`; both are
`duplicate:` → `OK true`, so the wire answer is unchanged.
- 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/`.
+527
View File
@@ -0,0 +1,527 @@
# Cordn UI: every feature their client has, and what it costs us
Status: proposed. No code yet.
Companion to `quartz/plans/2026-09-17-cordn-interop.md`, which covers the protocol. That plan's
Stage 4 shipped the headless layer (`commons/…/cordn/`) and the §8 disclosure surface
(`commonsUI/…/cordn/ui/`, `Settings → Cordn group link`). This plan covers the rest: the group UI,
and every other user-facing feature the reference client has.
Sources read on 2026-09-19:
- `Cordn-msg/cordn-web` @ `c38e307` (2026-09-16), version `0.4.0` — **MIT**, the client at
<https://cordn.net>. Read for its feature surface; nothing is translated from it.
- `Cordn-msg/cordn` @ `b465df0` — `spec/applications/*` for the normative half.
Read the licensing correction in the interop plan's §7 before touching anything outside
`packages/core` and `packages/cli`: the rest of that repo is unlicensed.
## 1. Executive summary
**Marmot is frozen. Nothing in this plan changes how Marmot works.** The two protocols do not
interoperate, neither is a layer of the other, and the design requirement is that **either side
can walk away from the other** without the other noticing. That is a standing constraint, not a
stage, and §3 is what it means in practice.
`amethyst/…/chats/marmotGroup/` is 3,308 lines covering the same *kinds* of screen cordn needs —
group list, chat view, group info, create-group. None of it is a reuse candidate. cordn gets its
own screens, and the only thing the two share is ordinary app furniture (§3.2).
**The gating dependency is not a screen.** There is no account-level cordn runtime: nothing
constructs a `CordnGroupManager` for the logged-in account, wires it to a relay pool, publishes
key packages, or persists state. Until that exists, every group screen has nothing to render.
That is Stage A, and it is most of the risk.
**One feature is an explicit non-goal** (multi-device, §5.3), and several are cordn.net product
features rather than protocol (§6).
## 2. What their client actually has
From `cordn-web`'s routes and components, not from the spec. Anything marked ✅ we already have in
some form; ⚠️ means we have a Marmot-shaped equivalent that does not transfer as-is.
### 2.1 Coordinators (`/chat/coordinators`, `config/`)
| Feature | Their file | Ours |
| ------- | ---------- | ---- |
| Coordinator list + per-coordinator page | `coordinators/[coordinatorKey]` | ✅ `CoordinatorConfig` model, no screen |
| Add a coordinator (pubkey + relays) | `CoordinatorAddForm` | model only |
| "Add default coordinator" | `coordinators/+page` | — |
| Health | `coordinatorHealth.svelte.ts` | ✅ `CoordinatorHealth` + `CoordinatorHealthRow` |
| Server info | `coordinatorServerInfo.svelte.ts` | ContextVM `initialize()` exists; unsurfaced |
| Purge a coordinator and its groups | `CoordinatorPurgeDialog` | — |
### 2.2 Key packages (`config/key-packages`)
| Feature | Their file | Ours |
| ------- | ---------- | ---- |
| Create a key package, with a label | `config/key-packages` | engine has `createKeyPackage`; no lifecycle |
| Publish to a coordinator | `chatCoordinatorActions` | ✅ `CordnGroupManager.publishKeyPackage` |
| Stored (local) key packages | `KeyPackageCard` | ⚠️ Marmot has `KeyPackageBundleStore` |
| Browse a coordinator's directory | `AvailableKeyPackageDirectory`, `VirtualKeyPackageList` | `kp_list` client exists; no screen |
| Last-resort conflict resolution | `LastResortConflictDialog` | — |
Note §4.2 of the interop plan: cordn has **no key-package event kind**, so none of Marmot's
kind-443 machinery (rotation manager, relay list, publish obligations) applies. This is new work,
not an extraction.
### 2.3 Group lifecycle
| Feature | Their file | Ours |
| ------- | ---------- | ---- |
| Create group (name, description, icon, image, coordinator, key package) | `create-group` | ✅ `createGroup` + ⚠️ `CreateGroupScreen` (Marmot) |
| Group list / sidebar / tab bar | `ChatSidebar`, `ChatTabBar` | ⚠️ `MarmotGroupListScreen` |
| Group info: metadata, admins, participants, created, **current epoch**, coordinator, group id, **MLS snapshots**, **sync issues** | `chat/[id]/info` | ⚠️ `MarmotGroupInfoScreen` (1,154 lines) |
| Share link + QR | `share`, `QrShareDialog`, `QrScanner`, `GroupLinkInput` | ✅ `shareRef`, ✅ link screen; no QR |
| Join requests (accept / decline) | `JoinRequestCard` | client calls exist; no flow, no screen |
| Welcome notifications ("invited you", "accepted") | `WelcomeNotificationCard` | ✅ `joinPendingWelcomes`; no surface |
| New conversation (pick pubkeys, max 50) | `NewConversationDialog`, `ChatPubkeyMultiSelect` | ⚠️ Marmot equivalent |
| Admin policy gate | `chatAdminPolicy.ts` | ✅ `CordnGroupPolicy` (egalitarian-when-empty) |
| Delete local group / mark as read | `ChatGroupActions` | ⚠️ Marmot equivalent |
### 2.4 Messaging
Their kind catalog is `src/lib/chat/kinds.ts`, and it is the sharpest divergence in this document:
| Purpose | cordn-web | Marmot (ours) | Spec says |
| ------- | --------- | ------------- | --------- |
| Chat message | 9 | ✅ 9 | NIP-C7, `spec/02.md` §6 |
| Threaded reply | 1111 | ✅ 1111 | NIP-22, §6 |
| Reaction | 7 | ✅ 7 | NIP-25, §6 |
| Edit | **1010** | **1009** | nothing |
| Delete | 5 | 5 | nothing |
| Pin / unpin | **1011** (`op` tag, LWW) | — | nothing |
| System rows | synthetic **client-side** `-1`, derived from Commits | kind **1210** on the wire | nothing |
`spec/02.md` §6 is explicit that there is "no required set of `kind` values". So edits, pins and
system rows are **app conventions, not protocol** — and the two apps picked differently. §5.1
decides what we do.
Message-level features: reply, react (quick + custom emoji), edit, delete, copy, download, pin,
"message info", a pinned ribbon, unread chips, drafts, mentions, presence, and a per-message
route with a rich renderer beside the compact stream row (`src/lib/chat/README.md` documents that
two-renderer contract — worth reading; Amethyst's feed has the same shape implicitly).
Media: `ChatMessageMedia`, `InlineMediaUrl`, `MediaLightbox`, Blossom upload, plus **voice notes**
(`voiceRecorder` / `voicePlayback`). Encrypted per `spec/applications/encrypted-media.md`, which
§4.5 of the interop plan already records as **diverging from our MIP-04 v2**: cordn uses the
exporter output directly as the file key with `aad = mime‖0x00‖filename‖0x00‖sha256(plaintext)`,
where MIP-04 v2 does `HKDF-Expand(exporter, context)` with a different AAD. Separate codec,
shared primitives (ChaCha20-Poly1305, NIP-92 `imeta`, Blossom) that Amethyst already has.
### 2.5 Settings
Backup & recovery (passphrase-encrypted export/restore, optional message history), media
(upload server, auto-load policy), notifications (background poll interval), appearance, and
multi-device.
## 3. Independence is the design constraint
### 3.1 The rule
Marmot and cordn are two bindings of RFC 9420 onto two unrelated delivery models. They share no
groups, no messages, no identities and no servers. So:
- **Marmot is frozen.** No cordn work may edit, refactor or generalise Marmot code. A cordn
requirement is never a reason to touch `marmotGroup/`, `commons/…/marmot/` or
`quartz/…/marmot/`.
- **Neither may import the other.** Not models, not state holders, not screens, not helpers.
- **Either must be deletable.** Removing cordn entirely should not compile-break Marmot, and the
reverse should hold too.
This is now **enforced, not documented**:
| Guard | Covers |
| ----- | ------ |
| `quartz/…/BindingIsolationTest` | `mls/` imports neither binding nor Nostr; `cordn/` ⊥ `marmot/` both ways |
| `commons/…/cordn/CordnIndependenceTest` | `commons/…/cordn/` ⊥ `commons/…/marmot/` both ways |
Both scan shipped source sets only — an interop test that drives both profiles through one engine
is how we *demonstrate* they cannot collide, and a fixture is data, not a dependency. Both also
assert that the scan found real files and real imports, because an architecture test that passes
for the wrong reason is worse than none.
Writing the first one immediately found a leak: **`mls/group/MlsGroup.kt` imported
`nip01Core.core.toHexKey`** — a Nostr import in an engine whose own README says it "knows nothing
about Marmot, Nostr, or cordn". The README only ever grepped for `marmot`, so the stated invariant
was wider than the check. `toHexKey()` is defined as `Hex.encode(this)`, so the fix was a
one-for-one swap to the binding-neutral `quartz/utils/Hex` with no behaviour change.
**When the Android screens land, add the third guard** over `chats/cordnGroup/` ⊥
`chats/marmotGroup/`, in `amethyst/src/test`. It is the one that matters most, because screens are
where "just reuse that composable" is most tempting.
### 3.2 What cordn may share
App furniture, and nothing that knows what a group is:
- The theme, typography and colour scheme.
- Generic components — buttons, text fields, avatars, dialogs, QR encode/scan, image and video
viewers, the media upload pipeline, rich-text rendering, icons.
- Platform services — Blossom, the relay pool, the signer, notifications.
Not shareable, by the rule above: chatroom models, message view models, feed filters, group list
rows, chat composers, group info screens, system-row renderers, or anything named `Marmot*`.
### 3.3 The one thing this rules out
There is no `Note`/`LocalCache` adaptation. Beyond the coupling, it would be the wrong call
anyway: `LocalCache` is the note graph for relay events, cordn envelopes are unsigned
(`spec/02.md` forbids `sig`) with no relay provenance, and one escaping into relay-bound code —
an outbox calculation, a NIP-65 decision, a relay-facing query — is a confidentiality bug rather
than a rendering bug. cordn gets its own store, keyed by envelope id.
**Cost, stated plainly:** roughly 2,000–3,000 lines of screen code that resembles Marmot's, and
chat fixes that have to be made twice when they apply to both. That is the price of the
constraint, and it is the constraint that was asked for. The compensation is that cordn can move
at its own pace — it is the less settled protocol, with an unlicensed reference coordinator and
several unspecified conventions (§5.1) — without any of that reaching a shipped feature.
## 4. Plan
Each stage lists how it is verified. Note throughout: **Tier B is blocked** (interop plan §7 —
the reference coordinator is unlicensed), so nothing below can be checked against a live cordn
deployment. What *can* be checked: the in-memory coordinator (`FakeCoordinator`), the ContextVM
Tier C fixture server, and the ts-mls/`@cordn/core` vectors. Every stage below is verifiable that
way; none of them is verified against cordn.net until §7 of the interop plan is resolved.
### Stage A — the account-level runtime (blocks everything) — **LANDED**
No UI. This is the gap between "`CordnGroupManager` has tests" and "an account has cordn groups".
1. **Wire the transport.** ✅ **LANDED.** `CvmTransport` over Amethyst's relay pool, with the
account signer as the stable identity and a per-session ephemeral signer (`spec/00.md` §8; the
split is already enforced by `CoordinatorMethod`). Encryption pinned `REQUIRED` —
`:contextvm`'s `CvmGiftWrap` defaults that way and nothing here may undo it (§8.6).
`CordnTransportHarness` + `CordnTransportIntegrationTest` drive the whole lifecycle over it.
2. **A per-account, per-coordinator manager registry.** ✅ **LANDED** as
`CordnCoordinatorRegistry` + `CordnSession`, opened through a `CordnCoordinatorScopeFactory`
seam so production passes relay-backed transport and encrypted stores while tests pass the
fixture. Three rules it enforces, each with a test that dies without it:
- **Idempotent per coordinator, under a mutex.** A second `CordnGroupManager` would
deserialise its own copy of every `MlsGroup` from the same store and commit against the same
epoch; MLS has no recovery from a forked ratchet tree. The UI and the sync loop both open a
session at launch, so concurrent opens are ordinary, not a race to wave off.
- **Never merged across coordinators.** A `gid` is unique only within one (§4), so two
coordinators serving `gid = "abc"` are unrelated groups.
- **Editing relays reopens the transport but keeps the stores**, and `forget` closes the wire
without wiping state — removing a coordinator is not purging it (cordn-web draws the same
line in `CoordinatorPurgeDialog`).
A session also restores its own groups and KeyPackages on open rather than leaving it to the
caller: a session that reports no groups is indistinguishable from a failure, and its first
commit for a group it forgot would start at epoch zero.
3. **Persistence.** ✅ **LANDED** as `FileCordnGroupStore` / `FileCordnKeyPackageStore` in
`commons/jvmAndroid`, behind a `CordnBlobCipher` seam whose Android implementation is
AES-GCM under the Android KeyStore (`KeyStoreCordnBlobCipher`).
`FileBackedCordnScopeFactory` assembles them into a `CordnCoordinatorScope`, leaving only the
transport for the front end to supply (`CordnCoordinatorLinkFactory`).
The cipher is an interface for one reason: the KeyStore cannot run in a JVM unit test, and
everything below it can fail silently. Marmot's equivalent store is welded to the KeyStore and
has no unit test at all; this one has sixteen, including that **the plaintext never reaches the
disk**. The rules they cover:
- **Scoped per account AND per coordinator.** Two accounts on a device must not read each
other's groups, and two coordinators can both serve `gid = "abc"` as unrelated groups (§4) —
a layout ignoring either would have one overwrite the other's ratchet tree.
- **A `gid` is caller-chosen, so it is encoded, not validated.** Marmot's store demands hex
because a Marmot group id is a hash; a cordn `gid` is whatever its creator picked and may
contain `..` or `/`. Base64url accepts every legal gid, keeps the write inside the store
directory, and still reverses — which is what lets `listGroups` return the real gid.
- **Atomic writes.** A truncated `MlsGroupState` is not a stale group but an unreadable one,
and it cannot be re-derived from anywhere else on the device.
- **Deleting a group takes its cursor.** Otherwise a later re-join of the same gid resumes from
a cursor belonging to a group it is no longer in and silently skips everything before it.
The Android cipher **serialises** its calls: `KeyStoreEncryption` keeps one `Cipher` in a field
and `init` + `doFinal` is not atomic, while the stores run on `Dispatchers.IO`. Left alone,
two groups saving at once corrupts one of them. (The underlying class is shared with Marmot and
account storage and was not touched — the locking is in the cordn wrapper.)
4. **Key-package lifecycle.** ✅ **LANDED** as `CordnKeyPackages` + `CordnKeyPackageStore`, with
`KeyPackageBundleCodec` in `mls/` for the private half. cordn has no event kind for this
(§4.2), so it is all coordinator calls plus local storage — none of Marmot's rotation
machinery transferred, and none of it was reused. The ordering rules are the load-bearing
part and each has a test:
- **Publish stores the private half first.** A publish that lands on the coordinator and then
fails locally leaves a KeyPackage others can invite us with and we cannot open. Storing
first makes the failure the harmless direction — an unused bundle on disk, cleaned up when
the call fails.
- **Withdraw tells the coordinator first**, for the mirror reason: while it still serves the
package, we still need the key.
- **Top-up counts what the coordinator holds**, not what we published — `kp_take` consumes a
single-use package, so our count never falls. Last-resort packages do not fill pool slots.
- **`kp_ref` is the RFC 9420 KeyPackageRef.** Getting it wrong fails silently in the worst
way: every take misses, every Welcome lands at an address nobody listens on, no group forms.
- **A second device publishes its own last-resort package.** `kp_list` is per account, not per
device, so adopting the other device's reusable key would send every fallback Welcome
somewhere this device cannot open.
5. **A foreground sync loop.** ✅ **LANDED** as `CordnSyncLoop` over a `CordnSyncSource` seam
(which `CordnGroupManager` implements). `catch_up` drains history, then a `subscribe` is held
open and re-opened when it closes; the cursor makes the seam safe, so the two phases cannot
leave a hole.
Wrapping two calls would be pointless if the happy path were the story. It is not — three
things separate an unattended loop from a call, and each has a test that dies without it:
- **A failure must not end the loop.** A coordinator down for a minute is ordinary; an
exception that escapes stops syncing for the session and looks exactly like a quiet group.
Every attempt is caught, recorded on `CoordinatorHealth`, and retried with a backoff that
**resets on success** — without the reset one bad stretch pins the loop at its cap forever.
- **An account with no groups must not spin.** Both calls return immediately when the manager
holds nothing, so the obvious `while (true)` burns a core on every new account, silently.
The loop parks on `gids` instead.
- **A group joined mid-subscription must not wait.** A subscription is opened for a fixed set
of gids, so a group joined a moment later is not in it; a change to the set cancels and
re-opens rather than leaving the new room empty until the stream times out.
A closed stream is a re-subscribe, not a failure — counting a normal timeout as an error would
put a healthy loop into permanent backoff. `start()` is idempotent: two loops on one session
would both deliver, and every message would appear twice.
Nothing in Stage A may touch Marmot. Where a cordn need resembles a Marmot one — an encrypted
state store, a key-package store — cordn gets its own, because §3.1 forbids a shared abstraction
and §3.1's guards will say so.
*Verified by:* an integration test driving two accounts through the Tier C fixture end to end —
publish, invite, join, send, receive — with the real transport rather than `FakeCoordinator`;
plus store and loop suites over their own seams, because the failures they guard against
(a silent spin, an outage that ends sync, a plaintext write) cannot be provoked on a schedule
through a transport.
**What Stage A still does not do:** nothing constructs a `FileBackedCordnScopeFactory` yet, because
its `CordnCoordinatorLinkFactory` needs Amethyst's relay pool and account signers. That wiring is
the first thing Stage B does, and it is one function.
### Stage B — groups exist and are visible — **LANDED**
6. **A cordn chatroom model + ViewModel** in `commons` (`model/cordnGroups/`, beside
`marmotGroups/`), fed by `CordnGroupManager.Delivery`. Messages keyed by envelope id
(`spec/02.md` §7 — the cursor is a delivery primitive, never a message identity).
7. **Group list**, with `CordnGroupBadge` (built, unused) on every row.
8. **Minimal chat screen**: ordered messages, sender, send a kind-9. No reactions, no media yet.
9. **Group info**: metadata, members, admins, epoch, coordinator, `gid`, share ref, and the
**`CordnExposureCard`** (built, and this is its real home — the link screen was the pre-join
half of the same disclosure).
Add the **third isolation guard** here, over `chats/cordnGroup/` ⊥ `chats/marmotGroup/` (§3.1).
It belongs with the first screen, not after the fifth.
*Verified by:* commons tests on the model; render tests on the new composables, the way
`CordnExposureRenderTest` does it; the new guard.
### Stage C — joining and being joined — **LANDED**
10. **Welcome inbox surface** — "X invited you to join", accept/decline, wired to
`joinPendingWelcomes`. The skip reasons that method already returns are user-facing text.
11. **Join requests** — `join_request_store` from a share link; the admin side lists pending
requests and completes them (take key package → verify → Add+Commit → `welcome_store`).
12. **QR share + scan**, reusing Amethyst's existing QR components.
13. **Create group**, including choosing a coordinator and the `gid` (their client uses a random
UUID; anything unique works and it must not be derived from the MLS `group_id`, which is
secret).
*Verified by:* a two-account fixture test covering link → request → accept → first message.
### Stage D — message features — **LANDED**
14. Reply (1111), reaction (7), delete (5) — the three that are spec-suggested and match Marmot.
15. **Edit and pin** — only after §5.1 is decided.
16. Drafts, unread state, mentions, pinned ribbon.
17. **Encrypted media**: the cordn codec (§4.5), then image/file send and view over Blossom.
18. Voice notes, if we want parity; Amethyst has audio recording already.
*Verified by:* codec vectors for media (generated the way `cordn-vector-gen` does), fixture tests
for the rest.
### Stage E — settings — **LANDED**
19. Coordinator management screen (add, remove, purge, health, server info).
20. Key-package screen (create, publish, directory, last-resort).
21. Backup/restore — **only if** we decide cordn state belongs in Amethyst's existing backup
story rather than a cordn-specific one.
## 5. Decisions this plan needs
### 5.1 Edit and pin kinds — DECIDED: follow cordn-web, and LANDED
Marmot edits are **1009**, cordn-web edits are **1010**, and pins (**1011**) exist only in
cordn-web. `spec/02.md` §6 blesses none of them — it names chat/reply/reaction and then says there
is "no required set of kind values", so these are app conventions two apps picked differently.
**Decision: follow cordn-web.** Being the second implementation of an unspecified convention is
how it becomes a specification; a third numbering would leave two clients that silently no-op on
each other's edits. Amethyst carries one kind table per binding, which it needs anyway because
system rows already differ (§5.2).
Landed 2026-09-19 in `quartz/…/cordn/spec02Envelopes/`:
- `CordnMessageKinds` — the catalog, with the divergence table in its KDoc.
- `CordnMessageReferences` — parse **and** build in one file, because they are two halves of one
format and the failure of splitting them is a client that emits tags its own parser rejects.
A test asserts exactly that round-trip.
- `CordnAnnotationIndex` — the fold, and the reason any of this is protocol code rather than view
model: **the authorization rules live here.**
The rules, each with a test that fails if it is loosened:
| Annotation | Who may | Ordering |
| ---------- | ------- | -------- |
| Reaction | anyone | n/a — a set per emoji |
| Edit | **author only** | newest `created_at`, ties on cursor |
| Deletion | **author only**, and `k` must match the target | n/a |
| Pin | **any member** | newest wins, ties on cursor |
Two of those are load-bearing and easy to get wrong:
- **Deletion is resolved before edits**, so a late edit cannot resurrect text its author already
withdrew. The pass order in the fold is not incidental.
- **Ties break on the coordinator's cursor**, which is the only total order two clients both see
(`spec/00.md` §4). Without it, two edits in the same second are a coin flip that two clients
could call differently — and §7 of `spec/02.md` still holds: the cursor breaks ties, it is never
an identity.
Mutation-checked: dropping the edit author check, the delete author check, the cursor tie-break,
or the delete-before-edit ordering each kills its own test.
Still worth asking upstream to write 1010, 1011 and the derived system row into a spec.
### 5.2 System rows are structurally different
Marmot puts system events **on the wire** (kind 1210, a real envelope). cordn-web **derives** them
client-side from Commits and never transmits them (synthetic kind `-1`). Deriving is strictly
better here — it cannot disagree with the MLS state it describes, and it costs no bytes — and it
is what a cordn peer will do anyway. Take theirs for cordn. Do not change Marmot.
### 5.3 Multi-device — the fleet stays a non-goal; migration LANDED
This section twice recorded multi-device as a non-goal, and twice for a reason
that was narrower than stated. The decision below supersedes both.
**What is still a non-goal: a live fleet.** Two devices of one identity staying
in step is what `multi-device.md` §10 leaves unresolved — committing inside one
delivery round-trip lands them on the same epoch with different states, and §15
concedes *equal-epoch MLS states have no merge function*. Nothing here changes
that, and nothing should ship that depends on it.
**What landed: migration.** Moving an account from one Amethyst phone to
another is the same documents, the same seal and the same tip, minus the thing
that makes a fleet hard — a handoff has **one writer**. The old phone publishes
a snapshot and stands down; the new one seeds from it (§9) and starts. The
equal-epoch race is out of reach by construction rather than by mitigation.
Built:
| What | Where |
| ---- | ----- |
| §4/§5/§6/§7 documents, seal, tip inventory | `quartz/…/cordn/appMultiDevice/` |
| §6/§11 handoff code (`cordndev1…`) | `CordnHandoffCode` |
| publish / fetch | `commons/…/cordn/CordnMigration` |
| snapshot read + write over the stores | `CordnMigrationStores` |
| the fork guard | `CordnHandoffState`, enforced at `CordnRuntime.session()` |
| Blossom, two platforms | `AndroidCordnBlobStore`, `HttpCordnBlobStore` |
| `amy cordn migrate export\|import` | `cli/…/CordnMigrateCommands` |
| live end-to-end harness | `cli/tests/cordn/migrate.sh` |
| screens | `CordnMigrateScreen`, `CordnHubScreen` |
Deliberately **not** built, because each exists to keep two *live* devices in
step: §8.5 `prev` chains, §10 sibling-Commit convergence, §10.5
publish-on-every-Commit, §8 tombstone reconciliation.
Five additive fields beyond the spec's documents, each because the spec has
nowhere to say it and a migration loses something real otherwise:
`clientStateFormat` (so a ts-mls document is refused with a reason rather than
crashing inside our MLS engine), `amethystRoomState` / `amethystEchoState` /
`amethystJoinedViaRequest` (drafts, read positions and echo bookkeeping —
`CordnBackup` loses all three today), `amethystCoordinatorRelays` (§8.5 of
`spec/00.md` gives a coordinator no address but its pubkey), and the full
key-package list on the meta document (§11.5 carries only the last-resort one,
which is right for a fleet and wrong for a phone that is going away).
**Cross-client migration remains impossible**, unchanged: `clientState` is
library-private by design (§4.2), so an Amethyst device and a cordn-web device
can never share a leaf. What landed is Amethyst-to-Amethyst, which is what was
asked for.
**The cost that was weighed and accepted:** the sealed documents leave the
device for a storage server. They are NIP-44 v2 under a DEK reachable only
through a seal to the owner's own npub, so the server sees size and timing and
nothing else — but group state including leaf private keys is on someone else's
disk until the blobs are deleted. The export screen says so before the button.
Revisit the *fleet* question if upstream specifies the equal-epoch tiebreaker.
A library-neutral `clientState` would separately unlock cross-client migration.
Neither has happened.
### 5.4 Which backup story — DECIDED: our own format, and LANDED
cordn-web has its own passphrase-encrypted backup. Amethyst has account backup already. Folding
cordn group state into Amethyst's backup is better for users and worse for portability (their
file will not import). Needs a call; no strong opinion here.
**Answered while building it, and neither half of the trade survived contact.**
- *Portability was never available.* The valuable content of any cordn backup is MLS group
state, which is an engine's internal serialization rather than a wire format — the same
reasoning §4.6 of the interop plan uses to rule out *cross-client* multi-device. Theirs is ts-mls's
`ClientState`, ours is `MlsGroupState`, and neither reads the other whatever container wraps
it. A byte-compatible file would buy a restore that cannot restore. Their client also sits
outside the two MIT packages, and the spec prose is unlicensed.
- *There was nothing to fold into.* "Amethyst has account backup already" means **key** backup.
A relay-backed account rebuilds itself from an nsec; a cordn group does not, because the key
alone restores nothing.
So: `CordnBackup` — our own versioned format, scrypt at NIP-49's cost plus ChaCha20-Poly1305.
Restoring **replaces** a device rather than merging, because an MLS state export is a cloneable
identity and two devices committing from one state fork the ratchet tree — the same equal-epoch
divergence §5.3 records as unresolved in `multi-device.md` §10, reached here by restore instead of
by a race.
## 6. Out of scope, and why
- **News feed, donations, supporters** — cordn.net product surface, not protocol.
- **Theme editor / gallery** — Amethyst has its own theming.
- **App update banners, native bridge/shims** — packaging concerns of a web app shipped as a
native wrapper.
- **Profile pages, "why" page** — Amethyst has these.
- **Multi-device** — §5.3.
## 7. Risks
1. **Stage A is most of the work and all of the uncertainty.** Every screen is cheap once an
account really holds cordn groups; nothing is possible before that.
2. **No live verification.** Tier B is blocked on licensing, so "works against the fixture" is the
strongest claim any stage below can make. Shipping a chat feature to users on that basis is a
product decision, not a technical one — it should be made deliberately, and probably means
shipping behind a flag until a licensed coordinator exists to test against.
3. **Two group-chat UIs, permanently.** This is the accepted cost of §3, not a risk to mitigate —
but it has a failure mode worth naming: a chat bug fixed in one and forgotten in the other.
The mitigation is not a shared abstraction (§3.1 forbids it); it is that cordn's screens stay
small enough to hold in one head, which means resisting the urge to port Marmot's feature set
wholesale. Build what cordn users need, not what Marmot happens to have.
4. **§8 exposure must ride along.** The badge and the card exist; if the group list and info
screens ship without them the disclosure requirement quietly regresses to what it was before.
## 8. Open questions
1. Do we ship cordn to users at all before a licensed coordinator exists to test against (risk 2)?
**Still open, and still the only thing between this plan and users — but the question has
narrowed.** Every stage below it has landed, and the protocol layer is no longer
fixture-only: Tier B ran on 2026-09-22 against the reference coordinator and the full
lifecycle passes end to end (interop plan §7.1). So "untested against a real peer" is no
longer an argument against shipping.
What has *not* changed is the licensing, and that was always the sharper half of risk 2. A
coordinator we may not depend on is one we cannot put in CI, cannot regression-test against
on every release, and cannot point a user at. Tier B is a diagnostic someone runs by hand,
which is worth a great deal at this stage and nothing at all as a shipping guarantee.
Worth separating when this is decided: the two bugs Tier B found were both invisible to five
tiers of our own tests, which is evidence about how much fixture-only verification is worth
— in either direction, depending on whether you read it as "the live tier works" or "we do
not know what else is hiding".
2. ~~§5.1 — follow cordn-web's 1010/1011, or wait for a spec?~~ **Answered: follow cordn-web.**
Landed; see §5.1.
3. ~~§5.4 — one backup story or two?~~ **Answered: our own.** Landed; see §5.4.
4. ~~Is there appetite for a shared chat surface?~~ **Answered: no.** Marmot is frozen and the
two must stay independently deletable; §3 is the standing constraint and the guards enforce it.
5. Whose coordinator do we default to, if any? The interop plan's open question 5 applies harder
once there is a UI with a "use default" button: shipping a default is an endorsement.
+1
View File
@@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-09-19-cordn-ui.md](2026-09-19-cordn-ui.md) | Every user-facing feature cordn-web has, mapped onto Amethyst — the account-level cordn runtime (the real gate), group list/chat/info screens, welcomes, join requests, message features and settings. Marmot is frozen and the two bindings must stay independently deletable, enforced by isolation tests rather than a README; flags the 1009-vs-1010 edit-kind divergence. |
| [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
@@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
@@ -207,8 +208,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.image
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -377,8 +378,17 @@ class Account(
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
/**
* Where cordn keeps its encrypted group state, or null to run without it.
*
* A directory rather than a built runtime, because `CordnRuntime` needs
* this account's [scope] and that only exists once the account does.
* Nothing about it is shared with Marmot's stores above — cordn has its
* own, by the §3.1 rule in `amethyst/plans/2026-09-19-cordn-ui.md`.
*/
val cordnFilesDir: java.io.File? = null,
val mlsGroupStateStore: MlsGroupStateStore? = null,
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
val marmotMessageStore: com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore? = null,
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
/**
* Durable publish obligations. Null means publish-before-apply does not
@@ -945,6 +955,24 @@ class Account(
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
/**
* cordn, for this account, or null when no directory was supplied.
*
* Built here for the same reason [marmotManager] is: it needs [scope].
* Opening coordinators and starting their sync loops is a separate,
* suspending step ([CordnRuntime.start]) — constructing this touches no
* network and no coordinator learns anything from it.
*/
val cordnRuntime: CordnRuntime? =
cordnFilesDir?.let {
CordnRuntime(
accountSigner = signer,
client = client,
filesDir = it,
scope = scope,
)
}
val marmotManager: MarmotManager? =
mlsGroupStateStore?.let {
MarmotManager(
@@ -3885,6 +3913,19 @@ class Account(
// the race where a publish would land on a half-built Account.
cashuWalletState.start { event -> sendLiterallyEverywhere(event) }
// Reopen the cordn coordinators this account used last time.
//
// Deliberately its own launch rather than a branch of Marmot's block
// below: the two protocols are independent by design (§3.1 of
// amethyst/plans/2026-09-19-cordn-ui.md), and a cordn coordinator that
// is down must not delay Marmot's restore, or the reverse. Failures
// are already contained per coordinator inside start().
cordnRuntime?.let { runtime ->
scope.launch(Dispatchers.IO) {
runtime.restore()
}
}
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
// Derived kind:1210 rows go straight into the conversation. Only
@@ -931,7 +931,7 @@ class AccountMarmotActions(
/**
* 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
* in [com.vitorpamplona.quartz.mls.group.MlsGroup] would otherwise
* throw at commit time.
*/
suspend fun revokeMarmotGroupAdmin(
@@ -330,6 +330,10 @@ class AccountCacheState(
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable)
},
),
// The same per-account directory the Marmot stores use. cordn
// scopes itself further by coordinator underneath it, because a
// gid is unique only within one (spec/00.md §4).
cordnFilesDir = accountDir,
mlsGroupStateStore = mlsStore,
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
@@ -0,0 +1,114 @@
/*
* 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.cordn
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.ByteArrayInputStream
/**
* Where a migration's sealed documents are stored, on Android.
*
* ## The authorization is signed by a throwaway, and that is the point
*
* `multi-device.md` §12 requires the BUD-01 upload authorization to be signed
* by an ephemeral key and **never** the owner `npub` — the same rule as the
* tip. Signing as the owner would tell the storage server, in the clear and
* under the account's own name, that this person is uploading right now. That
* is precisely the linkage the opaque tip is built to avoid, and it would
* arrive by a side door.
*
* So this does NOT reuse `account.createBlossomUploadAuth` the way
* [CordnMediaService] does for message attachments. Those are already
* attributable — they ride inside a group the coordinator can see traffic for
* — whereas the point of a migration blob is that nothing links it to anyone.
* [signer] is minted per instance and derived from nothing.
*/
class AndroidCordnBlobStore(
private val servers: List<String>,
private val context: Context,
) : CordnBlobStore {
private val signer = NostrSignerInternal(KeyPair())
override suspend fun put(blob: ByteArray): List<String> {
val hash = sha256(blob).toHexKey()
return servers.filter { server ->
runCatching {
BlossomUploader()
.upload(
inputStream = ByteArrayInputStream(blob),
hash = hash,
length = blob.size.toLong(),
baseFileName = hash,
// Opaque on purpose: the server learns a size and a
// hash, and nothing about what kind of thing this is.
contentType = OPAQUE,
alt = null,
sensitiveContent = null,
serverBaseUrl = server,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = { h, size, alt -> BlossomAuthorizationEvent.createUploadAuth(h, size, alt ?: "", signer) },
context = context,
useMediaEndpoint = false,
).url != null
}.getOrDefault(false)
}
}
override suspend fun get(
address: String,
servers: List<String>,
): ByteArray? =
withContext(Dispatchers.IO) {
// Ordered: §6 has the reader try the tip's servers as listed, most
// reliable first.
servers.firstNotNullOfOrNull { server ->
runCatching {
val url = "${server.trimEnd('/')}/$address"
Amethyst.instance.roleBasedHttpClientBuilder
.okHttpClientForImage(url)
.newCall(
Request
.Builder()
.url(url)
.get()
.build(),
).execute()
.use { if (it.isSuccessful) it.body?.bytes() else null }
}.getOrNull()
}
}
companion object {
private const val OPAQUE = "application/octet-stream"
}
}
@@ -0,0 +1,194 @@
/*
* 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.cordn
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnEncryptedMedia
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaEncryption
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
import com.vitorpamplona.quartz.mls.group.MlsGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.ByteArrayInputStream
/**
* Sending and fetching cordn attachments.
*
* ## What each party sees
*
* | | sees |
* | --- | --- |
* | the blob host | opaque bytes, their size, and who uploaded them |
* | the coordinator | a sealed payload; not the URL, not the type, not the name |
* | the group | everything, because the key rides in the sealed descriptor |
*
* Getting that split right is most of this file. The `imeta` descriptor —
* URL, MIME type, filename, plaintext hash, nonce — rides **inside** the MLS
* envelope, so it is as private as the message it belongs to.
*
* ## Three deliberate choices on the upload
*
* 1. **`application/octet-stream`, always.** The real MIME type goes in the
* encrypted descriptor. Declaring `image/jpeg` to the host would tell it
* what kind of file this is for no benefit to anyone.
* 2. **No `alt` text, no content warning.** Both are plaintext on a Blossom
* upload. An alt string describing a private photo is the photo's caption,
* handed to a server that was supposed to see nothing.
* 3. **`/upload`, never `/media`.** The `/media` endpoint asks the server to
* re-encode. Re-encoding ciphertext destroys it, and an account with
* "optimize uploads" on would otherwise silently break every attachment.
*
* Blossom addresses a blob by the SHA-256 of the bytes it stores — the
* ciphertext. The `imeta` descriptor carries the hash of the **plaintext**.
* Two different hashes on purpose: the host needs one to name the blob, the
* group needs the other to know it got the file that was sent, and neither
* can be derived from the other.
*/
class CordnMediaService(
private val account: Account,
) {
/**
* Encrypts [bytes] under a fresh per-file key and uploads the ciphertext.
*
* @return the `imeta` tag to put on the message, or null when the account
* has no Blossom server configured — there is nowhere to put a file and
* saying so beats a failure deeper in.
*/
suspend fun upload(
group: MlsGroup,
bytes: ByteArray,
mimeType: String,
filename: String,
context: Context,
/** The host to put it on. Defaults to the account's, which is what the
* upload dialog's server spinner starts on. */
serverBaseUrl: String = account.settings.defaultFileServer.baseUrl,
/**
* Display hints for the `imeta`. All optional and none authenticated:
* they are the spec's "passed through unchanged" fields, so they cost
* nothing to omit and are worth a great deal to include — without
* [dimensions] the bubble has no aspect ratio to reserve and the list
* jumps when the picture lands, and without [waveform] a voice note the
* recorder already measured comes back as bare bars.
*/
dimensions: String? = null,
blurhash: String? = null,
alt: String? = null,
waveform: List<Float>? = null,
): Array<String>? =
withContext(Dispatchers.IO) {
val server = serverBaseUrl.ifBlank { return@withContext null }
// Derived from the group's epoch exporter, never sent: that is what
// spec/applications/encrypted-media.md §3.1 requires, and what lets
// any cordn client open the blob from group state alone.
val fileKey = CordnMediaEncryption.mediaKey(group)
val sealed = CordnMediaEncryption.encrypt(bytes, fileKey, mimeType, filename)
CordnMediaTag.build(
media = sealed,
url = put(sealed, server, context),
dimensions = dimensions,
blurhash = blurhash,
alt = alt,
waveform = waveform,
)
}
/**
* Uploads the ciphertext and returns the URL the host gave it.
*
* Every privacy decision is in [CordnBlobUpload]; this forwards it. Read
* that KDoc before changing an argument here — the wrong constant does not
* fail, it just tells a server something.
*/
private suspend fun put(
sealed: CordnEncryptedMedia,
server: String,
context: Context,
): String {
val blob = CordnBlobUpload.of(sealed)
val result =
BlossomUploader().upload(
inputStream = ByteArrayInputStream(blob.bytes),
hash = blob.hash,
length = blob.length,
baseFileName = blob.baseFileName,
contentType = blob.contentType,
alt = blob.alt,
sensitiveContent = blob.sensitiveContent,
serverBaseUrl = server,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) },
context = context,
useMediaEndpoint = blob.useMediaEndpoint,
)
return result.url ?: throw IllegalStateException("the blob server returned no URL")
}
/**
* Fetches [attachment] and opens it with the key it carries.
*
* Throws if the bytes do not authenticate. That is the right outcome and
* not a rare one: a blob host can serve anything it likes for a URL, and
* the AEAD tag plus the plaintext hash are the only reasons to believe
* what came back is what was sent.
*/
suspend fun download(
group: MlsGroup,
attachment: CordnMediaAttachment,
): ByteArray =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(attachment.url)
.get()
.build()
val response =
Amethyst.instance.roleBasedHttpClientBuilder
.okHttpClientForImage(attachment.url)
.newCall(request)
.execute()
val body =
response.use {
check(it.isSuccessful) { "the blob server answered ${it.code}" }
it.body.bytes()
}
CordnMediaEncryption.decrypt(
ciphertext = body,
fileKey = CordnMediaEncryption.mediaKey(group),
nonce = attachment.nonceBytes,
plaintextHash = attachment.hashBytes,
mimeType = attachment.mimeType,
filename = attachment.filename,
)
}
}
File diff suppressed because it is too large Load Diff
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -432,7 +432,7 @@ class UploadOrchestrator {
* Deletes a temporary file created during the upload pipeline if its URI
* differs from the original (meaning it's an intermediate temp file, not the user's content).
*/
private fun deleteTempUri(
internal fun deleteTempUri(
tempUri: Uri,
originalUri: Uri,
) {
@@ -102,6 +102,13 @@ class VoiceMessageRecorder {
amplitudeSamplingJob =
recorderScope?.launch {
while (isActive) {
// Delay FIRST. `maxAmplitude` reports the peak since the
// previous call, and the call that happens immediately
// after start() covers a window in which the encoder has
// captured nothing — it answers 0 and puts a bar of silence
// at the head of every recording that is not in the audio.
delay(SAMPLE_INTERVAL_MS)
val recorderRef = recorder ?: break
try {
val amplitude = recorderRef.maxAmplitude.toFloat()
@@ -113,7 +120,6 @@ class VoiceMessageRecorder {
Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e)
break
}
delay(1000)
}
}
}
@@ -131,7 +137,7 @@ class VoiceMessageRecorder {
val amplitudesCopy =
synchronized(amplitudes) {
amplitudes.toList()
}
}.downsampled(MAX_WAVEFORM_POINTS)
val duration = (currentTime - startTime).toInt()
// Clean up recorder and scope
@@ -197,4 +203,43 @@ class VoiceMessageRecorder {
amplitudes.clear()
}
}
companion object {
/**
* How often the peak is read while recording.
*
* Was one second, which is not a waveform: a five-second note produced
* five numbers, stretched across fifty-odd bars, and the result could
* not track speech at any resolution a person would recognise. Ten a
* second is fine to sample — `maxAmplitude` is a cheap read — and the
* count is bounded on the way out rather than here.
*/
const val SAMPLE_INTERVAL_MS = 100L
/**
* The most amplitudes a recording reports.
*
* Sampling finely and reducing at the end keeps the shape faithful
* without letting a long recording turn into a long list: these travel
* inside events and `imeta` tags, so the size has to depend on the
* detail wanted rather than on how long somebody spoke.
*/
const val MAX_WAVEFORM_POINTS = 100
}
}
/**
* Averages [this] down to at most [max] buckets, keeping the ends in place.
*
* Averaging rather than dropping samples: taking every Nth would let a single
* loud frame stand for a whole bucket and make the bars flicker with the
* sampling phase rather than with the sound.
*/
internal fun List<Float>.downsampled(max: Int): List<Float> {
if (max <= 0 || size <= max) return this
return List(max) { bucket ->
val from = bucket * size / max
val to = ((bucket + 1) * size / max).coerceAtLeast(from + 1)
subList(from, to.coerceAtMost(size)).average().toFloat()
}
}
@@ -128,6 +128,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen
@@ -297,6 +303,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnBackupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnCoordinatorsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnHubScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnKeyPackagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnLinkScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnMigrateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
@@ -662,6 +674,12 @@ fun BuildNavigation(
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
}
composableFromEnd<Route.CordnLink> { CordnLinkScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnCoordinators> { CordnCoordinatorsScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnKeyPackages> { CordnKeyPackagesScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnBackup> { CordnBackupScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnHub> { CordnHubScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnMigrate> { CordnMigrateScreen(accountViewModel, nav) }
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEnd<Route.EditBolt12Offers> { Bolt12OffersScreen(accountViewModel, nav) }
@@ -707,6 +725,17 @@ fun BuildNavigation(
}
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.CordnGroupChat> {
CordnGroupChatScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav)
}
composableFromEndArgs<Route.CordnGroupInfo> {
CordnGroupInfoScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav)
}
composableFromEnd<Route.CordnGroupList> { CordnGroupListScreen(accountViewModel, nav) }
composableFromBottom<Route.CordnCreateGroup> { CordnCreateGroupScreen(accountViewModel, nav) }
composableFromBottom<Route.CordnCreateGroupMembers> { CordnCreateMembersScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnInvitations> { CordnInvitationsScreen(accountViewModel, nav) }
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.MarmotGroupEditInfo> { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.resources.bottom_bar_category_you
import com.vitorpamplona.amethyst.commons.resources.browser
import com.vitorpamplona.amethyst.commons.resources.communities
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.resources.discover_marketplace
import com.vitorpamplona.amethyst.commons.resources.discover_reads
import com.vitorpamplona.amethyst.commons.resources.drafts
@@ -391,6 +392,16 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Lock,
resolveRoute = { Route.MarmotGroupList },
),
NavBarItem.CORDN_GROUPS to
NavBarItemDef(
id = NavBarItem.CORDN_GROUPS,
labelRes = Res.string.cordn_groups_title,
// Dns, the symbol every other cordn surface uses: a coordinator
// is a server, and that is the one thing that distinguishes
// these from the Marmot rooms directly above.
icon = MaterialSymbols.Dns,
resolveRoute = { Route.CordnGroupList },
),
NavBarItem.GEOHASH_CHATS to
NavBarItemDef(
id = NavBarItem.GEOHASH_CHATS,
@@ -527,6 +538,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.CORDN_GROUPS,
NavBarItem.GEOHASH_CHATS,
),
),
@@ -153,6 +153,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.CORDN_GROUPS,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -254,8 +254,18 @@ class UserSuggestionState(
state: TextFieldState,
word: String,
item: User,
/**
* Always insert the resolved `nostr:` URI, never the `@npub1…` short form.
*
* The short form is only half a mention: it relies on a send-time tagger to
* rewrite it and emit the `p` tag. Surfaces that have one (every nostr-event
* composer) keep it. cordn does not — its content goes into a sealed envelope
* verbatim, and its reader parses `nostr:` URIs out of that content — so an
* `@npub1…` there would ship as literal text and mention nobody.
*/
forceNostrUri: Boolean = false,
) {
val wordToInsert = mentionInsertion(word, item)
val wordToInsert = mentionInsertion(word, item, forceNostrUri)
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
@@ -280,7 +290,10 @@ class UserSuggestionState(
private fun mentionInsertion(
word: String,
item: User,
forceNostrUri: Boolean = false,
): String {
if (forceNostrUri) return "nostr:${item.toNProfile()} "
val typed = userSearchTermOrNull(word)
val wasNip05Mention =
typed != null &&
@@ -148,37 +148,15 @@ fun RenderAudioWithWaveform(
val callbackUri = remember(note) { note.toNostrUri() }
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
Modifier.fillMaxWidth().height(100.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = mediaUrl,
title = title,
artworkUri = null,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
mimeType = mimeType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
)
}
}
}
RenderAudioWaveformPlayer(
mediaUrl = mediaUrl,
title = title,
mimeType = mimeType,
waveform = waveform,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
)
if (noteEvent.hasHashtags()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@@ -188,6 +166,86 @@ fun RenderAudioWithWaveform(
}
}
/**
* The voice player on its own, with no [Note] behind it.
*
* Separated because a chat that carries audio does not necessarily have a Note
* to hand: cordn messages never enter LocalCache, so the only way they can show
* the same player as every other chat is for the player not to demand one. The
* Note-shaped overload above is this plus the hashtag row, which is the only
* part that genuinely needs the event.
*
* [waveform] may be null. Nothing that arrives as an encrypted blob carries one
* today, and the player then shows its transport controls without the bars —
* still the voice UI, which is the point, rather than a video surface with no
* picture in it.
*/
@Composable
fun RenderAudioWaveformPlayer(
mediaUrl: String,
title: String?,
mimeType: String?,
waveform: WaveformData?,
authorName: String?,
callbackUri: String?,
accountViewModel: AccountViewModel,
) {
Row(
Modifier.fillMaxWidth().height(100.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = mediaUrl,
title = title,
artworkUri = null,
authorName = authorName,
callbackUri = callbackUri,
mimeType = mimeType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
)
}
}
}
}
/**
* The bars drawn when a track carries no amplitudes of its own.
*
* A voice note with no waveform used to render as a play button on an empty
* black bar, which reads as a broken video rather than as audio. Every
* encrypted format can hit this: MIP-04 and encrypted-media-v2 define no
* waveform field at all, and a cordn message only carries one if the sender's
* client wrote the hint.
*
* **It has to vary.** `AudioWaveformReadOnly` normalises the amplitudes onto
* the bar height, so a constant list has no range to normalise and every bar
* collapses to the minimum — a flat 0.35f placeholder drew a dotted line, not
* bars. The shape below is a short pattern tiled across the width: enough
* relief to read as audio, regular enough that nobody mistakes it for a
* measurement of their recording.
*
* What it buys beyond looking right is somewhere for the progress fill to run,
* so position stays legible while it plays.
*/
private val PlaceholderPattern = listOf(0.35f, 0.62f, 0.45f, 0.85f, 0.5f, 0.72f, 0.4f, 0.58f)
private val PlaceholderWaveform = WaveformData(List(48) { PlaceholderPattern[it % PlaceholderPattern.size] })
@Composable
@OptIn(UnstableApi::class)
fun RenderVoicePlayer(
@@ -224,7 +282,7 @@ fun RenderVoicePlayer(
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
PlayPauseButton(controllerState)
waveform?.let { Waveform(it, controllerState, Modifier) }
Waveform(waveform ?: PlaceholderWaveform, controllerState, Modifier)
}
RenderTopButtonsForVoice(
@@ -184,6 +184,44 @@ class AccountFeedContentStates(
}
}
// Same for cordn, and more so: a cordn message is an MLS envelope that
// never enters LocalCache at all (§3.3 of
// amethyst/plans/2026-09-19-cordn-ui.md), so nothing about a cordn room
// can ever reach the additive path. Without this the inbox never shows
// a cordn room — not when one is created, not when a Welcome is
// accepted, and not after a relaunch that restored it — and since the
// room list is the only way back into a room, a group became
// unreachable the moment its screen was closed.
//
// `revision` and not `all`: `all` re-emits only when the room *set*
// changes, so a message arriving for a room the inbox already lists
// never reached this collector. The rows therefore kept whatever order
// the build that first saw them gave — and since a cordn row sorts on
// its newest message, that meant the inbox ordered cordn rooms by when
// they were joined and never moved them again. `revision` bumps on the
// set *and* on every message filed into a room. sample() keeps the
// restore burst, when every coordinator's groups arrive at once, from
// rebuilding the feed once per room.
//
// No drop(1), unlike the collectors around it. Those drop the replay
// because the feed's first build already saw their state; cordn's
// restore runs in its own launch from Account's constructor and often
// finishes *after* that build, so the current value is exactly the one
// that matters — a StateFlow replays it on subscribe and dropping it
// waits for a change that, for an account whose rooms are all restored
// rather than newly created, never comes. The cost of keeping it is one
// extra rebuild at login.
account.cordnRuntime?.let { runtime ->
scope.launch(Dispatchers.IO) {
@OptIn(FlowPreview::class)
runtime.groups.revision
.sample(500)
.collect {
dmKnown.invalidateData()
}
}
}
// Same for the NIP-29 joined-group list (kind 10009): joining/leaving changes the list but
// doesn't flow through newEventBundles, so force a rebuild — otherwise a just-joined group
// (whose messages haven't loaded yet) wouldn't appear on the Messages tab until a later event.
@@ -160,6 +160,13 @@ private fun PreloadFor(
// to warm up, and adding one would duplicate those.
NavBarItem.MARMOT_GROUPS -> Unit
// Same as Marmot, for a different reason: a cordn group's messages never
// arrive over a relay REQ at all. They are coordinator calls made by
// CordnRuntime's sync loop, which is already running for every
// coordinator this account holds -- there is no relay subscription a
// list could warm up.
NavBarItem.CORDN_GROUPS -> Unit
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,296 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
/**
* The cordn room's composer, on the same field every other Amethyst chat uses.
*
* [ThinPaddingTextField] + [ThinSendButton] inside [EditFieldModifier], with the shared
* mention machinery on top: [ShowUserSuggestionList] for `@` autocomplete,
* [MentionPreservingInputTransformation] so an IME cannot rewrite half a bech32, and
* [UrlUserTagOutputTransformation] so a pasted or completed mention reads as a name
* while you type it. Before this the room had a bare `OutlinedTextField` over a
* `String`: cordn could *render* mentions and highlight a message that named you, but
* the only way to enter one was to type `nostr:npub1…` by hand.
*
* Mentions are inserted as resolved `nostr:` URIs (`forceNostrUri`). See the parameter's
* KDoc — cordn has no send-time tagger, so the short `@npub1…` form would ship as text.
*
* Looking users up to offer them is the same safe direction as everywhere else on this
* screen: profiles are public relay data the cache already holds, and reading one puts
* no part of this conversation into it.
*/
@Composable
internal fun CordnComposer(
room: CordnGroupChatroom,
attaching: Boolean,
pendingVoice: RecordingResult?,
accountViewModel: AccountViewModel,
onAttach: (Uri) -> Unit,
onVoiceNote: (RecordingResult) -> Unit,
onRemoveVoice: () -> Unit,
onSend: (String) -> Unit,
) {
// `room.draft` is the persisted String — commons holds the draft and may not depend
// on Compose UI, so it cannot hold a TextFieldState. The field owns text *and*
// selection; these two effects keep them equal. Both writes are guarded on
// inequality, so neither direction can bounce off the other.
val draftState = remember(room.gid) { TextFieldState(room.draft.value) }
val suggestions =
remember(room.gid, accountViewModel) {
UserSuggestionState(
accountViewModel.account,
accountViewModel.nip05ClientBuilder(),
// Members of this room rank above the rest of the address book: in a
// group, the person you are about to name is almost always in it.
priorityPubkeys = { room.members.value.toSet() },
)
}
// `:shortcode:` completion, as the DM and Concord composers have. Cordn inserts the
// emoji's URL rather than its shortcode: a shortcode only resolves for a reader who
// also got the NIP-30 `emoji` tags, and a cordn message carries none — so everyone
// else would have read a literal ":shrug:". Same reasoning as forcing `nostr:` for
// user mentions, and the URL renders as the image for every reader.
val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) }
WatchAndLoadMyEmojiList(accountViewModel)
DisposableEffect(suggestions, emojiSuggestions) {
onDispose {
suggestions.reset()
emojiSuggestions.reset()
}
}
LaunchedEffect(draftState, room) {
snapshotFlow { draftState.text.toString() }.collect {
if (room.draft.value != it) room.draft.value = it
}
}
LaunchedEffect(draftState, room) {
room.draft.collect { external ->
// The screen writes the draft from outside on three paths: clearing it on
// send, restoring it when a send fails, and loading a message's text into
// it to edit. Cursor to the end, as editFromDraft does elsewhere.
if (external != draftState.text.toString()) {
if (external.isEmpty()) draftState.clearText() else draftState.setTextAndPlaceCursorAtEnd(external)
// onTextChanged only fires for typing, so a list left open by a
// half-typed "@na" survived the field being cleared on send.
suggestions.reset()
emojiSuggestions.reset()
}
}
}
// A recorded voice note is something to send even with nothing typed; the text
// beside it rides along as its caption.
val canPost by remember(pendingVoice) { derivedStateOf { draftState.text.isNotBlank() || pendingVoice != null } }
Column(modifier = EditFieldModifier) {
// The same preview the post screens use: listen back, re-record, or drop it.
// Recording used to send the moment you released the button, which is a hard
// thing to get right first time and impossible to take back afterwards.
pendingVoice?.let { recording ->
val meta =
remember(recording) {
AudioMeta(
// Empty: nothing is uploaded yet, so the preview plays the
// local file instead.
url = "",
mimeType = recording.mimeType,
duration = recording.duration,
waveform = recording.amplitudes,
)
}
VoiceMessagePreview(
voiceMetadata = meta,
localFile = recording.file,
onRemove = onRemoveVoice,
onReRecord = onVoiceNote,
isUploading = attaching,
modifier = Modifier.fillMaxWidth(),
)
}
ShowEmojiSuggestionList(
emojiSuggestions,
onSelect = { insertEmojiUrl(draftState, emojiSuggestions, it) },
onFullSize = { insertEmojiUrl(draftState, emojiSuggestions, it) },
modifier = SuggestionListDefaultHeightChat,
)
ShowUserSuggestionList(
suggestions,
onSelect = { user ->
suggestions.replaceCurrentWord(draftState, draftState.currentWord(), user, forceNostrUri = true)
suggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
)
ThinPaddingTextField(
state = draftState,
onTextChanged = {
// Only while the caret is a point: during a range selection the "current
// word" is whatever is highlighted, which is not something being typed.
if (draftState.selection.collapsed) {
val lastWord = draftState.currentWord()
when {
lastWord.startsWith("@") -> {
suggestions.processCurrentWord(lastWord)
emojiSuggestions.reset()
}
lastWord.startsWith(":") -> {
emojiSuggestions.processCurrentWord(lastWord)
suggestions.reset()
}
else -> {
suggestions.reset()
emojiSuggestions.reset()
}
}
}
},
// Anything the keyboard or a paste hands over as content — a GIF, a shared
// image — takes the same encrypted-upload path as the file picker.
onContentReceived = { uri, _ -> onAttach(uri) },
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(R.string.cordn_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
// Same frame every other chat composer uses for its leading
// icons (`MarmotGalleryLeadingIcon`): 4.dp either side and the
// placeholder tint. Without it these two sat further from the
// edge and further apart than the rest of the app's composers,
// because a bare IconButton keeps its full 48.dp touch target
// and nothing was pulling the row back in.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
// The app's own gallery picker, so this composer offers the same
// affordance and the same multi-select as the DM and Marmot ones
// rather than a bare file dialog. Each pick is its own encrypted
// upload and its own message, which is what the send path does.
SelectFromGallery(
isUploading = attaching,
enabled = !attaching,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier,
onImageChosen = { picked -> picked.forEach { onAttach(it.uri) } },
)
VoiceNoteButton(enabled = !attaching, onRecorded = onVoiceNote)
}
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
onSend(draftState.text.toString())
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
/**
* Puts [item]'s URL where the half-typed `:shortcode:` was, and closes the list.
*
* [EmojiSuggestionState.autocompleteInto] inserts the shortcode, which is right for a
* composer whose send path attaches the matching `emoji` tags. Cordn's does not, so the
* URL goes in instead — see the state's comment above.
*/
private fun insertEmojiUrl(
field: TextFieldState,
state: EmojiSuggestionState,
item: EmojiPackState.EmojiMedia,
) {
field.replaceCurrentWord(item.link + " ")
state.reset()
}
@@ -0,0 +1,987 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator
import com.vitorpamplona.amethyst.commons.cordn.GroupExposure
import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnCoverage
import com.vitorpamplona.amethyst.model.cordn.CordnGroupCreation
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata
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.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
/**
* Starting a cordn group: pick the coordinator, name it, create it.
*
* ## The coordinator is the first field, not a setting
*
* `spec/00.md` §4 makes the coordinator the sole authority for a group's
* stream, and a `gid` means nothing outside the one that issued it. So
* "which coordinator" is not a preference that could sensibly default — it is
* half of the group's identity, chosen once and unchangeable afterwards. It is
* asked first, in the open, with the exposure card underneath saying what
* choosing it costs.
*
* ## Creating tells the coordinator nothing
*
* `CordnRuntime.createGroup` is local MLS work. The coordinator hears about
* the group when the first Commit or message is posted, which is why a group
* of one is still entirely private and why this screen works against a
* coordinator that is down. The exposure card is therefore about what will
* become true once someone is invited, not about what just happened.
*
* Inviting is not here. `CordnGroupManager.invite` exists and works, but its
* user-facing half — key-package discovery, share links, join requests — is
* Stage C of `amethyst/plans/2026-09-19-cordn-ui.md`. A member picker that
* could only offer accounts that happen to have published a KeyPackage to this
* exact coordinator would promise more than it can do.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CordnCreateGroupScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back))
}
},
title = { Text(stringRes(R.string.cordn_create_title)) },
)
},
) { padding ->
if (runtime == null) {
// The app's own empty state, centred and titled, rather than a
// sentence stranded in the top-left corner.
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val known by runtime.coordinators.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val me = accountViewModel.account.signer.pubKey
// The draft outlives this screen: picking people is its own
// destination now, and a Navigation destination is disposed when you
// leave it, so `remember` here would lose a half-typed name on the way
// back from the draft.roster.
val draft = rememberCordnGroupDraft(accountViewModel)
var creation by remember { mutableStateOf<CordnGroupCreation?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val failureFallback = stringRes(R.string.cordn_create_failed)
var busy by remember { mutableStateOf(false) }
// Coordinators this account has never used, from their CEP-6
// announcements. Not added to the account by looking: picking one here
// is what commits to it, and `createGroup` opens the session.
var discovering by remember { mutableStateOf(false) }
val discoverFailed = stringRes(R.string.cordn_coordinators_discover_failed)
// Offers this account already holds are not offers; they are the choices
// above, and listing them twice would let the same coordinator be picked
// from two places with different relays.
val offers =
remember(draft.discovered, known) {
val knownKeys = known.mapTo(mutableSetOf()) { it.pubKey }
draft.discovered
?.coordinators
?.filter { it.pubKey !in knownKeys }
.orEmpty()
}
val choices = remember(known, offers) { known + offers.map { it.toConfig() } }
// A re-discovery can drop the coordinator that was picked -- it stopped
// announcing, or it is now in `known` and therefore not an offer. Left
// alone, `draft.selected` would name a coordinator no row shows: no radio
// checked, no exposure card, Create disabled, and nothing saying why.
// Falling back to the manual entry is the one state that explains itself,
// because its fields appear.
LaunchedEffect(choices) {
if (draft.selected != null && choices.none { it.pubKey == draft.selected }) draft.selected = null
}
val config = remember(draft.selected, draft.pubKeyInput, draft.relaysInput, choices) { resolve(choices, draft.selected, draft.pubKeyInput, draft.relaysInput) }
val coverage by rememberCordnCoverage(runtime, draft.roster)
// The best-covering coordinator is offered, not imposed: the moment the
// user picks one themselves it stops moving under them, even if the
// roster later changes and a different one would now reach more people.
LaunchedEffect(coverage, draft.userPicked) {
if (draft.userPicked || coverage.isEmpty()) return@LaunchedEffect
val best = coverage.values.filter { it.answered }.maxByOrNull { it.reachable.size }
if (best != null && best.reachable.isNotEmpty()) draft.selected = best.coordinatorPubKey
}
val chosenCoverage = draft.selected?.let { coverage[it] }
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(R.string.cordn_create_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StepHeading(1, stringRes(R.string.cordn_create_step_name))
OutlinedTextField(
value = draft.name,
onValueChange = { draft.name = it },
label = { Text(stringRes(R.string.cordn_create_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = draft.description,
onValueChange = { draft.description = it },
label = { Text(stringRes(R.string.cordn_create_description)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
StepHeading(2, stringRes(R.string.cordn_create_step_people))
RosterSummary(
roster = draft.roster,
coverage = coverage,
onOpen = { nav.nav(Route.CordnCreateGroupMembers) },
accountViewModel = accountViewModel,
nav = nav,
)
StepHeading(3, stringRes(R.string.cordn_create_step_where))
// Collapsed to the one coordinator that will be used, because with a
// draft.roster in hand there is usually nothing left to decide. The whole
// list is still one tap away for the times there is.
CoordinatorSummary(
config = config,
coverage = chosenCoverage,
rosterSize = draft.roster.size,
expanded = draft.coordinatorOpen,
onToggle = { draft.coordinatorOpen = !draft.coordinatorOpen },
accountViewModel = accountViewModel,
nav = nav,
)
if (draft.coordinatorOpen) {
known.forEach { coordinator ->
CoordinatorChoice(
label = coordinatorDisplayName(coordinator.pubKey, coordinator.label, accountViewModel),
pubKey = coordinator.pubKey,
selected = draft.selected == coordinator.pubKey,
onSelect = {
draft.selected = coordinator.pubKey
draft.userPicked = true
},
relays = relayLabel(coordinator.relays),
accountViewModel = accountViewModel,
nav = nav,
)
}
// Split on staleness rather than listing everything flat. A run
// against a public relay returns a long tail of coordinators that
// last announced months ago, and creating a group on one that has
// gone away fails at the first call -- so the live ones come first
// and the rest sit behind a count.
val cutoff = TimeUtils.now() - TimeUtils.ONE_MONTH
val live = offers.filter { it.announcedAt >= cutoff }
val stale = offers.filter { it.announcedAt < cutoff }
val names = offers.map { resolvedName(it, accountViewModel) }
// Bounded rather than lazy. The obvious fix for a long list is a
// LazyColumn, and it is the wrong one here: this screen is a form,
// and a lazy list disposes what scrolls off it -- which for the
// text fields below would throw away focus and IME state mid-typing.
// Capping the rows keeps composition bounded without putting a form
// inside a recycler.
val shownLive = if (draft.showAllLive) live else live.take(LIVE_PREVIEW)
shownLive.forEach { offer ->
CoordinatorChoice(
// Its own word for itself, and only that: nothing here has
// verified the name a coordinator announces.
label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names),
pubKey = offer.pubKey,
selected = draft.selected == offer.pubKey,
onSelect = {
draft.selected = offer.pubKey
draft.userPicked = true
},
detail = announcedLabel(offer.announcedAt),
relays = relayLabel(offer.relays),
about = offer.surface.about?.takeIf { it.isNotBlank() },
accountViewModel = accountViewModel,
nav = nav,
)
}
if (live.size > LIVE_PREVIEW) {
TextButton(onClick = { draft.showAllLive = !draft.showAllLive }) {
Text(
if (draft.showAllLive) {
stringRes(R.string.cordn_coordinators_show_fewer)
} else {
stringRes(R.string.cordn_coordinators_show_all, live.size)
},
)
}
}
if (stale.isNotEmpty()) {
TextButton(onClick = { draft.showStale = !draft.showStale }) {
Text(
if (draft.showStale) {
stringRes(R.string.cordn_coordinators_hide_older)
} else {
stringRes(R.string.cordn_coordinators_show_older, stale.size)
},
)
}
}
if (draft.showStale && stale.isNotEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_stale_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
stale.forEach { offer ->
CoordinatorChoice(
label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names),
pubKey = offer.pubKey,
selected = draft.selected == offer.pubKey,
onSelect = {
draft.selected = offer.pubKey
draft.userPicked = true
},
detail = announcedLabel(offer.announcedAt),
relays = relayLabel(offer.relays),
about = offer.surface.about?.takeIf { it.isNotBlank() },
dimmed = true,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
CoordinatorChoice(
label = stringRes(R.string.cordn_create_coordinator_new),
pubKey = null,
selected = draft.selected == null,
onSelect = {
draft.selected = null
draft.userPicked = true
},
accountViewModel = accountViewModel,
nav = nav,
)
// Asks relays, never a coordinator: an announcement is an ordinary
// event, so looking costs nothing with any of them and tells none
// of them anything.
OutlinedButton(
onClick = {
discovering = true
error = null
scope.launch {
try {
draft.discovered = runtime.discover(accountViewModel.account.outboxRelays.flow.value)
} catch (e: Exception) {
error = e.message ?: discoverFailed
} finally {
discovering = false
}
}
},
enabled = !discovering && !busy,
) {
Text(stringRes(R.string.cordn_create_coordinator_discover))
}
draft.discovered?.takeIf { offers.isEmpty() }?.let { result ->
Text(
text =
if (result.unreachable.isEmpty()) {
stringRes(R.string.cordn_coordinators_discover_none)
} else {
// "Nobody is announcing" and "we were not told" are
// different answers and only one of them is final.
// Reporting the first for the second sends someone
// off to paste a pubkey by hand over a timeout.
stringRes(R.string.cordn_coordinators_discover_unheard, result.unreachable.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (draft.selected == null) {
OutlinedTextField(
value = draft.pubKeyInput,
onValueChange = {
draft.pubKeyInput = it
// Typing one in is choosing it. Without this the
// best-coverage effect would replace a hand-entered
// coordinator the moment the roster produced a number.
draft.userPicked = true
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = draft.relaysInput,
onValueChange = {
draft.relaysInput = it
draft.userPicked = true
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) },
// A coordinator has no address beyond its pubkey (§8.5), so
// the relays are how it is reached and more than one is
// ordinary. One per line rather than comma-separated,
// because a relay URL can contain a comma and a newline
// cannot be mistyped into one.
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
}
}
StepHeading(4, stringRes(R.string.cordn_create_step_admins))
// spec/01.md §5.3: leaving the admin list empty is not "set it up
// later", it is choosing draft.egalitarian permanently -- so it is offered
// as a decision here rather than described as one.
//
// With a draft.roster in hand the choice is the real one the web client
// makes: which of the people being added can add and remove others.
// Choosing anybody at all must include yourself, or the group is
// born unadministrable, so "you" is never one of the toggles.
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(stringRes(R.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium)
Text(
text =
if (draft.egalitarian) {
stringRes(R.string.cordn_create_egalitarian_note)
} else if (draft.coAdmins.isEmpty()) {
stringRes(R.string.cordn_create_admin_only_me_on)
} else {
pluralStringRes(LocalContext.current, R.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = draft.egalitarian, onCheckedChange = { draft.egalitarian = it })
}
config?.let {
CordnExposureCard(
GroupExposure(
coordinator = it.pubKey,
// This group, plus whatever this coordinator already
// serves for this account: the disclosure is about what
// one coordinator can correlate, so a second group on
// the same one widens it.
linkedGroupCount =
1 +
runtime.groups.all.value
.count { room -> room.coordinatorPubKey == it.pubKey },
joinedFromShareLink = false,
publishedKeyPackage = false,
encryptionPinned = true,
),
)
}
error?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
}
Button(
onClick = {
val target = config ?: return@Button
busy = true
error = null
scope.launch {
try {
// Only the people this coordinator can reach are
// attempted. An invitation is a Welcome left against
// a KeyPackage the invitee published *here*, so
// asking for one it does not hold is a call that can
// only fail -- and the draft.roster keeps them either way,
// for the link the outcome offers.
// takeIf(answered): an unanswered coordinator reports an
// EMPTY reachable set, so reading it directly would turn
// "we could not ask" into "invite nobody" and create the
// group silently empty. Unknown means attempt everyone
// and let each call say what went wrong.
val reachable = chosenCoverage?.takeIf { it.answered }?.reachable
val invitees = draft.roster.filter { reachable == null || it in reachable }
val result =
runtime.createGroupAndInvite(
config = target,
metadata =
CordnGroupMetadata(
name = draft.name.trim(),
description = draft.description.trim(),
adminPubkeys = draft.adminPubKeys(me),
),
invitees = invitees,
)
// Straight through when there is nothing to report:
// a dialog that only ever says "all five went out"
// is one nobody reads the sixth time.
if (result.failed.isEmpty() && invitees.size == draft.roster.size) {
draft.clear()
nav.nav(Route.CordnGroupChat(target.pubKey, result.gid))
} else {
creation = result
}
} catch (e: Exception) {
error = e.message ?: failureFallback
} finally {
busy = false
}
}
},
enabled = !busy && config != null && draft.name.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (draft.roster.isEmpty()) {
stringRes(R.string.cordn_create_action)
} else {
pluralStringRes(LocalContext.current, R.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size)
},
)
}
}
creation?.let { done ->
CordnCreationOutcome(
creation = done,
roster = draft.roster,
coordinatorPubKey = config?.pubKey,
accountViewModel = accountViewModel,
nav = nav,
// The draft is spent either way: the group exists, so a
// dismissal must not leave this roster behind to seed the next
// new group with the last one's people.
onDismiss = {
creation = null
draft.clear()
},
)
}
}
}
@Composable
private fun CoordinatorChoice(
label: String,
pubKey: HexKey?,
selected: Boolean,
onSelect: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
detail: String? = null,
/** Where it answers. A coordinator has no address beyond its pubkey (§8.5). */
relays: String? = null,
/** Its own sentence about itself, if it published one. */
about: String? = null,
/** Dimmed for a coordinator that stopped announcing long ago. */
dimmed: Boolean = false,
) {
val fade = if (dimmed) 0.6f else 1f
Row(
modifier = Modifier.fillMaxWidth().selectable(selected = selected, onClick = onSelect).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
// Null on the "use a different one" row, which names no coordinator
// yet, so there is no identity to show for it.
if (pubKey != null) {
// Spacing goes outside, not in `pictureModifier`. That one is the
// INNER modifier: it styles the image within a box already fixed at
// `size`, which is why the sibling screen uses it for a border. An
// end padding there took 8.dp off the drawable's width and none off
// its height, so every coordinator's avatar drew as a 16x24 oval.
UserPicture(
userHex = pubKey,
size = 24.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(Modifier.width(8.dp))
}
Column(Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = fade),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
relays?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
about?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
detail?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade),
)
}
}
}
}
/** How many live coordinators a discovery run shows before it asks. */
private const val LIVE_PREVIEW = 8
/** Its announced name, or null when it published none. */
private fun DiscoveredCoordinator.announcedName(): String? = surface.name?.takeIf { it.isNotBlank() }
/**
* What to call an offer: its announced name, else its profile, else a short
* npub -- never a hex prefix. The announcement still wins, because in a
* discovery list the CEP-6 surface is the thing being offered.
*/
@Composable
private fun resolvedName(
offer: DiscoveredCoordinator,
accountViewModel: AccountViewModel,
): String = coordinatorDisplayName(offer.pubKey, offer.announcedName(), accountViewModel)
/**
* How long ago a coordinator last announced, in words that stay words.
*
* `timeAgoNoDot` answers with a span for something recent and an absolute date
* for anything past a month, and the caller used one template for both — so
* every stale coordinator on the discovery list read "Last announced Aug 7
* ago". The date branch gets a template with no "ago" in it.
*/
@Composable
private fun announcedLabel(announcedAt: Long): String =
if (TimeUtils.now() - announcedAt > TimeUtils.ONE_MONTH) {
stringRes(R.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim())
} else {
stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim())
}
/** The hosts it answers on, the first two and a count of the rest. */
@Composable
private fun relayLabel(relays: List<NormalizedRelayUrl>): String? {
if (relays.isEmpty()) return null
val hosts = relays.map { it.url.substringAfter("://").trim('/') }.distinct()
val shown = hosts.take(2).joinToString(", ")
return if (hosts.size <= 2) {
shown
} else {
stringRes(R.string.cordn_coordinators_relays_more, shown, hosts.size - 2)
}
}
/**
* Names are the coordinator's own word for itself and collide constantly — the
* reference server ships as "My coordinator", so a discovery run returns a
* dozen rows with that name and nothing to tell them apart. A pubkey prefix is
* added only to the ones that actually clash, so the common case stays clean.
*/
internal fun disambiguate(
name: String,
pubKey: HexKey,
allNames: List<String>,
): String = if (allNames.count { it == name } > 1) "$name \u00b7 ${pubKey.take(8)}" else name
/**
* The coordinator this screen would create against, or null while the form
* cannot name one.
*
* Null rather than a thrown error: an unfinished form is the normal state of a
* form, and the button is disabled off this rather than the user being told
* they are wrong while they type. `CoordinatorConfig` itself rejects a bad
* pubkey or an empty relay list in its `init`, so the checks here are only
* about not constructing one yet.
*/
private fun resolve(
known: List<CoordinatorConfig>,
selected: String?,
pubKeyInput: String,
relaysInput: String,
): CoordinatorConfig? {
if (selected != null) return known.firstOrNull { it.pubKey == selected }
// npub as well as hex: a coordinator pubkey is shared the same ways any
// other Nostr pubkey is, and refusing the bech32 form would just move the
// conversion to the user.
val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim()) ?: return null
val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
if (relays.isEmpty()) return null
return CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL)
}
/** A numbered step label, so the form reads as an order rather than a pile. */
@Composable
private fun StepHeading(
step: Int,
title: String,
) {
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = step.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(text = title, style = MaterialTheme.typography.titleSmall)
}
}
/**
* Who is in the group, as a row that opens the screen for choosing them.
*
* A count and a few faces rather than the roster itself: the roster is where
* every later step's answer comes from, so it earns its own destination, and
* repeating it here would put two editors of the same list on screen at once.
*/
@Composable
private fun RosterSummary(
roster: List<HexKey>,
coverage: Map<HexKey, CordnCoverage>,
onOpen: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onOpen).padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (roster.isEmpty()) {
Text(
text = stringRes(R.string.cordn_create_people_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
roster.take(FACES).forEach {
UserPicture(userHex = it, size = 28.dp, accountViewModel = accountViewModel, nav = nav)
}
}
Column(Modifier.weight(1f)) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, roster.size + 1, roster.size + 1),
style = MaterialTheme.typography.bodyMedium,
)
// Counted over coordinators that answered, so an unreachable one
// never turns into "nobody can be reached".
val unreachable = roster.count { member -> coverage.values.none { it.answered && member in it.reachable } }
if (unreachable > 0 && coverage.values.any { it.answered }) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_create_unreachable_count, unreachable, unreachable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
Icon(
symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** How many faces the summary row shows before it just counts. */
private const val FACES = 5
/**
* The coordinator that will be used, and why, in one row.
*
* Collapsed by default because with a roster entered there is usually nothing
* left to decide -- one coordinator reaches everybody and the rest do not. The
* full list is one tap away for when that is not true.
*/
@Composable
private fun CoordinatorSummary(
config: CoordinatorConfig?,
coverage: CordnCoverage?,
rosterSize: Int,
expanded: Boolean,
onToggle: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (config == null) {
Text(
text = stringRes(R.string.cordn_create_no_coordinator),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
} else {
CoordinatorIdentityRow(
pubKey = config.pubKey,
label = config.label,
accountViewModel = accountViewModel,
nav = nav,
size = 32.dp,
modifier = Modifier.weight(1f),
) { name ->
Column(Modifier.weight(1f)) {
Text(name, style = MaterialTheme.typography.bodyMedium)
if (rosterSize > 0) {
// Says which of the three it is: reaches everybody,
// leaves somebody out, or was never asked. The third is
// not the second.
val reached = coverage?.reachable?.size
Text(
text =
when {
coverage == null || !coverage.answered -> stringRes(R.string.cordn_create_coverage_unknown)
reached == rosterSize -> stringRes(R.string.cordn_create_coverage_all, rosterSize)
else -> stringRes(R.string.cordn_create_coverage_partial, reached ?: 0, rosterSize)
},
style = MaterialTheme.typography.labelSmall,
color =
if (coverage != null && coverage.answered && reached != rosterSize) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
TextButton(onClick = onToggle) {
Text(
if (expanded) {
stringRes(R.string.cordn_create_coordinator_hide)
} else {
stringRes(R.string.cordn_create_coordinator_change)
},
)
}
}
}
/**
* What happened after Create, when it was not simply "everything".
*
* Shown instead of a thrown error because the group exists either way: a
* failed invitation is one row, not a failed creation. Every unfinished person
* gets the action that would actually work for them -- a retry where the
* coordinator refused, a link where it holds no KeyPackage at all and no retry
* ever could.
*/
@Composable
private fun CordnCreationOutcome(
creation: CordnGroupCreation,
roster: List<HexKey>,
coordinatorPubKey: HexKey?,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
val open: () -> Unit = {
onDismiss()
coordinatorPubKey?.let { nav.nav(Route.CordnGroupChat(it, creation.gid)) }
Unit
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.cordn_create_outcome_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringRes(R.string.cordn_create_outcome_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val attempted = creation.outcomes.associateBy { it.pubKey }
roster.forEach { member ->
val outcome = attempted[member]
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserPicture(userHex = member, size = 26.dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
Text(
text = observeUserNameByHex(member, accountViewModel),
style = MaterialTheme.typography.bodySmall,
)
Text(
text =
when {
outcome == null -> stringRes(R.string.cordn_create_outcome_skipped)
outcome.sent -> stringRes(R.string.cordn_create_outcome_waiting)
outcome.joinedWithoutWelcome -> stringRes(R.string.cordn_create_outcome_no_welcome)
else -> outcome.failure?.message ?: stringRes(R.string.cordn_create_outcome_failed)
},
style = MaterialTheme.typography.labelSmall,
color =
if (outcome?.sent == true) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
)
}
// Nobody was told anything, so the only thing that can
// reach somebody with no KeyPackage here is a link they
// open themselves -- which needs the group to exist,
// and now it does.
if (outcome == null) {
TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_link)) }
}
}
}
}
},
confirmButton = { TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_open)) } },
)
}
@@ -0,0 +1,405 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.viewmodels.CordnGroupDraft
import com.vitorpamplona.amethyst.model.cordn.CordnCoverage
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Who the new group is for.
*
* ## Why this is a screen and not a field on the create form
*
* Choosing people is the step every later one depends on. cordn can only add a
* member by spending a KeyPackage they published **to the coordinator being
* used**, so until the roster exists "which coordinator?" has no answer worth
* giving -- and once it exists the answer is arithmetic. Putting it inline made
* the create form a scroll again, with a search field, a results list and a
* roster competing with the group's own name.
*
* ## Reachability is counted across coordinators, not for the chosen one
*
* A person reachable nowhere cannot be helped by changing the choice on the
* previous screen, and that is a different fact from "not reachable on the one
* currently selected". Counted only over coordinators that actually answered:
* an unreachable server contributes nothing rather than a zero, because a
* network failure must never be rendered as a claim about a person.
*/
@Composable
fun CordnCreateMembersScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
val draft = rememberCordnGroupDraft(accountViewModel)
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.cordn_create_step_people), nav) },
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val me = accountViewModel.account.signer.pubKey
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
var search by remember { mutableStateOf("") }
val coverage by rememberCordnCoverage(runtime, draft.roster)
// The badge on a SEARCH RESULT cannot come from coverage: coverage is
// the roster intersected with what a coordinator holds, so somebody not
// yet added is never in it. This is the unfiltered set, which is what
// the question "could this person be added at all" needs.
val reachable by rememberReachableIdentities(runtime)
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(R.string.cordn_create_people_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = search,
onValueChange = {
search = it
if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset()
},
label = { Text(stringRes(R.string.cordn_create_add_member)) },
placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Three characters, as everywhere else this list appears: fewer
// matches most of the address book and is never what someone meant.
if (search.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user: User ->
search = ""
userSuggestions.reset()
if (user.pubkeyHex != me) draft.add(user.pubkeyHex)
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
onEmpty = {
Text(
text = stringRes(R.string.cordn_info_add_member_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
},
trailingContent = { user ->
// Marks only what the coordinator told us. An unmarked row
// covers both "has not published here" and "we could not
// ask", so it claims nothing either way.
Row(verticalAlignment = Alignment.CenterVertically) {
if (user.pubkeyHex in reachable) {
Icon(
symbol = MaterialSymbols.Key,
contentDescription = stringRes(R.string.cordn_info_has_key_package),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = {
search = ""
userSuggestions.reset()
if (user.pubkeyHex != me) draft.add(user.pubkeyHex)
}) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = stringRes(R.string.cordn_create_add_member),
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
HorizontalDivider()
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1),
style = MaterialTheme.typography.titleSmall,
)
// You are always in it and always able to administer it, so there is
// nothing to toggle or remove on this row -- it is here so the count
// above is not one short of what the group will look like.
CreatorRow(me, draft.egalitarian, accountViewModel, nav)
draft.roster.forEach { member ->
MemberRow(
member = member,
isAdmin = member in draft.coAdmins,
egalitarian = draft.egalitarian,
coverage = coverage,
onToggleAdmin = { draft.toggleAdmin(member) },
onRemove = { draft.remove(member) },
accountViewModel = accountViewModel,
nav = nav,
)
}
if (draft.roster.isEmpty()) {
Text(
text = stringRes(R.string.cordn_create_people_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
onClick = { nav.popBack() },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Text(stringRes(R.string.cordn_create_people_done))
}
}
}
}
/** The creator's own row: in the group, administering it, not editable. */
@Composable
private fun CreatorRow(
me: HexKey,
egalitarian: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(userHex = me, size = 32.dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
Text(observeUserNameByHex(me, accountViewModel), style = MaterialTheme.typography.bodyMedium)
Text(
text = stringRes(R.string.cordn_create_you),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!egalitarian) {
Text(
text = stringRes(R.string.cordn_create_admin),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
/** One person in the roster, with what a coordinator could do about them. */
@Composable
private fun MemberRow(
member: HexKey,
isAdmin: Boolean,
egalitarian: Boolean,
coverage: Map<HexKey, CordnCoverage>,
onToggleAdmin: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val answered = coverage.values.count { it.answered }
val reaching = coverage.values.count { it.answered && member in it.reachable }
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(userHex = member, size = 32.dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
Text(observeUserNameByHex(member, accountViewModel), style = MaterialTheme.typography.bodyMedium)
Text(
text =
when {
answered == 0 -> stringRes(R.string.cordn_create_reach_unknown)
reaching > 0 -> pluralStringRes(LocalContext.current, R.plurals.cordn_create_reach_count, reaching, reaching)
else -> stringRes(R.string.cordn_create_reach_none)
},
style = MaterialTheme.typography.labelSmall,
color =
if (answered > 0 && reaching == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
// Hidden in egalitarian mode: there the admin list is empty by
// definition, so a toggle would offer a choice nothing can record.
if (!egalitarian) {
TextButton(onClick = onToggleAdmin) {
Text(
text = stringRes(R.string.cordn_create_admin),
style = MaterialTheme.typography.labelMedium,
color = if (isAdmin) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.PersonRemove,
contentDescription = stringRes(R.string.cordn_info_remove_member),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The one draft both create screens fill in.
*
* Scoped to the Activity, the way the chess lobby and board share theirs: a
* Navigation destination is disposed when you leave it, so a draft kept in
* `remember` would lose the group's name on the way to picking people. Keyed
* per account so switching accounts starts a new draft rather than inheriting
* a roster built under another key.
*/
@Composable
fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft {
val activity = LocalActivity.current as FragmentActivity
return viewModel(
viewModelStoreOwner = activity,
key = "CordnGroupDraft-${accountViewModel.account.signer.pubKey}",
)
}
/**
* What each coordinator this account uses could do with [roster].
*
* Asked of coordinators there is already a session with and no others: opening
* one is what commits to a coordinator, so a screen that is only looking must
* not open one on the user's behalf. Cheap to call from more than one screen --
* `CordnKeyPackages` reuses its `kp_list` snapshot for a minute, so the second
* screen's copy of this question usually costs nothing.
*/
@Composable
fun rememberCordnCoverage(
runtime: CordnRuntime,
roster: List<HexKey>,
): State<Map<HexKey, CordnCoverage>> {
val known by runtime.coordinators.collectAsStateWithLifecycle()
return produceState<Map<HexKey, CordnCoverage>>(emptyMap(), runtime, known, roster) {
value =
if (roster.isEmpty()) {
emptyMap()
} else {
val target = roster.toSet()
known.associate { it.pubKey to runtime.coverage(it.pubKey, target) }
}
}
}
/**
* Every identity any coordinator this account uses holds a KeyPackage for.
*
* The union, unfiltered, for answering "could this person be added anywhere"
* about somebody who is not in the roster yet -- which
* [rememberCordnCoverage] cannot answer, because it intersects with the roster
* by construction. Empty also covers "nothing answered", so callers must only
* ever use membership in it to mark a yes, never absence to mark a no.
*/
@Composable
fun rememberReachableIdentities(runtime: CordnRuntime): State<Set<HexKey>> {
val known by runtime.coordinators.collectAsStateWithLifecycle()
return produceState<Set<HexKey>>(emptySet(), runtime, known) {
val all = mutableSetOf<HexKey>()
known.forEach { runtime.identitiesWithKeyPackages(it.pubKey)?.let(all::addAll) }
value = all
}
}
@@ -0,0 +1,320 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth
import com.vitorpamplona.amethyst.commons.cordn.ui.CoordinatorHealthRow
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Every cordn group this account is in, grouped by the coordinator serving it.
*
* ## Why it groups rather than lists
*
* A flat list here would be the Messages inbox with everything else removed,
* which is not worth a screen. What the inbox structurally cannot show is the
* thing that decides whether a cordn group works at all: a cordn message never
* arrives over a relay, it arrives because one coordinator answered a call, so
* when a group goes quiet the question is always "is that server up" and never
* "which relay". Sectioning by coordinator puts the answer above the groups it
* explains.
*
* ## The health is free
*
* `CordnRuntime.health` is a local StateFlow of what the calls the sync loop
* was making anyway observed -- it never polls, because a poll is a call and
* every call to a coordinator is metadata (`spec/00.md` §8). So this screen
* costs no network at all, and "unknown" means nothing has been asked of that
* coordinator yet, which is shown differently from "down".
*
* Key-package health is deliberately NOT here even though an empty pool means
* nobody can invite you: the only truthful source is `kp_list`, which is
* unpaginated, and fetching it per coordinator to decorate a list would be the
* cost this branch has otherwise been careful to avoid. It stays one tap away
* on the coordinators screen.
*/
@Composable
fun CordnGroupListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) {
IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = stringRes(R.string.cordn_groups_manage),
modifier = Modifier.size(22.dp),
)
}
IconButton(onClick = { nav.nav(Route.CordnCreateGroup) }) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.cordn_groups_start),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
// `revision` rather than `all`: a room's newest message changes INSIDE
// the room object, which `all` cannot see, so ordering off `all` alone
// would freeze after each group's first message. See
// CordnGroupList.revision.
val revision by runtime.groups.revision.collectAsStateWithLifecycle()
// Coordinators ordered by their liveliest group, groups ordered within
// them the same way: the section that just received something rises,
// which is the order somebody opening this screen is looking for.
val sections =
remember(revision) {
runtime.groups.all.value
.groupBy { it.coordinatorPubKey }
.map { (coordinator, rooms) -> coordinator to rooms.sortedByDescending { it.newestAt() } }
.sortedByDescending { (_, rooms) -> rooms.firstOrNull()?.newestAt() ?: 0L }
}
if (sections.isEmpty()) {
EmptyState(
title = stringRes(R.string.cordn_groups_none),
description = stringRes(R.string.cordn_groups_none_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
state = rememberLazyListState(),
) {
sections.forEach { (coordinator, rooms) ->
item(key = "head-$coordinator") {
CoordinatorSection(
coordinatorPubKey = coordinator,
runtime = runtime,
groupCount = rooms.size,
accountViewModel = accountViewModel,
nav = nav,
)
}
itemsIndexed(rooms, key = { _, room -> coordinator + room.gid }) { index, room ->
CoordinatorGroupRow(
room = room,
coordinatorPubKey = coordinator,
runtime = runtime,
isLast = index == rooms.lastIndex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
/** When this room last heard anything, for ordering. */
private fun CordnGroupChatroom.newestAt(): Long = newest.value?.envelope?.createdAt ?: 0L
/**
* One coordinator, above the groups it serves.
*
* Tinted by its own health rather than decorated uniformly: a coordinator that
* has stopped answering is the reason every group under it looks idle, and
* saying so here is the whole argument for this screen existing.
*/
@Composable
private fun CoordinatorSection(
coordinatorPubKey: HexKey,
runtime: CordnRuntime,
groupCount: Int,
accountViewModel: AccountViewModel,
nav: INav,
) {
val health = healthOf(runtime, coordinatorPubKey)
Column(
Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
CoordinatorIdentityRow(
pubKey = coordinatorPubKey,
label = runtime.sessionOrNull(coordinatorPubKey)?.config?.label,
accountViewModel = accountViewModel,
nav = nav,
size = 26.dp,
) { name ->
Column(Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
color =
if (health.isDown) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_groups_count, groupCount, groupCount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
CoordinatorHealthRow(health, Modifier.padding(start = 34.dp))
// Said once per coordinator rather than once per group: the groups are
// not each broken, the one server they share is.
if (health.isDown) {
Row(
Modifier.fillMaxWidth().padding(start = 34.dp, top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = stringRes(R.string.cordn_groups_down_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/**
* One group row, dimmed while its coordinator is down.
*
* Dimmed and still tappable: the history already on this device reads fine
* offline, and blocking the tap would hide what the user came for. The dimming
* is there so a row that cannot be receiving does not look like one that is.
*/
@Composable
private fun CoordinatorGroupRow(
room: CordnGroupChatroom,
coordinatorPubKey: HexKey,
runtime: CordnRuntime,
isLast: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val health = healthOf(runtime, coordinatorPubKey)
Column(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().alpha(if (health.isDown) DIMMED else 1f)) {
// The inbox's own row, not a second rendering of it: it already
// reads the live name, preview, annotations and unread count off
// the room, and two of those drifting apart would be a bug nobody
// would look for in a list screen.
CordnGroupRoomCompose(room, accountViewModel, nav)
}
// Between rows only. A divider after the last one leaves a rule hanging
// under the section, where the next section's own spacing already
// separates them.
if (!isLast) HorizontalDivider(thickness = DividerThickness)
}
}
/**
* This coordinator's health, or a blank state when there is no session.
*
* Blank rather than absent so the caller can render unconditionally: a
* coordinator with no open session has told us nothing, which is exactly what
* [CoordinatorHealth.State.isUnknown] means.
*
* The null is resolved into a flow BEFORE collecting, never by skipping the
* collect. `collectAsStateWithLifecycle` behind a `?.` is a conditional
* @Composable call, and this is a branch that flips at runtime -- a session
* opening mid-screen would change the shape of the slot table under Compose.
* Keyed on the coordinator list so it re-resolves when a session does open.
*/
@Composable
private fun healthOf(
runtime: CordnRuntime,
coordinatorPubKey: HexKey,
): CoordinatorHealth.State {
val coordinators by runtime.coordinators.collectAsStateWithLifecycle()
val flow =
remember(runtime, coordinatorPubKey, coordinators) {
runtime.health(coordinatorPubKey) ?: MutableStateFlow(CoordinatorHealth.State())
}
return flow.collectAsStateWithLifecycle().value
}
/** How much a group under a coordinator that stopped answering fades. */
private const val DIMMED = 0.55f
@@ -0,0 +1,371 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnInvitation
import com.vitorpamplona.amethyst.model.cordn.CordnInvitations
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/**
* Invitations to cordn groups, waiting to be answered.
*
* ## Why this is a screen you open, not a badge that appears
*
* Finding out whether anyone has invited you means asking each coordinator,
* and every call to a coordinator is metadata (`spec/00.md` §8). A live badge
* would tell each of them how often this account opens the app, forever, to
* save a tap. So the fetch happens when someone comes here, and the screen
* says what it is showing and when it looked.
*
* ## What an invitation can honestly say
*
* The group's name, who is already in it, and which coordinator it came
* through — all read out of the Welcome. Not "X invited you": a Welcome
* carries the ratchet tree, not the identity of whoever signed the Commit
* that produced it, so naming an inviter would be a guess presented as a
* fact.
*
* Declining is permanent, and the screen says so rather than discovering it
* afterwards. A retired Welcome is gone from the coordinator and the
* KeyPackage it was addressed to has been spent; getting back in means being
* invited again.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CordnInvitationsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
val scope = rememberCoroutineScope()
var invitations by remember { mutableStateOf<CordnInvitations?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val loadFailed = stringRes(R.string.cordn_invitations_load_failed)
suspend fun reload() {
busy = true
error = null
try {
invitations = runtime?.invitations()
} catch (e: Exception) {
error = e.message ?: loadFailed
} finally {
busy = false
}
}
LaunchedEffect(runtime) { reload() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back))
}
},
title = { Text(stringRes(R.string.cordn_invitations_title)) },
actions = {
IconButton(onClick = { scope.launch { reload() } }, enabled = !busy) {
Icon(MaterialSymbols.Refresh, contentDescription = stringRes(R.string.cordn_invitations_refresh))
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
)
return@Column
}
Text(
text = stringRes(R.string.cordn_invitations_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (busy) CircularProgressIndicator()
error?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
}
val loaded = invitations
if (loaded != null && !busy) {
loaded.pending.forEach { invitation ->
InvitationCard(
invitation = invitation,
accountViewModel = accountViewModel,
nav = nav,
onAccept = {
scope.launch {
try {
val gid = runtime.accept(invitation)
nav.nav(Route.CordnGroupChat(invitation.coordinator.pubKey, gid))
} catch (e: Exception) {
error = e.message ?: loadFailed
reload()
}
}
},
onDecline = {
scope.launch {
try {
runtime.decline(invitation)
} catch (e: Exception) {
error = e.message ?: loadFailed
}
reload()
}
},
)
}
// A coordinator that did not answer is its own row. Folding it
// into "nothing waiting" would tell someone they have no
// invitations when the truth is that nobody asked.
loaded.unreachable.forEach {
NoticeCard(
title =
stringRes(
R.string.cordn_invitations_unreachable,
coordinatorDisplayName(it.coordinator.pubKey, it.coordinator.label, accountViewModel),
),
detail = it.reason,
isError = true,
)
}
// Left on the coordinator for another device of this account.
// Shown rather than hidden, because otherwise the invitation a
// friend swears they sent simply does not exist here.
loaded.skipped.forEach {
NoticeCard(
title = stringRes(R.string.cordn_invitations_skipped),
detail = it.reason,
isError = false,
)
}
if (loaded.isEmpty) {
Text(
text =
if (runtime.coordinators.value.isEmpty()) {
stringRes(R.string.cordn_invitations_no_coordinators)
} else {
stringRes(R.string.cordn_invitations_none)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** Faces on the invitation card before it folds into "+N". */
private const val MEMBER_FACES = 5
/** How many of them are also named in full underneath. */
private const val MEMBER_NAMES = 3
@Composable
private fun InvitationCard(
invitation: CordnInvitation,
accountViewModel: AccountViewModel,
nav: INav,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
val welcome = invitation.welcome
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = welcome.metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, welcome.gid.take(8)),
style = MaterialTheme.typography.titleMedium,
)
welcome.metadata?.description?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
// "Already in it", never "invited you" — see the screen KDoc.
Text(
text = pluralStringResource(R.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Who, not just how many. This is the moment someone decides
// whether to join an encrypted group, and the roster is already in
// the Welcome — printing only its size withheld the one fact the
// decision actually turns on.
if (welcome.members.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
welcome.members.take(MEMBER_FACES).forEach { member ->
UserPicture(
userHex = member,
size = 28.dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (welcome.members.size > MEMBER_FACES) {
Text(
text = stringRes(R.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Resolved through `map`, which is inline and so keeps the
// composable context; joinToString's transform is not.
val names = welcome.members.take(MEMBER_NAMES).map { observeUserNameByHex(it, accountViewModel) }
Text(
// Named in full for the first few, so a decision does not
// rest on recognising an avatar.
text = names.joinToString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Accepting is a decision about the coordinator as much as about
// the group -- it is the party that will hold the membership and
// serve the messages -- so it is named and pictured rather than
// abbreviated to a key.
CoordinatorIdentityRow(
pubKey = invitation.coordinator.pubKey,
label = invitation.coordinator.label,
accountViewModel = accountViewModel,
nav = nav,
size = 20.dp,
) { name ->
Text(
text = stringRes(R.string.cordn_invitations_via, name),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = onAccept) { Text(stringRes(R.string.cordn_invitations_accept)) }
TextButton(onClick = onDecline) { Text(stringRes(R.string.cordn_invitations_decline)) }
}
Text(
text = stringRes(R.string.cordn_invitations_decline_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun NoticeCard(
title: String,
detail: String,
isError: Boolean,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isError) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(detail, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
/**
* Keeps a reverse-laid-out chat sitting on its newest message.
*
* [newest] is whatever identifies the bottom-most row — a note, a message id,
* anything whose equality changes when a message arrives. In a `reverseLayout`
* list the newest row is index 0, and a list anchors itself to the row that was
* already first visible, so without this a sent message lands just below the
* viewport and the sender never sees it.
*
* Someone else's message only pulls the view down when the reader is already at
* the bottom. A reader who scrolled up into history is deliberately left there:
* yanking them away because somebody else typed is worse than a missed arrival,
* which the unread divider covers anyway. Index 1 counts as "at the bottom"
* because a row that just arrived has already pushed the previous newest up one.
*
* [mine] lifts that guard for the reader's own message, and is the whole reason
* this takes a flag. Sending is an explicit act with an obvious expectation —
* every chat app puts you on what you just sent — so someone who scrolls up,
* types and sends must land on it rather than be left reading history with the
* message they just wrote somewhere off-screen below.
*/
@Composable
internal fun AutoScrollToNewest(
listState: LazyListState,
newest: Any?,
mine: Boolean = false,
) {
LaunchedEffect(newest) {
if (mine || listState.firstVisibleItemIndex <= 1) {
listState.animateScrollToItem(0)
}
}
}
@@ -206,11 +206,8 @@ fun ChatFeedLoaded(
// reorders no longer re-fire paging. The per-gap markers below are pure UI.
sentinels?.invoke(items.list, listState)
LaunchedEffect(items.list.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1) {
listState.animateScrollToItem(0)
}
}
val newest = items.list.firstOrNull()
AutoScrollToNewest(listState, newest, mine = accountViewModel.isLoggedUser(newest?.author?.pubkeyHex))
val scope = rememberCoroutineScope()
val highlightedNoteId = remember { mutableStateOf<String?>(null) }
@@ -425,7 +425,7 @@ private fun RelayGroupPinTile(
* zap, or a reply) and reveals everything else on demand.
*/
@Composable
private fun MoreActionsToggle(
internal fun MoreActionsToggle(
expanded: Boolean,
onToggle: () -> Unit,
) {
@@ -487,7 +487,7 @@ private fun ChatOnlyRow(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TileRow(content: @Composable () -> Unit) {
internal fun TileRow(content: @Composable () -> Unit) {
FlowRow(
modifier =
Modifier
@@ -501,7 +501,7 @@ private fun TileRow(content: @Composable () -> Unit) {
}
@Composable
private fun ActionTile(
internal fun ActionTile(
symbol: MaterialSymbol,
label: String,
isDestructive: Boolean = false,
@@ -540,7 +540,7 @@ private fun ActionTile(
private const val SECTION_DIVIDER_ALPHA = 0.5f
@Composable
private fun SectionDivider() {
internal fun SectionDivider() {
HorizontalDivider(
thickness = DividerThickness,
color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA),
@@ -83,7 +83,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
private data class ReactionChip(
internal data class ReactionChip(
val type: String,
val count: Int,
val includesMe: Boolean,
@@ -212,7 +212,25 @@ fun ChatReactionChips(
}
}
/**
* The strip of engagement chips that rides a chat bubble's bottom border. Shared so
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from
* a [Note]'s reactions and zaps, cordn fills it from its own annotation fold.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ChatChipFlowRow(content: @Composable () -> Unit) {
FlowRow(
// Inset from the bubble's edge so overlapping chips ride the border without
// poking past the bubble's rounded corners.
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
@Composable
private fun RenderChatReactionChips(
chips: ImmutableList<ReactionChip>,
@@ -225,13 +243,7 @@ private fun RenderChatReactionChips(
) {
if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0 && !isZapping) return
FlowRow(
// Inset from the bubble's edge so overlapping chips ride the border without
// poking past the bubble's rounded corners.
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
ChatChipFlowRow {
if (zapAmount.isNotBlank()) {
ZapChip(zapAmount, onClick = onOpenDetails)
} else if (isZapping) {
@@ -290,7 +302,7 @@ private fun MinichatChip(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ReactionChipView(
internal fun ReactionChipView(
chip: ReactionChip,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
import com.vitorpamplona.amethyst.commons.util.codePointCharCount
@@ -137,3 +139,19 @@ private fun isEmojiBase(cp: Int): Boolean =
cp in 0x3297..0x3299 || // circled ideographs
cp == 0x3030 || // wavy dash
cp == 0x303D // part alternation mark
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
/**
* How large to draw an emoji-only message of [jumboCount] emoji. Shared by every chat
* surface that renders one, so a jumbo message is the same size wherever it appears.
*/
fun jumboEmojiFontSize(jumboCount: Int): TextUnit =
when (jumboCount) {
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
}
@@ -88,7 +88,7 @@ fun chatBubbleShapeFor(
}
/** Messages more than this far apart never group, even from the same author. */
private const val GROUP_WINDOW_SECONDS = 10 * 60L
internal const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L
/**
* Event kinds that don't render as regular bubbles (zaps, raids, clips) or that
@@ -120,7 +120,7 @@ private fun groupsWith(
val olderAuthor = older.author?.pubkeyHex ?: return false
if (newerAuthor != olderAuthor) return false
if (abs(newerEvent.createdAt - olderEvent.createdAt) > GROUP_WINDOW_SECONDS) return false
if (abs(newerEvent.createdAt - olderEvent.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false
// A subject header renders as a divisor above the newer message.
if (newerEvent.subject() != null) return false
@@ -39,8 +39,10 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip31Alts.alt
@@ -64,6 +66,25 @@ fun RenderEncryptedFile(
if (algo == AESGCM.NAME && key != null && nonce != null) {
Amethyst.instance.keyCache.add(noteEvent.content, AESGCM(key, nonce), mimeType)
// Audio is neither an image nor a video, and the image-or-video split
// below would hand it to the video player: a picture-less surface where
// the voice UI belongs. Checked before that split rather than inside it,
// since the whole ZoomableContentView pipeline is about visual media.
if (RichTextParser.isAudioContent(mimeType, noteEvent.content)) {
val waveform = remember(noteEvent) { noteEvent.waveform()?.let { WaveformData(it) } }
RenderAudioWithWaveform(
mediaUrl = noteEvent.content,
title = noteEvent.alt(),
mimeType = mimeType,
waveform = waveform,
note = note,
accountViewModel = accountViewModel,
nav = nav,
)
return
}
val content by remember(noteEvent) {
val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content)
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
@@ -130,6 +131,24 @@ fun RenderEncryptedMediaV2(
Amethyst.instance.keyCache.add(url, cipher, reference.mediaType)
val description = event.alt()
// Audio would otherwise fall to the video branch below and play on a
// picture-less video surface. MIP-04 carries no waveform, so the player
// shows its transport without bars — still the voice UI rather than a
// video one.
if (RichTextParser.isAudioContent(reference.mediaType, url)) {
RenderAudioWithWaveform(
mediaUrl = url,
title = description,
mimeType = reference.mediaType,
waveform = null,
note = note,
accountViewModel = accountViewModel,
nav = nav,
)
return
}
val dim = reference.dim?.let { DimensionTag.parse(it) }
val content by remember(reference) {
mutableStateOf<BaseMediaContent>(
@@ -236,6 +255,21 @@ private fun RenderMip04Content(
Amethyst.instance.keyCache.add(meta.url, cipher, meta.mimeType)
val description = note.event?.alt()
// Same reason as the reference path above.
if (RichTextParser.isAudioContent(meta.mimeType, meta.url)) {
RenderAudioWithWaveform(
mediaUrl = meta.url,
title = description,
mimeType = meta.mimeType,
waveform = null,
note = note,
accountViewModel = accountViewModel,
nav = nav,
)
return
}
val isImage = meta.mimeType.startsWith("image/") || RichTextParser.isImageUrl(meta.url)
val dim = meta.dimensions?.let { DimensionTag.parse(it) }
@@ -26,7 +26,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
@@ -37,14 +36,12 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta
import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls
import com.vitorpamplona.amethyst.ui.note.types.getAudioMetaWithWaveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize
@Composable
fun RenderRegularTextNote(
@@ -63,17 +60,23 @@ fun RenderRegularTextNote(
) {
val jumboCount = remember(eventContent) { jumboEmojiCount(eventContent) }
if (jumboCount > 0) {
// A voice message is an audio URL whose imeta carries the waveform.
// The main feed's note renderer short-circuits those to the waveform
// player; without the same check here the generic media pipeline
// takes them and a recorded message arrives as a player surface
// instead of the waveform it was previewed as. Matched against the
// DECRYPTED content rather than `isAudioOnlyContent()`, because in a
// chat the note's own `content` may still be the sealed payload.
val audioMeta = remember(note.event) { note.event?.getAudioMetaWithWaveform() }
if (audioMeta != null && eventContent.trim() == audioMeta.url) {
RenderAudioFromIMeta(note, accountViewModel, nav)
} else if (jumboCount > 0) {
// Emoji-only messages render as jumbo emoji (the bubble behind
// them is transparent — see NormalChatNote).
Text(
text = eventContent.trim(),
fontSize =
when (jumboCount) {
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
},
fontSize = jumboEmojiFontSize(jumboCount),
)
} else {
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
@@ -78,6 +80,14 @@ import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting
import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_server_label
import com.vitorpamplona.amethyst.commons.resources.cordn_group_no_messages_yet
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.resources.cordn_group_via_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_preview_deleted
import com.vitorpamplona.amethyst.commons.resources.cordn_preview_file
import com.vitorpamplona.amethyst.commons.resources.cordn_preview_photo
import com.vitorpamplona.amethyst.commons.resources.cordn_preview_video
import com.vitorpamplona.amethyst.commons.resources.cordn_preview_voice_note
import com.vitorpamplona.amethyst.commons.resources.could_not_decrypt_the_message
import com.vitorpamplona.amethyst.commons.resources.ephemeral_relay_chat
import com.vitorpamplona.amethyst.commons.resources.geohash_chat
@@ -142,9 +152,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -155,6 +168,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.jetbrains.compose.resources.StringResource
@@ -168,12 +182,21 @@ fun ChatroomHeaderCompose(
// joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel
// as a gatherer). Render these directly instead of waiting for an event that never arrives, which
// would blank the row.
//
// A cordn room has no event *ever*, not just before its first message: its
// messages are MLS envelopes that never become Notes (§3.3 of
// amethyst/plans/2026-09-19-cordn-ui.md), so the row is only ever a carrier
// for the room in `inGatherers`. Leaving it out of this list sent every
// cordn room down the branch that waits for an event and drew `BlankNote()`
// instead — the room was in the feed, correctly, and simply had no pixels.
val rendersWithoutEvent =
baseNote is RelayGroupServerRoomNote ||
baseNote is ConcordServerRoomNote ||
(
baseNote.event == null &&
baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true
baseNote.inGatherers?.any {
it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel || it is CordnGroupChatroom
} == true
)
if (baseNote.event != null || rendersWithoutEvent) {
@@ -228,6 +251,12 @@ private fun ChatroomEntry(
return
}
val cordnGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? CordnGroupChatroom }
if (cordnGroup != null) {
CordnGroupRoomCompose(cordnGroup, accountViewModel, nav)
return
}
val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
if (relayGroup != null) {
RelayGroupRoomCompose(lastMessage, relayGroup, accountViewModel, nav)
@@ -527,6 +556,110 @@ private fun MarmotGroupRoomCompose(
)
}
/**
* What to call a message whose whole content is an attachment.
*
* The first attachment decides, which is what the eye does too: a row is one
* line and a message with a photo and a file in it is, to a reader skimming the
* inbox, a message with a photo in it.
*/
private fun attachmentLabelFor(tags: TagArray): StringResource {
val first = CordnMediaTag.parseAll(tags).firstOrNull() ?: return Res.string.cordn_preview_file
return when {
first.isAudio -> Res.string.cordn_preview_voice_note
first.isImage -> Res.string.cordn_preview_photo
first.mimeType.startsWith("video/") -> Res.string.cordn_preview_video
else -> Res.string.cordn_preview_file
}
}
/**
* One cordn room in the Messages list.
*
* Takes the room rather than the Note, unlike every other row here. The Note is
* only a carrier: cordn messages are MLS envelopes that never reach LocalCache,
* so there is no `lastMessage.event` to read a preview from and the live data
* is on the room. Reading the Note would render an empty row forever.
*/
@Composable
internal fun CordnGroupRoomCompose(
chatroom: CordnGroupChatroom,
accountViewModel: AccountViewModel,
nav: INav,
) {
val name by chatroom.name.collectAsStateWithLifecycle()
val newest by chatroom.newest.collectAsStateWithLifecycle()
val annotations by chatroom.annotations.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val groupName = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, chatroom.gid.take(8))
// Who carries this conversation. "cordn" was the same word on every cordn
// row and told a reader nothing they could act on; the coordinator is the
// one server that sees every message in the group, so naming it is the
// thing worth the space.
val coordinators by (
accountViewModel.account.cordnRuntime?.coordinators
?: remember { MutableStateFlow(emptyList<CoordinatorConfig>()) }
).collectAsStateWithLifecycle()
val coordinatorName =
coordinatorDisplayName(
pubKey = chatroom.coordinatorPubKey,
label = coordinators.firstOrNull { it.pubKey == chatroom.coordinatorPubKey }?.label,
accountViewModel = accountViewModel,
)
val lastContent =
newest?.let { message ->
val authorName by observeUserName(LocalCache.getOrCreateUser(message.envelope.pubKey), accountViewModel)
// The edited text when there is one: showing the original in the
// inbox while the room shows the edit is the kind of mismatch that
// reads as a sync bug.
val text = annotations.contentOf(message.envelope.id) ?: message.envelope.content
// A voice note or a photo carries no text, so the row used to read
// "Someone: " and trail off — an attachment looked like an empty
// message. Name what was sent instead, the way the bubble does.
val preview =
text.takeIf { it.isNotBlank() }
?: if (annotations.isDeleted(message.envelope.id)) {
stringRes(Res.string.cordn_preview_deleted)
} else {
stringRes(attachmentLabelFor(message.envelope.tags))
}
"$authorName: ${preview.take(200)}"
} ?: stringRes(Res.string.cordn_group_no_messages_yet)
ChannelName(
channelIdHex = chatroom.gid,
channelPicture = null,
channelTitle = { modifier ->
ChannelTitleWithLabelInfo(
channelName = groupName,
labelIcon = MaterialSymbols.Dns,
labelText = coordinatorName,
modifier = modifier,
// The pill no longer says "cordn", so the kind of room has to
// reach a screen reader some other way.
labelContentDescription = stringRes(Res.string.cordn_group_via_coordinator, coordinatorName),
)
},
channelLastTime = newest?.envelope?.createdAt,
channelLastContent = lastContent,
// Counted against the read position the room persists, on the
// coordinator's cursor rather than the sender's clock -- see
// CordnGroupChatroom.unreadCount for why a clock cannot be trusted
// with this. Annotations and this account's own echoes do not count.
hasNewMessages = unread > 0,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) },
)
}
@Composable
private fun RelayGroupRoomCompose(
lastMessage: Note,
@@ -944,6 +1077,23 @@ private fun ChannelTitleWithLabelInfo(
label: StringResource,
modifier: Modifier,
labelContentDescription: String? = null,
) = ChannelTitleWithLabelInfo(channelName, labelIcon, stringRes(id = label), modifier, labelContentDescription)
/**
* As above, for a pill whose text is a name rather than a fixed word.
*
* A cordn room's pill carries its coordinator, which is a value and not a
* string resource: the coordinator is the one server that carries every message
* in that group, so which one it is tells a reader more than being told twice
* that this is a cordn chat.
*/
@Composable
private fun ChannelTitleWithLabelInfo(
channelName: String,
labelIcon: MaterialSymbol,
labelText: String,
modifier: Modifier,
labelContentDescription: String? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
@@ -957,7 +1107,7 @@ private fun ChannelTitleWithLabelInfo(
Spacer(Modifier.width(6.dp))
HeaderPill(
symbol = labelIcon,
text = stringRes(id = label),
text = labelText,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
contentDescription = labelContentDescription,
)
@@ -142,6 +142,26 @@ class ChatroomListKnownFeedFilter(
}
}
// cordn groups. Every one this account holds is a room it is IN — a
// cordn group is joined by accepting a Welcome, so there is no
// "known vs new" split to make here the way there is for a DM from a
// stranger. (The pending side is the Welcome inbox, Stage C.)
//
// One row per room, carried by the room's own Note: cordn messages are
// MLS envelopes and never enter LocalCache, so the row reads its name
// and preview from the room through Note.inGatherers.
val cordnGroups =
if (!isEnabled(ChatFeedType.CORDN)) {
emptyList()
} else {
account.cordnRuntime
?.groups
?.all
?.value
?.map { it.inboxRow() }
.orEmpty()
}
// NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its
// own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse
// to a single relay row positioned by that relay's newest message. Both interleave with the
@@ -210,7 +230,12 @@ class ChatroomListKnownFeedFilter(
}
}
return sort((privateMessages + publicChannels + ephemeralChats + geohashChannels + marmotGroups + relayGroups + concordChannels).toSet())
return sort(
(
privateMessages + publicChannels + ephemeralChats + geohashChannels +
marmotGroups + cordnGroups + relayGroups + concordChannels
).toSet(),
)
}
override fun updateListWith(
@@ -99,6 +99,14 @@ fun ChatFileUploadDialog(
accountViewModel: AccountViewModel,
nav: INav,
isNip17: Boolean = false,
/**
* Whether the sensitive-content switch is offered.
*
* False for a surface with nowhere to put the answer: cordn's blob host is told a
* fixed set of constants on purpose (see CordnBlobUpload), and its `imeta` tag has
* no field for a warning, so the switch would be a control that does nothing.
*/
showContentWarning: Boolean = true,
) {
val scrollState = rememberScrollState()
@@ -150,7 +158,7 @@ fun ChatFileUploadDialog(
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel, isNip17)
ImageVideoPostChat(state, accountViewModel, isNip17, showContentWarning)
}
}
}
@@ -163,6 +171,7 @@ private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
isNip17: Boolean = false,
showContentWarning: Boolean = true,
) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState()
@@ -201,13 +210,15 @@ private fun ImageVideoPostChat(
),
)
SettingSwitchItem(
title = Res.string.add_sensitive_content_label,
description = Res.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
if (showContentWarning) {
SettingSwitchItem(
title = Res.string.add_sensitive_content_label,
description = Res.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
}
if (isNip17) {
SettingSwitchItem(
@@ -81,7 +81,12 @@ class ChatFileUploadState(
multiOrchestrator?.remove(selected)
}
fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null
/**
* Deleting the last picked item leaves an *empty* orchestrator, not a null one, so
* a non-null check alone kept Send live with nothing to send. Callers that loop
* over the items then post nothing; one that indexes item 0 crashes.
*/
fun canPost(): Boolean = !mediaUploadTracker.isUploading && (multiOrchestrator?.size() ?: 0) > 0
fun hasPickedMedia() = multiOrchestrator != null
@@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.napplet.ui.PolicyCard
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_desc
import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_title
import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_desc
import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_title
import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_desc
import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_title
import com.vitorpamplona.amethyst.commons.resources.chat_type_geohash_desc
@@ -124,6 +126,7 @@ private val CHAT_FEED_TYPES =
ChatFeedTypeUi(ChatFeedType.NIP29, Res.string.chat_type_nip29_title, Res.string.chat_type_nip29_desc, Color(0xFF9E77ED)),
ChatFeedTypeUi(ChatFeedType.MARMOT, Res.string.chat_type_marmot_title, Res.string.chat_type_marmot_desc, Color(0xFF5B6AD0)),
ChatFeedTypeUi(ChatFeedType.CONCORD, Res.string.chat_type_concord_title, Res.string.chat_type_concord_desc, Color(0xFFEC4899)),
ChatFeedTypeUi(ChatFeedType.CORDN, Res.string.chat_type_cordn_title, Res.string.chat_type_cordn_desc, Color(0xFF14B8A6)),
ChatFeedTypeUi(ChatFeedType.GEOHASH, Res.string.chat_type_geohash_title, Res.string.chat_type_geohash_desc, Color(0xFFEF4444)),
ChatFeedTypeUi(ChatFeedType.EPHEMERAL, Res.string.chat_type_ephemeral_title, Res.string.chat_type_ephemeral_desc, Color(0xFF06B6D4)),
)
@@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.commons.resources.call_settings
import com.vitorpamplona.amethyst.commons.resources.call_settings_search_keywords
import com.vitorpamplona.amethyst.commons.resources.compose_search_keywords
import com.vitorpamplona.amethyst.commons.resources.compose_settings
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_search_keywords
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title
import com.vitorpamplona.amethyst.commons.resources.danger_zone
import com.vitorpamplona.amethyst.commons.resources.drawer_search_keywords
import com.vitorpamplona.amethyst.commons.resources.drawer_settings
@@ -162,6 +164,14 @@ fun buildSettingsCatalog(
symEntry(Res.string.napplet_permissions_title, MaterialSymbols.Apps, Res.string.napplet_connected_apps_search_keywords, Route.ConnectedApps),
symEntry(Res.string.relay_auth_settings_title, MaterialSymbols.Lock, Res.string.relay_auth_search_keywords, Route.RelayAuthSettings),
symEntry(Res.string.call_settings, MaterialSymbols.Phone, Res.string.call_settings_search_keywords, Route.CallSettings),
// One entry, not five. cordn's screens are coordinators,
// key packages, link inspection, backup and migration —
// each a page a user visits rarely and only because they
// are already thinking about cordn. Five flat rows made it
// the largest feature in this list by count and the least
// used by far; the hub keeps them all reachable and
// searchable under one name.
symEntry(Res.string.cordn_hub_title, MaterialSymbols.Dns, Res.string.cordn_hub_search_keywords, Route.CordnHub),
),
)
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A button label that shows work in progress.
*
* Every cordn action screen tracked a `busy` flag and spent it only on
* `enabled = !busy`, so the whole signal that anything was happening was a
* button going grey. Backup and restore derive a key with scrypt — slow by
* design — and migration publishes to relays, so "grey for a few seconds"
* was indistinguishable from "did not register the tap".
*/
@Composable
fun BusyLabel(
busy: Boolean,
label: String,
) {
if (busy) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
Spacer(Modifier.width(8.dp))
}
Text(label)
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* What to call a coordinator, and the face to put next to it.
*
* A coordinator is a Nostr identity -- ContextVM addresses it with ordinary
* p-tags, and CEP-23 says outright that a server MAY publish NIP-01 profile
* metadata (`CvmKinds.PROFILE_METADATA` is kind 0 for exactly this reason). So
* the app can name one the same way it names anybody else, and showing 64 hex
* characters instead was throwing that away.
*
* ## The order, and why
*
* 1. `CoordinatorConfig.label` -- documented as "what the user calls it. Never
* a claim -- a coordinator cannot prove a name." The only name here that
* means anything, so it wins.
* 2. The CEP-6 announcement's name, when the cache holds one. Before the
* profile because a coordinator added from discovery was *picked* by this
* name, and having the settings screen rename it afterwards would be its own
* small confusion.
* 3. The kind 0's display name (CEP-23).
* 4. The key's first characters.
*
* Both 2 and 3 are the coordinator's own word for itself, and two servers may
* publish the same one, which is why neither outranks a label.
*
* The profile is observed whatever the outcome, so the avatar still loads for a
* coordinator that is named by 1 or 2.
*/
@Composable
fun coordinatorDisplayName(
pubKey: HexKey,
label: String?,
accountViewModel: AccountViewModel,
): String {
// Both unconditional: each one registers the subscription that fetches what
// it reads, so neither may come and go with whether an earlier source in the
// chain happens to have an answer.
val fromProfile = observeUserNameByHex(pubKey, accountViewModel)
val announced = observeAnnouncedServerName(pubKey, accountViewModel)
return label?.takeIf { it.isNotBlank() }
?: announced
?: fromProfile
}
/**
* The name from the coordinator's CEP-6 announcement, as the cache holds it.
*
* A [CvmServerAnnouncementEvent] is a replaceable event like any other now, so
* reading it here gets the newest one per coordinator, already verified, and
* fetched by the same event-finder data source the rest of the app uses --
* rather than a hand-rolled fetch, a hand-rolled newest-wins and a second copy
* of the name kept beside the cache.
*/
@Composable
fun observeAnnouncedServerName(
pubKey: HexKey,
accountViewModel: AccountViewModel,
): String? {
val note =
remember(pubKey) {
LocalCache.getOrCreateAddressableNote(Address(CvmServerAnnouncementEvent.KIND, pubKey, ""))
}
val announcement by observeNoteEvent<CvmServerAnnouncementEvent>(note, accountViewModel)
return announcement?.serverName()
}
/**
* A coordinator rendered as the user it is: avatar, name, and whatever acts on
* it.
*
* The avatar navigates to the profile, because [UserPicture] already does and
* a coordinator's profile is as worth reading as anyone's -- more, given it is
* the party whose metadata exposure the group info screen is about.
*
* [name] is a `RowScope` slot so a caller can weight it, which both of them
* need; before it was, they weighted it anyway and compiled only because
* `ColumnScope.weight` happens to produce the same element. [trailing] is its
* own slot for the same reason: an action smuggled through a slot called `name`
* is a lie about where it lands.
*/
@Composable
fun CoordinatorIdentityRow(
pubKey: HexKey,
label: String?,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
size: Dp = 28.dp,
trailing: @Composable RowScope.() -> Unit = {},
name: @Composable RowScope.(String) -> Unit,
) {
Row(
modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserPicture(userHex = pubKey, size = size, accountViewModel = accountViewModel, nav = nav)
name(coordinatorDisplayName(pubKey, label, accountViewModel))
trailing()
}
}
@@ -0,0 +1,313 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_export
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_passphrase
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_restore
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Exporting and restoring cordn state.
*
* ## Why cordn has this when the rest of Amethyst does not
*
* Amethyst's "backup" is a key backup: your nsec, from which a relay-backed
* account rebuilds itself. A cordn group does not work that way. It is MLS
* state on **one device** plus an ordered stream on a coordinator that cannot
* read a byte of it, so the key alone restores nothing. Lose the device and
* the group is gone — not "until someone re-invites you", because a fresh
* invitation begins at a new epoch and everything before it stays unreadable
* forever.
*
* ## Restoring replaces a device, and the screen says so before the tap
*
* An MLS state export is a cloneable identity. Two devices holding one
* group's state and both committing fork the ratchet tree, and MLS does not
* recover: after the fork, messages silently fail to decrypt for somebody,
* with nothing on screen to explain it. So restoring wipes this device's
* cordn state and takes the file's — it is not a merge and it is not sync,
* and presenting it as either would be selling multi-device support that
* §5.3 deliberately does not exist.
*
* ## The file is as sensitive as the conversations
*
* It carries ratchet trees and KeyPackage private halves. The passphrase is
* the only thing protecting it once it leaves the app, which is why there is
* no "export without a passphrase" and why the warning names what is inside.
*/
@Composable
fun CordnBackupScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
val context = LocalContext.current
val scope = rememberCoroutineScope()
var passphrase by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var pendingRestore by remember { mutableStateOf<Uri?>(null) }
val exported = stringRes(R.string.cordn_backup_exported)
val restored = stringRes(R.string.cordn_backup_restored)
val failed = stringRes(R.string.cordn_backup_failed)
val saver =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
val target = uri ?: return@rememberLauncherForActivityResult
val runtimeNow = runtime ?: return@rememberLauncherForActivityResult
scope.launch {
busy = true
error = null
status = null
try {
val bytes = runtimeNow.exportArchive(passphrase)
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(target)?.use { it.write(bytes) }
}
status = exported
passphrase = ""
} catch (e: Exception) {
error = e.message ?: failed
} finally {
busy = false
}
}
}
// OpenDocument, not GetContent. `GetContent("*/*")` sends ACTION_GET_CONTENT,
// which on this Android version is intercepted by the system photo picker's
// shim (`PhotopickerGetContentActivity`) before it hands off to DocumentsUI —
// and a file chosen through that handoff came back as a cancelled result, so
// Restore opened a picker, took a tap, and quietly did nothing. An archive is
// not media; ACTION_OPEN_DOCUMENT is the right intent for it and reaches SAF
// directly.
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
pendingRestore = uri
}
val snackbarHostState = remember { SnackbarHostState() }
// Outcomes go to a snackbar rather than two Text lines at the bottom of a
// scrolling form. They were easy to scroll past, they never left once
// shown, and an export that had just failed sat under a button that looked
// ready to try again.
LaunchedEffect(status) {
status?.let {
snackbarHostState.showSnackbar(it)
status = null
}
}
LaunchedEffect(error) {
error?.let {
snackbarHostState.showSnackbar(it)
error = null
}
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_backup_title), nav) },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
if (runtime == null) {
// The app's own empty state, centred and titled, rather than a
// sentence stranded in the top-left corner.
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringRes(R.string.cordn_backup_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The passphrase is its own section because it governs both of the
// two below: the same word exports and restores, and a field
// floating above two unrelated-looking cards did not say so.
SettingsSection(Res.string.cordn_backup_section_passphrase) {
SettingsFormBlock {
OutlinedTextField(
value = passphrase,
onValueChange = {
passphrase = it
error = null
},
label = { Text(stringRes(R.string.cordn_backup_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
// The dots are only half of it. Without the password
// keyboard type the IME treats this as ordinary prose:
// it offers the passphrase in the suggestion strip, in
// the clear, and learns it into the personalised
// dictionary, where it outlives the app. Every other
// secret field in Amethyst already sets this; this one
// was the only one that did not.
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
SettingsSection(Res.string.cordn_backup_section_export) {
SettingsFormBlock {
Text(stringRes(R.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(R.string.cordn_backup_contents_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { saver.launch("cordn-backup.bin") },
// No passphrase, no export. The file carries ratchet
// trees and private key material; there is no version
// of it that is safe to write unprotected.
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
// Key derivation here is scrypt, which is slow on
// purpose, so a greyed-out button was the only sign
// anything was happening for several seconds.
BusyLabel(busy, stringRes(R.string.cordn_backup_export))
}
}
}
SettingsSection(Res.string.cordn_backup_section_restore) {
SettingsFormBlock {
Text(
text = stringRes(R.string.cordn_backup_restore_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { picker.launch(arrayOf("*/*")) },
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
BusyLabel(busy, stringRes(R.string.cordn_backup_restore))
}
}
}
}
}
pendingRestore?.let { uri ->
AlertDialog(
onDismissRequest = { pendingRestore = null },
title = { Text(stringRes(R.string.cordn_backup_restore_confirm_title)) },
text = { Text(stringRes(R.string.cordn_backup_restore_confirm_body)) },
confirmButton = {
TextButton(onClick = {
pendingRestore = null
val runtimeNow = runtime ?: return@TextButton
scope.launch {
busy = true
error = null
status = null
try {
val bytes = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }
if (bytes == null) {
error = failed
} else {
runtimeNow.importArchive(bytes, passphrase)
status = restored
passphrase = ""
}
} catch (e: Exception) {
error = e.message ?: failed
} finally {
busy = false
}
}
}) {
Text(stringRes(R.string.cordn_backup_restore), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingRestore = null }) { Text(stringRes(Res.string.cancel)) }
},
)
}
}
@@ -0,0 +1,710 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth
import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery
import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_discover
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_manual
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_yours
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CopyableKeyRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
/**
* The coordinators this account talks to.
*
* ## Why a coordinator is a first-class thing and not a relay setting
*
* Relays are interchangeable and redundant; losing one costs nothing. A
* coordinator is the sole authority for the groups it serves (`spec/00.md`
* §4) — losing it loses the ordering, and a second one does not mirror the
* first. So this screen lists named things a user chose, with what each one
* costs and two very different ways to stop using one.
*
* ## Remove and purge are different decisions
*
* Removing closes the session and leaves the MLS state on disk, so re-adding
* brings the groups back. Purging deletes the ratchet trees, the cursors and
* the KeyPackage private halves, after which those groups can only be
* re-entered by a fresh invitation — MLS state cannot be rebuilt from
* anywhere else. The confirmation says that, because "delete" normally means
* the reversible one.
*
* Neither tells the coordinator anything. It keeps what it already had; this
* screen is about the device, and implying otherwise would be selling a
* deletion nobody can perform.
*/
@Composable
fun CordnCoordinatorsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_coordinators_title), nav) },
) { padding ->
if (runtime == null) {
// The app's own empty state, centred and titled, rather than a
// sentence stranded in the top-left corner.
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val coordinators by runtime.coordinators.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringRes(R.string.cordn_coordinators_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Three sections rather than one column split by rules: what you
// already use, what is on offer, and the manual escape hatch. The
// dividers said "something else follows" without saying what.
SettingsSection(Res.string.cordn_coordinators_section_yours) {
SettingsFormBlock {
coordinators.forEachIndexed { index, config ->
CoordinatorCard(config, runtime, accountViewModel, nav)
// Between coordinators, never after the last: the
// section's own card edge already ends the list.
if (index != coordinators.lastIndex) HorizontalDivider(thickness = DividerThickness)
}
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
SettingsSection(Res.string.cordn_coordinators_section_discover) {
SettingsFormBlock {
DiscoverCoordinators(runtime, accountViewModel, nav, coordinators.map { it.pubKey }.toSet())
}
}
// Last, and it reads as the fallback it is now that discovery is
// above it: pasting a 64-character key is what you do when nobody
// announced the one you were told to use.
SettingsSection(Res.string.cordn_coordinators_section_manual) {
SettingsFormBlock {
AddCoordinator(runtime)
}
}
}
}
}
@Composable
private fun CoordinatorCard(
config: CoordinatorConfig,
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scope = rememberCoroutineScope()
var info by remember(config.pubKey) { mutableStateOf<CoordinatorServerInfo?>(null) }
var infoChecked by remember(config.pubKey) { mutableStateOf(false) }
var confirmingPurge by remember(config.pubKey) { mutableStateOf(false) }
var renaming by remember(config.pubKey) { mutableStateOf(false) }
var menuOpen by remember(config.pubKey) { mutableStateOf(false) }
val health = runtime.health(config.pubKey)?.collectAsStateWithLifecycle()?.value
// No Card here: SettingsSection already IS the card, and a second one
// inside it rendered as a square-cornered slab of a different grey inside
// the section's rounded one -- every other cordn settings section puts its
// content straight into the form block.
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
CoordinatorIdentityRow(
pubKey = config.pubKey,
label = config.label,
accountViewModel = accountViewModel,
nav = nav,
size = 36.dp,
trailing = {
// The label was only ever settable while adding a coordinator
// by hand, so one that arrived from discovery, or came back
// with a restore, could not be named at all.
IconButton(onClick = { renaming = true }) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = stringRes(R.string.cordn_coordinators_rename),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
) { name ->
// Name and state together at the top, because "is this one working"
// is the question this screen is opened with. Everything verifiable
// sits below, where it is read once rather than scanned.
Column(Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
health?.let { HealthLine(it) }
}
}
if (renaming) {
RenameCoordinatorDialog(
current = config.label,
onDismiss = { renaming = false },
onSave = {
renaming = false
scope.launch { runtime.relabel(config.pubKey, it) }
},
)
}
// An npub, not the 64 hex characters that used to sit here. This is the
// screen where you check that the coordinator you were told to use is
// the one you added -- and what you were told is an npub, which a hex
// string cannot be compared against at all. The full value is one tap
// away on the clipboard, which is how a comparison is actually done.
val coordinator = remember(config.pubKey) { LocalCache.getOrCreateUser(config.pubKey) }
CopyableKeyRow(
label = stringRes(R.string.cordn_coordinators_key),
shown = coordinator.pubkeyDisplayHex(),
copied = coordinator.pubkeyNpub(),
copyDescription = stringRes(R.string.cordn_coordinators_copy_key),
)
// Every relay, not the first two and a count: this is the management
// screen, and an address the coordinator answers on is the thing you
// came here to check or to rule out.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringRes(R.string.cordn_coordinators_relays_label),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
config.relays.forEach { relay ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(
symbol = MaterialSymbols.Link,
contentDescription = null,
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = relay.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Only ever what a call of ours already observed -- nothing here
// polls. The handshake below is a call the user asked for.
if (infoChecked) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text =
info?.let {
stringRes(
R.string.cordn_coordinators_server,
listOfNotNull(it.name, it.version, it.protocolVersion).joinToString(" \u00b7 "),
)
} ?: stringRes(R.string.cordn_coordinators_server_silent),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
// Said next to it, every time, because a name on a settings
// screen reads as verified and this one is not: §8.5 makes
// the pubkey the identity and nothing else.
text = stringRes(R.string.cordn_coordinators_server_claim),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {
scope.launch {
info = runCatching { runtime.serverInfo(config.pubKey) }.getOrNull()
infoChecked = true
}
}) {
Text(stringRes(R.string.cordn_coordinators_identify))
}
Spacer(Modifier.weight(1f))
// Behind a menu rather than beside Identify. Purge deletes this
// coordinator's groups and keys off the device, and it sat one
// tap away from a harmless handshake button, at the same size.
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.cordn_coordinators_more),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.cordn_coordinators_remove)) },
onClick = {
menuOpen = false
scope.launch { runtime.forget(config.pubKey) }
},
)
DropdownMenuItem(
text = {
Text(
text = stringRes(R.string.cordn_coordinators_purge),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
confirmingPurge = true
},
)
}
}
}
}
if (confirmingPurge) {
AlertDialog(
onDismissRequest = { confirmingPurge = false },
title = { Text(stringRes(R.string.cordn_coordinators_purge_title)) },
text = { Text(stringRes(R.string.cordn_coordinators_purge_body)) },
confirmButton = {
TextButton(onClick = {
confirmingPurge = false
scope.launch { runtime.purge(config.pubKey) }
}) {
Text(stringRes(R.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmingPurge = false }) {
Text(stringRes(Res.string.cancel))
}
},
)
}
}
/**
* What this coordinator's own traffic has shown, and nothing more.
*
* `CoordinatorHealth` records what the calls the app was making anyway
* observed; it never polls, because a poll is a call and every call is
* metadata (§8). So "unknown" here means nothing has been asked of it yet,
* which is genuinely different from "down" and is shown differently.
*/
@Composable
private fun HealthLine(state: CoordinatorHealth.State) {
val text =
when {
state.isUnknown -> stringRes(R.string.cordn_coordinators_health_unknown)
state.isDown -> stringRes(R.string.cordn_coordinators_health_down, state.consecutiveFailures)
state.consecutiveFailures > 0 -> stringRes(R.string.cordn_coordinators_health_retrying)
else -> stringRes(R.string.cordn_coordinators_health_ok)
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = if (state.isDown) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* Coordinators that announced themselves, offered instead of a hex field.
*
* Adding one used to mean pasting a 64-character public key and a list of
* relay URLs, which is not something anyone can do without being told the
* answer out of band. Coordinators publish CEP-6 announcements; reading them
* is a relay query that touches no coordinator, so nothing here tells anyone
* that this account exists.
*
* Deliberately not automatic on entry. The query is cheap but it is still the
* user's relays being asked a question on their behalf, and a screen that
* reaches out the moment it opens is the kind of thing this feature is
* supposed to be careful about.
*/
@Composable
private fun DiscoverCoordinators(
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
nav: INav,
known: Set<String>,
) {
val scope = rememberCoroutineScope()
var result by remember { mutableStateOf<CordnCoordinatorDiscovery.Result?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val failed = stringRes(R.string.cordn_coordinators_discover_failed)
Text(
text = stringRes(R.string.cordn_coordinators_discover_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = {
busy = true
error = null
scope.launch {
try {
result = runtime.discover(accountViewModel.account.outboxRelays.flow.value)
} catch (e: Exception) {
error = e.message ?: failed
} finally {
busy = false
}
}
},
enabled = !busy,
) {
if (busy) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(stringRes(R.string.cordn_coordinators_discover_action))
}
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
// The app's own crossfade, so performance mode still turns it off.
CrossfadeIfEnabled(
targetState = result,
label = "cordn-discovery",
) { found ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (found == null) return@Column
// Already-added ones are dropped rather than shown disabled: this
// list is "what you could add", and a row that does nothing is
// just something else to read.
val offers = found.coordinators.filter { it.pubKey !in known }
if (offers.isEmpty()) {
Text(
text =
if (found.unreachable.isEmpty()) {
stringRes(R.string.cordn_coordinators_discover_none)
} else {
// "Nobody is announcing" and "we were not told" are
// different answers and only one of them is final.
stringRes(R.string.cordn_coordinators_discover_unheard, found.unreachable.size)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
offers.forEach { offer -> DiscoveredCard(offer, runtime, accountViewModel, nav) }
}
}
}
/**
* Names a coordinator, or clears the name it was given.
*
* Empty saves as no label rather than an empty one, so the display falls back
* to the profile and then the key instead of rendering a blank line.
*/
@Composable
private fun RenameCoordinatorDialog(
current: String?,
onDismiss: () -> Unit,
onSave: (String?) -> Unit,
) {
var input by remember { mutableStateOf(current.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.cordn_coordinators_rename)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text(stringRes(R.string.cordn_coordinators_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
// The same caution the add form gives, for the same reason.
text = stringRes(R.string.cordn_coordinators_label_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = { onSave(input.trim().ifEmpty { null }) }) {
Text(stringRes(R.string.cordn_info_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
},
)
}
@Composable
private fun DiscoveredCard(
offer: DiscoveredCoordinator,
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
// The announcement's name, not the profile's: in a discovery list
// the CEP-6 surface is the thing being offered, and both are the
// server's own word for itself anyway. The avatar is worth having
// regardless -- it is the only part of this card that is hard to
// impersonate at a glance.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserPicture(userHex = offer.pubKey, size = 24.dp, accountViewModel = accountViewModel, nav = nav)
Text(
// Its own word for itself, and said so: a coordinator cannot
// prove a name, which is why this never becomes the label.
// Passed as the label so the announcement still wins, with
// the profile catching an offer that announced no name.
text = coordinatorDisplayName(offer.pubKey, offer.surface.name, accountViewModel),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
}
offer.surface.about?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
text = offer.relays.joinToString { it.url },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
// Staleness matters more here than anywhere: the last live
// survey found most announcements were months-dead demos.
text = stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
busy = true
scope.launch {
try {
runtime.session(offer.toConfig())
} finally {
busy = false
}
}
},
enabled = !busy,
modifier = Modifier.padding(top = 4.dp),
) {
Text(stringRes(R.string.cordn_coordinators_discover_add))
}
}
}
}
@Composable
private fun AddCoordinator(runtime: CordnRuntime) {
val scope = rememberCoroutineScope()
var pubKeyInput by remember { mutableStateOf("") }
var relaysInput by remember { mutableStateOf("") }
var labelInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val failed = stringRes(R.string.cordn_coordinators_add_failed)
val config =
remember(pubKeyInput, relaysInput, labelInput) {
val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim())
val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
if (pubKey == null || relays.isEmpty()) {
null
} else {
CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL, labelInput.trim().ifEmpty { null })
}
}
OutlinedTextField(
value = pubKeyInput,
onValueChange = {
pubKeyInput = it
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = relaysInput,
onValueChange = {
relaysInput = it
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = labelInput,
onValueChange = { labelInput = it },
label = { Text(stringRes(R.string.cordn_coordinators_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
// A label is ours, never theirs. A coordinator cannot prove a name and
// this one is not asked for one.
text = stringRes(R.string.cordn_coordinators_label_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
val target = config ?: return@Button
busy = true
error = null
scope.launch {
try {
runtime.session(target)
pubKeyInput = ""
relaysInput = ""
labelInput = ""
} catch (e: Exception) {
error = e.message ?: failed
} finally {
busy = false
}
}
},
enabled = !busy && config != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.cordn_coordinators_add_action))
}
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_device
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_service
import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsControlRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import org.jetbrains.compose.resources.StringResource
/**
* One door into cordn, instead of five rows in the account settings list.
*
* cordn's pages — coordinators, key packages, link inspection, backup,
* migration — are each visited rarely and only by someone already thinking
* about cordn. As flat entries they made it the biggest feature in that list
* by count and among the least used, pushing everything else down. Grouping
* them costs one tap and keeps every page searchable under a name a user
* would actually look for.
*
* Built from [SettingsSection] and [SettingsItem] like every other settings
* page. The first version hand-rolled its own cards, which made the one screen
* reached FROM the settings list the one screen that did not look like it.
*/
@Composable
fun CordnHubScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_hub_title), nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringRes(Res.string.cordn_hub_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
// Split by what the page is about rather than listed flat: the
// first three are about a coordinator, the last two about this
// device's own state.
SettingsSection(Res.string.cordn_hub_section_service) {
HubEntry(MaterialSymbols.Dns, Res.string.cordn_hub_coordinators, Res.string.cordn_hub_coordinators_desc) {
nav.nav(Route.CordnCoordinators)
}
SettingsDivider()
HubEntry(MaterialSymbols.Key, Res.string.cordn_hub_keypackages, Res.string.cordn_hub_keypackages_desc) {
nav.nav(Route.CordnKeyPackages)
}
SettingsDivider()
HubEntry(MaterialSymbols.Link, Res.string.cordn_hub_link, Res.string.cordn_hub_link_desc) {
nav.nav(Route.CordnLink)
}
}
SettingsSection(Res.string.cordn_hub_section_device) {
HubEntry(MaterialSymbols.Save, Res.string.cordn_hub_backup, Res.string.cordn_hub_backup_desc) {
nav.nav(Route.CordnBackup)
}
SettingsDivider()
HubEntry(MaterialSymbols.SwapHoriz, Res.string.cordn_hub_migrate, Res.string.cordn_hub_migrate_desc) {
nav.nav(Route.CordnMigrate)
}
}
}
}
}
/**
* A navigation row that keeps its description.
*
* [SettingsItem] is the plain navigation row and has no room for one. Every
* page behind this hub is obscure enough that its title alone does not say
* what it does, so the row with a description — and a chevron supplied as the
* trailing slot, since [SettingsControlRow] is built for inline controls —
* is the honest fit.
*/
@Composable
private fun HubEntry(
icon: MaterialSymbol,
title: StringResource,
description: StringResource,
onClick: () -> Unit,
) {
SettingsControlRow(
icon = icon,
title = stringRes(title),
description = stringRes(description),
onClick = onClick,
) {
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview
@Composable
fun CordnHubScreenPreview() {
ThemeComparisonColumn {
CordnHubScreen(mockAccountViewModel(), EmptyNav())
}
}
@@ -0,0 +1,321 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_section
import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.model.cordn.CordnKeyPackageRow
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/**
* The KeyPackages this account has published, per coordinator.
*
* ## Why this screen has to exist at all
*
* `spec/00.md` §4.2 gives cordn **no KeyPackage event kind**. There is no
* relay to fall back on: the coordinator is the only place an inviter can
* find one, so an account with none published simply cannot be added to a
* group, and nothing anywhere would say why. Marmot's equivalent is invisible
* because its packages live on relays and the app republishes them for you;
* here the publish is a call to one named party, which makes it a decision
* rather than a housekeeping detail.
*
* ## Publishing is attributable, and that is the whole cost
*
* §8.4: a KeyPackage is published under the account key, with a signed payload
* the coordinator stores and serves. It tells that coordinator this account
* exists and is invitable — permanently, and whether or not anyone ever
* invites anyone. That is stated above the button, not discovered after it.
*
* ## Two different truths, shown separately
*
* The coordinator's listing says what an inviter can take. The local store
* says whether the Welcome that results can be opened. A package in the first
* without the second belongs to another device of this account — or to an
* install that is gone, in which case anyone who uses it sends a Welcome
* nobody will ever read. Collapsing the two would hide exactly that case.
*/
@Composable
fun CordnKeyPackagesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_keypackages_title), nav) },
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val coordinators by runtime.coordinators.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringRes(R.string.cordn_keypackages_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// One section holding every coordinator rather than one each:
// SettingsSection titles come from a StringResource and a
// coordinator's label is runtime text, so a section per
// coordinator is not something this primitive can say. The
// dividers stay, inside, where they now separate peers within a
// box instead of floating in open page.
SettingsSection(Res.string.cordn_keypackages_section) {
SettingsFormBlock {
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
coordinators.forEachIndexed { index, config ->
// Between, not after: a rule under the last one drew a
// line to nothing.
if (index > 0) HorizontalDivider()
CoordinatorKeyPackages(config, runtime, accountViewModel, nav)
}
}
}
}
}
}
@Composable
private fun CoordinatorKeyPackages(
config: CoordinatorConfig,
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scope = rememberCoroutineScope()
var rows by remember(config.pubKey) { mutableStateOf<List<CordnKeyPackageRow>?>(null) }
var busy by remember(config.pubKey) { mutableStateOf(false) }
var error by remember(config.pubKey) { mutableStateOf<String?>(null) }
val failed = stringRes(R.string.cordn_keypackages_failed)
suspend fun reload() {
busy = true
error = null
try {
rows = runtime.keyPackages(config.pubKey)
} catch (e: Exception) {
error = e.message ?: failed
} finally {
busy = false
}
}
LaunchedEffect(config.pubKey) { reload() }
CoordinatorIdentityRow(
pubKey = config.pubKey,
label = config.label,
accountViewModel = accountViewModel,
nav = nav,
size = 24.dp,
) { name ->
Text(text = name, style = MaterialTheme.typography.titleSmall)
}
if (busy) CircularProgressIndicator()
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
val loaded = rows
if (loaded != null && !busy) {
val single = loaded.count { !it.lastResort }
val hasLastResort = loaded.any { it.lastResort }
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_summary, single, single),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text =
if (hasLastResort) {
stringRes(R.string.cordn_keypackages_last_resort_yes)
} else {
stringRes(R.string.cordn_keypackages_last_resort_no)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
loaded.filterNot { it.openableHere }.takeIf { it.isNotEmpty() }?.let { orphans ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.padding(12.dp)) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_orphans, orphans.size, orphans.size),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.cordn_keypackages_orphans_body),
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = {
scope.launch {
try {
runtime.withdrawKeyPackages(config.pubKey, orphans.map { it.keyPackageRef })
} catch (e: Exception) {
error = e.message ?: failed
}
reload()
}
}) {
Text(stringRes(R.string.cordn_keypackages_withdraw_orphans))
}
}
}
}
Text(
// Above the buttons, because it is the part that cannot be taken
// back: §8.4 makes publishing an attributable, permanent record.
text = stringRes(R.string.cordn_keypackages_disclosure),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
scope.launch {
try {
runtime.publishKeyPackage(config.pubKey)
} catch (e: Exception) {
error = e.message ?: failed
}
reload()
}
},
enabled = !busy,
) {
Text(stringRes(R.string.cordn_keypackages_publish))
}
OutlinedButton(
onClick = {
scope.launch {
try {
runtime.publishKeyPackage(config.pubKey, lastResort = true)
} catch (e: Exception) {
error = e.message ?: failed
}
reload()
}
},
enabled = !busy && !hasLastResort,
) {
Text(stringRes(R.string.cordn_keypackages_publish_last_resort))
}
if (loaded.isNotEmpty()) {
TextButton(
onClick = {
scope.launch {
try {
runtime.withdrawKeyPackages(config.pubKey, loaded.map { it.keyPackageRef })
} catch (e: Exception) {
error = e.message ?: failed
}
reload()
}
},
enabled = !busy,
) {
Text(stringRes(R.string.cordn_keypackages_withdraw_all), color = MaterialTheme.colorScheme.error)
}
}
}
Text(
// The rule CordnRuntime.maintainKeyPackages implements, said where
// someone can see that it applies to them.
text = stringRes(R.string.cordn_keypackages_topup_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,352 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CordnLinkInspection
import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_link_clear
import com.vitorpamplona.amethyst.commons.resources.cordn_link_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_link_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_link_field
import com.vitorpamplona.amethyst.commons.resources.cordn_link_group_id
import com.vitorpamplona.amethyst.commons.resources.cordn_link_inspect
import com.vitorpamplona.amethyst.commons.resources.cordn_link_invalid
import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable
import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste
import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays
import com.vitorpamplona.amethyst.commons.resources.cordn_link_section_input
import com.vitorpamplona.amethyst.commons.resources.cordn_link_title
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
/**
* "Someone sent me a `cordn1…` link" — what it points at, and what following it
* would cost.
*
* This screen exists because of §8 of `quartz/plans/2026-09-17-cordn-interop.md`:
* a cordn group and a Marmot group look the same and are not the same, and the
* moment the difference can still change a decision is **before** joining. A
* link is where that moment happens, so the disclosure lives here rather than
* in a settings sub-page nobody opens.
*
* It deliberately does not join anything. Joining needs a live coordinator, and
* Tier B of the interop plan is blocked (§7) — so the honest scope is "read the
* link, tell the truth about it" rather than a join button whose other half has
* never been run.
*
* All parsing is [CordnLinkInspection] in `commons`, which has its own tests;
* everything here is drawing.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CordnLinkScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var input by remember { mutableStateOf("") }
var inspection by remember { mutableStateOf<CordnLinkInspection?>(null) }
var requestState by remember { mutableStateOf<String?>(null) }
var requesting by remember { mutableStateOf(false) }
val runtime = accountViewModel.account.cordnRuntime
val scope = rememberCoroutineScope()
val requestFailed = stringRes(R.string.cordn_link_request_failed)
val asked = stringRes(R.string.cordn_link_request_sent)
var scanning by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_link_title), nav) },
) { insets ->
Column(
modifier =
Modifier
.padding(insets)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringResource(Res.string.cordn_link_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The field, its buttons and the scanner are one thing to do; the
// verdict below is another. Boxing the input says where the screen
// asks something of you and where it answers.
SettingsSection(Res.string.cordn_link_section_input) {
SettingsFormBlock {
OutlinedTextField(
value = input,
onValueChange = {
input = it
// Clearing on edit rather than re-parsing per keystroke: a
// half-typed ref is always invalid, and showing that while
// someone is still pasting is noise, not feedback.
inspection = null
requestState = null
},
label = { Text(stringResource(Res.string.cordn_link_field)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { inspection = CordnLinkInspection.of(input) },
enabled = input.isNotBlank(),
) {
Text(stringResource(Res.string.cordn_link_inspect))
}
OutlinedButton(
onClick = {
clipboard.getText()?.text?.let {
input = it
inspection = CordnLinkInspection.of(it)
}
},
) {
Text(stringResource(Res.string.cordn_link_paste))
}
OutlinedButton(onClick = { scanning = true }) {
Text(stringRes(R.string.cordn_link_scan))
}
if (input.isNotEmpty()) {
OutlinedButton(
onClick = {
input = ""
inspection = null
},
) {
Text(stringResource(Res.string.cordn_link_clear))
}
}
}
}
}
if (scanning) {
// A cordn ref is a long bech32 string nobody types twice.
// Reusing the app's scanner rather than writing one: the
// ref goes through exactly the same parse as a pasted link,
// so a scanned link cannot take a shortcut a typed one
// cannot.
SimpleQrCodeScanner {
scanning = false
if (!it.isNullOrEmpty()) {
input = it
inspection = CordnLinkInspection.of(it)
requestState = null
}
}
}
when (val result = inspection) {
null -> Unit
is CordnLinkInspection.Invalid ->
Text(
text = stringResource(Res.string.cordn_link_invalid, result.reason),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
is CordnLinkInspection.Valid -> {
LabelledValue(stringResource(Res.string.cordn_link_group_id), result.ref.gid)
result.coordinator?.let { coordinator ->
// The screen exists to answer "should I trust this
// link", and the coordinator is the party being
// trusted -- so it is shown as whoever it is, with a
// face, rather than as 64 characters nobody reads.
LabelledCoordinator(
label = stringResource(Res.string.cordn_link_coordinator),
pubKey = coordinator.pubKey,
coordinatorLabel = coordinator.label,
accountViewModel = accountViewModel,
nav = nav,
)
LabelledValue(
stringResource(Res.string.cordn_link_relays),
coordinator.relays.joinToString("\n") { it.url },
)
}
result.exposure?.let { CordnExposureCard(it) }
if (!result.isFollowable) {
Text(
text = stringResource(Res.string.cordn_link_no_coordinator),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
// Still true, and still worth saying: reading a link
// is not joining, and the button below is the only
// thing on this screen that tells anyone anything.
text = stringResource(Res.string.cordn_link_not_joinable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val coordinator = result.coordinator
if (coordinator != null && runtime != null) {
Button(
onClick = {
requesting = true
requestState = null
scope.launch {
requestState =
try {
runtime.requestToJoin(coordinator, result.ref.gid)
asked
} catch (e: Exception) {
e.message ?: requestFailed
}
requesting = false
}
},
enabled = !requesting,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.cordn_link_request))
}
Text(
// Said before the tap, because it is the part that
// cannot be undone: asking to join publishes a
// KeyPackage under this account's own key and tells
// the coordinator this account wants into this
// group, whether or not anyone ever answers (§8.4).
text = stringRes(R.string.cordn_link_request_disclosure),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
requestState?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color =
if (it == asked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
)
}
}
}
}
}
}
}
/**
* A [LabelledValue] whose value is a coordinator, rendered as its profile.
*
* The pubkey is still reachable -- tapping the avatar opens the profile, which
* carries the npub -- so nothing is hidden by not printing it here.
*/
@Composable
private fun LabelledCoordinator(
label: String,
pubKey: HexKey,
coordinatorLabel: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
CoordinatorIdentityRow(
pubKey = pubKey,
label = coordinatorLabel,
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.padding(top = 2.dp),
) { name ->
Text(text = name, style = MaterialTheme.typography.bodyMedium)
}
}
}
/**
* A field the user may need to compare against something they were sent, so it
* is selectable and never truncated.
*/
@Composable
private fun LabelledValue(
label: String,
value: String,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SelectionContainer {
Text(text = value, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,389 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CordnMigration
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_code_note
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_done
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_export
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exporting
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_body
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_title
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off_desc
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_import
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_importing
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_groups
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_server
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_paste
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_receive
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_replaces_warning
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan_this
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_receive
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_send
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_sign_in_first
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.model.cordn.AndroidCordnBlobStore
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
import kotlinx.coroutines.launch
/**
* Moving this account's cordn groups to a phone the user is switching to.
*
* ## Why this is a handoff and not sync
*
* `spec/applications/multi-device.md` §10 leaves one case unresolved: two
* devices of one identity committing inside a single delivery round-trip reach
* the same epoch with different states, and §15 concedes that equal-epoch MLS
* states have no merge function. This screen is buildable because a migration
* has one writer — which is only true if this device stops afterwards, which
* is what the handed-off state below is.
*
* ## What the user is told, and where
*
* Two things are said at the moment they are decided rather than in a help
* page. Before exporting: the encrypted documents leave the device for a
* storage server. Before importing: this replaces whatever cordn groups are
* already here, because MLS state cannot be merged. Both are irreversible in
* the ways that matter, and neither is discoverable afterwards.
*/
@Composable
fun CordnMigrateScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_migrate_title), nav) },
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val handedOff by runtime.handoff.handedOff.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
if (handedOff) {
HandedOff(runtime)
return@Column
}
Text(
text = stringRes(Res.string.cordn_migrate_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Two boxes, because they are two different devices' jobs and
// only one of them is yours today. A rule between them said they
// were separate without saying which was which — and doing the
// wrong one here hands your account to another phone.
SettingsSection(Res.string.cordn_migrate_section_send) {
SettingsFormBlock {
SendSide(runtime, accountViewModel)
}
}
SettingsSection(Res.string.cordn_migrate_section_receive) {
SettingsFormBlock {
ReceiveSide(runtime, accountViewModel)
}
}
}
}
}
/**
* The state that makes the rest of this safe.
*
* Deliberately not phrased as an error. Nothing is broken and nothing was
* deleted — the device stood down on purpose, and the groups are still on
* disk so taking it back is possible.
*/
@Composable
private fun HandedOff(runtime: CordnRuntime) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(Res.string.cordn_migrate_handed_off), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(Res.string.cordn_migrate_handed_off_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = stringRes(Res.string.cordn_migrate_cancel_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = {
busy = true
scope.launch {
try {
runtime.cancelHandOff()
} finally {
busy = false
}
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(Res.string.cordn_migrate_cancel))
}
}
@Composable
private fun SendSide(
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var code by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val noServer = stringRes(Res.string.cordn_migrate_no_server)
val noGroups = stringRes(Res.string.cordn_migrate_no_groups)
Text(
text = stringRes(Res.string.cordn_migrate_sign_in_first),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Said before the button, because this is the moment the decision is made
// and the upload cannot be taken back afterwards.
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringRes(Res.string.cordn_migrate_exposure_title), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(Res.string.cordn_migrate_exposure_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
code?.let {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(stringRes(Res.string.cordn_migrate_scan_this), style = MaterialTheme.typography.titleSmall)
QrCodeDrawer(it, Modifier.size(260.dp))
SelectionContainer { Text(it, style = MaterialTheme.typography.labelSmall) }
Text(
text = stringRes(Res.string.cordn_migrate_code_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
busy = true
error = null
scope.launch {
try {
val servers = listOfNotNull(accountViewModel.account.settings.defaultFileServer.baseUrl)
if (servers.isEmpty()) {
error = noServer
return@launch
}
if (runtime.migrationSnapshot().groups.isEmpty()) {
error = noGroups
return@launch
}
val migration =
CordnMigration(
accountViewModel.account.client,
accountViewModel.account.signer,
AndroidCordnBlobStore(servers, context),
)
code = runtime.handOff(migration, accountViewModel.account.outboxRelays.flow.value).encode()
} catch (e: Exception) {
error = e.message
} finally {
busy = false
}
}
},
enabled = !busy && code == null,
modifier = Modifier.fillMaxWidth(),
) {
// The label already says "Exporting"; the spinner says it is still
// going, which a static word cannot.
BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_exporting else Res.string.cordn_migrate_export))
}
}
@Composable
private fun ReceiveSide(
runtime: CordnRuntime,
accountViewModel: AccountViewModel,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var typed by remember { mutableStateOf("") }
var scanning by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var done by remember { mutableStateOf<Int?>(null) }
val badCode = stringRes(Res.string.cordn_migrate_scan)
Text(stringRes(Res.string.cordn_migrate_receive), style = MaterialTheme.typography.titleSmall)
// Said before the button, for the same reason as the exposure card: an
// import replaces, and MLS state cannot be merged back afterwards.
Text(
text = stringRes(Res.string.cordn_migrate_replaces_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (scanning) {
SimpleQrCodeScanner {
scanning = false
if (it != null) typed = it
}
}
OutlinedButton(onClick = { scanning = true }, modifier = Modifier.fillMaxWidth()) {
Text(stringRes(Res.string.cordn_migrate_scan))
}
OutlinedTextField(
value = typed,
onValueChange = {
typed = it
error = null
},
label = { Text(stringRes(Res.string.cordn_migrate_paste)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
done?.let { Text(stringRes(Res.string.cordn_migrate_done, it), style = MaterialTheme.typography.bodyMedium) }
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
val parsed = CordnHandoffCode.decodeOrNull(typed.trim())
if (parsed == null) {
error = badCode
return@Button
}
busy = true
error = null
scope.launch {
try {
val migration =
CordnMigration(
accountViewModel.account.client,
accountViewModel.account.signer,
AndroidCordnBlobStore(emptyList(), context),
)
val snapshot = migration.fetch(parsed)
runtime.adoptMigration(snapshot)
done = snapshot.groups.size
typed = ""
} catch (e: Exception) {
error = e.message
} finally {
busy = false
}
}
},
enabled = !busy && typed.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_importing else Res.string.cordn_migrate_import))
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Form content inside a `SettingsSection`.
*
* The section's card deliberately has no padding of its own: it is built for
* rows like `SettingsItem`, which carry theirs so a divider can run edge to
* edge between them. cordn's pages are forms rather than row lists — text
* fields, explainers and buttons — and those need the inset the rows would
* otherwise have supplied.
*
* One helper rather than a `Modifier.padding(16.dp)` repeated down five
* screens, so the pages stay identical to each other as they change.
*/
@Composable
fun SettingsFormBlock(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content,
)
}
+260 -3
View File
@@ -305,6 +305,250 @@
<string name="cordn_groups_manage">Coordinators</string>
<string name="cordn_groups_down_note">Nothing is arriving from these groups. Amethyst keeps retrying.</string>
<string name="cordn_groups_none">No cordn groups yet</string>
<string name="cordn_groups_none_detail">A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes.</string>
<string name="cordn_groups_start">Start one</string>
<string name="cordn_group_unavailable">This group is not available. Its coordinator may have been removed.</string>
<string name="cordn_group_info">Group info</string>
<string name="cordn_message_deleted">Message deleted</string>
<string name="cordn_message_edited">edited</string>
<string name="cordn_action_go_to_message">Go to message</string>
<string name="cordn_delete_confirm_title">Withdraw message</string>
<string name="cordn_delete_confirm_body">Everyone in the group will see this message as withdrawn, and Amethyst will stop showing its text. The coordinator still holds the encrypted copy it was sent as, and anyone who already read it has already read it.</string>
<string name="cordn_delivery_accepted">Accepted by the coordinator</string>
<string name="cordn_message_details_title">Message details</string>
<string name="cordn_message_details_sent_at">Sent</string>
<string name="cordn_message_details_cursor">Cursor</string>
<string name="cordn_message_details_coordinator">Coordinator</string>
<string name="cordn_composer_hint">Message</string>
<string name="cordn_action_reply">Reply</string>
<string name="cordn_action_edit">Edit</string>
<string name="cordn_action_delete">Delete</string>
<string name="cordn_action_pin">Pin</string>
<string name="cordn_pinned_previous">Previous pinned message</string>
<string name="cordn_pinned_next">Next pinned message</string>
<string name="cordn_pinned_show_all">Show all pinned messages</string>
<string name="cordn_pinned_title">Pinned messages</string>
<string name="cordn_pinned_by">Pinned by %1$s</string>
<plurals name="cordn_pinned_count">
<item quantity="one">%1$d pinned message in this group</item>
<item quantity="other">%1$d pinned messages in this group</item>
</plurals>
<string name="cordn_action_unpin">Unpin</string>
<string name="cordn_action_editing">Editing your message</string>
<string name="cordn_media_upload_failed">Could not send that file.</string>
<string name="cordn_send_failed">That did not send.</string>
<string name="cordn_send_no_session">This group\'s coordinator is not open, so nothing can be sent yet.</string>
<string name="cordn_media_download_failed">Could not open that file.</string>
<string name="cordn_media_no_server">No media server is set for this account, so there is nowhere to put the file. Pick one in Settings.</string>
<string name="cordn_media_unreadable">That file could not be read.</string>
<string name="cordn_voice_record">Record a voice note</string>
<string name="cordn_voice_stop">Stop and send</string>
<string name="cordn_voice_play">Play voice note</string>
<string name="cordn_backup_explainer">Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it.</string>
<string name="cordn_backup_passphrase">Passphrase</string>
<string name="cordn_backup_contents_title">What is in the file</string>
<string name="cordn_backup_contents_body">Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst.</string>
<string name="cordn_backup_export">Export</string>
<string name="cordn_backup_exported">Backup saved.</string>
<string name="cordn_backup_restore_title">Restore</string>
<string name="cordn_backup_restore_body">Restoring replaces the cordn groups on this device with the ones in the file. It is for a phone that has taken over from another — not a way to use the same groups on two devices at once.</string>
<string name="cordn_backup_restore">Restore</string>
<string name="cordn_backup_restore_confirm_title">Replace this device\'s cordn groups?</string>
<string name="cordn_backup_restore_confirm_body">Every cordn group now on this device will be removed and replaced by the ones in the file.\n\nDo not restore onto a second phone while the first is still in use. Both would hold the same group keys, and once both have sent a message the group breaks for everyone — silently, and with no way back.</string>
<string name="cordn_backup_restored">Restored.</string>
<string name="cordn_backup_failed">That did not work. Check the passphrase and the file.</string>
<string name="cordn_send">Send</string>
<string name="cordn_info_coordinator">Coordinator</string>
<string name="cordn_info_coordinator_key">Coordinator key</string>
<string name="cordn_info_coordinator_nprofile">Coordinator link</string>
<string name="cordn_info_copy_nprofile">Copy the coordinator\'s nprofile</string>
<string name="cordn_info_gid">Group id</string>
<string name="cordn_info_epoch">Epoch</string>
<string name="cordn_info_members">Members</string>
<string name="cordn_info_egalitarian">This group has no admins: everyone can add, remove and rename, permanently.</string>
<string name="cordn_info_technical">Technical details</string>
<string name="cordn_create_title">New Cordn group</string>
<string name="cordn_create_explainer">A cordn group is ordered by one coordinator you choose. Creating the group happens on this device — the coordinator only learns the group exists once you invite someone or send a message.</string>
<string name="cordn_create_people_none">Nobody yet &#8212; tap to add people</string>
<string name="cordn_create_people_empty">Nobody added yet. A group can be created empty and invitations sent later, but naming people now is what shows which coordinator can reach them.</string>
<string name="cordn_create_people_done">Done</string>
<string name="cordn_create_you">You, always an admin</string>
<string name="cordn_create_step_name">Name it</string>
<string name="cordn_create_step_people">Who is in it</string>
<string name="cordn_create_step_where">Where it lives</string>
<string name="cordn_create_step_admins">Who can add and remove</string>
<string name="cordn_create_add_member">Add someone</string>
<string name="cordn_create_people_note">Naming people now is what tells us which coordinator can reach them. Each one gets a Welcome left on that coordinator, which they pick up next time they open their app.</string>
<string name="cordn_create_reach_unknown">Not checked yet</string>
<string name="cordn_create_reach_none">No key on any coordinator you use</string>
<string name="cordn_create_egalitarian">Everyone can add and remove</string>
<string name="cordn_create_admin">Admin</string>
<string name="cordn_create_no_coordinator">No coordinator chosen</string>
<string name="cordn_create_coordinator_change">Change</string>
<string name="cordn_create_coordinator_hide">Done</string>
<string name="cordn_create_coverage_unknown">Not checked &#8212; picking it will ask</string>
<string name="cordn_create_coverage_all">Reaches all %1$d of your people</string>
<string name="cordn_create_coverage_partial">Reaches %1$d of %2$d &#8212; the rest would be left out</string>
<string name="cordn_create_outcome_title">The group exists</string>
<string name="cordn_create_outcome_explainer">Each invitation is a Welcome left on the coordinator, so they can fail one at a time. Nobody has been notified: they see it next time they open their app.</string>
<string name="cordn_create_outcome_no_welcome">Added to the group, but the invitation did not reach them &#8212; try again from the group screen</string>
<string name="cordn_create_outcome_waiting">Waiting for them</string>
<string name="cordn_create_outcome_failed">The coordinator refused the invitation</string>
<string name="cordn_create_outcome_skipped">No key here, so no Welcome could be left</string>
<string name="cordn_create_outcome_link">Send a link</string>
<string name="cordn_create_outcome_open">Open the chat</string>
<string name="cordn_create_coordinator">Coordinator</string>
<string name="cordn_create_coordinator_discover">Find coordinators</string>
<string name="cordn_create_coordinator_new">Another coordinator</string>
<string name="cordn_create_coordinator_pubkey">Coordinator public key (hex or npub)</string>
<string name="cordn_create_coordinator_relays">Relays it answers on, one per line</string>
<string name="cordn_create_name">Group name</string>
<string name="cordn_create_description">Description (optional)</string>
<string name="cordn_create_egalitarian_note">Everyone in the group can invite, remove members and change its details. Any member can name admins later, which ends that.</string>
<string name="cordn_create_admin_only_me">Only I manage this group</string>
<string name="cordn_admin_action_failed">That change could not be made.</string>
<string name="cordn_info_remove_member">Remove</string>
<string name="cordn_info_remove_confirm_title">Remove from group?</string>
<string name="cordn_info_remove_confirm_body">%1$s stops receiving messages from here. They keep what they have already read, and this cannot be undone — they would have to be invited again.</string>
<string name="cordn_info_edit_details">Edit details</string>
<string name="cordn_info_save">Save</string>
<string name="cordn_create_admin_only_me_on">Only you can invite, remove members or change the group\'s details. You can hand that to someone else later.</string>
<string name="cordn_create_action">Create group</string>
<string name="cordn_create_failed">The group could not be created.</string>
<string name="cordn_invitations_title">Cordn invitations</string>
<string name="cordn_invitations_explainer">Checked when you open this screen, and at no other time — asking a coordinator whether anyone invited you tells it you are here, so Amethyst does not do it in the background.</string>
<string name="cordn_invitations_refresh">Check again</string>
<string name="cordn_invitations_accept">Join</string>
<string name="cordn_invitations_decline">Decline</string>
<string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string>
<string name="cordn_chat_yesterday">Yesterday</string>
<string name="cordn_chat_unread_divider">New messages</string>
<string name="cordn_chat_empty_title">No messages yet</string>
<string name="cordn_chat_empty_description">Anything sent here is encrypted for this group. The coordinator relays it without being able to read it.</string>
<string name="cordn_reactions_title">Reactions</string>
<string name="cordn_group_unavailable_detail">Add a coordinator in Settings to start using cordn groups.</string>
<string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string>
<string name="cordn_coordinators_discover_action">Look for coordinators</string>
<string name="cordn_coordinators_discover_add">Add</string>
<string name="cordn_coordinators_discover_failed">Could not read announcements</string>
<string name="cordn_coordinators_discover_none">Nobody is announcing on your relays.</string>
<string name="cordn_coordinators_discover_unheard">Nothing found, and %1$d of your relays did not answer.</string>
<string name="cordn_coordinators_discover_seen">Announced %1$s ago</string>
<string name="cordn_coordinators_discover_seen_on">Last announced %1$s</string>
<string name="cordn_coordinators_relays">%1$s</string>
<string name="cordn_coordinators_relays_more">%1$s +%2$d more</string>
<string name="cordn_coordinators_show_all">Show all %1$d</string>
<string name="cordn_coordinators_show_fewer">Show fewer</string>
<string name="cordn_coordinators_show_older">Show %1$d that stopped announcing</string>
<string name="cordn_coordinators_hide_older">Hide the ones that stopped announcing</string>
<string name="cordn_coordinators_stale_note">These have not announced in over a month. Creating a group on one that has gone away will fail.</string>
<string name="cordn_info_add_member">Add someone</string>
<string name="cordn_info_add_member_placeholder">Name, npub or name@domain</string>
<string name="cordn_info_has_key_package">Can be added on this coordinator</string>
<string name="cordn_info_add_member_note">They can only be added if they published a key package to this coordinator.</string>
<string name="cordn_exposure_open">What this coordinator can see</string>
<string name="cordn_exposure_close">Got it</string>
<string name="cordn_admin_someone">That person</string>
<string name="cordn_admin_no_key_package">%1$s has not published a key package to this coordinator, so they cannot be added yet. Ask them to open cordn on this coordinator first.</string>
<string name="cordn_admin_wrong_key_package">This coordinator served a key package that belongs to someone else, so %1$s was not added. Nothing was changed.</string>
<string name="cordn_admin_no_welcome">%1$s could not be given a way into the group, so the add was abandoned.</string>
<string name="cordn_admin_not_a_member">%1$s is not in this group.</string>
<string name="cordn_admin_no_self_remove">You cannot remove yourself from a cordn group.</string>
<string name="cordn_admin_coordinator_silent">The coordinator did not answer, so nothing was changed. Try again in a moment.</string>
<string name="cordn_info_add_member_none">Nobody found by that name.</string>
<string name="cordn_info_admin_badge">Admin</string>
<string name="cordn_invitations_members_more">+%1$d more</string>
<string name="cordn_invitations_via">Through %1$s</string>
<string name="cordn_invitations_none">No invitations waiting.</string>
<string name="cordn_invitations_no_coordinators">You have no coordinator open. cordn invitations arrive through a coordinator, so there is nowhere to look yet.</string>
<string name="cordn_invitations_unreachable">%1$s did not answer</string>
<string name="cordn_invitations_skipped">An invitation was left for another device</string>
<string name="cordn_invitations_load_failed">Could not read invitations.</string>
<string name="cordn_link_request">Ask to join this group</string>
<string name="cordn_link_request_disclosure">This publishes a key package under your own account key and tells the coordinator you want into this group — whether or not anyone answers.</string>
<string name="cordn_link_request_sent">Asked. A member of the group has to add you; the invitation will show up under cordn invitations.</string>
<string name="cordn_link_request_failed">Could not ask to join.</string>
<string name="cordn_requests_title">Requests to join</string>
<string name="cordn_requests_check">Check for requests</string>
<string name="cordn_requests_none">Nobody is waiting to join.</string>
<string name="cordn_requests_accept">Add</string>
<string name="cordn_requests_decline">Dismiss</string>
<string name="cordn_requests_admin_only">Only an admin can accept. In a group with no admins, that is everyone.</string>
<string name="cordn_requests_failed">Could not read the requests.</string>
<string name="cordn_link_scan">Scan</string>
<string name="cordn_share_title">Share this group</string>
<string name="cordn_share_explainer">Anyone with this link can ask to join. An admin still has to let them in.</string>
<string name="cordn_share_copy">Copy link</string>
<string name="cordn_share_copied">Copied</string>
<string name="cordn_coordinators_explainer">A coordinator orders one group\'s messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays.</string>
<string name="cordn_coordinators_none">No coordinators yet.</string>
<string name="cordn_coordinators_add_action">Add</string>
<string name="cordn_coordinators_add_failed">Could not open that coordinator.</string>
<string name="cordn_coordinators_label">A name for it (yours, optional)</string>
<string name="cordn_coordinators_relays_label">Answers on</string>
<string name="cordn_coordinators_key">Coordinator key</string>
<string name="cordn_coordinators_copy_key">Copy the coordinator\'s npub</string>
<string name="cordn_coordinators_more">More actions</string>
<string name="cordn_coordinators_rename">Rename</string>
<string name="cordn_coordinators_label_note">This name is yours and stays on this device. A coordinator cannot prove a name, so it is never asked for one.</string>
<string name="cordn_coordinators_identify">Ask who it is</string>
<string name="cordn_coordinators_server">Says it is: %1$s</string>
<string name="cordn_coordinators_server_silent">Answered, but named nothing.</string>
<string name="cordn_coordinators_server_claim">A coordinator\'s name and version are strings it chose. Its public key is the only thing that identifies it.</string>
<string name="cordn_coordinators_health_ok">Answering.</string>
<string name="cordn_coordinators_health_retrying">A call failed; retrying.</string>
<string name="cordn_coordinators_health_down">Not answering — %1$d calls in a row have failed. Groups on it are not syncing.</string>
<string name="cordn_coordinators_health_unknown">Nothing asked of it yet.</string>
<string name="cordn_coordinators_remove">Remove</string>
<string name="cordn_coordinators_purge">Purge</string>
<string name="cordn_coordinators_purge_title">Purge this coordinator?</string>
<string name="cordn_coordinators_purge_body">Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had.</string>
<string name="cordn_keypackages_explainer">Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why.</string>
<plurals name="cordn_create_unreachable_count">
<item quantity="one">%1$d person cannot be reached by any coordinator you use</item>
<item quantity="other">%1$d people cannot be reached by any coordinator you use</item>
</plurals>
<plurals name="cordn_groups_count">
<item quantity="one">%1$d group</item>
<item quantity="other">%1$d groups</item>
</plurals>
<plurals name="cordn_create_reach_count">
<item quantity="one">Reachable on %1$d coordinator</item>
<item quantity="other">Reachable on %1$d coordinators</item>
</plurals>
<plurals name="cordn_create_admin_count">
<item quantity="one">%1$d person can add and remove</item>
<item quantity="other">%1$d people can add and remove</item>
</plurals>
<plurals name="cordn_create_action_invite">
<item quantity="one">Create and invite %1$d person</item>
<item quantity="other">Create and invite %1$d people</item>
</plurals>
<plurals name="cordn_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="cordn_keypackages_summary">
<item quantity="one">%1$d single-use package available.</item>
<item quantity="other">%1$d single-use packages available.</item>
</plurals>
<string name="cordn_keypackages_last_resort_yes">A last-resort package is published, so an invitation can still be made once the single-use ones run out.</string>
<string name="cordn_keypackages_last_resort_no">No last-resort package. Once the single-use ones run out, invitations fail.</string>
<plurals name="cordn_keypackages_orphans">
<item quantity="one">%1$d package this device cannot open</item>
<item quantity="other">%1$d packages this device cannot open</item>
</plurals>
<string name="cordn_keypackages_orphans_body">The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open.</string>
<string name="cordn_keypackages_withdraw_orphans">Withdraw those</string>
<string name="cordn_keypackages_disclosure">Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you.</string>
<string name="cordn_keypackages_publish">Publish one</string>
<string name="cordn_keypackages_publish_last_resort">Publish last-resort</string>
<string name="cordn_keypackages_withdraw_all">Withdraw all</string>
<string name="cordn_keypackages_topup_note">Once you have published here, Amethyst quietly replaces packages as they are used up. It never publishes the first one for you.</string>
<string name="cordn_keypackages_failed">Could not read the key packages.</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_flag_forum">Forum channel</string>
<string name="buzz_channel_flag_forum_desc">Threaded posts instead of a chat timeline. This cannot be changed later.</string>
@@ -568,8 +812,6 @@
<!-- Marmot (MLS) group chats -->
<string name="app_name_debug" translatable="false">Amy Debug</string>
<string name="app_name_benchmark" translatable="false">Amy Benchmark</string>
<!-- Buzz workflow run board -->
@@ -582,7 +824,6 @@
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
rationale screen. Needs to be an Android resource (not a commons Compose resource)
because android:label on the manifest entry can only reference @string/. -->
<string name="health_connect_rationale_activity_label">Health Connect and Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
@@ -650,6 +891,18 @@
<string name="voice_post">Voice Post</string>
<string name="voice_reply">Voice Reply</string>
<string name="pow_kind_report">Report</string>
<!-- Emoji packs -->
<string name="app_name_debug" translatable="false">Amy Debug</string>
<string name="app_name_benchmark" translatable="false">Amy Benchmark</string>
<string name="health_connect_rationale_activity_label">Health Connect and Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<plurals name="cordn_invitations_members">
<item quantity="one">%1$d person is already in this group</item>
<item quantity="other">%1$d people are already in this group</item>
</plurals>
<string name="private_message">Private Message</string>
<string name="pow_kind_chat_message">Chat message</string>
<string name="nest_notification_broadcasting">Nest — Live</string>
@@ -996,4 +1249,8 @@
<string name="backup_profile_field_birthday">Birthday</string>
<string name="backup_profile_field_clink_offer">CLINK offer</string>
<string name="backup_profile_field_bot">Bot flag</string>
<plurals name="cordn_info_admins_kept">
<item quantity="one">The group\'s %1$d admin stays as it is.</item>
<item quantity="other">The group\'s %1$d admins stay as they are.</item>
</plurals>
</resources>
@@ -0,0 +1,60 @@
/*
* 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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.disambiguate
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Telling two coordinators apart on the discovery list.
*
* An announced name is the coordinator's own word for itself and collides
* constantly: the reference server ships as "My coordinator", so a run against
* a public relay comes back with a dozen rows carrying that name and nothing
* else to separate them.
*/
class CordnCoordinatorNameTest {
@Test
fun `a name nobody else uses is left alone`() {
val names = listOf("cordn-net", "My coordinator")
assertEquals("cordn-net", disambiguate("cordn-net", "aabbccdd11223344", names))
}
@Test
fun `a colliding name gains its key`() {
val names = listOf("My coordinator", "My coordinator", "cordn-net")
assertEquals("My coordinator · aabbccdd", disambiguate("My coordinator", "aabbccdd11223344", names))
}
@Test
fun `two that collide get different suffixes`() {
val names = listOf("My coordinator", "My coordinator")
val first = disambiguate("My coordinator", "aaaaaaaa11112222", names)
val second = disambiguate("My coordinator", "bbbbbbbb33334444", names)
assertEquals("My coordinator · aaaaaaaa", first)
assertEquals("My coordinator · bbbbbbbb", second)
}
}
@@ -0,0 +1,77 @@
/*
* 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
import com.vitorpamplona.amethyst.ui.actions.uploads.downsampled
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The bars a recording reports.
*
* Sampling used to run once a second, so a five-second note produced five
* numbers and the drawn waveform could not track speech at all. It now samples
* ten times a second and reduces here, which is only worth doing if the
* reduction keeps the shape and bounds the size.
*/
class VoiceWaveformDownsampleTest {
@Test
fun `a short recording is left exactly as it is`() {
val short = listOf(1f, 2f, 3f)
assertEquals(short, short.downsampled(100))
}
@Test
fun `a long recording is bounded`() {
// Ten minutes at ten samples a second. These travel inside events and
// imeta tags, so the size has to depend on the detail wanted rather
// than on how long somebody spoke.
val long = List(6000) { it.toFloat() }
assertEquals(100, long.downsampled(100).size)
}
@Test
fun `reducing averages rather than dropping samples`() {
// Taking every Nth would let one loud frame stand for a whole bucket
// and make the bars flicker with the sampling phase. The first bucket
// here averages 0..9, whose mean is 4.5.
val ramp = List(1000) { it.toFloat() }
val reduced = ramp.downsampled(100)
assertEquals(4.5f, reduced.first(), 0.001f)
assertEquals(994.5f, reduced.last(), 0.001f)
}
@Test
fun `a loud burst survives the reduction`() {
// Averaging must not flatten real signal away: a quiet recording with
// one loud moment should still show that moment.
val quietWithBurst = MutableList(1000) { 100f }.also { for (i in 500..509) it[i] = 30000f }
val reduced = quietWithBurst.downsampled(100)
assertTrue("the burst was averaged away: ${reduced.max()}", reduced.max() > 10000f)
}
}
@@ -0,0 +1,132 @@
/*
* 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.cordn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.cordnGroupPositionFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.sameDayAs
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* Where a cordn bubble sits in a run of its sender's messages.
*
* The feed is reverse-laid-out, so the newer/older argument order is easy to invert and
* impossible to notice from the code alone — an inverted version still produces
* plausible-looking bubbles, just with the tail on the wrong end of every burst. These
* assert the ends of a run by name.
*/
class CordnMessageGroupingTest {
private val alice: HexKey = "aa".repeat(32)
private val bob: HexKey = "bb".repeat(32)
/** Local noon today, so a ±10 minute window never crosses midnight by accident. */
private val noon =
LocalDate
.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault())
.plusHours(12)
.toEpochSecond()
private var cursor = 0L
private fun msg(
author: HexKey,
at: Long,
) = CordnDeliveredMessage(
envelope = CordnEnvelope.build(author, at, 9, emptyArray(), "hi"),
cursor = ++cursor,
)
@Test
fun `a lone message is SINGLE`() {
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, msg(alice, noon), null))
}
@Test
fun `a run of three from one sender is TOP MIDDLE BOTTOM oldest first`() {
val oldest = msg(alice, noon)
val middle = msg(alice, noon + 60)
val newest = msg(alice, noon + 120)
// The oldest of a run renders at the visual top of the burst and carries the
// author line; the newest carries the bubble tail.
assertEquals(ChatGroupPosition.TOP, cordnGroupPositionFor(middle, oldest, null))
assertEquals(ChatGroupPosition.MIDDLE, cordnGroupPositionFor(newest, middle, oldest))
assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, newest, middle))
}
@Test
fun `a different sender between them breaks the run`() {
val mine = msg(alice, noon)
val theirs = msg(bob, noon + 60)
val mineAgain = msg(alice, noon + 120)
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(theirs, mine, null))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(mineAgain, theirs, mine))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, mineAgain, theirs))
}
@Test
fun `the shared ten-minute window is what decides, not a cordn-local one`() {
val first = msg(alice, noon)
// Inside the window the run holds...
val nine = msg(alice, noon + 9 * 60)
assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, nine, first))
// ...and just past it, it breaks. A cordn-local 5-minute window (what this
// screen used before adopting the shared one) would already have broken at 9.
val eleven = msg(alice, noon + 11 * 60)
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, eleven, first))
}
@Test
fun `a run does not cross local midnight even inside the time window`() {
val midnight =
LocalDate
.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault())
.toEpochSecond()
val lastNight = msg(alice, midnight - 120)
val thisMorning = msg(alice, midnight + 120)
// Four minutes apart, same sender — but a day separator lands between them, so
// joining them into one burst would draw the separator inside a sealed bubble run.
assertFalse(thisMorning.sameDayAs(lastNight))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, thisMorning, lastNight))
}
@Test
fun `sameDayAs treats a missing neighbour as a new day`() {
// Drives the day separator above the very first message in the list.
assertFalse(msg(alice, noon).sameDayAs(null))
assertTrue(msg(alice, noon).sameDayAs(msg(bob, noon + 3600)))
}
}
@@ -0,0 +1,743 @@
/*
* 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.cordn
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher
import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLink
import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory
import com.vitorpamplona.amethyst.commons.cordn.CordnHandedOffException
import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.quartz.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef
import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator
import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage
import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* The rules `CordnRuntime` holds, made executable.
*
* It is the assembly point for everything cordn: the KeyPackage pool policy,
* purge, restore, the invitation fan-out. Every one of those was a sentence in
* a commit message and none of them was a test, which is the combination that
* makes a rule quietly stop being true.
*
* Two seams make this possible without a device or a relay. `cipher` was
* always one, because the Android KeyStore cannot run in a JVM unit test.
* `links` is the second: production opens a real ContextVM transport per
* coordinator, so without it nothing in this class could be reached at all.
* Both default to the real thing.
*/
class CordnRuntimeTest {
/** Reversible and unmistakably not the input — the same trick the store tests use. */
private class XorCipher : CordnBlobCipher {
override fun encrypt(bytes: ByteArray) = ByteArray(bytes.size) { (bytes[it].toInt() xor 0x5A).toByte() }
override fun decrypt(bytes: ByteArray) = encrypt(bytes)
}
private val signer = NostrSignerInternal(KeyPair())
private val account: HexKey get() = signer.pubKey
private val coordinatorA = StubCoordinator()
private val coordinatorB = StubCoordinator()
private val keyA = "a".repeat(64)
private val keyB = "b".repeat(64)
private val root =
File.createTempFile("cordn-runtime", "").also {
it.delete()
it.mkdirs()
}
private val scopes = mutableListOf<CoroutineScope>()
/**
* Anything a runtime coroutine threw and nobody caught.
*
* A bare `CoroutineScope(Dispatchers.Default + Job())` has no handler, so
* an exception escaping a `launch` goes to the THREAD's uncaught handler —
* out of this test entirely and into the JVM the whole module shares. The
* next test to call `runTest` then fails with
* `UncaughtExceptionsBeforeTest`, blaming a test that did nothing wrong.
* That is a miserable failure to chase: it is a race between a coroutine
* outliving its test and the next one starting, so it moves between
* classes and build variants and vanishes when run in isolation.
*
* Collecting them here keeps them inside this suite, and [cleanup] fails
* this test rather than a stranger's.
*/
private val leaked = java.util.concurrent.CopyOnWriteArrayList<Throwable>()
private fun configFor(pubKey: HexKey) =
CoordinatorConfig(
pubKey = pubKey,
relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!),
)
/**
* Every connection opened, newest last.
*
* One per `connect`, so a coordinator whose relays were corrected appears
* twice -- which is the only way to tell whether the sync loop followed the
* reopen or stayed on the connection that was replaced. The stub behind
* them is shared per pubkey, so key packages and published state survive a
* reopen exactly as the real stores do.
*/
private val connections = java.util.concurrent.CopyOnWriteArrayList<CountingCoordinator>()
/** Hands each coordinator pubkey its own stub, with no transport in between. */
private val links =
CordnCoordinatorLinkFactory { _, config ->
val counting =
CountingCoordinator(if (config.pubKey == keyA) coordinatorA else coordinatorB)
.also { connections += it }
object : CordnCoordinatorLink {
override val coordinator = counting
override suspend fun close() = Unit
}
}
/**
* A scope for the runtime, on real threads.
*
* Real rather than a `TestScope` dispatcher, and that is forced rather
* than chosen: `maintainKeyPackages` reads the KeyPackage store, which
* hops to `Dispatchers.IO`. Virtual time cannot reach across that, so no
* amount of `advanceUntilIdle()` makes the upkeep deterministic — an
* earlier version of this suite looked green while the work had simply not
* run yet, which is the failure mode a test is supposed to prevent.
*
* Its own `Job`, so nothing waits on `CordnSyncLoop`, whose design is
* never to return; cancelled in [cleanup].
*/
private fun runtimeScope() =
CoroutineScope(
Dispatchers.Default + Job() +
CoroutineExceptionHandler { _, throwable -> leaked += throwable },
).also { scopes += it }
private fun runtime(
scope: CoroutineScope,
client: INostrClient = EmptyNostrClient(),
) = CordnRuntime(
accountSigner = signer,
client = client,
filesDir = root,
scope = scope,
cipher = XorCipher(),
links = links,
)
/**
* Waits for [condition], or fails by name.
*
* A bounded poll rather than a barrier, for the reason above: the work is
* on another dispatcher. The budget is orders of magnitude more than it
* needs, so a timeout here is a real failure and not a slow machine.
*/
private suspend fun awaitUntil(
what: String,
condition: () -> Boolean,
) {
withTimeoutOrNull(AWAIT_BUDGET_MS) {
while (!condition()) delay(POLL_MS)
} ?: error("timed out waiting for $what")
}
/**
* Gives the runtime's detached upkeep room to run, then returns.
*
* Only used before asserting something did NOT happen. Weaker than a
* barrier by nature — the honest way to assert a negative about another
* dispatcher is to give it far more time than it needs and look again.
*/
private suspend fun settle() = delay(SETTLE_MS)
@After
fun cleanup() {
scopes.forEach { it.cancel() }
root.deleteRecursively()
// Cancellation is not a leak; anything else is, and it would otherwise
// have surfaced as an unrelated test failing somewhere else entirely.
val real = leaked.filterNot { it is CancellationException }
check(real.isEmpty()) { "a runtime coroutine leaked: ${real.joinToString { it.toString() }}" }
}
/**
* Records every REQ, so a test can say which relays were asked what.
*
* [EmptyNostrClient] answers nothing, which is all this needs: the
* assertion is about the filter that goes out, not the events that come
* back.
*/
private class RecordingClient(
delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
val reqs = java.util.concurrent.CopyOnWriteArrayList<Map<NormalizedRelayUrl, List<Filter>>>()
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
reqs += filters
}
}
@Test
fun `opening a coordinator asks its own relays who it is`() =
runBlocking {
// The screens render a coordinator as the Nostr user it is, which
// puts it in LocalCache and sets the user-metadata machinery looking
// for its kind 0. Left alone, that lookup has no outbox to go on and
// falls through to THIS ACCOUNT's index and home relays -- telling
// them the account is interested in a pubkey that CEP-6
// announcements publicly identify as a coordinator. Fetching the
// kind 0 and the kind 10002 here, from the coordinator's own relays,
// is what stops that: with a relay list cached the outbox finder
// issues no discovery filter at all.
val client = RecordingClient()
val config = configFor(keyA)
runtime(runtimeScope(), client).session(config)
awaitUntil("the coordinator's profile to be requested") { client.reqs.isNotEmpty() }
val profileReqs =
client.reqs.filter { req ->
req.values.any { filters ->
filters.any { it.authors == listOf(keyA) }
}
}
assertTrue("no request carried the coordinator as its author", profileReqs.isNotEmpty())
profileReqs.forEach { req ->
assertEquals(
"asked the wrong relays -- this must not reach the account's own relays",
config.relays.toSet(),
req.keys,
)
req.values.flatten().forEach {
assertEquals(
"expected the profile, the relay list and the announcement (CEP-23, CEP-17, CEP-6)",
listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND, CvmKinds.SERVER_ANNOUNCEMENT),
it.kinds,
)
}
}
}
@Test
fun `correcting a coordinator's relays moves the sync loop onto the new connection`() =
runBlocking {
// The registry cannot carry a transport across a relay change, so it
// opens a new one and builds a new manager over it. CordnSyncLoop
// captures its source for life, so a loop kept across that swap goes
// on polling the connection that was just closed -- the coordinator
// then retries forever and its rooms never update again, with a
// restart the only way out.
val runtime = runtime(runtimeScope())
// A group first: the loop has nothing to subscribe to until the
// coordinator holds one, so a bare session would never subscribe and
// the assertion below would pass for the wrong reason.
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "moved-gid")
awaitUntil("the first connection to be subscribed") {
connections.firstOrNull()?.subscriptions?.let { it > 0 } == true
}
runtime.session(
configFor(keyA).copy(
relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://moved.example.com")!!),
),
)
assertEquals("the relay change did not open a second connection", 2, connections.size)
awaitUntil("the loop to follow the reopen onto the new connection") {
connections[1].subscriptions > 0
}
}
@Test
fun `renaming a coordinator keeps the connection it already had`() =
runBlocking {
// The registry reopens the transport for a config that differs,
// because the transport is bound to the relays -- and that builds a
// NEW manager while CordnSyncLoop goes on holding the old one, whose
// scope has just been closed. A label is not part of the address, so
// renaming must not take that path.
val runtime = runtime(runtimeScope())
val config = configFor(keyA)
runtime.session(config)
val before = runtime.sessionOrNull(keyA)?.manager
assertNotNull("no session to rename", before)
runtime.relabel(keyA, " My coordinator ")
assertSame(
"renaming reopened the transport, which orphans the running sync loop",
before,
runtime.sessionOrNull(keyA)?.manager,
)
assertEquals(
"the label is not trimmed to what the user meant",
"My coordinator",
runtime.coordinators.value
.first { it.pubKey == keyA }
.label,
)
}
@Test
fun `clearing a coordinator's name stores no name rather than an empty one`() =
runBlocking {
// An empty label would render as a blank line where a name goes, and
// the display chain could never fall through to the profile or the
// key behind it.
val runtime = runtime(runtimeScope())
runtime.session(configFor(keyA))
runtime.relabel(keyA, "named")
runtime.relabel(keyA, " ")
assertNull(
runtime.coordinators.value
.first { it.pubKey == keyA }
.label,
)
}
@Test
fun `opening a coordinator with nothing published does not publish a key package`() =
runBlocking {
// §8.4: publishing tells a coordinator this account exists and is
// invitable, permanently. Doing that on someone's behalf at login
// is the decision this rule exists to refuse.
val runtime = runtime(runtimeScope())
runtime.session(configFor(keyA))
settle()
assertFalse("published without being asked", coordinatorA.published.isNotEmpty())
}
@Test
fun `a forgotten coordinator stays forgotten across a relaunch`() =
runBlocking {
// `remember()` merges the open sessions over what is on disk rather
// than replacing it, because a coordinator whose relay was down
// this launch is missing from the open set and saving that set
// verbatim would erase it. A forgotten coordinator is missing for
// the other reason and looks identical, so the merge protected it
// too and wrote it straight back: Remove worked until the next
// launch and then undid itself.
val runtime = runtime(runtimeScope())
runtime.session(configFor(keyA))
runtime.session(configFor(keyB))
settle()
runtime.forget(keyA)
settle()
// A second runtime over the same files is the relaunch: it reads
// the coordinator list back off disk, which is where the removal
// either stuck or did not.
val reopened = runtime(runtimeScope())
reopened.restore()
settle()
val keys = reopened.coordinators.value.map { it.pubKey }
assertFalse("the forgotten coordinator came back", keys.contains(keyA))
assertTrue("the other coordinator was lost with it", keys.contains(keyB))
}
@Test
fun `opening a coordinator that already has one tops the pool back up`() =
runBlocking {
// The other half of the rule. Once a package is published there the
// coordinator already knows, and a drained pool fails the next
// invitation for a reason the inviter sees and the invitee never
// does.
val runtime = runtime(runtimeScope())
runtime.session(configFor(keyA))
runtime.publishKeyPackage(keyA)
val afterFirst = coordinatorA.published.size
// A second runtime over the same files: a relaunch, with one
// package already published and its private half on disk.
runtime(runtimeScope()).session(configFor(keyA))
awaitUntil("the pool to be topped up after a relaunch") { coordinatorA.published.size > afterFirst }
}
@Test
fun `purging one coordinator leaves the other's groups on disk`() =
runBlocking {
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "shared-gid")
runtime.createGroup(configFor(keyB), CordnGroupMetadata(name = "B"), gid = "shared-gid")
runtime.purge(keyA)
// A gid is unique only within one coordinator, so both hold
// "shared-gid" as unrelated groups. Deleting a level too high takes
// both, and the only visible symptom is a room that vanished.
assertFalse(CordnStorageLayout.directoryFor(root, account, keyA).exists())
assertTrue(CordnStorageLayout.directoryFor(root, account, keyB).exists())
assertEquals(
listOf(keyB),
runtime.groups.all.value
.map { it.coordinatorPubKey },
)
}
@Test
fun `an archive from another account is refused`() =
runBlocking {
// Restoring one account's groups under another's key gives a device
// MLS state whose credentials name somebody else; every Commit it
// made would be rejected by the rest of the group.
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine")
val archive = runtime.exportArchive("pw")
val stranger =
CordnRuntime(
accountSigner = NostrSignerInternal(KeyPair()),
client = EmptyNostrClient(),
filesDir = root,
scope = runtimeScope(),
cipher = XorCipher(),
links = links,
)
val failure = runCatching { stranger.importArchive(archive, "pw") }.exceptionOrNull()
assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException)
}
@Test
fun `a snapshot carries the groups, their cursors and their key packages`() =
runBlocking {
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine")
val snapshot = runtime.migrationSnapshot()
assertEquals(listOf("mine"), snapshot.groups.map { it.gid })
assertEquals(keyA, snapshot.groups[0].coordinatorPubKey)
assertTrue(snapshot.groups[0].coordinatorRelays.isNotEmpty())
assertTrue(snapshot.groups[0].clientStateBase64.isNotEmpty())
}
@Test
fun `adopting a snapshot replaces this device's groups rather than merging them`() =
runBlocking {
// Same rule as restoring a backup, and for the same reason: a merge
// is what produces two devices holding one group's state.
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the snapshot"), gid = "kept")
val snapshot = runtime.migrationSnapshot()
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped")
runtime.adoptMigration(snapshot)
assertEquals(
setOf("kept"),
runtime.groups.all.value
.map { it.gid }
.toSet(),
)
}
@Test
fun `a snapshot from another account is refused`() =
runBlocking {
val runtime = runtime(runtimeScope())
val snapshot = runtime.migrationSnapshot().copy(accountPubKey = "ff".repeat(32))
val failure = runCatching { runtime.adoptMigration(snapshot) }.exceptionOrNull()
assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException)
}
@Test
fun `a handed-off device refuses to open a session`() =
runBlocking {
// The fork guard. Without it both phones hold one leaf and both
// commit, and MLS does not recover from that.
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine")
runtime.handoff.markHandedOff()
val failure = runCatching { runtime.session(configFor(keyA)) }.exceptionOrNull()
assertTrue("expected a refusal, got $failure", failure is CordnHandedOffException)
}
@Test
fun `cancelling a handoff lets the device work again`() =
runBlocking {
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine")
runtime.handoff.markHandedOff()
runtime.cancelHandOff()
assertNotNull(runtime.session(configFor(keyA)))
}
@Test
fun `adopting a snapshot clears a handoff, because this device now holds the newest copy`() =
runBlocking {
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine")
val snapshot = runtime.migrationSnapshot()
runtime.handoff.markHandedOff()
runtime.adoptMigration(snapshot)
assertFalse(runtime.handoff.handedOff.value)
}
@Test
fun `restoring replaces this device's groups rather than merging them`() =
runBlocking {
// The rule the confirmation dialog states. A merge is what produces
// two devices holding one group's state, and MLS does not recover
// from the fork that follows.
val runtime = runtime(runtimeScope())
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the backup"), gid = "kept")
val archive = runtime.exportArchive("pw")
runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped")
assertEquals(
setOf("kept", "dropped"),
runtime.groups.all.value
.map { it.gid }
.toSet(),
)
runtime.importArchive(archive, "pw")
assertEquals(
setOf("kept"),
runtime.groups.all.value
.map { it.gid }
.toSet(),
)
}
@Test
fun `a coordinator that will not answer is reported, not counted as empty`() =
runBlocking {
// "We could not ask" and "there is nothing for you" look identical
// on screen and mean opposite things.
val runtime = runtime(runtimeScope())
runtime.session(configFor(keyA))
coordinatorA.failEverything = true
val invitations = runtime.invitations()
assertTrue(invitations.pending.isEmpty())
assertEquals(listOf(keyA), invitations.unreachable.map { it.coordinator.pubKey })
assertFalse("an unreachable coordinator is not an empty inbox", invitations.isEmpty)
}
/**
* One opened connection, counting what was asked of it.
*
* Only the subscribe matters: it is what `CordnSyncLoop` does forever, so a
* connection that is never subscribed is one no loop is pointing at.
*/
private class CountingCoordinator(
private val delegate: ICoordinator,
) : ICoordinator by delegate {
@Volatile
var subscriptions = 0
private set
override suspend fun subscribeMessages(
cursors: Map<String, Long?>,
timeoutMs: Long,
onMessage: (GroupMessage) -> Unit,
) {
subscriptions++
delegate.subscribeMessages(cursors, timeoutMs, onMessage)
}
}
/**
* Just enough coordinator to let the runtime run.
*
* Not a fidelity fixture — `CordnFixtureCoordinator` is that, and it sits
* behind a transport because it answers `CvmRequest`s. What these tests
* need is the `ICoordinator` surface the runtime talks to, with a way to
* make it stop answering.
*/
private class StubCoordinator : ICoordinator {
val published = mutableMapOf<String, String>()
var failEverything = false
private var clock = 1L
private fun guard() {
if (failEverything) throw IllegalStateException("coordinator is down")
}
override suspend fun publishKeyPackage(
keyPackageRef: String,
keyPackageBase64: String,
): PublishedKeyPackage {
guard()
published[keyPackageRef] = keyPackageBase64
return PublishedKeyPackage(keyPackageRef, false, clock++)
}
override suspend fun removeKeyPackages(keyPackageRefs: List<String>): List<String> {
guard()
return keyPackageRefs.filter { published.remove(it) != null }
}
override suspend fun listKeyPackages(): List<AvailableKeyPackage> {
guard()
return published.keys.map { AvailableKeyPackage(OWNER, it, false, clock) }
}
override suspend fun takeKeyPackage(id: String): TakenKeyPackage? {
guard()
return null
}
override suspend fun storeWelcome(
targetPubKey: HexKey,
keyPackageRef: String,
welcomeBase64: String,
after: Long?,
): Long {
guard()
return clock++
}
override suspend fun takeWelcomes(consumed: List<ConsumedWelcomeRef>): List<PendingWelcome> {
guard()
return emptyList()
}
override suspend fun storeJoinRequest(
gid: String,
keyPackageRef: String,
): Long {
guard()
return clock++
}
override suspend fun takeJoinRequests(
gids: List<String>,
consumed: List<ConsumedJoinRequestRef>,
): List<JoinRequest> {
guard()
return emptyList()
}
override suspend fun postMessage(
gid: String,
sealedBase64: String,
): PostedMessage {
guard()
return PostedMessage(gid, clock++, clock)
}
override suspend fun fetchMessages(cursors: Map<String, Long?>): List<GroupMessage> {
guard()
return emptyList()
}
override suspend fun subscribeMessages(
cursors: Map<String, Long?>,
timeoutMs: Long,
onMessage: (GroupMessage) -> Unit,
) {
guard()
// Suspends for the whole window, like a real subscription: the
// coordinator holds the call open and returns when it closes.
// Returning immediately turns `CordnSyncLoop` into a spin, which
// on a virtual-time dispatcher is an unbounded one.
delay(timeoutMs)
}
private companion object {
/** Whose packages these are. The runtime filters `kp_list` by account. */
const val OWNER = "owner"
}
}
private companion object {
const val AWAIT_BUDGET_MS = 10_000L
const val POLL_MS = 10L
/** Long enough that upkeep which was going to happen already has. */
const val SETTLE_MS = 1_000L
}
}
@@ -0,0 +1,135 @@
/*
* 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.cordn
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* The screen half of the Marmot/cordn boundary.
*
* `CordnIndependenceTest` in `commons` guards the models and the protocol
* code; this guards the Android screens, which is where the coupling would
* actually be convenient. A cordn chat screen that imported a Marmot composable
* would work — and would then make Marmot's UI, which is frozen, unable to
* change without breaking cordn.
*
* What cordn may share is what any feature shares: the design system, the
* theme, navigation, and generic components. The line is drawn at
* `marmotGroup`-named code, because that is where a real coupling would land.
*
* Third of the three guards §3.1 of `amethyst/plans/2026-09-19-cordn-ui.md`
* asks for, added with the first screen rather than after the fifth.
*/
class CordnScreenIndependenceTest {
private val screensRoot: File by lazy {
// Resolved, not assumed: a wrong root makes an architecture test pass
// for the wrong reason, which is worse than not having one.
generateSequence(File(".").absoluteFile) { it.parentFile }
.map { File(it, "amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats") }
.firstOrNull { it.isDirectory }
?: error("cannot locate the chats screen package from ${File(".").absolutePath}")
}
private fun kotlinFilesIn(pkg: String): List<File> =
File(screensRoot, pkg)
.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.toList()
private fun importsMatching(
pkg: String,
forbidden: Regex,
): List<String> =
kotlinFilesIn(pkg).flatMap { file ->
file.readLines().withIndex().mapNotNull { (i, line) ->
val trimmed = line.trimStart()
if (trimmed.startsWith("import ") && forbidden.containsMatchIn(trimmed)) {
"${file.name}:${i + 1} $trimmed"
} else {
null
}
}
}
/**
* Plain substring, NOT `\bmarmot\b`.
*
* The word-boundary form cannot match `…marmotGroups.MarmotGroupChatroom`
* — a word character follows `marmot` both times, so `\b` fails and the
* guard passes exactly the import it exists to stop. Only `import` lines
* are scanned, so a substring is both safe and the correct test.
*/
private val marmotImport = Regex("marmot", RegexOption.IGNORE_CASE)
private val cordnImport = Regex("cordn", RegexOption.IGNORE_CASE)
@Test
fun `the matcher catches the import it exists to catch`() {
// A guard that can never fire is indistinguishable from a codebase
// that never violates the rule. This tells them apart.
assertTrue(
"the Marmot matcher would not flag a real Marmot import — the guard cannot fire",
marmotImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom"),
)
assertTrue(
"the cordn matcher cannot fire either",
cordnImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom"),
)
}
@Test
fun `the cordn screens do not reach into Marmot's`() {
val offences = importsMatching("cordnGroup", marmotImport)
assertTrue(
"cordn screens must not import Marmot — Marmot's UI is frozen and cordn's is not\n " +
offences.joinToString("\n "),
offences.isEmpty(),
)
}
@Test
fun `Marmot's screens do not reach into cordn's`() {
val offences = importsMatching("marmotGroup", cordnImport)
assertTrue(
"Marmot screens must not import cordn — cordn moves and Marmot is frozen\n " +
offences.joinToString("\n "),
offences.isEmpty(),
)
}
@Test
fun `the scan sees real files and real imports`() {
// Without this the two tests above pass just as happily when the
// scanner is pointed at nothing at all.
listOf("cordnGroup", "marmotGroup").forEach {
assertTrue(
"found ${kotlinFilesIn(it).size} files under $it — the scan is broken, not the code",
kotlinFilesIn(it).size >= 2,
)
}
assertTrue(
"the scanner found no amethyst imports in cordnGroup, so it would not find a marmot one either",
importsMatching("cordnGroup", Regex("amethyst", RegexOption.IGNORE_CASE)).isNotEmpty(),
)
}
}
+59
View File
@@ -680,6 +680,65 @@ also carried on-relay as an encrypted kind:13302.
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. |
### cordn (MLS over an MCP coordinator)
MLS group chat where delivery is one **coordinator** — an MCP server reached
over ContextVM — instead of relays. State lives under
`~/.amy/<account>/cordn/`, encrypted with a key at
`~/.amy/<account>/cordn/blob.key`; the coordinator list is encrypted beside it.
Two things shape every verb:
- **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so
`--coordinator` is part of a group's address. Every live verb takes
`[--coordinator PK] [--relay URL[,URL…]]`, and you can leave them off only
while exactly one coordinator is remembered. A coordinator has no address
besides its pubkey (§8.5), which is why its relays have to be remembered
rather than looked up.
- **Delivery is pulled, not pushed.** A CLI run is a process and cannot hold a
subscription, so `amy cordn fetch` drains what the cursor has not seen and
exits. Nothing arrives while amy is not running; the coordinator holds the
ordered stream until asked.
| Command | What it does |
|---|---|
| `amy cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L]` | Remember a coordinator. Local only — nothing is announced to it. |
| `amy cordn coordinator list` | Coordinators this account knows, with their relays. |
| `amy cordn coordinator info` | The MCP `initialize` handshake. Every field is a claim the coordinator signed with the key that was already answering (§8.5) — the pubkey is the identity, the name is what an operator typed. |
| `amy cordn coordinator forget --coordinator PK` | Drop it from the list. Local: the group state on disk is kept, and so is anything the coordinator holds. |
| `amy cordn keypackage publish [--last-resort] [--count N]` | Publish KeyPackages. **Attributable** (§8.4): a signed, re-servable record that this npub uses cordn here. |
| `amy cordn keypackage list` | Ours on the coordinator, and whether this device holds the private half that could open the Welcome one produces. |
| `amy cordn keypackage withdraw --kp-ref REF[,REF…] \| --all` | Remove them. Does not undo the exposure — that already happened. |
| `amy cordn group create --name N [--about A] [--gid GID] [--admin PK[,PK…]] [--icon I] [--image URL]` | Create a group. The `gid` is ours to choose and the coordinator never interprets it (§4); random unless given. No `--admin` means egalitarian **permanently** (`spec/01.md` §5.3). |
| `amy cordn group list` | Groups on this coordinator, with epoch and member count. |
| `amy cordn group info [--gid GID]` | Metadata, members, the shareable `cordn1…` ref, and what this coordinator learns about the group. |
| `amy cordn invite --pubkey PK [--gid GID] [--kp-ref REF]` | Take their KeyPackage, verify the publication payload binds it to that npub, commit, and leave a Welcome. |
| `amy cordn request --gid GID \| --ref cordn1…` | Ask to join. Publishes a KeyPackage first if none exists. Attributable (§8.1), and asking is not joining. |
| `amy cordn requests list` | Who is asking to join a group we hold. |
| `amy cordn requests accept --pubkey PK \| --all` / `decline` | Answer them. Any member may — `admin_pubkeys` is presentation metadata with nothing enforcing it (§5.3). |
| `amy cordn welcomes` | Open every pending invitation **without joining**: a Welcome is opaque until processed, so the gid, name and members can only be shown after opening it. |
| `amy cordn join --gid GID \| --all` / `decline` | Accept or refuse one. |
| `amy cordn send --text "…" [--gid GID]` | A kind-9 chat message. |
| `amy cordn send [--reply-to ID \| --react-to ID \| --edit ID \| --delete ID \| --pin ID \| --unpin ID] --to-author PK [--to-kind N]` | Annotate a message. `--to-author` is required because an annotation's tags name the target's author and kind, not just its id, and amy keeps no message store. |
| `amy cordn fetch` | Drain the stream and print it: messages, epoch changes, echoes, and anything undecryptable (reported, not hidden — it is a gap in a conversation). |
| `amy cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL…]]` | Build a `cordn1…` group reference. |
| `amy cordn ref decode REF` | Read one back. |
| `amy cordn exposure --coordinator PK [--groups N] [--published]` | What a coordinator would learn, before joining anything (§8). |
> A group ref is a **locator, not an invitation**: holding one lets you ask to
> join, it does not make you a member, and nothing obliges anyone to answer.
Two live harnesses, neither wired into any build — read
[`tests/cordn/stack.sh`](tests/cordn/stack.sh) first, it boots an unlicensed
reference coordinator:
- [`tests/cordn/tier-b.sh`](tests/cordn/tier-b.sh) — amy against amy through
the reference coordinator. Proves the transport and the coordinator client.
- [`tests/cordn/interop-client.sh`](tests/cordn/interop-client.sh) — amy and
the reference client (`@cordn/cli`, MIT) in one group. Proves the MLS layer
against a second implementation, in both directions, including a
public-framed Commit of ours that their engine has to apply.
### Geochat (Bitchat geohash channels)
Bitchat-interoperable public location chat: ephemeral kind:20000 events
@@ -239,6 +239,26 @@ class DataDir(
/** Inbound events this account has terminally decided about. */
val ingestDedupFile = File(marmotDir, "ingested.ids")
/**
* cordn state. `CordnStorageLayout` lays out `<account>/<coordinator>/`
* underneath, the same shape the Android app writes, so a directory can be
* read by either without a migration.
*/
val cordnDir = File(root, "cordn")
/**
* The key `KeyedCordnBlobCipher` encrypts cordn blobs with.
*
* A random 32 bytes at 0600, generated on first use. Be clear about what
* that buys: the blobs are genuinely encrypted, and the key sits beside
* them, so this protects a copied directory or a stale backup and not a
* process running as this user — which is already the threat model of
* every other file under `~/.amy/`. Android's equivalent key lives in the
* KeyStore and does protect against a reader of the device's storage; a
* CLI has nowhere comparable to put one.
*/
val cordnBlobKeyFile = File(cordnDir, "blob.key")
/**
* SQLite event-store DB file, a sibling of [eventsDir] under
* `<root>/shared/`. Used when the store backend is SQLite (the
@@ -272,6 +292,7 @@ class DataDir(
SecureFileIO.tighten(identityFile)
SecureFileIO.tighten(stateFile)
SecureFileIO.tighten(marmotDir)
SecureFileIO.tighten(cordnDir)
SecureFileIO.tighten(keyPackageBundleFile)
}
}
@@ -377,6 +377,13 @@ class Context(
*/
val cashu: CashuContext by lazy { CashuContext(this) }
/**
* cordn wiring (coordinator registry, MLS groups, key packages) — see
* [CordnContext]. Lazy for the same reason: a run that never says `cordn`
* opens no ContextVM transport and writes no blob key.
*/
val cordn: CordnContext by lazy { CordnContext(this) }
/** See [CashuContext.ops]. */
fun cashuOps(): CashuWalletOps = cashu.ops()
@@ -0,0 +1,203 @@
/*
* 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.cli
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher
import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry
import com.vitorpamplona.amethyst.commons.cordn.CordnLinks
import com.vitorpamplona.amethyst.commons.cordn.CordnSession
import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout
import com.vitorpamplona.amethyst.commons.cordn.FileBackedCordnScopeFactory
import com.vitorpamplona.amethyst.commons.cordn.FileCordnCoordinatorStore
import com.vitorpamplona.amethyst.commons.cordn.KeyedCordnBlobCipher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import java.io.File
/**
* cordn wiring for the CLI, split out of [Context] the way [CashuContext] is
* and built lazily, so a run that never says `cordn` opens no transport and
* generates no blob key.
*
* Everything below this class is shared: the registry, the manager, the
* stores, and `CordnLinks` are the same objects the Android app runs. What is
* here is the three things a CLI has to answer differently.
*
* **Where the blob key comes from.** Android hides it in the KeyStore; a CLI
* has nowhere comparable, so it is a 0600 file beside the blobs. See
* [DataDir.cordnBlobKeyFile] for what that does and does not protect.
*
* **There is no sync loop.** Each invocation is a process: it opens a
* session, does one thing, and exits. `CordnSyncLoop` is for a front end that
* stays running, and a CLI that started one would hang. So a `fetch` verb
* drains explicitly and the cursor on disk is what carries continuity between
* runs — which is exactly the primitive `spec/02.md` §7 says a cursor is.
*
* **A coordinator has to be named.** The app has a screen listing them; here
* `--coordinator` selects one, and is optional only while exactly one is
* remembered. Guessing would be worse than asking: a `gid` means nothing
* outside the coordinator that issued it (`spec/00.md` §4), so the wrong
* coordinator is not a slower answer, it is an answer about a different
* group.
*/
class CordnContext(
private val ctx: Context,
) {
private val accountPubKey: HexKey get() = ctx.identity.pubKeyHex
private val cipher: CordnBlobCipher by lazy { KeyedCordnBlobCipher(blobKey()) }
private val registry: CordnCoordinatorRegistry by lazy {
CordnCoordinatorRegistry(
accountPubKey = accountPubKey,
scopes =
FileBackedCordnScopeFactory(
root = ctx.dataDir.root,
cipher = cipher,
links = CordnLinks.over(ctx.signer, ctx.client),
),
)
}
private val coordinatorStore by lazy {
FileCordnCoordinatorStore(
CordnStorageLayout.accountDirectoryFor(ctx.dataDir.root, accountPubKey),
cipher,
)
}
/** Every coordinator this account has been told about, in the order added. */
suspend fun coordinators(): List<CoordinatorConfig> = coordinatorStore.load()
/**
* Adds [config], replacing any entry for the same pubkey.
*
* Keyed by pubkey because that IS the coordinator's identity (§8.5) — a
* second entry for the same key with different relays is a corrected
* address, not a second coordinator.
*/
suspend fun remember(config: CoordinatorConfig) {
val kept = coordinators().filterNot { it.pubKey == config.pubKey }
coordinatorStore.save(kept + config)
}
suspend fun forget(coordinatorPubKey: HexKey): Boolean {
val before = coordinators()
val after = before.filterNot { it.pubKey == coordinatorPubKey }
if (after.size == before.size) return false
coordinatorStore.save(after)
registry.forget(coordinatorPubKey)
return true
}
/** The live session for [config], restored from disk. */
suspend fun session(config: CoordinatorConfig): CordnSession =
registry.session(config).also {
it.manager.restore()
it.keyPackages.restore()
}
/** Where the cordn tree lives, for the migration verbs. */
val migrationRoot: File get() = ctx.dataDir.root
/** The at-rest cipher, so a migration can read and write the same blobs. */
val blobCipher: CordnBlobCipher get() = cipher
/** Replaces the remembered coordinator list, after adopting a migration. */
suspend fun replaceCoordinators(configs: List<CoordinatorConfig>) = coordinatorStore.save(configs)
/**
* Picks the coordinator a command should act on.
*
* `--coordinator` wins. With no flag and exactly one remembered, that one
* is used; with none or several, the caller is asked rather than guessed
* at. `--relay` is accepted alongside a pubkey so a first call can name a
* coordinator that is not remembered yet — that is how `coordinator add`
* and a one-shot `--coordinator … --relay …` are the same code path.
*/
suspend fun resolve(args: Args): CoordinatorConfig {
val requested = args.flag("coordinator")
val relays = relayFlag(args)
val known = coordinators()
if (requested == null) {
if (relays.isNotEmpty()) throw CordnArgException("--relay needs --coordinator to belong to")
return when (known.size) {
0 -> throw CordnArgException("no coordinator known yet: amy cordn coordinator add --coordinator PK --relay URL")
1 -> known.single()
else ->
throw CordnArgException(
"several coordinators known, name one with --coordinator: " +
known.joinToString(", ") { it.pubKey.take(8) },
)
}
}
val remembered = known.firstOrNull { it.pubKey == requested }
if (relays.isEmpty()) {
return remembered
?: throw CordnArgException("coordinator $requested is not known here; add --relay to reach it")
}
// A coordinator named with relays is reachable whether or not it was
// remembered, and the relays given now are the current ones.
return (remembered ?: CoordinatorConfig(pubKey = requested, relays = relays)).copy(relays = relays)
}
/** `--relay URL[,URL…]`; the flag map collapses repeats, so they arrive comma-separated. */
fun relayFlag(args: Args): List<NormalizedRelayUrl> =
args
.flag("relay")
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
.map { url ->
RelayUrlNormalizer.normalizeOrNull(url) ?: throw CordnArgException("not a relay URL: $url")
}
suspend fun close() = registry.close()
/**
* Reads the blob key, generating one on first use.
*
* Not derived from the account key on purpose. A derivation would mean a
* bunker-backed identity — which has no local private key at all — could
* not use cordn, and it would tie every blob to a key the user may rotate.
*/
private fun blobKey(): ByteArray {
val file = ctx.dataDir.cordnBlobKeyFile
if (file.exists()) {
val bytes = file.readBytes()
require(bytes.size == KeyedCordnBlobCipher.KEY_LENGTH) {
"$file is ${bytes.size} bytes, expected ${KeyedCordnBlobCipher.KEY_LENGTH}"
}
return bytes
}
return KeyedCordnBlobCipher.newKey().also { SecureFileIO.writeBytesAtomic(file, it) }
}
}
/** A bad-args failure a command turns into `Output.error("bad_args", …)`. */
class CordnArgException(
message: String,
) : IllegalArgumentException(message)
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
import com.vitorpamplona.amethyst.cli.commands.CordnCommands
import com.vitorpamplona.amethyst.cli.commands.CountCommand
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
import com.vitorpamplona.amethyst.cli.commands.CyberspaceCommands
@@ -337,6 +338,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
FofCommand.dispatch(dataDir, tail)
}
"concord" -> ConcordCommands.dispatch(dataDir, tail)
"cordn" -> CordnCommands.dispatch(dataDir, tail)
else -> {
Output.error("bad_args", "unknown subcommand: $head")
printVerbList()
@@ -359,7 +361,7 @@ private fun printVerbList() {
| primitives: decode encode verify key filter nip kind pow namecoin
| events: event publish fetch subscribe count sync encrypt decrypt gift
| social: notes profile follow unfollow search zap dm outbox
| groups: marmot relaygroup concord geochat
| groups: marmot relaygroup concord cordn geochat
| relays: relay admin serve store
| trust: graperank fof
| media/sites: blossom nsite napplet podcast podcast20 git
@@ -885,6 +887,27 @@ private fun printUsage() {
| concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone)
| concord join URL redeem an invite link and save the community
|
| cordn coordinator add --coordinator PK --relay URL[,URL]
| remember one (it has no other address)
| cordn coordinator list|info|forget what we know; MCP initialize; drop it
| cordn keypackage publish [--last-resort] [--count N]
| attributable: names this npub (§8.4)
| cordn keypackage list|withdraw ours on the coordinator
| cordn group create --name N [--gid GID] [--admin PK[,PK]]
| cordn group list|info [--gid GID] metadata, members, exposure
| cordn invite --pubkey PK [--gid GID] take their KeyPackage, commit, leave a Welcome
| cordn request --gid GID | --ref cordn1… ask to join (§8.1)
| cordn requests list|accept|decline answer askers (any member may, §5.3)
| cordn welcomes open invitations without joining
| cordn join|decline --gid GID | --all accept or refuse one
| cordn send --text "…" [--gid GID] a kind-9 chat message
| cordn fetch drain the stream and print it
| cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL]]
| build a cordn1… group reference
| cordn ref decode REF read one back
| cordn exposure --coordinator PK [--groups N] [--published]
| what that coordinator would learn (spec/00.md §8)
|
|Local event store (shared, under `<data-dir>/shared/`):
| Backend selected by AMY_STORE: sqlite (default; `shared/events.db`)
| or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is
@@ -0,0 +1,261 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.ExposureNote
import com.vitorpamplona.amethyst.commons.cordn.GroupExposure
import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* `amy cordn …` — the cordn (MLS-over-an-MCP-coordinator) surface.
*
* Two halves. The offline half needs no coordinator: the `cordn1…` group-ref
* codec, and the §8 metadata-exposure model. A ref is the one cordn artifact a
* human copies by hand, and `exposure` is how a script or a person checks what
* a coordinator would learn *before* joining anything.
*
* The rest drives a live coordinator — `coordinator`, `keypackage`, `group`,
* `invite`, `request`, `requests`, `welcomes`, `join`, `decline`, `send`,
* `fetch` — and lives in [CordnCoordinatorCommands] and
* [CordnGroupCommands]. They exist so the binding can be run end to end
* against a real counterparty from a shell script, which is the only kind of
* interop test that proves anything.
*
* Two shapes to know before reading the verbs:
*
* - **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so
* `--coordinator` is part of a group's address and not a convenience.
* Omitting it works only while exactly one coordinator is remembered.
* - **Delivery is pulled, not pushed.** A CLI invocation is a process and
* cannot hold a subscription, so `fetch` drains what the cursor has not seen
* and exits. The cursor on disk is the whole continuity mechanism between
* runs.
*/
object CordnCommands {
val USAGE: String =
"""
|cordn (MLS group chat over an MCP coordinator):
| offline — no coordinator needed:
| cordn ref encode --gid GID [--coordinator PK] build a cordn1… group reference
| [--relay URL[,URL…]]
| cordn ref decode REF read one back
| cordn exposure --coordinator PK what that coordinator would learn
| [--groups N] [--published] (spec/00.md §8)
|
| live — every verb below takes [--coordinator PK] [--relay URL[,URL…]],
| optional while exactly one coordinator is remembered:
| cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L]
| cordn coordinator list what this account knows
| cordn coordinator info MCP initialize; every field a claim
| cordn coordinator forget --coordinator PK local only; state is kept
| cordn keypackage publish [--last-resort] attributable (§8.4)
| [--count N]
| cordn keypackage list ours on the coordinator
| cordn keypackage withdraw --kp-ref REF[,REF…] | --all
| cordn group create --name N [--about A] gid is ours to choose (§4)
| [--gid GID] [--admin PK[,PK…]]
| [--icon I] [--image URL]
| cordn group list groups on this coordinator
| cordn group info [--gid GID] metadata, members, exposure
| cordn invite --pubkey PK [--gid GID] [--kp-ref REF]
| cordn request --gid GID | --ref cordn1… ask to join (§8.1)
| cordn requests list who is asking
| cordn requests accept --pubkey PK | --all any member may answer (§5.3)
| cordn requests decline --pubkey PK | --all
| cordn welcomes open invitations, joining none
| cordn join --gid GID | --all accept one
| cordn decline --gid GID | --all refuse and retire it
| cordn send --text "…" [--gid GID] a kind-9 chat message
| [--reply-to ID] [--react-to ID]
| cordn fetch drain the stream and print it
| cordn watch [--timeout MS] hold a live subscription
|
|A group ref is a locator, not an invitation: holding one lets you ASK to
|join, it does not make you a member. Relays say where to reach the
|coordinator and are meaningless without --coordinator.
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn",
tail,
"cordn <coordinator|keypackage|migrate|group|invite|request|requests|welcomes|join|decline|send|fetch|watch|ref|exposure>",
help = USAGE,
routes =
mapOf(
"ref" to { rest -> ref(rest) },
"exposure" to { rest -> exposure(rest) },
"coordinator" to { rest -> CordnCoordinatorCommands.coordinator(dataDir, rest) },
"keypackage" to { rest -> CordnCoordinatorCommands.keyPackage(dataDir, rest) },
"migrate" to { rest -> CordnMigrateCommands.migrate(dataDir, rest) },
"group" to { rest -> CordnGroupCommands.group(dataDir, rest) },
"invite" to { rest -> CordnGroupCommands.invite(dataDir, rest) },
"request" to { rest -> CordnGroupCommands.request(dataDir, rest) },
"requests" to { rest -> CordnGroupCommands.requests(dataDir, rest) },
"welcomes" to { rest -> CordnGroupCommands.welcomes(dataDir, rest) },
"join" to { rest -> CordnGroupCommands.join(dataDir, rest) },
"decline" to { rest -> CordnGroupCommands.decline(dataDir, rest) },
"send" to { rest -> CordnGroupCommands.send(dataDir, rest) },
"fetch" to { rest -> CordnGroupCommands.fetch(dataDir, rest) },
"watch" to { rest -> CordnGroupCommands.watch(dataDir, rest) },
),
)
private suspend fun ref(tail: Array<String>): Int =
route(
"cordn ref",
tail,
"cordn ref <encode|decode>",
help = USAGE,
routes =
mapOf(
"encode" to { rest -> encode(rest) },
"decode" to { rest -> decode(rest) },
),
)
private fun encode(tail: Array<String>): Int {
val args = Args(tail)
val gid = args.flag("gid") ?: return Output.error("bad_args", "cordn ref encode needs --gid")
val coordinator = args.flag("coordinator")
// The flag map collapses repeats, so several relays arrive comma-separated.
val relays =
args
.flag("relay")
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
args.rejectUnknown()
val normalized =
relays.map { url ->
RelayUrlNormalizer.normalizeOrNull(url)
?: return Output.error("bad_args", "not a relay URL: $url")
}
val ref =
try {
CordnGroupRef(gid, coordinator, normalized.map { it.url })
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "invalid group reference")
}
Output.emit(
mapOf(
"ref" to ref.encode(),
"gid" to ref.gid,
"coordinator" to ref.coordinatorPubKey,
"relays" to ref.relays,
),
)
return 0
}
private fun decode(tail: Array<String>): Int {
val args = Args(tail)
val encoded = args.positional.firstOrNull() ?: return Output.error("bad_args", "cordn ref decode needs a cordn1… reference")
args.rejectUnknown()
val ref =
try {
CordnGroupRef.decode(encoded)
} catch (e: IllegalArgumentException) {
return Output.error("bad_ref", e.message ?: "not a cordn group reference")
}
Output.emit(
mapOf(
"gid" to ref.gid,
"coordinator" to ref.coordinatorPubKey,
"relays" to ref.relays,
// A ref carrying no coordinator is legal (spec §2) and means the
// recipient must already know who serves this group.
"reachable" to (ref.coordinatorPubKey != null && ref.relays.isNotEmpty()),
),
)
return 0
}
private fun exposure(tail: Array<String>): Int {
val args = Args(tail)
val coordinator = args.flag("coordinator") ?: return Output.error("bad_args", "cordn exposure needs --coordinator")
val groups = args.flag("groups")?.toIntOrNull() ?: 1
val published = "published" in args.booleans
val fromLink = "from-link" in args.booleans
args.rejectUnknown("published", "from-link")
if (groups < 1) return Output.error("bad_args", "--groups must be at least 1")
val exposure =
GroupExposure(
coordinator = coordinator,
linkedGroupCount = groups,
joinedFromShareLink = fromLink,
publishedKeyPackage = published,
// Our transport pins CEP-4 encryption to REQUIRED and fails
// closed (§8.6), so this is a property of the client, not a
// setting a coordinator can talk us out of.
encryptionPinned = true,
)
Output.emit(
mapOf(
"coordinator" to coordinator,
"content" to exposure.content.name,
"membership" to exposure.membership.name,
"messaging" to exposure.messaging.name,
"linked_groups" to exposure.linkedGroupCount,
"differs_from_marmot" to exposure.differsFromMarmot(),
"notes" to exposure.notes().map { it.name to describe(it) }.toMap(),
),
)
return 0
}
/** One line per note, for the human-readable half of the dual output. */
private fun describe(note: ExposureNote): String =
when (note) {
ExposureNote.MEMBERSHIP_IS_IDENTIFIED ->
"admission names real npubs on both ends, so the coordinator sees who is in this group"
ExposureNote.GROUPS_LINKED_BY_SESSION ->
"one throwaway key posts and fetches for every group you have here, linking them to each other"
ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY ->
"one operator holds the complete ordered history of every group it serves"
ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD ->
"your published KeyPackage is a signed, re-servable record that this account uses cordn"
ExposureNote.MESSAGE_SIZES_UNPADDED ->
"sealed payloads are not padded, so message sizes are visible"
ExposureNote.ENCRYPTION_NOT_PINNED ->
"BUG: this client is not pinning transport encryption; report it"
}
/** The coordinator a ref points at, for callers wiring one up. */
fun coordinatorOf(ref: CordnGroupRef): CoordinatorConfig? = CoordinatorConfig.from(ref)
}
@@ -0,0 +1,308 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
/**
* `amy cordn coordinator …` and `amy cordn keypackage …`.
*
* ## Why a coordinator has to be remembered at all
*
* A coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so
* "which relays reach it" is knowledge the client holds and nothing on the
* network can supply. Losing it does not degrade to a slow lookup — it
* degrades to a coordinator you cannot reach and groups you cannot open.
* That is why `coordinator add` exists as its own verb and why the list is
* encrypted at rest alongside the group state.
*
* ## Why publishing a KeyPackage is a separate, explicit verb
*
* It is the one attributable thing a client does before anyone has invited it
* anywhere (§8.4): a signed, re-servable record that this npub uses cordn on
* this coordinator. Doing it implicitly — at `coordinator add`, say — would
* make being told about a coordinator indistinguishable from announcing
* yourself to it.
*/
internal object CordnCoordinatorCommands {
suspend fun coordinator(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn coordinator",
tail,
"cordn coordinator <add|list|info|forget>",
help = CordnCommands.USAGE,
routes =
mapOf(
"add" to { rest -> add(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"info" to { rest -> info(dataDir, rest) },
"forget" to { rest -> forget(dataDir, rest) },
),
)
private suspend fun add(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator add needs --coordinator")
val label = args.flag("label")
args.rejectUnknown("relay")
return Context.open(dataDir).use { ctx ->
try {
val relays = ctx.cordn.relayFlag(args)
if (relays.isEmpty()) return@use Output.error("bad_args", "cordn coordinator add needs --relay URL[,URL…]")
val config = CoordinatorConfig(pubKey = pubKey, relays = relays, label = label)
ctx.cordn.remember(config)
Output.emit(
mapOf(
"coordinator" to config.pubKey,
"relays" to config.relays.map { it.url },
"label" to config.label,
"known" to ctx.cordn.coordinators().size,
// Said here because this is where a person decides. The
// list is local knowledge; nothing has been told to the
// coordinator by adding it.
"announced" to false,
),
)
0
} catch (e: IllegalArgumentException) {
Output.error("bad_args", e.message ?: "bad coordinator")
}
}
}
private suspend fun list(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown()
return Context.open(dataDir).use { ctx ->
Output.emit(
mapOf(
"coordinators" to
ctx.cordn.coordinators().map {
mapOf(
"coordinator" to it.pubKey,
"relays" to it.relays.map { relay -> relay.url },
"label" to it.label,
"origin" to it.origin.name,
)
},
),
)
0
}
}
/**
* `cordn coordinator info` — the MCP `initialize` handshake.
*
* Every field it returns is a claim signed with nothing but the key that
* was already signing the response (§8.5). Reported with the pubkey beside
* it so the two are never confused: the pubkey is the identity, the name is
* what the operator typed.
*/
private suspend fun info(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val info = scope.session.serverInfo()
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"reachable" to (info != null),
"name" to info?.name,
"version" to info?.version,
"protocol_version" to info?.protocolVersion,
"capabilities" to info?.capabilities?.keys?.sorted(),
// The one field worth branching on, and the reason the rest
// are not: a coordinator answering a protocol this client
// does not implement is one whose later answers may not
// mean what they appear to.
"claims_are_unverified" to true,
),
)
if (info == null) 1 else 0
}
}
private suspend fun forget(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator forget needs --coordinator")
args.rejectUnknown()
return Context.open(dataDir).use { ctx ->
val forgotten = ctx.cordn.forget(pubKey)
Output.emit(
mapOf(
"coordinator" to pubKey,
"forgotten" to forgotten,
// Forgetting is local. The coordinator still holds every
// message of every group it served, and still holds any
// KeyPackage published to it — `keypackage withdraw` is
// the verb for that, and it is a different act.
"state_on_disk_kept" to true,
),
)
if (forgotten) 0 else 1
}
}
suspend fun keyPackage(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn keypackage",
tail,
"cordn keypackage <publish|list|withdraw>",
help = CordnCommands.USAGE,
routes =
mapOf(
"publish" to { rest -> publish(dataDir, rest) },
"list" to { rest -> keyPackageList(dataDir, rest) },
"withdraw" to { rest -> withdraw(dataDir, rest) },
),
)
private suspend fun publish(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val lastResort = "last-resort" in args.booleans
val count = args.intFlag("count", 1)
args.rejectUnknown("coordinator", "relay", "last-resort")
if (count < 1) return Output.error("bad_args", "--count must be at least 1")
return CordnRun.withSession(dataDir, args) { _, scope ->
val published = (1..count).map { scope.keyPackages.publishNew(lastResort = lastResort) }
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"published" to
published.map {
mapOf("kp_ref" to it.keyPackageRef, "last_resort" to it.lastResort, "at" to it.at)
},
// Worth printing every time, not once in a doc: this is the
// call that tells the coordinator this npub exists here.
"attributable" to true,
),
)
0
}
}
private suspend fun keyPackageList(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val onCoordinator = scope.keyPackages.listPublished()
val held = scope.keyPackages.published.value
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"on_coordinator" to
onCoordinator.map {
mapOf(
"kp_ref" to it.keyPackageRef,
"last_resort" to it.lastResort,
"at" to it.at,
// A package the coordinator will serve but we
// have no private half for cannot open the
// Welcome it produces. It belongs to another
// device of this account, or to a wiped one.
"openable_here" to (it.keyPackageRef in held),
)
},
"held_locally" to held.size,
),
)
0
}
}
private suspend fun withdraw(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val refs =
args
.flag("kp-ref")
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
args.rejectUnknown("coordinator", "relay", "all")
return CordnRun.withSession(dataDir, args) { _, scope ->
val target =
refs
?: if ("all" in args.booleans) {
scope.keyPackages.published.value
.toList()
} else {
return@withSession Output.error("bad_args", "cordn keypackage withdraw needs --kp-ref REF[,REF…] or --all")
}
val removed = scope.keyPackages.withdraw(target)
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"requested" to target,
"removed" to removed,
// Withdrawing removes the package, not the record that it
// was once published: §8.4's exposure is a past event and
// no call undoes it.
"exposure_is_not_undone" to true,
),
)
0
}
}
}
@@ -0,0 +1,694 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager
import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef
import com.vitorpamplona.quartz.cordn.groups.CordnCredential
import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest
import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* `amy cordn group …`, `invite`, `request`, `welcomes`, `join`, `send`, `fetch`
* — the group lifecycle over a live coordinator.
*
* ## What this is for
*
* A second implementation is the only thing that tests a protocol, and a
* non-interactive client is the only way to drive one from a script. These
* verbs exist so the cordn binding can be run end to end against the
* reference coordinator without a phone, and so an interop scenario can be a
* shell script rather than a description of one.
*
* ## A fetch is explicit here, and that is a real difference
*
* The Android app runs `CordnSyncLoop`, which catches up and then holds a
* live subscription. A CLI invocation is a process: it cannot hold anything.
* So `fetch` drains what the cursor has not seen and exits, and the cursor on
* disk is the entire continuity mechanism between runs. `spec/02.md` §7 calls
* a cursor a delivery primitive rather than message identity — here it is
* also the only state that makes two separate invocations one conversation.
*
* A consequence worth stating: nothing arrives while amy is not running. A
* message posted between two `fetch` calls is not lost — the coordinator holds
* the ordered stream — but it is not seen until asked for. That is the correct
* model for a script and the wrong one for a chat app, which is why both exist.
*/
internal object CordnGroupCommands {
suspend fun group(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn group",
tail,
"cordn group <create|list|info>",
help = CordnCommands.USAGE,
routes =
mapOf(
"create" to { rest -> create(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"info" to { rest -> info(dataDir, rest) },
),
)
/**
* `cordn group create --name NAME`
*
* The `gid` is ours to choose and the coordinator never interprets it
* (`spec/00.md` §4). A random 128-bit hex value by default; `--gid` is
* accepted because an interop script needs to name the group it is about
* to assert things about. It must not be derived from the MLS `group_id`,
* which is secret — here the relationship runs the other way, which is
* what lets a joiner read the delivery id out of the Welcome alone.
*/
private suspend fun create(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val name = args.flag("name") ?: return Output.error("bad_args", "cordn group create needs --name")
val about = args.flag("about") ?: ""
val icon = args.flag("icon") ?: ""
val imageUrl = args.flag("image") ?: ""
val gid = args.flag("gid") ?: RandomInstance.bytes(16).joinToString("") { "%02x".format(it) }
val admins =
args
.flag("admin")
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val metadata =
try {
CordnGroupMetadata(name = name, description = about, adminPubkeys = admins, icon = icon, imageUrl = imageUrl)
} catch (e: IllegalArgumentException) {
return@withSession Output.error("bad_args", e.message ?: "bad group metadata")
}
val group = scope.manager.createGroup(gid, metadata)
val ref = scope.manager.shareRef(gid)
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"gid" to gid,
"epoch" to group.epoch,
"ref" to ref.encode(),
"name" to name,
// Empty is not "no admins yet" — spec/01.md §5.3 makes it
// egalitarian, permanently, because there is no way to add
// an admin later to a field that has no enforcement behind
// it anyway.
"egalitarian" to admins.isEmpty(),
// Nothing has been posted: creating a group is local until
// the first message or the first invite.
"posted" to false,
),
)
0
}
}
private suspend fun list(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"groups" to
scope.manager.gids.value.sorted().map { gid ->
val group = scope.manager.group(gid)
mapOf(
"gid" to gid,
"epoch" to group?.epoch,
"name" to group?.let { CordnGroupMetadata.fromExtensions(it.extensions)?.name },
"members" to group?.let { CordnCredential.memberIdentities(it).size },
)
},
),
)
0
}
}
private suspend fun info(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay", "gid")
return CordnRun.withSession(dataDir, args) { _, scope ->
val gid = CordnRun.gid(args, scope)
val group = scope.manager.group(gid) ?: return@withSession Output.error("not_found", "no group $gid here")
val metadata = CordnGroupMetadata.fromExtensions(group.extensions)
val exposure = scope.session.exposure(gid)
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"gid" to gid,
"epoch" to group.epoch,
"name" to metadata?.name,
"about" to metadata?.description,
"members" to CordnCredential.memberIdentities(group).toList(),
"admins" to metadata?.adminPubkeys.orEmpty(),
"egalitarian" to metadata?.adminPubkeys.orEmpty().isEmpty(),
"ref" to scope.manager.shareRef(gid).encode(),
"exposure" to
mapOf(
"content" to exposure.content.name,
"membership" to exposure.membership.name,
"messaging" to exposure.messaging.name,
"notes" to exposure.notes().map { it.name },
),
),
)
0
}
}
/**
* `cordn invite --gid GID --pubkey PK`
*
* Takes their KeyPackage from the coordinator, verifies the publication
* payload binds it to that npub, commits, and leaves a Welcome. The
* verification is not belt-and-braces: §9/§10 require the client to check
* even though the coordinator does, because a coordinator that skipped it
* could hand any account's name over any account's key material and we
* would add the wrong person.
*/
suspend fun invite(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val target = args.flag("pubkey") ?: return Output.error("bad_args", "cordn invite needs --pubkey")
val keyPackageRef = args.flag("kp-ref")
args.rejectUnknown("coordinator", "relay", "gid")
return CordnRun.withSession(dataDir, args) { _, scope ->
val gid = CordnRun.gid(args, scope)
val result = scope.manager.invite(gid, target, keyPackageRef)
Output.emit(
mapOf(
"gid" to result.gid,
"invited" to result.invited,
// Which of their one-time packages this spent. Their
// client needs it to find the Welcome; nobody else can
// use it again.
"kp_ref" to result.keyPackageRef,
"commit_cursor" to result.commitCursor,
"welcome_at" to result.welcomeAt,
"epoch" to scope.manager.group(gid)?.epoch,
),
)
0
}
}
/**
* `cordn request --gid GID`
*
* Asks to join. Needs a published KeyPackage, because the request names
* the exact one the inviter should use — a request with nothing to add is
* a request nobody can accept. The asking itself is attributable (§8.1):
* the coordinator learns this npub wants into this group whether or not
* anyone ever answers, and that is unavoidable rather than an oversight.
*/
suspend fun request(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val gid = args.flag("gid")
val ref = args.flag("ref")
val keyPackageRef = args.flag("kp-ref")
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val target =
gid
?: ref?.let { CordnGroupRef.decode(it).gid }
?: return@withSession Output.error("bad_args", "cordn request needs --gid or --ref cordn1…")
val using =
keyPackageRef
?: scope.keyPackages.published.value
.firstOrNull()
?: scope.keyPackages.publishNew().keyPackageRef
val at = scope.manager.requestToJoin(target, using)
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"gid" to target,
"kp_ref" to using,
"at" to at,
// Holding a ref lets you ask. It does not make you a member,
// and nothing obliges anyone to answer.
"member" to false,
"attributable" to true,
),
)
0
}
}
/**
* `cordn requests` — everyone asking to join a group we hold.
*
* Any member can answer these, not only an admin: `spec/01.md` §5.3 makes
* `admin_pubkeys` presentation metadata and nothing enforces it, so a
* check here would be inventing a boundary cordn does not have.
*/
suspend fun requests(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn requests",
tail,
"cordn requests <list|accept|decline>",
help = CordnCommands.USAGE,
routes =
mapOf(
"list" to { rest -> requestList(dataDir, rest) },
"accept" to { rest -> answerRequest(dataDir, rest, accept = true) },
"decline" to { rest -> answerRequest(dataDir, rest, accept = false) },
),
)
private suspend fun requestList(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"requests" to scope.manager.pendingJoinRequests().map { it.toMap() },
),
)
0
}
}
private suspend fun answerRequest(
dataDir: DataDir,
tail: Array<String>,
accept: Boolean,
): Int {
val args = Args(tail)
val who = args.flag("pubkey")
args.rejectUnknown("coordinator", "relay", "gid", "all")
return CordnRun.withSession(dataDir, args) { _, scope ->
val pending = scope.manager.pendingJoinRequests()
val gidFilter = args.flag("gid")
val chosen =
pending.filter { (who == null || it.pubKey == who) && (gidFilter == null || it.gid == gidFilter) }
if (chosen.isEmpty()) {
Output.emit(mapOf("answered" to emptyList<String>(), "pending" to pending.size))
return@withSession 1
}
if (who == null && "all" !in args.booleans && chosen.size > 1) {
return@withSession Output.error(
"bad_args",
"${chosen.size} requests pending; name one with --pubkey or pass --all",
)
}
// Taken, so the loop cannot hand the same request to two calls.
val answered =
chosen.map { req ->
if (accept) {
val result = scope.manager.acceptJoinRequest(req)
mapOf("pubkey" to req.pubKey, "gid" to req.gid, "welcome_at" to result.welcomeAt)
} else {
scope.manager.declineJoinRequest(req)
mapOf("pubkey" to req.pubKey, "gid" to req.gid, "declined" to true)
}
}
Output.emit(mapOf("accepted" to accept, "answered" to answered))
0
}
}
/**
* `cordn welcomes` — open every pending Welcome without joining anything.
*
* Opening and accepting are separate acts on purpose. A Welcome is opaque
* until processed: the `gid`, the group's name and who is already in it all
* live inside it, so a person cannot be asked about an invitation that has
* not been opened. Printing them without joining is that split, in a shell.
*/
suspend fun welcomes(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor)
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"pending" to
inbox.pending.map {
mapOf(
"gid" to it.gid,
"kp_ref" to it.keyPackageRef,
"at" to it.at,
"epoch" to it.epoch,
"name" to it.metadata?.name,
"members" to it.members.toList(),
)
},
// Not errors. A Welcome for a KeyPackage this device never
// held belongs to another device of the same account, and
// draining it here would destroy it.
"skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) },
),
)
0
}
}
/** `cordn join [--gid GID | --all]` — accept a pending Welcome. */
suspend fun join(
dataDir: DataDir,
tail: Array<String>,
): Int = acceptOrDecline(dataDir, tail, accept = true)
/** `cordn decline [--gid GID | --all]` — refuse one, and retire it. */
suspend fun decline(
dataDir: DataDir,
tail: Array<String>,
): Int = acceptOrDecline(dataDir, tail, accept = false)
private suspend fun acceptOrDecline(
dataDir: DataDir,
tail: Array<String>,
accept: Boolean,
): Int {
val args = Args(tail)
val gid = args.flag("gid")
val all = "all" in args.booleans
args.rejectUnknown("coordinator", "relay", "all")
return CordnRun.withSession(dataDir, args) { _, scope ->
val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor)
val chosen = inbox.pending.filter { gid == null || it.gid == gid }
if (chosen.isEmpty()) {
Output.emit(mapOf("joined" to emptyList<String>(), "skipped" to inbox.skipped.size))
return@withSession 1
}
if (gid == null && !all && chosen.size > 1) {
return@withSession Output.error(
"bad_args",
"${chosen.size} invitations pending; name one with --gid or pass --all",
)
}
val done =
chosen.map {
if (accept) {
scope.manager.accept(it)
} else {
scope.manager.decline(it)
it.gid
}
}
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
(if (accept) "joined" else "declined") to done,
"epochs" to done.associateWith { scope.manager.group(it)?.epoch },
"skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) },
),
)
0
}
}
/**
* `cordn send --text "…"` — a chat message, or an annotation of one.
*
* `--reply-to` / `--react-to` / `--edit` / `--delete` / `--pin` / `--unpin`
* take a message id and need `--to-author` beside it, and that is not an
* awkward flag — it is the shape of the data. An annotation's tags name the
* target's author and kind as well as its id, and the CLI keeps no message
* store: cursors and MLS state persist between runs, decrypted messages
* deliberately do not. So the fields a reference needs have to come from
* the caller, who has them from the `fetch` that printed the message.
*
* One limitation worth naming rather than discovering. Threading reads the
* *target's* tags to find the thread root, and those are not passed here,
* so replying to a reply roots the new message at the message it answers
* instead of at the original root. Correct for a one-level reply, which is
* what a script tends to send; a client with a message store does better.
*/
suspend fun send(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val text = args.flag("text") ?: ""
val author = args.flag("to-author")
val targetKind = args.intFlag("to-kind", CordnGroupManager.CHAT_KIND)
val replyTo = args.flag("reply-to")
val reactionTo = args.flag("react-to")
val editTo = args.flag("edit")
val deleteTo = args.flag("delete")
val pinTo = args.flag("pin")
val unpinTo = args.flag("unpin")
args.rejectUnknown("coordinator", "relay", "gid")
val referenced = listOfNotNull(replyTo, reactionTo, editTo, deleteTo, pinTo, unpinTo)
if (referenced.size > 1) {
return Output.error("bad_args", "cordn send takes at most one of --reply-to/--react-to/--edit/--delete/--pin/--unpin")
}
val targetId = referenced.firstOrNull()
if (targetId != null && author == null) {
return Output.error("bad_args", "a reference needs --to-author PK: its tags name the target's author, not only its id")
}
if (targetId == null && text.isEmpty()) {
return Output.error("bad_args", "cordn send needs --text, or a reference to annotate")
}
val target = targetId?.let { CordnMessageReferences.Target(id = it, pubKey = author!!, kind = targetKind) }
return CordnRun.withSession(dataDir, args) { _, scope ->
val gid = CordnRun.gid(args, scope)
val envelope =
try {
scope.manager.post(
gid = gid,
content = text,
replyTo = target.takeIf { replyTo != null },
reactionTo = target.takeIf { reactionTo != null },
editTo = target.takeIf { editTo != null },
deleteTo = target.takeIf { deleteTo != null },
pinTo = target.takeIf { pinTo != null || unpinTo != null },
pinOp = if (unpinTo != null) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD,
)
} catch (e: IllegalArgumentException) {
// The manager refuses an edit or delete of somebody else's
// message. That is a rule, not a failure to handle here.
return@withSession Output.error("refused", e.message ?: "refused")
}
Output.emit(
mapOf(
"gid" to gid,
// The envelope id is the message's identity (spec/02.md §7)
// — the cursor a fetch reports is not, so a script that
// needs to refer to this message later must use this.
"id" to envelope.envelope.id,
"kind" to envelope.envelope.kind,
"created_at" to envelope.envelope.createdAt,
// The coordinator's cursor for this post, so a script can
// see where in the stream its own message landed.
"cursor" to envelope.cursor,
"epoch" to scope.manager.group(gid)?.epoch,
),
)
0
}
}
/**
* `cordn fetch` — drain everything the cursor has not seen, and print it.
*
* Every outcome is reported, including the ones that are not messages. An
* `undecryptable` payload especially: the cursor moves past it, because the
* alternative is a stream that never advances, and a client that printed
* nothing would be hiding a gap in a conversation rather than reporting one.
*/
suspend fun fetch(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
args.rejectUnknown("coordinator", "relay")
return CordnRun.withSession(dataDir, args) { _, scope ->
val messages = mutableListOf<Map<String, Any?>>()
val epochs = mutableListOf<Map<String, Any?>>()
val echoes = mutableListOf<Map<String, Any?>>()
val undecryptable = mutableListOf<Map<String, Any?>>()
val drained = scope.manager.catchUp { it.collectInto(messages, epochs, echoes, undecryptable) }
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"drained" to drained,
"messages" to messages,
"epoch_changes" to epochs,
"echoes" to echoes,
"undecryptable" to undecryptable,
// A fetch is the response most likely to outgrow one relay
// event, so this is where CEP-22 shows up if it shows up at
// all. Zero is the normal answer and not a warning.
"oversized_transfers" to scope.session.oversizedTransfers,
),
)
0
}
}
/**
* `cordn watch` - holds a live subscription and prints what arrives.
*
* The sibling of `fetch`, and deliberately not a superset of it: this
* calls `msg_sub_many` and nothing else, so what it prints arrived over
* an open CEP-41 stream rather than a poll. That distinction is the
* point - it is the only way to exercise the one coordinator tool a
* request/response client never reaches.
*
* `--timeout` is a budget, not a failure: the call returns when the
* coordinator closes the stream or the budget runs out, whichever comes
* first, and either way what was delivered has been ingested and saved.
*/
suspend fun watch(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val timeoutMs = args.longFlag("timeout", DEFAULT_WATCH_MS)
args.rejectUnknown("coordinator", "relay", "timeout")
if (timeoutMs < 1) return Output.error("bad_args", "--timeout must be positive")
return CordnRun.withSession(dataDir, args) { _, scope ->
val messages = mutableListOf<Map<String, Any?>>()
val epochs = mutableListOf<Map<String, Any?>>()
val echoes = mutableListOf<Map<String, Any?>>()
val undecryptable = mutableListOf<Map<String, Any?>>()
scope.manager.subscribe(timeoutMs) { delivery -> delivery.collectInto(messages, epochs, echoes, undecryptable) }
Output.emit(
mapOf(
"coordinator" to scope.config.pubKey,
"watched_ms" to timeoutMs,
"messages" to messages,
"epoch_changes" to epochs,
"echoes" to echoes,
"undecryptable" to undecryptable,
// Everything above came over the subscription, so this is
// a fact about the run rather than a label.
"via" to "msg_sub_many",
),
)
0
}
}
/** Files one delivery under the list its kind belongs in. */
private fun CordnGroupManager.Delivery.collectInto(
messages: MutableList<Map<String, Any?>>,
epochs: MutableList<Map<String, Any?>>,
echoes: MutableList<Map<String, Any?>>,
undecryptable: MutableList<Map<String, Any?>>,
) {
when (this) {
is CordnGroupManager.Delivery.Message ->
messages +=
mapOf(
"gid" to gid,
"cursor" to cursor,
"id" to received.envelope.id,
// What MLS authenticated, not what the envelope
// claims: the envelope is unsigned (spec/02.md), so
// its pubKey field is a claim and this is the fact.
"sender" to received.sender,
"epoch" to received.epoch,
"kind" to received.envelope.kind,
"created_at" to received.envelope.createdAt,
"content" to received.envelope.content,
)
is CordnGroupManager.Delivery.EpochAdvanced ->
epochs += mapOf("gid" to gid, "cursor" to cursor, "epoch" to epoch)
is CordnGroupManager.Delivery.Echo -> echoes += mapOf("gid" to gid, "cursor" to cursor)
is CordnGroupManager.Delivery.Undecryptable ->
undecryptable += mapOf("gid" to gid, "cursor" to cursor, "reason" to reason)
}
}
/** Long enough for a round trip, short enough to be a foreground command. */
private const val DEFAULT_WATCH_MS = 15_000L
private fun JoinRequest.toMap() =
mapOf(
"gid" to gid,
"pubkey" to pubKey,
"kp_ref" to keyPackageRef,
"at" to at,
)
}
@@ -0,0 +1,178 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.HttpCordnBlobStore
import com.vitorpamplona.amethyst.commons.cordn.CordnMigration
import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationException
import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationStores
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
/**
* `amy cordn migrate …` — the device handoff, drivable without a phone.
*
* Here because the whole point of the CLI is that a protocol flow can be
* exercised end to end before any UI exists. A migration is two devices and a
* scan; two `amy` homes and a string is the same thing with the camera taken
* out.
*
* ## Why `export` does not stand the CLI down
*
* On a phone, exporting is a handoff: the device stops, because two devices
* committing from one MLS leaf fork the ratchet tree irrecoverably. `amy` has
* no sync loop to stop — each invocation is a process — so there is no
* equivalent state to set, and `--and-stop` would be a flag that did nothing.
* The check that matters lives on the runtime that actually keeps loops
* running. Exporting from an `amy` home and then continuing to send from it is
* the same mistake, and this verb says so rather than pretending to prevent it.
*/
internal object CordnMigrateCommands {
suspend fun migrate(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"cordn migrate",
tail,
"cordn migrate <export|import>",
help = CordnCommands.USAGE,
routes =
mapOf(
"export" to { rest -> export(dataDir, rest) },
"import" to { rest -> import(dataDir, rest) },
),
)
private suspend fun export(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val servers =
args
.flag("server")
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
args.rejectUnknown("relay", "server")
if (servers.isEmpty()) {
return Output.error("bad_args", "cordn migrate export needs --server URL[,URL…] to store the documents on")
}
return Context.open(dataDir).use { ctx ->
val relays = ctx.cordn.relayFlag(args)
if (relays.isEmpty()) {
return@use Output.error("bad_args", "cordn migrate export needs --relay URL[,URL…] to publish the tip on")
}
try {
val snapshot =
CordnMigrationStores.read(
ctx.cordn.migrationRoot,
ctx.identity.pubKeyHex,
ctx.cordn.blobCipher,
ctx.cordn.coordinators(),
)
val code =
CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(servers))
.publish(snapshot, relays.toSet())
Output.emit(
mapOf(
"code" to code.encode(),
"groups" to snapshot.groups.size,
"key_packages" to snapshot.keyPackages.size,
"relays" to relays.map { it.url },
"servers" to servers,
// Said here because this is where a person decides. The
// sealed state is on someone else's disk now; a reader
// learns size and timing but not content.
"uploaded" to true,
// amy keeps no sync loop, so nothing was stood down —
// see the object KDoc.
"this_device_stopped" to false,
),
)
0
} catch (e: CordnMigrationException) {
Output.error("migration_failed", e.message ?: "the handoff could not be published")
} catch (e: IllegalArgumentException) {
Output.error("bad_args", e.message ?: "bad migration")
}
}
}
private suspend fun import(
dataDir: DataDir,
tail: Array<String>,
): Int {
val args = Args(tail)
val raw = args.flag("code") ?: args.positionalOrNull(0)
args.rejectUnknown("code")
if (raw == null) return Output.error("bad_args", "cordn migrate import needs a cordndev1… code")
val code =
CordnHandoffCode.decodeOrNull(raw)
?: return Output.error("bad_args", "that is not a cordndev1… handoff code")
return Context.open(dataDir).use { ctx ->
try {
val snapshot =
CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(emptyList()))
.fetch(code)
// Replaces, never merges: two devices holding one group's state
// and both committing fork the ratchet tree, and MLS does not
// recover from that.
val configs =
CordnMigrationStores.write(
ctx.cordn.migrationRoot,
ctx.identity.pubKeyHex,
ctx.cordn.blobCipher,
snapshot,
)
ctx.cordn.replaceCoordinators(configs)
Output.emit(
mapOf(
"groups" to snapshot.groups.size,
"key_packages" to snapshot.keyPackages.size,
"coordinators" to configs.map { it.pubKey },
"replaced_local_state" to true,
),
)
0
} catch (e: CordnMigrationException) {
Output.error("migration_failed", e.message ?: "the handoff could not be read")
} catch (e: IllegalArgumentException) {
Output.error("bad_args", e.message ?: "bad migration")
}
}
}
}
@@ -0,0 +1,101 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.CordnArgException
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.CordnSession
/**
* The plumbing every `amy cordn` verb that touches a coordinator shares.
*
* Two things live here rather than in each command. The first is opening and
* closing a session around a block, because a CLI run that forgets to close
* leaves a relay subscription behind. The second is turning the three ways a
* cordn call fails into the CLI's three error codes, which matters more than
* it looks: `bad_args` means the caller can fix it, `coordinator` means the
* other end did something, and anything else is ours. A script that cannot
* tell those apart has to treat every failure as fatal.
*/
internal object CordnRun {
/**
* Opens a session on the coordinator [args] names, runs [block], closes.
*
* The session is restored from disk first, so a `gid` from a previous run
* is already known and a cursor already positioned. That is the whole
* continuity story for a process-per-command client: `spec/02.md` §7 calls
* a cursor a delivery primitive, and here it is also the only thing
* carried between invocations.
*/
suspend fun withSession(
dataDir: DataDir,
args: Args,
block: suspend (Context, CordnSessionScope) -> Int,
): Int =
Context.open(dataDir).use { ctx ->
ctx.prepare()
try {
val config = ctx.cordn.resolve(args)
val session = ctx.cordn.session(config)
try {
block(ctx, CordnSessionScope(config, session))
} finally {
ctx.cordn.close()
}
} catch (e: CordnArgException) {
Output.error("bad_args", e.message ?: "bad coordinator selection")
}
}
/** Config plus session, so a command does not have to carry both. */
class CordnSessionScope(
val config: CoordinatorConfig,
val session: CordnSession,
) {
val manager get() = session.manager
val keyPackages get() = session.keyPackages
}
/**
* The `gid` a command was given, or the only one we are in.
*
* Same rule as `--coordinator`: convenient when there is one, refuses to
* guess when there are several. A `gid` is unique only within a
* coordinator (`spec/00.md` §4), so a wrong guess is not a near miss.
*/
fun gid(
args: Args,
scope: CordnSessionScope,
): String {
val requested = args.flag("gid")
if (requested != null) return requested
val known = scope.manager.gids.value
return when (known.size) {
0 -> throw CordnArgException("not in any group on this coordinator yet")
1 -> known.single()
else -> throw CordnArgException("several groups here, name one with --gid: ${known.sorted().joinToString(", ")}")
}
}
}
@@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextSt
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.InMemoryAgentTextStreamSequenceStore
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.RecordOutcome
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.cli.stores
import com.vitorpamplona.amethyst.cli.SecureFileIO
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -0,0 +1,118 @@
/*
* 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.cli.stores
import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import java.util.concurrent.TimeUnit
/**
* A Blossom store for `amy`, over plain OkHttp.
*
* Exists because the app's `BlossomUploader` needs an Android `Context` for a
* MIME lookup that a migration blob does not need — every document is opaque
* bytes. Keeping a JVM implementation means the whole handoff is drivable and
* testable without a phone, which is how the interop scripts exercise it.
*
* ## The ephemeral signer is the point, not a detail
*
* §12 requires the BUD-01 authorization event to be signed by an ephemeral key
* and never the owner. Signing as the owner would publish, in the clear to the
* storage server, that this account is uploading right now — the linkage the
* opaque tip exists to prevent. [signer] is minted per instance and is not
* derived from the account.
*/
class HttpCordnBlobStore(
private val servers: List<String>,
private val http: OkHttpClient =
OkHttpClient
.Builder()
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build(),
) : CordnBlobStore {
private val signer = NostrSignerInternal(KeyPair())
override suspend fun put(blob: ByteArray): List<String> =
withContext(Dispatchers.IO) {
val hash = sha256(blob).toHexKey()
servers.filter { server -> runCatching { upload(server, blob, hash) }.getOrDefault(false) }
}
override suspend fun get(
address: String,
servers: List<String>,
): ByteArray? =
withContext(Dispatchers.IO) {
// Ordered: §6 says the reader tries the tip's servers in the order
// it listed them, most reliable first.
servers.firstNotNullOfOrNull { server ->
runCatching {
http
.newCall(
Request
.Builder()
.url("${server.trimEnd('/')}/$address")
.get()
.build(),
).execute()
.use { if (it.isSuccessful) it.body?.bytes() else null }
}.getOrNull()
}
}
private suspend fun upload(
server: String,
blob: ByteArray,
hash: String,
): Boolean {
val auth =
BlossomAuthorizationEvent.createUploadAuth(
hash = hash,
size = blob.size.toLong(),
alt = "",
signer = signer,
)
val request =
Request
.Builder()
.url("${server.trimEnd('/')}/upload")
.header("Authorization", "Nostr " + Base64.getEncoder().encodeToString(auth.toJson().encodeToByteArray()))
.put(blob.toRequestBody(null))
.build()
return http.newCall(request).execute().use { it.isSuccessful }
}
companion object {
private const val CALL_TIMEOUT_SECONDS = 60L
}
}
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
#
# interop-client.sh — amy and the REFERENCE CLIENT in one group.
#
# The claim Tier B does not test. `tier-b.sh` runs amy against amy through the
# reference *coordinator*: it proves our transport and our coordinator client,
# but both MLS endpoints are ours, so the ratchet tree, the Welcome and the
# Commit are only ever agreeing with themselves. This script puts
# **`@cordn/cli` (ts-mls)** on one end and **amy (quartz)** on the other, which
# is the actual interop claim: two independent RFC 9420 implementations in one
# group, over a live wire.
#
# `@cordn/cli` is **MIT** and comes from npm, so this half carries no licensing
# problem. The coordinator underneath it still does — see stack.sh, and read it
# before running this.
#
# What it covers, and why each direction is its own test:
#
# 1. Their group, our joiner — our engine opens a ts-mls Welcome, reads
# the group metadata extension and the
# roster out of it, and decrypts their
# application messages.
# 2. Our group, their joiner — their engine opens OUR Welcome. This is
# the direction that tests our output, and
# it is the one a fixture can never check,
# because a fixture we wrote accepts what we
# emit by construction.
# 3. Our later Commit — the sharpest one. Our engine emits
# **public-framed** handshake messages
# (`MlsMessage(PublicMessage)`, wireformat
# 2) while cordn's client emits
# private-framed. `CordnGroupManager.invite`
# asserts in its KDoc that their
# `processMessageBase64` admits both — a
# claim read off their source and never
# executed. Here they must process our
# Commit to stay in the group at all: if
# they cannot, their epoch stalls and every
# later message fails to open.
#
# Prereqs: see stack.sh, plus network access to npm for `@cordn/cli`.
#
# Usage:
# ./cli/tests/cordn/interop-client.sh
# KEEP=1 ./cli/tests/cordn/interop-client.sh # leave the stack up
#
# Exit 0 only if every step passed.
set -uo pipefail
WORK="${WORK:-$(mktemp -d)}"
PORT="${PORT:-7452}"
CONTAINER="cordn-interop-client"
# shellcheck source=stack.sh
. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh"
export AMY_PASSPHRASE="${AMY_PASSPHRASE:-interop}"
fail=0
step() { echo; echo "── $*"; }
ok() { echo " ok: $*"; }
bad() { echo " FAIL: $*"; fail=1; }
check() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (expected '$2', got '$1')"; fi; }
trap stack_down EXIT
stack_require
command -v npm >/dev/null 2>&1 || { echo "npm is needed to install @cordn/cli"; exit 2; }
# A stuck reference client should fail the run rather than hang it, but macOS
# ships no `timeout` (it is GNU coreutils; `gtimeout` if Homebrew installed
# them). Without this the whole harness died at the first `ref` call with
# nothing but "could not read the reference client's pubkey", because ref()
# folds stderr into the output it parses and "command not found" matches no
# field. Running untimed is better than not running.
if command -v timeout >/dev/null 2>&1; then
REF_TIMEOUT="timeout 120"
elif command -v gtimeout >/dev/null 2>&1; then
REF_TIMEOUT="gtimeout 120"
else
REF_TIMEOUT=""
echo "note: no timeout(1) — the reference client runs untimed"
fi
step "boot geode on $RELAY and the reference coordinator"
stack_up
ok "coordinator $COORD"
step "install @cordn/cli (MIT) from npm"
mkdir -p "$WORK/ref"
npm install --silent --prefix "$WORK/ref" @cordn/cli >"$WORK/npm.log" 2>&1 || {
echo "npm install failed; see $WORK/npm.log"
exit 1
}
CORDN="$WORK/ref/node_modules/.bin/cordn"
[ -x "$CORDN" ] || { echo "no cordn binary at $CORDN"; exit 1; }
ok "$("$CORDN" --version 2>/dev/null || echo unknown)"
# ---- the two clients ------------------------------------------------------
# amy is process-per-command by design; the reference client is driven the
# same way with --command, so neither side gets to hold state in RAM that the
# other cannot see. Whatever agreement they reach went over the wire.
amy() { HOME="$WORK/amy" "$AMY" --account a --secret-backend ncryptsec "$@" 2>/dev/null; }
amy2() { HOME="$WORK/amy2" "$AMY" --account b --secret-backend ncryptsec "$@" 2>/dev/null; }
ref() {
# shellcheck disable=SC2086 # REF_TIMEOUT is a command prefix, or empty
$REF_TIMEOUT "$CORDN" \
--private-key-file "$WORK/ref.key" \
--server-pubkey "$COORD" \
--relay "$RELAY" \
--state-file "$WORK/ref-state.json" \
--command "$1" 2>&1
}
field() { python3 -c "import json,sys; d=json.load(sys.stdin); v=d$1; print(v if isinstance(v,str) else json.dumps(v))"; }
# Their output comes in two shapes and it is worth having both readers rather
# than one clever one: `group-info` and `available-kps` print `key=value`
# tokens, `status` prints `key: value`.
reffield() { grep -oE "$1=[^ ]+" | head -1 | cut -d= -f2-; }
refcolon() { grep -oE "^$1: .*" | head -1 | cut -d' ' -f2-; }
step "identities"
mkdir -p "$WORK/amy" "$WORK/amy2"
openssl rand -hex 32 >"$WORK/ref.key"
amy create --json >/dev/null
amy2 create --json >/dev/null
AMY_PK=$(amy whoami --json | field "['hex']")
AMY2_PK=$(amy2 whoami --json | field "['hex']")
REF_PK=$(ref "status" | refcolon "stablePubkey")
[ -n "$REF_PK" ] || { echo "could not read the reference client's pubkey"; exit 1; }
ok "amy $AMY_PK"
ok "amy(2nd) $AMY2_PK"
ok "ts-mls $REF_PK"
for a in amy amy2; do
$a cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --json >/dev/null
done
step "both sides publish a KeyPackage"
amy cordn keypackage publish --json >/dev/null
amy2 cordn keypackage publish --json >/dev/null
ref "gen-kp k1" >/dev/null
# Each client has to be able to READ the other's publication off the
# coordinator, which means our KeyPackage has to parse under their zod schema
# and their capability flags have to survive our encoder.
KPS=$(ref "available-kps")
echo "$KPS" | grep -q "$AMY_PK" && ok "ts-mls can read our published KeyPackage" || bad "our KeyPackage is invisible to them"
echo "$KPS" | grep -q "groupMetadataSupport=yes" && ok "and reads our metadata capability" || bad "our capability flags did not survive"
# ---------------------------------------------------------------------------
step "DIRECTION 1 — their group, our joiner"
# ---------------------------------------------------------------------------
ref "create-group g1 --name TheirGroup" >/dev/null
THEIR_GID=$(ref "group-info g1" | reffield "groupId")
ok "ts-mls created $THEIR_GID"
ref "add-member g1 $AMY_PK" >/dev/null
PENDING=$(amy cordn welcomes --json)
check "$(echo "$PENDING" | field "['pending'][0]['gid']")" "$THEIR_GID" "our engine opened a ts-mls Welcome"
# Read out of the Welcome itself, so these assert that their GroupContext
# extensions and their credentials decode under our parser.
check "$(echo "$PENDING" | field "['pending'][0]['name']")" "TheirGroup" "and read their metadata extension"
echo "$PENDING" | grep -q "$REF_PK" && ok "and their credential in the roster" || bad "their credential did not decode"
amy cordn join --all --json >/dev/null
ref "send-to g1 hello from ts-mls" >/dev/null
GOT=$(amy cordn fetch --json)
check "$(echo "$GOT" | field "['messages'][0]['content']")" "hello from ts-mls" "we decrypt their application message"
check "$(echo "$GOT" | field "['messages'][0]['sender']")" "$REF_PK" "and MLS authenticates them as the sender"
amy cordn send --gid "$THEIR_GID" --text "hello from quartz" --json >/dev/null
ref "sync g1" >/dev/null
ref "messages g1" | grep -q "hello from quartz" && ok "they decrypt ours" || bad "they could not read our message"
# ---------------------------------------------------------------------------
step "DIRECTION 2 — our group, their joiner"
# ---------------------------------------------------------------------------
# The direction a fixture cannot test: a fixture we wrote accepts what we emit
# by construction, so only a foreign implementation can say our Welcome is
# well formed.
OUR_GID="quartz-side-group"
amy cordn group create --gid "$OUR_GID" --name "OurGroup" --json >/dev/null
ref "gen-kp k2" >/dev/null
INVITE=$(amy cordn invite --gid "$OUR_GID" --pubkey "$REF_PK" --json)
SPENT=$(echo "$INVITE" | field "['kp_ref']")
check "$(echo "$INVITE" | field "['epoch']")" "1" "our commit advanced us to epoch 1"
ref "fetch-welcomes" >/dev/null
ACCEPTED=$(ref "accept-welcome $SPENT g2")
echo "$ACCEPTED" | grep -q "name=OurGroup" && ok "ts-mls opened OUR Welcome and read our metadata" || bad "ts-mls could not open our Welcome: $ACCEPTED"
check "$(ref "group-info g2" | reffield "groupId")" "$OUR_GID" "and agrees on the gid"
amy cordn send --gid "$OUR_GID" --text "quartz made this group" --json >/dev/null
ref "sync g2" >/dev/null
ref "messages g2" | grep -q "quartz made this group" && ok "they read ours at epoch 1" || bad "they could not read ours"
ref "send-to g2 ts-mls replying in a quartz group" >/dev/null
amy cordn fetch --json | grep -q "ts-mls replying in a quartz group" && ok "we read theirs" || bad "we could not read theirs"
# ---------------------------------------------------------------------------
step "DIRECTION 3 — our LATER commit, which they must process"
# ---------------------------------------------------------------------------
# Until now their epoch came from a Welcome, which carries the group state
# ready-made. This is the first time they have to apply one of our handshake
# messages, and ours are public-framed (wireformat 2) where theirs are
# private-framed. If they cannot parse it their epoch stalls at 1 and the
# message they send afterwards is sealed under a key we do not have.
amy2 cordn keypackage publish --json >/dev/null
COMMIT=$(amy cordn invite --gid "$OUR_GID" --pubkey "$AMY2_PK" --json)
check "$(echo "$COMMIT" | field "['epoch']")" "2" "our second commit advanced us to epoch 2"
ref "sync g2" >/dev/null
ref "send-to g2 after the quartz commit" >/dev/null
AFTER=$(amy cordn fetch --json)
# The real assertion: a message they sealed at epoch 2 only opens if they
# applied our Commit. A stalled peer would have sealed at epoch 1, and this
# would come back undecryptable instead.
check "$(echo "$AFTER" | field "['messages'][0]['content']")" "after the quartz commit" "ts-mls applied our public-framed Commit"
check "$(echo "$AFTER" | field "['messages'][0]['epoch']")" "2" "and sealed at the new epoch"
step "the third member joins a group two implementations built"
amy2 cordn join --all --json >/dev/null
THIRD=$(amy2 cordn fetch --json)
echo "$THIRD" | grep -q "after the quartz commit" && ok "reads the ts-mls message it was welcomed into" || bad "third member could not read history at its join epoch"
step "all three agree"
A_EPOCH=$(amy cordn group info --gid "$OUR_GID" --json | field "['epoch']")
B_EPOCH=$(amy2 cordn group info --gid "$OUR_GID" --json | field "['epoch']")
R_CURSOR=$(ref "group-info g2" | reffield "cursor")
check "$A_EPOCH" "2" "quartz (inviter) at epoch 2"
check "$B_EPOCH" "2" "quartz (invitee) at epoch 2"
[ -n "$R_CURSOR" ] && ok "ts-mls advanced to cursor $R_CURSOR" || bad "ts-mls reported no cursor"
MEMBERS=$(amy cordn group info --gid "$OUR_GID" --json | field "['members']")
for pk in "$AMY_PK" "$AMY2_PK" "$REF_PK"; do
echo "$MEMBERS" | grep -q "$pk" || bad "roster is missing $pk"
done
ok "roster holds all three credentials"
echo
if [ "$fail" = "0" ]; then
echo "CLIENT INTEROP PASSED"
else
echo "CLIENT INTEROP FAILED"
fi
exit "$fail"
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
#
# migrate.sh — one account, two devices, a handoff between them.
#
# The claim tier-b.sh does not test. There, two accounts talk to each other.
# Here ONE account moves from the phone it is on to a new one, which is the
# case `spec/applications/multi-device.md` calls device addition (§11) and we
# implement as a one-shot handoff rather than continuous sync.
#
# What this proves that the unit tests cannot:
#
# * the sealed documents survive a real HTTP round trip to a blob server,
# * the tip survives a real relay as a real replaceable event,
# * the new device, starting from an empty home and a scanned string alone,
# ends up holding the same groups.
#
# The blob server is a throwaway python stand-in for Blossom: PUT /upload
# stores by sha256, GET /<sha256> serves it back. Enough to exercise the real
# HttpCordnBlobStore, and deliberately not a Blossom implementation.
#
# Usage: cli/tests/cordn/migrate.sh
set -uo pipefail
WORK="${WORK:-$(mktemp -d)}"
CONTAINER="cordn-migrate"
# shellcheck source=stack.sh
. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh"
export AMY_PASSPHRASE="${AMY_PASSPHRASE:-migrate}"
# Above 1024: binding below it needs root, and this test has no business
# asking for that. 877 was the default and bound for nobody.
BLOB_PORT="${BLOB_PORT:-8877}"
BLOB="http://127.0.0.1:$BLOB_PORT"
BLOB_DIR="$WORK/blobs"
BLOB_PID=""
fail=0
step() { echo; echo "── $*"; }
ok() { echo " ok: $*"; }
bad() { echo " FAIL: $*"; fail=1; }
# Two HOMEs, one identity: the same nsec on an old phone and a new one. That is
# what a migration is, and it is why the new device needs no Welcome — it
# adopts the shared leaf rather than joining as a member (§9).
old() { HOME="$WORK/old" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; }
new() { HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; }
field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; }
blob_up() {
mkdir -p "$BLOB_DIR"
python3 - "$BLOB_PORT" "$BLOB_DIR" >"$WORK/blob.log" 2>&1 &
BLOB_PID=$!
# Wait for the port, and say so here if it never opens. Letting a dead blob
# server through costs three misleading failures later — export reports "no
# server accepted the document", and the group/blob assertions all fall over
# behind it — none of which name the thing that is actually wrong.
for _ in $(seq 20); do
if python3 -c "import socket,sys; s=socket.socket(); s.settimeout(0.3); sys.exit(0 if s.connect_ex(('127.0.0.1',$BLOB_PORT))==0 else 1)"; then
return 0
fi
kill -0 "$BLOB_PID" 2>/dev/null || break
sleep 0.5
done
echo "the blob server never came up on $BLOB — see $WORK/blob.log"
tail -3 "$WORK/blob.log" 2>/dev/null
exit 2
} <<'PYEOF'
import hashlib, os, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port, root = int(sys.argv[1]), sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_PUT(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
digest = hashlib.sha256(body).hexdigest()
open(os.path.join(root, digest), "wb").write(body)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(('{"sha256":"%s","size":%d}' % (digest, len(body))).encode())
def do_GET(self):
path = os.path.join(root, self.path.lstrip("/"))
if not os.path.isfile(path):
self.send_response(404); self.end_headers(); return
body = open(path, "rb").read()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
PYEOF
cleanup() {
[ -n "$BLOB_PID" ] && kill "$BLOB_PID" 2>/dev/null
stack_down
}
trap cleanup EXIT
stack_require
step "boot geode on $RELAY, the reference coordinator, and a blob server on $BLOB"
stack_up
blob_up
ok "coordinator $COORD"
step "one account, on its old phone"
mkdir -p "$WORK/old" "$WORK/new"
old create --json >/dev/null
PK=$(old whoami --json | field "['hex']")
ok "npub $PK"
# The new phone is the SAME identity. Copy the key across the way a user would
# by signing in again; the migration carries group state, never identity (§4.3).
NSEC=$(old key export --json 2>/dev/null | field "['nsec']" 2>/dev/null)
if [ -z "$NSEC" ]; then
cp -r "$WORK/old/.amy" "$WORK/new/.amy"
ok "new phone signed in (identity copied)"
else
new import --nsec "$NSEC" --json >/dev/null
ok "new phone signed in"
fi
step "the old phone has a group"
old cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label migrate --json >/dev/null
GROUP=$(old cordn group create --name "Moves with me" --about "handoff" --json)
GID=$(echo "$GROUP" | field "['gid']")
[ -n "$GID" ] && ok "gid $GID" || bad "no group to migrate"
step "the old phone exports a handoff"
EXPORT=$(old cordn migrate export --relay "$RELAY" --server "$BLOB" --json)
echo " $EXPORT"
CODE=$(echo "$EXPORT" | field "['code']")
if [ -n "$CODE" ] && [ "${CODE:0:9}" = "cordndev1" ]; then ok "code minted"; else bad "no handoff code"; fi
[ "$(echo "$EXPORT" | field "['groups']")" = "1" ] && ok "one group in the snapshot" || bad "wrong group count"
[ "$(echo "$EXPORT" | field "['uploaded']")" = "true" ] && ok "documents stored" || bad "nothing stored"
step "blobs really landed on the server"
COUNT=$(ls "$BLOB_DIR" 2>/dev/null | wc -l | tr -d ' ')
# One group document plus the meta document.
[ "$COUNT" -ge 2 ] && ok "$COUNT blobs" || bad "expected >=2 blobs, found $COUNT"
step "the new phone has nothing yet"
BEFORE=$(new cordn group list --json 2>/dev/null | field "['groups']" 2>/dev/null || echo "[]")
[ "$BEFORE" = "[]" ] && ok "empty" || echo " (starting from: $BEFORE)"
step "the new phone imports, from the code alone"
IMPORT=$(new cordn migrate import --code "$CODE" --json)
echo " $IMPORT"
[ "$(echo "$IMPORT" | field "['groups']")" = "1" ] && ok "one group adopted" || bad "group did not arrive"
[ "$(echo "$IMPORT" | field "['replaced_local_state']")" = "true" ] && ok "replaced, not merged" || bad "did not replace"
step "the group is really there, with the same gid"
AFTER=$(new cordn group list --json 2>/dev/null)
echo " $AFTER"
echo "$AFTER" | grep -q "$GID" && ok "gid $GID on the new phone" || bad "gid missing after import"
step "a group ref is not a handoff code"
# Called directly rather than through new(): the --json error contract writes
# to stderr, which the helper sends to /dev/null. The first version of this
# check compared an empty string and passed for the wrong reason.
STRANGER=$(HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec \
cordn migrate import --code "cordn1qqqqqq" --json 2>&1 || true)
echo " $STRANGER"
echo "$STRANGER" | grep -q "bad_args" && ok "refused" || bad "accepted a non-handoff code"
echo
if [ "$fail" -eq 0 ]; then
echo "── migrate: PASS"
else
echo "── migrate: FAIL"
fi
exit "$fail"
+117
View File
@@ -0,0 +1,117 @@
# shellcheck shell=bash
#
# stack.sh — boot the cordn test stack: a geode relay plus the REFERENCE
# coordinator in Docker. Sourced by tier-b.sh and interop-client.sh.
#
# ─────────────────────────────────────────────────────────────────────────────
# The reference coordinator (`ghcr.io/cordn-msg/cordn`, and the
# `packages/coordinator` / `packages/server` sources it is built from) ships
# with NO LICENSE — default copyright, all rights reserved. See §7 of
# quartz/plans/2026-09-17-cordn-interop.md.
#
# Nothing here is wired into a build: no Gradle task, no CI job, and nothing
# pulls the image for you. You pull it by hand having decided that is
# something you want to do. Do not add these scripts to a build file.
# ─────────────────────────────────────────────────────────────────────────────
#
# The caller sets WORK (a scratch directory) and may set PORT. After
# `stack_up`, these are exported:
#
# RELAY ws://127.0.0.1:$PORT
# COORD the coordinator's pubkey, read from its own startup log
#
# `stack_down` is registered by the caller's EXIT trap; KEEP=1 skips it.
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)"
AMY="$ROOT/cli/build/install/amy/bin/amy"
GEODE="$ROOT/geode/build/install/geode/bin/geode"
IMAGE="ghcr.io/cordn-msg/cordn:latest"
PORT="${PORT:-7447}"
RELAY="ws://127.0.0.1:$PORT"
CONTAINER="${CONTAINER:-cordn-test}"
GEODE_PID=""
# Prereqs, each with its own message. A stopped daemon and an unpulled image
# both fail `docker image inspect`, and telling someone to pull an image they
# cannot pull sends them the wrong way.
stack_require() {
for f in "$AMY" "$GEODE"; do
[ -x "$f" ] || {
echo "missing $f — run ./gradlew :cli:installDist :geode:installDist"
exit 2
}
done
docker info >/dev/null 2>&1 || {
echo "the docker daemon is not reachable — start it (e.g. 'sudo dockerd &' or 'systemctl start docker') and retry"
exit 2
}
docker image inspect "$IMAGE" >/dev/null 2>&1 || {
echo "missing $IMAGE — pull it by hand, and read the licence note at the top of this file first"
exit 2
}
}
stack_up() {
"$GEODE" --port "$PORT" >"$WORK/geode.log" 2>&1 &
GEODE_PID=$!
for _ in $(seq 30); do
curl -sS --noproxy '*' -H 'Accept: application/nostr+json' "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break
sleep 1
done
# The container has to reach geode, which is on the host's loopback.
#
# On Linux `--network host` puts it there. On Docker Desktop (macOS,
# Windows) the daemon is inside a VM, so `--network host` is the *VM's*
# loopback and 127.0.0.1:$PORT is nothing at all: the coordinator retries
# "Relay connection error" until stack_up gives up on a pubkey that was
# never going to arrive. There the host is reachable by name instead, over
# the default bridge.
#
# A stable key so the coordinator pubkey survives a re-run against the
# same WORK directory.
if [ "$(uname -s)" = "Linux" ]; then
COORD_NET="--network host"
COORD_RELAY="$RELAY"
else
COORD_NET="--add-host=host.docker.internal:host-gateway"
COORD_RELAY="ws://host.docker.internal:$PORT"
fi
[ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key"
docker rm -f "$CONTAINER" >/dev/null 2>&1
# shellcheck disable=SC2086 # COORD_NET is two words on purpose
docker run -d --name "$CONTAINER" $COORD_NET \
-e CORDN_STORAGE_BACKEND=memory \
-e CORDN_ANNOUNCED=false \
-e CORDN_RELAY_URLS="$COORD_RELAY" \
-e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \
-e CORDN_SERVER_NAME="cordn-test" \
"$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; }
# Read the pubkey out of its own startup log rather than deriving it: the
# coordinator is the authority on its identity, and a key we derived
# wrongly would fail later as an unreachable coordinator.
COORD=""
for _ in $(seq 60); do
COORD=$(docker logs "$CONTAINER" 2>&1 | grep -oE 'serverPubkey":"[0-9a-f]{64}' | head -1 | cut -d'"' -f3)
[ -n "$COORD" ] && break
sleep 1
done
[ -n "$COORD" ] || {
echo "coordinator never announced its pubkey"
docker logs "$CONTAINER" | tail -20
exit 1
}
}
stack_down() {
if [ "${KEEP:-0}" != "1" ]; then
docker rm -f "$CONTAINER" >/dev/null 2>&1
[ -n "$GEODE_PID" ] && kill "$GEODE_PID" 2>/dev/null
else
echo
echo "KEEP=1: relay on $RELAY, coordinator $CONTAINER ($COORD), state in $WORK"
fi
}
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
#
# tier-b.sh — the cordn binding, end to end against the REFERENCE coordinator.
#
# Tier B of quartz/plans/2026-09-17-cordn-interop.md §6.4: a live
# counterparty, not a fixture. Three amy accounts, one local relay (geode),
# one reference coordinator, and the whole lifecycle — publish a KeyPackage,
# create a group, invite, open the Welcome without joining, join, talk in both
# directions, and check both sides agree on epoch and membership.
#
# Then the parts that lifecycle never reaches, each driven directly, so that
# all eleven coordinator tools of `spec/00.md` are exercised against a real
# coordinator: a third account who ASKS to join rather than being invited
# (join_request_store, join_request_take_many), a withdrawn KeyPackage
# (kp_remove), a backlog big enough to be chunked (CEP-22), and a message
# pushed down an open stream (msg_sub_many).
#
# The reference coordinator it runs against is UNLICENSED — read the header of
# stack.sh, which boots it, before running this. Nothing here is wired into a
# build, and it must not become so.
#
# Sibling: interop-client.sh puts amy and the reference CLIENT in one group,
# which is the test this one does not do — here both MLS endpoints are ours.
#
# Prereqs: see stack.sh.
#
# Usage:
# ./cli/tests/cordn/tier-b.sh # boot everything, run, tear down
# KEEP=1 ./cli/tests/cordn/tier-b.sh # leave the relay + coordinator up
#
# Exit 0 only if every step passed.
set -uo pipefail
WORK="${WORK:-$(mktemp -d)}"
PORT="${PORT:-7447}"
CONTAINER="cordn-tier-b"
# shellcheck source=stack.sh
. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh"
export AMY_PASSPHRASE="${AMY_PASSPHRASE:-tier-b}"
fail=0
step() { echo; echo "── $*"; }
ok() { echo " ok: $*"; }
bad() { echo " FAIL: $*"; fail=1; }
# Every amy run is its own process, which is the point: the cursor on disk is
# the only thing carrying continuity between them.
alice() { HOME="$WORK/alice" "$AMY" --account alice --secret-backend ncryptsec "$@" 2>/dev/null; }
bob() { HOME="$WORK/bob" "$AMY" --account bob --secret-backend ncryptsec "$@" 2>/dev/null; }
carol() { HOME="$WORK/carol" "$AMY" --account carol --secret-backend ncryptsec "$@" 2>/dev/null; }
field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; }
trap stack_down EXIT
stack_require
step "boot geode on $RELAY, and the reference coordinator"
stack_up
ok "coordinator $COORD"
step "three accounts"
# Carol is here for the door nobody knocks on in the two-party flow: bob is
# invited, so he never sends a join request.
mkdir -p "$WORK/alice" "$WORK/bob" "$WORK/carol"
alice create --json >/dev/null
bob create --json >/dev/null
carol create --json >/dev/null
ALICE_PK=$(alice whoami --json | field "['hex']")
BOB_PK=$(bob whoami --json | field "['hex']")
CAROL_PK=$(carol whoami --json | field "['hex']")
ok "alice $ALICE_PK"
ok "bob $BOB_PK"
ok "carol $CAROL_PK"
step "all three remember the coordinator"
alice cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null
bob cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null
carol cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null
step "the MCP handshake"
# The first thing to break if the transport regresses, and the cheapest to
# check. A timeout here means the coordinator never saw the request at all —
# which is exactly how §7.1's finding 1 presented.
INFO=$(alice cordn coordinator info --json)
echo " $INFO"
[ "$(echo "$INFO" | field "['reachable']")" = "true" ] && ok "reachable" || bad "no initialize response"
step "bob publishes a KeyPackage"
KP=$(bob cordn keypackage publish --json)
echo " $KP"
[ -n "$(echo "$KP" | field "['published'][0]['kp_ref']")" ] && ok "published" || bad "no kp_ref"
step "alice creates a group"
GROUP=$(alice cordn group create --name "Tier B" --about "live" --json)
echo " $GROUP"
GID=$(echo "$GROUP" | field "['gid']")
[ -n "$GID" ] && ok "gid $GID" || bad "no gid"
step "alice invites bob"
INVITE=$(alice cordn invite --pubkey "$BOB_PK" --json)
echo " $INVITE"
[ "$(echo "$INVITE" | field "['epoch']")" = "1" ] && ok "epoch 1 after the commit" || bad "epoch did not advance"
step "bob opens the invitation without joining"
PENDING=$(bob cordn welcomes --json)
echo " $PENDING"
[ "$(echo "$PENDING" | field "['pending'][0]['gid']")" = "$GID" ] && ok "sees $GID" || bad "no pending welcome"
[ "$(echo "$PENDING" | field "['pending'][0]['name']")" = "Tier B" ] && ok "reads the name out of the Welcome" || bad "no metadata in the Welcome"
step "bob joins"
JOINED=$(bob cordn join --all --json)
echo " $JOINED"
[ "$(echo "$JOINED" | field "['joined']")" = "[\"$GID\"]" ] && ok "joined" || bad "join failed"
step "alice sends, bob reads"
SENT=$(alice cordn send --text "hello from amy" --json)
MSG_ID=$(echo "$SENT" | field "['id']")
GOT=$(bob cordn fetch --json)
echo " $GOT"
[ "$(echo "$GOT" | field "['messages'][0]['id']")" = "$MSG_ID" ] && ok "same envelope id" || bad "bob did not read it"
[ "$(echo "$GOT" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "sender is what MLS authenticated" || bad "wrong sender"
step "bob replies, alice reads"
BACK=$(bob cordn send --text "and hello back" --json)
BACK_ID=$(echo "$BACK" | field "['id']")
GOT2=$(alice cordn fetch --json)
echo " $GOT2"
echo "$GOT2" | grep -q "$BACK_ID" && ok "alice read the reply" || bad "alice did not read the reply"
# §7.1 finding 2: a sender must not report its own traffic as a gap in its own
# conversation. Everything alice posted came back at a cursor she re-reads,
# and recognising it needs bookkeeping that survives the process exit.
[ "$(echo "$GOT2" | field "['undecryptable']")" = "[]" ] && ok "no self-inflicted gaps" || bad "own traffic came back undecryptable: $(echo "$GOT2" | field "['undecryptable']")"
# ── The three tools the two-party flow never reaches, and CEP-22 ────────────
# Every other step above exercises a tool as a side effect of the lifecycle.
# These four do not happen on that path, so they are driven directly.
step "carol asks to join — join_request_store"
REQ=$(carol cordn request --gid "$GID" --json)
echo " $REQ"
[ "$(echo "$REQ" | field "['gid']")" = "$GID" ] && ok "request stored" || bad "join request not stored"
# A ref is an invitation to ask, not membership (spec/01.md §5.3).
[ "$(echo "$REQ" | field "['member']")" = "false" ] && ok "asking is not joining" || bad "request reported membership"
step "alice reads it — join_request_take_many"
PEND=$(alice cordn requests list --json)
echo " $PEND"
[ "$(echo "$PEND" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "sees carol" || bad "no pending request"
[ "$(echo "$PEND" | field "['requests'][0]['gid']")" = "$GID" ] && ok "for $GID" || bad "wrong gid on the request"
# Listing must NOT consume. A request is retired by answering it, not by
# reading it, and the ack rides the next call — so a reader that lost the
# process between list and accept has to still find it. Two separate amy
# runs is exactly that case.
AGAIN=$(alice cordn requests list --json)
[ "$(echo "$AGAIN" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "still there for a second reader" || bad "reading a join request consumed it"
step "alice accepts, carol joins"
ACC=$(alice cordn requests accept --pubkey "$CAROL_PK" --json)
echo " $ACC"
[ "$(echo "$ACC" | field "['answered'][0]['pubkey']")" = "$CAROL_PK" ] && ok "accepted" || bad "accept failed"
CJOIN=$(carol cordn join --all --json)
[ "$(echo "$CJOIN" | field "['joined']")" = "[\"$GID\"]" ] && ok "carol joined" || bad "carol did not join"
# Now it is retired — and the ack for it rode a later call, so this also
# proves the retirement survived the process that issued it.
GONE=$(alice cordn requests list --json)
[ "$(echo "$GONE" | field "['requests']")" = "[]" ] && ok "answering retired it" || bad "an answered request is still pending: $(echo "$GONE" | field "['requests']")"
step "bob withdraws a KeyPackage — kp_remove"
# Two, so there is something left to prove the removal was targeted rather
# than a wipe.
PUB=$(bob cordn keypackage publish --count 2 --json)
KEEP=$(echo "$PUB" | field "['published'][0]['kp_ref']")
DROP=$(echo "$PUB" | field "['published'][1]['kp_ref']")
WD=$(bob cordn keypackage withdraw --kp-ref "$DROP" --json)
echo " $WD"
[ "$(echo "$WD" | field "['removed']")" = "[\"$DROP\"]" ] && ok "coordinator confirms the removal" || bad "kp_remove did not confirm $DROP"
LIST=$(bob cordn keypackage list --json)
echo "$LIST" | grep -q "$DROP" && bad "the withdrawn package is still served" || ok "gone from kp_list"
echo "$LIST" | grep -q "$KEEP" && ok "the other one survived" || bad "kp_remove took more than it was asked for"
step "a response too big for one event — CEP-22"
# The reference coordinator switches to oversized transfer at a 48000-byte
# published envelope. One message does not reach it; a backlog of them does,
# and a fetch is where a backlog is delivered. Nothing below reads the
# messages differently — a reassembled response is meant to be invisible —
# so the count is the only thing that can tell us the profile ran.
BIG=$(python3 -c "print('x' * 6000)")
for i in $(seq 12); do
alice cordn send --text "chunk-$i $BIG" --json >/dev/null
done
FETCH=$(bob cordn fetch --json)
COUNT=$(echo "$FETCH" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))")
CHUNKED=$(echo "$FETCH" | field "['oversized_transfers']")
echo " $COUNT messages, oversized_transfers=$CHUNKED"
[ "$COUNT" = "12" ] && ok "all 12 arrived" || bad "expected 12 messages, got $COUNT"
[ "${CHUNKED:-0}" -ge 1 ] && ok "reassembled over CEP-22" || bad "the response was never chunked — CEP-22 went unexercised"
# Carol was added at a later epoch, so she must read them too: an oversized
# response is still one response, not a per-member special case.
CGOT=$(carol cordn fetch --json)
CCOUNT=$(echo "$CGOT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))")
[ "$CCOUNT" = "12" ] && ok "carol read the same 12" || bad "carol got $CCOUNT of 12"
step "a message delivered over an open stream — msg_sub_many"
# The eleventh tool, and the only one a request/response client never
# reaches: `cordn watch` calls msg_sub_many and nothing else, so anything
# it prints arrived over an open CEP-41 stream rather than a poll.
#
# Ordering is the whole test. The watcher goes up FIRST and we wait for it
# to be listening; only then does alice send. A message sent beforehand
# would be backlog the subscription replays, which proves a stream opened
# but not that anything was pushed down it.
WATCH_OUT="$WORK/watch.json"
bob cordn watch --timeout 25000 --json >"$WATCH_OUT" 2>/dev/null &
WATCH_PID=$!
sleep 8
LIVE="live-$$-$(date +%s)"
alice cordn send --text "$LIVE" --json >/dev/null
wait "$WATCH_PID"
WATCHED=$(cat "$WATCH_OUT")
echo " $(echo "$WATCHED" | head -c 400)"
[ "$(echo "$WATCHED" | field "['via']")" = "msg_sub_many" ] && ok "the stream was the source" || bad "cordn watch did not report a subscription"
echo "$WATCHED" | grep -q "$LIVE" && ok "pushed live, not polled for" || bad "the subscription never delivered $LIVE"
[ "$(echo "$WATCHED" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "MLS authenticated the sender over the stream too" || bad "wrong sender on the streamed message"
step "the watcher saved what it ingested"
# Decrypting a streamed message advances the ratchet and the cursor. A
# subscription that ended without writing would leave that only in memory,
# and the next process would re-read its own progress as a gap.
AFTER=$(bob cordn fetch --json)
echo "$AFTER" | grep -q "$LIVE" && bad "the streamed message came back on the next fetch" || ok "the cursor survived the watching process"
[ "$(echo "$AFTER" | field "['undecryptable']")" = "[]" ] && ok "no gaps after the stream closed" || bad "gaps after the stream: $(echo "$AFTER" | field "['undecryptable']")"
step "both sides agree"
A=$(alice cordn group info --json)
B=$(bob cordn group info --json)
[ "$(echo "$A" | field "['epoch']")" = "$(echo "$B" | field "['epoch']")" ] && ok "same epoch" || bad "epochs differ"
[ "$(echo "$A" | field "['members']")" = "$(echo "$B" | field "['members']")" ] && ok "same members" || bad "membership differs"
echo
if [ "$fail" = "0" ]; then
echo "TIER B PASSED"
else
echo "TIER B FAILED"
fi
exit "$fail"
@@ -0,0 +1,192 @@
# cordn message store
*2026-09-24*
## The problem
A cordn room's messages exist in exactly one place on the device: a
`LinkedHashMap` inside `CordnGroupChatroom`. `CordnGroupStore` persists the MLS
group state, the `GroupCursor`, echo state, joined-via-request and
`CordnRoomState(draft, lastReadCursor)`. It does not persist a single message.
That is worse than an uncached history, because of how the cursor works.
`GroupCursor.fetchCursor` advances past every message the stream delivers and is
saved on the way out of `catchUp`/`subscribe`. On the next launch,
`msg_fetch_many(after: fetchCursor)` therefore returns nothing — the client has
already consumed everything. The room opens empty and **never refills**.
Rewinding the cursor would not rescue it either. A cordn payload is sealed twice:
the MLS application message, then `SealedPayload` under
`MLS-Exporter("cordn", "group-payload", 32)`. Both keys are epoch-derived, so
once the group ratchets forward the ciphertext the coordinator still holds is
unreadable to us. **Ingestion is the only moment the message is in the clear.**
Three visible symptoms, one cause:
- rooms are empty after every app restart
- the Messages inbox says "No messages yet" for every cordn room, because
`CordnGroupChatroom._newest` is only ever set by `add`/`addAll`
- leaving and re-opening a room mid-session is fine; killing the app is not
## What Marmot already does
`MarmotMessageStore` / `AndroidMarmotMessageStore` is the same subsystem, built:
an `EncryptedAppendLog` at `<root>/mls_groups/<gid>/messages`, storing the
**decrypted inner event JSON**, with idempotent appends. Its own KDoc states the
principle we need: *"the ratchet moved past the ciphertext long ago, so this
store is the only copy."*
Two differences shape our design rather than letting us copy it outright:
1. **Marmot has a second recovery path; cordn has none.** Marmot's kind-445
events sit on relays and it retains epoch secrets, so a replay can
re-decrypt — which is *why* it needs dedup. cordn has no replay, which makes
the store more load-bearing here, not less.
2. **Marmot messages are `Note`s in `LocalCache`; cordn's must never be.** The
isolation rule means our read path hydrates `CordnGroupChatroom` directly.
cordn also has **no message expiry**, so there is no prune-before-read step.
## Design
### What a stored entry is
`CordnDeliveredMessage` = `CordnEnvelope` + `cursor`. The envelope already has
`toJson()`/`encode()`/`decode()`. The cursor has to ride along: the room orders
on it and the unread divider compares against it, and it is not derivable from
the envelope.
Entry format — one JSON object per log entry, versioned:
```json
{"v":1,"c":<cursor>,"e":{…envelope…}}
```
A new `CordnDeliveredMessageCodec` beside `CordnEnvelope` in quartz owns it, so
the format is tested where the envelope's own round-trip test already lives.
### Where it is written
`CordnGroupManager.ingest` produces `Delivery.Message(gid, cursor, …)` but is
not `suspend`. The append belongs in the `suspend` caller — the `onDelivery`
path inside `catchUp`/`subscribe` — so that "the room has it" and "disk has it"
happen together rather than on separate beats.
**Ordering is the correctness property.** Append the message, *then* advance and
persist the cursor. Crash between the two and the message is on disk while the
cursor still points before it: the next `catchUp` re-delivers it and dedup drops
it. Do it in the other order and the message is gone forever, with no way back.
This is the one invariant a reviewer should check first.
### Dedup
Marmot dedups on whole-entry string equality via `EncryptedAppendLog.contains`.
That is not enough here: the same message re-delivered after a crash carries the
same envelope but the entry string is only equal if the cursor matches too, and
`Ingestion` can legitimately hand back a different cursor. Dedup on
`envelope.id`, using an in-memory `Set<HexKey>` per group built at load — cheap,
and the log is already read in full on open.
### Where it is read
`CordnRuntime.restoreRoomState` loads draft + read position when a screen opens
a room, deliberately: its KDoc notes that doing it at login would read state for
rooms nobody opens.
The inbox preview breaks that symmetry — it needs `_newest` for every room
*before* any room is opened. So two reads, not one:
- **per-group summary** (newest envelope + count), its own small whole-blob key,
read at login. Fixes "No messages yet" without touching the logs.
- **full log**, read on room open, hydrating via `addAll` so the existing
`recompute`/annotation fold runs exactly as it does for live delivery.
The summary is written on the same beat as the append.
### `EncryptedAppendLog` placement — needs a decision
`CordnIndependenceTest` forbids cordn importing anything whose import line
contains "marmot". The log currently lives in
`commons/.../commons/marmot/EncryptedAppendLog.kt`, so cordn cannot use it where
it is.
It is, however, entirely protocol-neutral: it takes `encrypt`/`decrypt` lambdas
and stores opaque strings. Nothing in it knows what Marmot is.
- **A — move it** to a neutral package (`commons/.../store/`), update Marmot's
import. One implementation, one file format, one migration path. Touches a
Marmot file (import line only, no behaviour change) and *improves*
independent-deletability: neither feature owns the primitive any more.
- **B — duplicate it** into a cordn package. ~400 lines of subtle framing,
folding and legacy-format migration, copied, free to drift.
- **C — whole-blob rewrites** with cordn's existing `atomicWrite` pattern. O(n)
bytes per send. This is the cost the append log exists to avoid.
**Recommend A.** The guard's own KDoc draws the line at "marmot-named code" —
the coupling it exists to prevent — and a shared neutral primitive is not that.
Worth Vitor's sign-off before it happens, since it edits a Marmot file.
### Backup and migration — needs a decision
`CordnBackup.Archive.Group` carries coordinator, gid, state, cursor and
joined-via-request. No messages.
- **Device migration** (`CordnMigrationStores`) is "this device becomes that
device". Arriving with no history would be the surprising outcome. **Include.**
*Done* — as `amethystMessages` on the group document, alongside the other
additive `amethyst*` fields, written before the cursor on import for the same
reason the live path writes it that way. Bounded per group by
`MESSAGE_BUDGET_BYTES`, newest first: these blobs go to hosts whose limits we
do not know, and a handoff that fails because one group is chatty is worse
than one that carries a deep but bounded history.
- **Backup** is a recovery artifact whose size the user sees. History could
multiply it by a large factor. **Exclude for now**, and say so in the backup
screen's copy rather than letting someone discover it at restore time. *Still
open.*
Both are reversible later; the format is versioned.
### Deletion
`deleteGroup(gid)` must remove the log and the summary. Leaving a group today
removes the MLS state; leaving plaintext history behind would be a quiet
regression in exactly the property this feature sells. Explicit test.
## Test plan
The failure modes here are lifecycle, not logic, and this branch has already
shipped two bugs of that shape. Tests that matter:
- **crash between append and cursor save** → message survives, re-delivery
dedups on id. The invariant above, tested directly.
- **round-trip** an envelope with tags, an edit, emoji, and empty content.
- **rehydrate equals live** — the annotation fold built from a loaded log
matches the one built by ingesting the same messages. The fold is what the
user sees; equality of the raw list is not enough.
- **`_newest` after load** → the inbox preview shows the last message.
- **`deleteGroup` removes the log** and the summary.
- **cancellation** — a load or append cancelled mid-flight leaves no partial
entry.
One trap, learned the hard way on this branch: `InMemoryCordnGroupStore`'s
`suspend` methods never reach a suspension point, so cancellation is
*unobservable* in any test using it. The fake for the message store must
actually suspend, or the cancellation test above proves nothing.
## Non-goals
No expiry (cordn has none), no search, no paging — the log loads whole. A room
with tens of thousands of messages would want paging; note it, do not build it
until a room gets there.
## Files
- `quartz/.../cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt` — new
- `commons/.../cordn/CordnGroupStore.kt` — append/load/summary/delete
- `commons/.../cordn/FileCordnStores.kt` — the log-backed implementation
- `commons/.../cordn/CordnGroupManager.kt` — append at ingest, ordered before
the cursor
- `commons/.../store/EncryptedAppendLog.kt` — moved (decision A)
- `commons/.../marmot/…` — import update only
- `amethyst/.../model/cordn/CordnRuntime.kt` — summary at login, log on open
- `commons/.../cordn/CordnMigrationStores.kt` — carry messages
@@ -0,0 +1,57 @@
/*
* 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.commons.cordn
import com.vitorpamplona.amethyst.commons.keystorage.KeyStoreEncryption
/**
* The Android [CordnBlobCipher]: AES-GCM under a key held in the Android
* KeyStore (StrongBox-backed where the device has it).
*
* ## Why there is no lock here any more
*
* There used to be one. [KeyStoreEncryption] kept a single `Cipher` in a field
* and ran `init(...)` then `doFinal(...)` against it, which is not atomic, so
* two groups saving at once on `Dispatchers.IO` could interleave and write
* bytes encrypted under the wrong IV — unrecoverable for an `MlsGroupState`.
*
* [KeyStoreEncryption] now holds its `Cipher` in a `ThreadLocal`, so the pair
* can no longer interleave and the lock guards nothing. Keeping it would
* serialise every cordn group save and load on this device for no reason,
* which is the opposite of what a thread pool is for.
*/
class KeyStoreCordnBlobCipher : CordnBlobCipher {
// Not a constructor parameter: KeyStoreEncryption is internal to commons,
// so taking one publicly would leak an internal type. Nothing needs to
// inject it either — a test that wanted a different cipher implements
// [CordnBlobCipher] directly, which is what the seam is for.
// Built on first use, not at construction. This cipher is reached from
// CordnRuntime, which Account builds eagerly, so a KeyStoreEncryption that
// throws here takes the whole account down before any UI exists — the app
// sits on "Loading account" forever. Every other user of KeyStoreEncryption
// already guards it (AccountCacheState falls back to an in-memory Marmot
// store); deferring keeps a cordn-only failure inside cordn.
private val encryption by lazy { KeyStoreEncryption() }
override fun encrypt(bytes: ByteArray): ByteArray = encryption.encrypt(bytes)
override fun decrypt(bytes: ByteArray): ByteArray = encryption.decrypt(bytes)
}
@@ -24,26 +24,71 @@ import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.GCMParameterSpec
internal class KeyStoreEncryption {
companion object {
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
/**
* GCM is a stream mode: it pads nothing, and `AES/GCM/NoPadding` is the
* only transformation JCA defines for it. [LEGACY_PADDING] is what this
* class has always asked for, and some providers accept it, so the keys
* and ciphertext already on those devices were made under that name.
*/
private const val LEGACY_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val GCM_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
private const val KEY_ALIAS = "AMETHYST_AES_KEY"
/** GCM's authentication tag, in bits. 128 is the default and the maximum. */
private const val TAG_BITS = 128
/** GCM's nonce, in bytes — what [encrypt] prefixes to its output. */
private const val IV_BYTES = 12
private fun transformationFor(padding: String) = "$ALGORITHM/$BLOCK_MODE/$padding"
/**
* Which padding name this device's providers actually answer to.
*
* Asking for the legacy name where it works keeps every existing key
* and every encrypted file readable — switching unconditionally would
* orphan them, and one of them holds account secrets. Where no provider
* offers it (seen on a Samsung API 34 tablet, where `Cipher.getInstance`
* throws `NoSuchAlgorithmException`), the correct GCM name is used
* instead. Before this, that throw propagated out of the constructor:
* callers that guarded it silently lost persistence, and the one that
* did not — cordn, from `Account`'s constructor — hung the app on
* "Loading account" forever.
*
* Resolved once per process: the answer cannot change under us, and
* every instance would otherwise repeat the failing lookup.
*/
private val resolvedPadding: String by lazy {
try {
Cipher.getInstance(transformationFor(LEGACY_PADDING))
LEGACY_PADDING
} catch (_: GeneralSecurityException) {
GCM_PADDING
}
}
}
private val padding = resolvedPadding
// One Cipher per thread rather than one shared instance: a Cipher holds the
// state of the operation in progress, so two callers on different threads
// through the same object would corrupt each other's output.
private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) }
// through the same object would corrupt each other's output. The
// transformation is resolved once, off [resolvedPadding], so every thread's
// instance is built with the spelling this device actually offers.
private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(transformationFor(padding)) }
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
@@ -69,7 +114,7 @@ internal class KeyStoreEncryption {
KeyGenParameterSpec
.Builder(KEY_ALIAS, PURPOSE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setEncryptionPaddings(padding)
.setIsStrongBoxBacked(true)
.build()
@@ -88,7 +133,7 @@ internal class KeyStoreEncryption {
KeyGenParameterSpec
.Builder(KEY_ALIAS, PURPOSE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setEncryptionPaddings(padding)
.build()
val generator = KeyGenerator.getInstance(ALGORITHM, "AndroidKeyStore")
@@ -116,11 +161,18 @@ internal class KeyStoreEncryption {
fun decrypt(bytes: ByteArray): ByteArray {
try {
// Extracts IV and decrypts the data
val iv = bytes.copyOfRange(0, 12) // GCM mode uses 12-byte IV
val data = bytes.copyOfRange(12, bytes.size)
val iv = bytes.copyOfRange(0, IV_BYTES)
val data = bytes.copyOfRange(IV_BYTES, bytes.size)
val cipher = ciphers.get()
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
// A GCMParameterSpec, not an IvParameterSpec. [BLOCK_MODE] is GCM,
// and AndroidKeyStore's GCM implementation rejects anything else
// outright — "Only GCMParameterSpec supported". Encrypting never
// noticed, because that direction lets the cipher choose its own
// nonce and passes no spec at all, so every store built on this
// class could write bytes it was then unable to read back: cordn
// lost its coordinators on each launch, and Marmot and the
// encrypted DataStore read nothing they had saved.
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(TAG_BITS, iv))
return cipher.doFinal(data)
} catch (e: Exception) {
cachedKey = null
@@ -0,0 +1,150 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* One coordinator this account talks to.
*
* A coordinator is not a relay and the difference matters to the user: relays
* are interchangeable and redundant, a coordinator is the single authority for
* the groups it serves. Losing it loses the ordering; a second one does not
* mirror the first. So this is a named, first-class thing a user chooses,
* never a URL buried in settings.
*
* [relays] is where the coordinator is *reachable* — the ContextVM kind-25910
* traffic goes over Nostr, so the coordinator has no address of its own beyond
* its pubkey (§8.5: the relay sees the traffic pattern, the coordinator never
* sees an IP).
*/
data class CoordinatorConfig(
val pubKey: HexKey,
val relays: List<NormalizedRelayUrl>,
/** How this account came to know about this coordinator. */
val origin: Origin = Origin.MANUAL,
/** What the user calls it. Never a claim — a coordinator cannot prove a name. */
val label: String? = null,
) {
init {
require(pubKey.length == PUBKEY_HEX_LENGTH) { "a coordinator pubkey is 32 bytes of hex" }
require(relays.isNotEmpty()) { "a coordinator with no relays cannot be reached" }
}
/** Where a coordinator came from, because it changes how much to trust it. */
enum class Origin {
/** Typed or pasted by the user. */
MANUAL,
/** Read out of a `cordn1…` group ref someone shared. */
GROUP_REF,
/**
* Picked off a CEP-6 announcement the coordinator published.
*
* Weaker than [MANUAL]: nobody vouched for it. The user saw a list, and
* everything in that list except the pubkey was self-asserted.
*/
ANNOUNCEMENT,
/** The application default. */
DEFAULT,
}
companion object {
private const val PUBKEY_HEX_LENGTH = 64
/**
* The coordinator a shared `cordn1…` ref points at, or null when the
* ref carries only a `gid`.
*
* A ref without a coordinator is not broken — §2 makes both optional —
* it just means the recipient has to already know which coordinator
* serves that group.
*/
fun from(ref: CordnGroupRef): CoordinatorConfig? {
val pubKey = ref.coordinatorPubKey ?: return null
val relays = ref.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isEmpty()) return null
return CoordinatorConfig(pubKey, relays, Origin.GROUP_REF)
}
}
}
/**
* Whether a coordinator is answering, kept per coordinator.
*
* Deliberately thin. This is not a health *check* — nothing here polls, because
* a poll is a call, and every call to a coordinator is metadata (§8). It
* records what the calls the app was making anyway have observed.
*/
class CoordinatorHealth {
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
data class State(
val lastSuccessAt: Long? = null,
val lastFailureAt: Long? = null,
val lastFailure: String? = null,
/** Failures since the last success. Resets on any success. */
val consecutiveFailures: Int = 0,
) {
/**
* Nothing has worked since the last success, repeatedly.
*
* A single failure is a network blip and worth no UI at all; the
* threshold is what separates "retrying" from "tell the user their
* groups are not syncing".
*/
val isDown: Boolean get() = consecutiveFailures >= DOWN_AFTER
/** True before the first call of the session — not the same as down. */
val isUnknown: Boolean get() = lastSuccessAt == null && lastFailureAt == null
}
fun recordSuccess(atSeconds: Long) {
_state.value = _state.value.copy(lastSuccessAt = atSeconds, consecutiveFailures = 0, lastFailure = null)
}
fun recordFailure(
atSeconds: Long,
reason: String?,
) {
val previous = _state.value
_state.value =
previous.copy(
lastFailureAt = atSeconds,
lastFailure = reason,
consecutiveFailures = previous.consecutiveFailures + 1,
)
}
companion object {
const val DOWN_AFTER = 3
}
}
@@ -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.commons.cordn
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec
import com.vitorpamplona.quartz.cordn.sync.GroupCursor
import com.vitorpamplona.quartz.mls.codec.TlsReader
import com.vitorpamplona.quartz.mls.codec.TlsWriter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.nip49PrivKeyEnc.SCrypt
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A passphrase-encrypted export of one account's cordn state.
*
* ## Why cordn needs this when Marmot does not
*
* A Marmot group lives on relays; a client that lost its state can at least
* see the group exists and be re-added to it. A cordn group exists as MLS
* state on **one device** plus an ordered stream on a coordinator that cannot
* read a byte of it. Lose the device and the group is gone — not "gone until
* someone re-invites you", because a fresh invitation starts at a new epoch
* and the history before it stays unreadable forever. That asymmetry is what
* makes an export worth the risk of having one.
*
* ## §5.4, answered: our own format, and portability was never available
*
* `amethyst/plans/2026-09-19-cordn-ui.md` §5.4 left the call open between
* matching cordn-web's backup and doing our own, noting the trade as "better
* for users, worse for portability". The portability half turns out to be
* unavailable, for the reason §4.6 gives about multi-device: the valuable
* content of any cordn backup is **MLS group state**, and that is an engine's
* internal serialization, not a wire format. Theirs is ts-mls's; ours is
* `MlsGroupState`. Neither can read the other whatever file format wraps it,
* so a byte-compatible container would buy a restore that cannot restore.
*
* (The other half also could not be matched cleanly: their client is outside
* the two MIT packages, and the spec prose is unlicensed.)
*
* ## Restoring replaces a device. It does not add one.
*
* An MLS state export is a **cloneable identity**. Two devices holding the
* same state and both committing fork the ratchet tree, and MLS does not
* recover: every message after the fork fails to decrypt for somebody, with
* no error that says why. §5.3 keeps multi-device a non-goal for exactly this
* reason, and a backup does not quietly become multi-device support because
* it is technically possible to restore twice.
*
* So: restore onto a device that has replaced the old one. A UI offering this
* must say that, and must not present it as sync.
*
* ## The file
*
* ```
* magic "CORDNBAK" | version:u16 | logN:u8 | salt:16 | nonce:12 | ChaCha20-Poly1305(payload)
* ```
*
* The header is passed as the AEAD's associated data. Today that is belt and
* braces rather than the thing protecting it: every header field either feeds
* the key derivation (`logN`, `salt`), feeds the cipher (`nonce`), or is
* checked outright (`magic`, `version`), so editing any of them already
* yields a wrong key or an outright refusal. The AAD earns its place if this
* header ever gains a field that does neither — at which point forgetting it
* would be the easy mistake. A mutation removing it survives the suite, and
* that is expected rather than a gap.
*/
object CordnBackup {
/**
* Version 2 adds the delivered messages to each group.
*
* Version 1 carried the MLS state, the cursor and the key packages but not
* a single message, and restoring it handed back the groups with an empty
* history: the messages were not in the file, and the cursor that *was*
* in the file told the sync loop it had already read to the end, so the
* coordinator was never asked to resend them. A backup that loses the
* conversation is not the thing the screen promises.
*
* The history could instead be re-pulled by resetting the cursor, since
* the coordinator holds it, but only for epochs the restored state can
* still open. Carrying the plaintext is what makes the restore whole
* regardless of how many times the group has rotated since.
*/
const val VERSION = 2
/** The last version that carried no messages; still readable. */
private const val VERSION_WITHOUT_MESSAGES = 1
/**
* scrypt cost, as `log2(N)`.
*
* Matches NIP-49's default. A backup is decrypted once in a while by its
* owner, never in a loop, so the cost belongs at the high end of what a
* phone will tolerate rather than tuned for throughput.
*/
const val DEFAULT_LOG_N = 16
private const val R = 8
private const val P = 1
private const val KEY_LENGTH = 32
private const val SALT_LENGTH = 16
private const val NONCE_LENGTH = 12
private val MAGIC = "CORDNBAK".encodeToByteArray()
/** Everything worth carrying to a replacement device. */
data class Archive(
val accountPubKey: HexKey,
val coordinators: List<CoordinatorConfig>,
val groups: List<Group>,
val keyPackages: List<KeyPackage>,
) {
data class Group(
val coordinatorPubKey: HexKey,
val gid: String,
/** An `MlsGroupState` blob. Opaque here, and engine-specific. */
val state: ByteArray,
val cursor: GroupCursor?,
val joinedViaRequest: Boolean,
/**
* The group's delivered messages, in cursor order.
*
* Empty when the archive was written by version 1, which carried
* none — a v1 restore still yields an empty conversation, and
* there is nothing in the file to do better with.
*/
val messages: List<CordnDeliveredMessage> = emptyList(),
) {
override fun equals(other: Any?): Boolean =
this === other ||
(
other is Group &&
coordinatorPubKey == other.coordinatorPubKey &&
gid == other.gid &&
state.contentEquals(other.state) &&
cursor?.fetchCursor == other.cursor?.fetchCursor &&
cursor?.lastCursor == other.cursor?.lastCursor &&
joinedViaRequest == other.joinedViaRequest &&
messages == other.messages
)
override fun hashCode(): Int {
var result = coordinatorPubKey.hashCode()
result = 31 * result + gid.hashCode()
result = 31 * result + state.contentHashCode()
result = 31 * result + (cursor?.fetchCursor?.hashCode() ?: 0)
result = 31 * result + joinedViaRequest.hashCode()
result = 31 * result + messages.hashCode()
return result
}
}
data class KeyPackage(
val coordinatorPubKey: HexKey,
val keyPackageRef: String,
/** A `KeyPackageBundle` blob — private key material. */
val bundle: ByteArray,
) {
override fun equals(other: Any?): Boolean =
this === other ||
(
other is KeyPackage &&
coordinatorPubKey == other.coordinatorPubKey &&
keyPackageRef == other.keyPackageRef &&
bundle.contentEquals(other.bundle)
)
override fun hashCode(): Int = 31 * (31 * coordinatorPubKey.hashCode() + keyPackageRef.hashCode()) + bundle.contentHashCode()
}
}
/** Encrypts [archive] under [passphrase]. */
fun seal(
archive: Archive,
passphrase: String,
logN: Int = DEFAULT_LOG_N,
): ByteArray {
val salt = RandomInstance.bytes(SALT_LENGTH)
val nonce = RandomInstance.bytes(NONCE_LENGTH)
val header = header(logN, salt, nonce)
val key = deriveKey(passphrase, salt, logN)
return header + ChaCha20Poly1305.encrypt(encode(archive), header, nonce, key)
}
/**
* Opens a sealed archive.
*
* Throws on a wrong passphrase, a truncated file or an edited header — all
* of which arrive as an AEAD authentication failure, which is the right
* answer to every one of them and deliberately does not distinguish
* between them.
*/
fun open(
sealed: ByteArray,
passphrase: String,
): Archive {
require(sealed.size > HEADER_LENGTH) { "not a cordn backup: too short" }
require(sealed.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { "not a cordn backup" }
val reader = TlsReader(sealed.copyOfRange(MAGIC.size, HEADER_LENGTH))
val version = reader.readUint16()
require(version == VERSION || version == VERSION_WITHOUT_MESSAGES) { "unknown cordn backup version $version" }
val logN = reader.readUint8()
val salt = reader.readBytes(SALT_LENGTH)
val nonce = reader.readBytes(NONCE_LENGTH)
val key = deriveKey(passphrase, salt, logN)
val header = sealed.copyOfRange(0, HEADER_LENGTH)
val plaintext = ChaCha20Poly1305.decrypt(sealed.copyOfRange(HEADER_LENGTH, sealed.size), header, nonce, key)
return decode(plaintext, version)
}
private fun header(
logN: Int,
salt: ByteArray,
nonce: ByteArray,
): ByteArray {
val writer = TlsWriter()
writer.putBytes(MAGIC)
writer.putUint16(VERSION)
writer.putUint8(logN)
writer.putBytes(salt)
writer.putBytes(nonce)
return writer.toByteArray()
}
private fun deriveKey(
passphrase: String,
salt: ByteArray,
logN: Int,
): ByteArray {
require(logN in MIN_LOG_N..MAX_LOG_N) { "unreasonable scrypt cost: 2^$logN" }
var n = 1
repeat(logN) { n *= 2 }
return SCrypt.scrypt(passphrase.encodeToByteArray(), salt, n, R, P, KEY_LENGTH)
}
private fun encode(archive: Archive): ByteArray {
val writer = TlsWriter()
writer.putOpaque2(archive.accountPubKey.encodeToByteArray())
writer.putOpaque4(CoordinatorListCodec.encode(archive.coordinators))
writer.putUint16(archive.groups.size)
archive.groups.forEach {
writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray())
writer.putOpaque2(it.gid.encodeToByteArray())
writer.putOpaque4(it.state)
writer.putUint8(if (it.cursor != null) 1 else 0)
it.cursor?.let { cursor ->
writer.putUint64(cursor.fetchCursor)
writer.putUint64(cursor.lastCursor)
}
writer.putUint8(if (it.joinedViaRequest) 1 else 0)
// uint32: a long-running group's log is not bounded by 65535.
// Each entry is the same JSON the on-disk message log stores, so
// the archive and the store cannot drift out of step.
writer.putUint32(it.messages.size.toLong())
it.messages.forEach { message ->
writer.putOpaque4(CordnDeliveredMessageCodec.encode(message).encodeToByteArray())
}
}
writer.putUint16(archive.keyPackages.size)
archive.keyPackages.forEach {
writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray())
writer.putOpaque2(it.keyPackageRef.encodeToByteArray())
writer.putOpaque4(it.bundle)
}
return writer.toByteArray()
}
private fun decode(
bytes: ByteArray,
version: Int,
): Archive {
val reader = TlsReader(bytes)
val accountPubKey = reader.readOpaque2().decodeToString()
val coordinators = CoordinatorListCodec.decode(reader.readOpaque4())
val groups =
(0 until reader.readUint16()).map {
val coordinator = reader.readOpaque2().decodeToString()
val gid = reader.readOpaque2().decodeToString()
val state = reader.readOpaque4()
val cursor = if (reader.readUint8() == 1) GroupCursor(reader.readUint64(), reader.readUint64()) else null
val joinedViaRequest = reader.readUint8() == 1
val messages =
if (version == VERSION_WITHOUT_MESSAGES) {
emptyList()
} else {
// A message that will not parse is dropped rather than
// failing the whole restore: losing one row beats
// refusing to bring the account back at all.
(0 until reader.readUint32()).mapNotNull {
CordnDeliveredMessageCodec.decodeOrNull(reader.readOpaque4().decodeToString())
}
}
Archive.Group(coordinator, gid, state, cursor, joinedViaRequest, messages)
}
val keyPackages =
(0 until reader.readUint16()).map {
Archive.KeyPackage(
coordinatorPubKey = reader.readOpaque2().decodeToString(),
keyPackageRef = reader.readOpaque2().decodeToString(),
bundle = reader.readOpaque4(),
)
}
return Archive(accountPubKey, coordinators, groups, keyPackages)
}
private const val MIN_LOG_N = 10
private const val MAX_LOG_N = 22
private val HEADER_LENGTH = MAGIC.size + 2 + 1 + SALT_LENGTH + NONCE_LENGTH
}
@@ -0,0 +1,44 @@
/*
* 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.commons.cordn
/**
* Encrypts the blobs the cordn stores put on disk.
*
* A seam, not an abstraction for its own sake. Everything cordn persists is key
* material or derived from it — an `MlsGroupState` carries the ratchet tree and
* epoch secrets, a `KeyPackageBundle` carries the private half that opens a
* Welcome — so "encrypted at rest" is not a preference here, and the stores
* refuse to be built without one.
*
* It exists as an interface for one concrete reason: the file layout, the key
* encoding and the atomic-write behaviour are worth testing, and the Android
* KeyStore cannot run in a JVM unit test. Marmot's equivalent store is welded
* to the KeyStore and consequently has no unit test at all; this one does.
*
* Implementations must be safe to call from several coroutines at once.
*/
interface CordnBlobCipher {
fun encrypt(bytes: ByteArray): ByteArray
/** @throws Exception if [bytes] were not produced by this cipher. */
fun decrypt(bytes: ByteArray): ByteArray
}
@@ -0,0 +1,62 @@
/*
* 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.commons.cordn
/**
* A content-addressed store for sealed migration documents (§12).
*
* Injected rather than depended on directly because the only Blossom uploader
* in this codebase needs an Android `Context`, and the migration service has to
* stay runnable from `amy` and from a test with no Android at all. The same
* reason `CordnCoordinatorLinkFactory` is injected.
*
* ## The one requirement that is not about bytes
*
* §12: a Blossom upload's BUD-01 authorization event **MUST be signed by an
* ephemeral key, never the owner `npub`** — consistent with the tip. Signing as
* the owner would publish, in the clear on the storage server, that this
* account is uploading blobs right now, which is exactly the linkage the
* opaque tip exists to prevent. An implementation that authorizes as the owner
* satisfies this interface's types and defeats its purpose.
*/
interface CordnBlobStore {
/**
* Uploads [blob] and returns the servers that accepted it.
*
* The address is `sha256(blob)` and the caller already knows it, so there
* is nothing to return but reachability: a blob nobody hosts is a document
* the other phone cannot fetch, and the tip's `server` list is built from
* this.
*/
suspend fun put(blob: ByteArray): List<String>
/**
* Fetches the blob at [address], trying [servers] in order.
*
* Returns null when no server served it. Verifying that the bytes hash to
* [address] is the **caller's** job (`CordnDocumentSeal.verifyAddress`) —
* an implementation is a transport and is not trusted to self-certify.
*/
suspend fun get(
address: String,
servers: List<String>,
): ByteArray?
}
@@ -0,0 +1,216 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools
import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface
import com.vitorpamplona.quartz.contextvm.cep06Announcements.ServerAnnouncement
import com.vitorpamplona.quartz.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorAdvertisement
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/**
* A coordinator found by listening for CEP-6 announcements.
*
* ## Everything here except [pubKey] and [relays] is a claim
*
* [surface] is what the coordinator said about itself in a self-signed event.
* A name is not an identity (`spec/00.md` §8.5 — the pubkey is), a website is
* not a proof of anything, and the capability flags are promises. Present this
* as "it says", never as fact, and never branch on it.
*
* [relays] is the one field the announcement did *not* provide, and the reason
* this type exists rather than a bare [ServerAnnouncement]. A CEP-6
* announcement carries no routing information at all, so there is nothing in
* it that says where to reach the coordinator. What we know instead is which
* relays *delivered* it — the coordinator publishes there, so a request
* published there is one it is positioned to see. That is an observation, not
* a promise, and it is the best the protocol offers today.
*/
data class DiscoveredCoordinator(
val pubKey: HexKey,
/** Relays that delivered an announcement for this pubkey. Never empty. */
val relays: List<NormalizedRelayUrl>,
val surface: DiscoverySurface,
/** The eleven tools it advertised, plus whatever else it serves. */
val tools: Set<String>,
/** The newest announcement's `created_at`, for judging staleness. */
val announcedAt: Long,
) {
/**
* What to add to the account.
*
* [CoordinatorConfig.label] is left null on purpose. A label is the user's
* word for a coordinator; the announced name is the coordinator's word for
* itself, and copying one into the other would launder a claim into
* something the UI presents as the user's own choice.
*/
fun toConfig(): CoordinatorConfig =
CoordinatorConfig(
pubKey = pubKey,
relays = relays,
origin = CoordinatorConfig.Origin.ANNOUNCEMENT,
)
}
/**
* Finds coordinators by reading CEP-6 announcements off relays.
*
* ## Why this is a relay query and not a coordinator call
*
* [CoordinatorHealth] never polls, because a call to a coordinator is metadata
* it gets to see (§8). This is the opposite shape: it reads events the
* coordinators already published, and touches no coordinator at all. Nothing
* discovered here learns that this account exists. The relays do see a query
* for kinds 11316/11317, which says "this user is shopping for an MCP server"
* and nothing about which one is chosen.
*
* ## Why the toolset is the filter
*
* There is no cordn marker on an announcement — see [CoordinatorAdvertisement].
* A server is recognised by serving the eleven tools of `spec/00.md`. Measured
* on the public relays, that separates coordinators from unrelated MCP servers
* cleanly, and it is the only signal available.
*
* ## What this list is not
*
* It is not a directory, a ranking, or a set of recommendations, and an
* announcement is not a sign of life: a coordinator that stopped running a
* month ago still has its replaceable announcement sitting on the relay, and a
* coordinator that never announced (the reference deployment's default is
* off) is absent from it entirely. [announcedAt] is exposed so a caller can
* show the age rather than imply freshness — this deliberately does not filter
* by age, because a quiet coordinator and a dead one look identical from here
* and only the user knows which theirs is.
*/
class CordnCoordinatorDiscovery(
private val client: INostrClient,
) {
/**
* Reads announcements from [relays] and returns the coordinators among them.
*
* [limit] caps each relay's response — these are replaceable events, so the
* cap bounds distinct servers rather than history. Results are newest
* announcement first.
*
* ## One fetch per relay, on purpose
*
* `fetchAllWithHooks` deduplicates by event id across every relay in one
* call, keeping only the first `(relay, event)` pair for an id. That is
* right for reading content and wrong here, where the whole point is to
* learn *every* relay that carries a coordinator's announcement — a single
* call would credit each announcement to whichever relay answered first
* and silently drop the rest of the reachable set. Querying each relay in
* its own call scopes the dedup to that relay, so attribution is correct
* by construction. The calls run concurrently, so this costs no wall time.
*/
suspend fun discover(
relays: Set<NormalizedRelayUrl>,
limit: Int = DEFAULT_LIMIT,
idleTimeoutMs: Long = DEFAULT_IDLE_TIMEOUT_MS,
): Result =
coroutineScope {
if (relays.isEmpty()) return@coroutineScope Result(emptyList(), emptySet())
val filters = listOf(Filter(kinds = CvmKinds.ANNOUNCEMENTS.toList(), limit = limit))
val perRelay =
relays
.map { relay ->
async {
client.fetchAllWithHooks(
filters = mapOf(relay to filters),
idleTimeoutMs = idleTimeoutMs,
onEvent = { _, _ -> true },
)
}
}.awaitAll()
Result(
coordinators = coordinatorsIn(perRelay.flatMap { it.events }),
unreachable = perRelay.flatMapTo(mutableSetOf()) { it.stalled + it.dead.keys },
)
}
/**
* The coordinators among `(relay, event)` pairs.
*
* Split out from [discover] so the grouping rules are testable without a
* relay: an announcement pair is only a coordinator when the 11317 half
* carries the eleven tools, and a 11316 alone says nothing about what a
* server serves.
*/
fun coordinatorsIn(events: List<Pair<NormalizedRelayUrl, Event>>): List<DiscoveredCoordinator> {
val byPubKey = mutableMapOf<HexKey, MutableList<Pair<NormalizedRelayUrl, ServerAnnouncement>>>()
events.forEach { (relay, event) ->
ServerAnnouncement.parseOrNull(event)?.let {
byPubKey.getOrPut(it.pubKey) { mutableListOf() }.add(relay to it)
}
}
return byPubKey
.mapNotNull { (pubKey, heard) ->
val latest = ServerAnnouncement.latestOf(heard.map { it.second })
val tools =
latest[CvmKinds.TOOLS_LIST]
?.let { AnnouncedTools.parseOrNull(it.content) }
?.takeIf(CoordinatorAdvertisement::matches)
?: return@mapNotNull null
val server = latest[CvmKinds.SERVER_ANNOUNCEMENT]
DiscoveredCoordinator(
pubKey = pubKey,
// Deduplicated and ordered by first hearing, so the relay
// that answered first is the one tried first.
relays = heard.map { it.first }.distinct(),
surface = server?.discovery ?: DiscoverySurface(),
tools = tools.names,
announcedAt = latest.values.maxOf { it.createdAt },
)
}.sortedByDescending { it.announcedAt }
}
/** What one [discover] call learned, including who did not answer. */
data class Result(
val coordinators: List<DiscoveredCoordinator>,
/**
* Relays that stalled or failed.
*
* An empty [coordinators] means "nobody is announcing" only when this
* is empty too; otherwise it means "we were not told".
*/
val unreachable: Set<NormalizedRelayUrl>,
)
companion object {
const val DEFAULT_LIMIT = 200
const val DEFAULT_IDLE_TIMEOUT_MS = 8_000L
}
}
@@ -0,0 +1,135 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient
import com.vitorpamplona.quartz.contextvm.transport.CvmTransport
import com.vitorpamplona.quartz.contextvm.transport.DualSigner
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
/** A live connection to one coordinator, and the way to close it. */
interface CordnCoordinatorLink {
val coordinator: ICoordinator
/**
* What the coordinator says about itself, or null.
*
* Deliberately **not** on `ICoordinator`. That interface is "the eleven
* coordinator tools, as a contract" and its KDoc says it adds nothing to
* them; `initialize` is the MCP handshake, not a twelfth tool. It belongs
* to whoever owns the transport, which is this.
*
* The default is null so a substitute link — a fixture, a test double —
* does not have to invent a handshake it never performed.
*/
suspend fun serverInfo(): CoordinatorServerInfo? = null
suspend fun close()
}
/**
* Opens the ContextVM transport to a coordinator.
*
* The one piece a scope factory cannot supply on its own: a `CvmTransport`
* needs a relay pool and the account's signers, which belong to whichever
* front end is running. Everything else about a scope — where the bytes go
* and how they are encrypted — is the same on every platform.
*/
fun interface CordnCoordinatorLinkFactory {
suspend fun connect(
accountPubKey: HexKey,
config: CoordinatorConfig,
): CordnCoordinatorLink
}
/**
* The production [CordnCoordinatorLinkFactory]: a real ContextVM transport per
* coordinator, over whatever relay client the caller is already running.
*
* Shared rather than per-front-end on purpose. Everything this assembles is a
* protocol decision — which signer signs which call, that gift wrapping stays
* REQUIRED, that `initialize` is not performed at open — and a second copy in
* a second front end is a second place those decisions can drift. Android, the
* desktop app and `amy` all take this one.
*
* ## The ephemeral signer is created here, once, and never persisted
*
* `spec/00.md` §8 splits the identity a coordinator sees: the account key
* signs what must be attributable (publishing a KeyPackage, posting to a
* group), and a throwaway key signs everything else, so the coordinator cannot
* link a session's reads to an account. Which key signs which call is fixed by
* `CoordinatorMethod` and not a choice made here; what IS decided here is that
* the throwaway key lives as long as this factory and no longer. Persisting it
* would quietly undo the split — a "session" key reused across launches is
* just a second account key with worse ergonomics.
*
* One consequence worth naming: a front end that builds a factory per run —
* `amy` does, since every invocation is its own process — gets a fresh
* throwaway key each time, which is the strong end of the split rather than a
* degradation of it.
*/
object CordnLinks {
fun over(
accountSigner: NostrSigner,
client: INostrClient,
): CordnCoordinatorLinkFactory {
val ephemeralSigner = NostrSignerInternal(KeyPair())
return CordnCoordinatorLinkFactory { _, config ->
val transport =
CvmTransport(
relays = NostrClientCvmRelayPool(client, config.relays.toSet()),
signers = DualSigner(accountSigner, ephemeralSigner),
serverPubKey = config.pubKey,
// Left at its default, which is REQUIRED (§8.6). Passing
// anything else here is the one line that would silently
// downgrade every coordinator call to plaintext.
crypto = CvmGiftWrap(),
)
object : CordnCoordinatorLink {
override val coordinator = CoordinatorClient(CvmMcpClient(transport))
// Handshaken on demand, not at open: `initialize` is a call
// like any other (§8), and a coordinator that only ever hears
// from us when we have something to say tells it less than one
// that is greeted at every launch.
override suspend fun serverInfo() = coordinator.serverInfo()
override suspend fun close() {
// Nothing to release. CvmTransport opens a subscription
// per request and closes it in the same call — kind 25910
// is ephemeral, so there is no long-lived stream to tear
// down and no connection of its own. The relays it used
// belong to the shared client, which outlives this link.
}
}
}
}
}
@@ -0,0 +1,309 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Everything one account needs to talk to one coordinator.
*
* The three pieces are scoped together because they are scoped the same way:
* a `gid` (`spec/00.md` §3), a `kp_ref` (§4.2), and a cursor (`spec/02.md` §7)
* are all only meaningful relative to the coordinator that issued them. Handing
* a manager the wrong store is not a subtle bug — it is one group's MLS state
* answering for another's.
*/
interface CordnCoordinatorScope {
val coordinator: ICoordinator
val groupStore: CordnGroupStore
val keyPackageStore: CordnKeyPackageStore
/** What the coordinator says about itself, if this scope performed a handshake. */
suspend fun serverInfo(): CoordinatorServerInfo? = null
/** Releases the transport. The stores outlive it; only the wire closes. */
suspend fun close()
}
/**
* Opens a [CordnCoordinatorScope] — the seam where the real transport enters.
*
* Production supplies relay-backed `CvmTransport` and encrypted-at-rest stores;
* tests supply the fixture. Nothing above this interface knows which.
*/
fun interface CordnCoordinatorScopeFactory {
suspend fun open(
accountPubKey: HexKey,
config: CoordinatorConfig,
): CordnCoordinatorScope
}
/** One account's live connection to one coordinator. */
class CordnSession(
config: CoordinatorConfig,
val manager: CordnGroupManager,
val keyPackages: CordnKeyPackages,
val health: CoordinatorHealth,
private val scope: CordnCoordinatorScope,
/** Seconds. Injected so tests are not at the mercy of the wall clock. */
private val clock: () -> Long = { TimeUtils.now() },
) {
/**
* This coordinator's address and the user's name for it.
*
* Settable only through [relabel], and only for the name: everything else
* here is the address the transport was opened against, so changing it
* without reopening would leave the session pointing somewhere it is not
* connected.
*/
var config: CoordinatorConfig = config
private set
val coordinatorPubKey: HexKey get() = config.pubKey
/**
* A new name for the same coordinator, leaving the connection alone.
*
* A label is not part of a coordinator's address -- §8.5 makes the pubkey
* its identity -- and nothing under this class reads one;
* `CordnCoordinatorStore` is the only reader, when it writes the list to
* disk. So a rename must not take the reopen path [replaceConfig] uses for
* a corrected relay: that builds a new [CordnGroupManager], and anything
* still holding the old one (`CordnSyncLoop` captures it for the life of
* the loop) would be left polling a transport that has been closed.
*/
internal fun relabel(newLabel: String?) {
config = config.copy(label = newLabel)
}
/**
* What this coordinator learns about [gid], answered from everything the
* session holds rather than everything one object happens to see.
*
* The manager alone cannot say whether this account has a KeyPackage
* published here: it only knows what it published itself, this run, so
* after a relaunch it would report "no" about a KeyPackage sitting on the
* coordinator right now. The KeyPackage store knows, and the session is
* the first place that holds both.
*/
suspend fun exposure(gid: String): GroupExposure = manager.exposure(gid, publishedKeyPackage = keyPackages.hasPublished())
/**
* What this coordinator says about itself. Claims, never identity (§8.5).
*
* Counts against [health] like any other call. It is a real round trip to
* the coordinator, and it is usually the *first* one a reader makes — the
* settings screen offers it precisely so someone can find out whether a
* coordinator they just added answers at all. Leaving it out had that
* screen say "Nothing asked of it yet" directly underneath what the
* coordinator had just answered.
*
* An answer counts as a success and a throw as a failure; `null` counts as
* neither. A scope returns `null` when it did no handshake at all, so
* treating that as a success would report a coordinator as reachable on
* the strength of a call that never left the device.
*/
suspend fun serverInfo(): CoordinatorServerInfo? =
try {
scope.serverInfo()?.also { health.recordSuccess(clock()) }
} catch (e: Exception) {
health.recordFailure(clock(), e.message)
throw e
}
/** How many of this session's responses arrived reassembled over CEP-22. */
val oversizedTransfers: Int get() = scope.coordinator.oversizedTransfers
internal suspend fun close() = scope.close()
}
/**
* One [CordnSession] per coordinator, for one account.
*
* ## Why this exists rather than a single manager
*
* `CordnGroupManager`'s KDoc says a `gid` is unique only within a coordinator.
* That is not a caching detail — it is the reason a registry has to be keyed by
* coordinator and can never merge two. Two coordinators can both serve
* `gid = "abc"`, and they are two unrelated groups with different members,
* different epochs and different ratchet trees. A flat map from `gid` to
* manager would let one answer for the other; from the user's side that looks
* like a group whose history changes when the coordinator it came from does.
*
* ## Why it is idempotent, under a lock
*
* [session] returns the *same* session for a coordinator it already holds, and
* serialises concurrent opens. A second `CordnGroupManager` for one coordinator
* would deserialise its own copy of every `MlsGroup` from the same store; both
* would then commit against the same epoch and each would save over the other.
* MLS has no recovery from that — the ratchet tree forks and every message
* after it fails to decrypt. The UI and the sync loop both reach for a session,
* so "two callers at once" is the ordinary case, not a race to dismiss.
*
* Nothing here is Marmot's, and Marmot has no equivalent to borrow. Marmot
* groups live on relays, so one `MarmotManager` serves an account outright and
* there is nothing to key a registry by; a cordn account has one of these per
* coordinator because the coordinator is what makes its ids mean anything. The
* two stay separate under the §3.1 rule in
* `amethyst/plans/2026-09-19-cordn-ui.md`.
*/
class CordnCoordinatorRegistry(
val accountPubKey: HexKey,
private val scopes: CordnCoordinatorScopeFactory,
) {
private val lock = Mutex()
private val sessions = mutableMapOf<HexKey, CordnSession>()
private val _coordinators = MutableStateFlow<List<CoordinatorConfig>>(emptyList())
/** The coordinators this account currently has a session for. */
val coordinators: StateFlow<List<CoordinatorConfig>> = _coordinators.asStateFlow()
/**
* The session for [config], opening one if this account has none.
*
* Idempotent by coordinator pubkey: the same call twice gives the same
* session, never a second manager over the same store.
*
* A [config] that differs from the open one reopens the transport, because
* the transport is bound to the relays — but the same stores come back, so
* the groups and published KeyPackages survive. Correcting a relay or
* renaming a coordinator has not made it a different coordinator.
*/
suspend fun session(config: CoordinatorConfig): CordnSession =
lock.withLock {
sessions[config.pubKey]?.let { existing ->
when {
existing.config == config -> existing
// Only the name changed. Reopening for that would cost the
// connection and orphan whoever holds the current manager,
// for a field the transport has never read.
existing.config.copy(label = config.label) == config ->
existing.also {
it.relabel(config.label)
publish()
}
else -> replaceConfig(existing, config)
}
} ?: open(config).also { publish() }
}
/** The session for [coordinatorPubKey], or null if none is open. */
fun sessionOrNull(coordinatorPubKey: HexKey): CordnSession? = sessions[coordinatorPubKey]
/**
* Restores sessions for [configs] and closes any coordinator not in it.
*
* The closing half is what makes this a restore rather than a bulk add: a
* coordinator the user removed on another launch must not come back to life
* because its session happened to still be open.
*/
suspend fun restore(configs: List<CoordinatorConfig>) {
configs.forEach { session(it) }
lock.withLock {
val keep = configs.map { it.pubKey }.toSet()
sessions.keys.filter { it !in keep }.forEach { sessions.remove(it)?.close() }
publish()
}
}
/**
* Closes the session for [coordinatorPubKey] and forgets it.
*
* The stores are **not** wiped. Forgetting a coordinator is not the same as
* leaving its groups: cordn-web separates removing a coordinator from
* purging it (`CoordinatorPurgeDialog`), and quietly destroying group state
* here would make an undo impossible. A purge is a store-level operation and
* belongs to whoever owns the stores.
*/
suspend fun forget(coordinatorPubKey: HexKey) {
lock.withLock {
sessions.remove(coordinatorPubKey)?.close()
publish()
}
}
/** Closes every session. Call when the account logs out. */
suspend fun close() {
lock.withLock {
sessions.values.forEach { it.close() }
sessions.clear()
publish()
}
}
private suspend fun replaceConfig(
existing: CordnSession,
config: CoordinatorConfig,
): CordnSession {
// Same coordinator, different address or label. The transport is bound
// to the relays, so it is reopened; the stores are not, so the groups
// and published KeyPackages survive untouched.
existing.close()
sessions.remove(config.pubKey)
return open(config).also { publish() }
}
private suspend fun open(config: CoordinatorConfig): CordnSession {
val scope = scopes.open(accountPubKey, config)
val health = CoordinatorHealth()
val session =
CordnSession(
config = config,
manager =
CordnGroupManager(
accountPubKey = accountPubKey,
config = config,
coordinator = scope.coordinator,
store = scope.groupStore,
health = health,
),
keyPackages = CordnKeyPackages(accountPubKey, scope.coordinator, scope.keyPackageStore),
health = health,
scope = scope,
)
// Restored here rather than left to the caller. A session that does not
// know its own groups is not a usable session — it reports none, and
// the first commit it makes for a group it forgot about starts from
// epoch zero against a coordinator that is not there. Forgetting to
// call this would look like "my groups are gone", which is also what a
// real failure looks like.
session.manager.restore()
session.keyPackages.restore()
sessions[config.pubKey] = session
return session
}
private fun publish() {
_coordinators.value = sessions.values.map { it.config }
}
}
@@ -0,0 +1,120 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.mls.codec.TlsReader
import com.vitorpamplona.quartz.mls.codec.TlsWriter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* The coordinators one account talks to, across launches.
*
* Separate from [CordnGroupStore] and [CordnKeyPackageStore] because its scope
* is different: those are per (account, coordinator) — a `gid` and a `kp_ref`
* only mean anything relative to one coordinator — while this is the list of
* coordinators itself, and so belongs to the account alone.
*
* Without it every cordn group on the device is unreachable after a relaunch.
* The MLS state survives on disk, but nothing knows which coordinator to ask
* for its stream, and a `gid` with no coordinator is not a group anyone can
* open. That is why this is storage rather than a preference.
*
* **Encrypt it at rest.** The contents are not secret the way key material is,
* but they are the sharpest metadata cordn produces about this account: which
* coordinators it uses is exactly what §8 spends its length keeping a
* coordinator from learning about *other* coordinators.
*/
interface CordnCoordinatorStore {
suspend fun save(configs: List<CoordinatorConfig>)
suspend fun load(): List<CoordinatorConfig>
}
/** For tests and for a front end that deliberately keeps nothing. */
class InMemoryCordnCoordinatorStore(
initial: List<CoordinatorConfig> = emptyList(),
) : CordnCoordinatorStore {
private var configs = initial
override suspend fun save(configs: List<CoordinatorConfig>) {
this.configs = configs
}
override suspend fun load(): List<CoordinatorConfig> = configs
}
/**
* The on-disk layout of a coordinator list.
*
* TLS-framed like everything else that gets persisted here, for the reason
* [com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec] gives: a
* length-prefixed format cannot silently mis-parse a label containing whatever
* separator a line-based one picked, and a version prefix means a future layout
* change is refused rather than guessed at.
*/
object CoordinatorListCodec {
const val VERSION = 1
fun encode(configs: List<CoordinatorConfig>): ByteArray {
val writer = TlsWriter()
writer.putUint16(VERSION)
writer.putUint16(configs.size)
configs.forEach { config ->
writer.putOpaque2(config.pubKey.encodeToByteArray())
writer.putOpaque2(config.origin.name.encodeToByteArray())
writer.putOpaque2(config.label.orEmpty().encodeToByteArray())
writer.putUint16(config.relays.size)
config.relays.forEach { writer.putOpaque2(it.url.encodeToByteArray()) }
}
return writer.toByteArray()
}
/**
* Reads a list back, dropping entries that no longer make sense.
*
* A coordinator whose relays all fail to normalise, or whose `origin` this
* build does not know, is skipped rather than throwing: one unreadable
* entry must not take the rest of the account's coordinators with it, and
* a coordinator the app cannot reach is not better represented by a crash
* at login.
*/
fun decode(bytes: ByteArray): List<CoordinatorConfig> {
val reader = TlsReader(bytes)
val version = reader.readUint16()
require(version == VERSION) { "unknown coordinator list layout version $version" }
val count = reader.readUint16()
val out = mutableListOf<CoordinatorConfig>()
repeat(count) {
val pubKey = reader.readOpaque2().decodeToString()
val originName = reader.readOpaque2().decodeToString()
val label = reader.readOpaque2().decodeToString()
val relayCount = reader.readUint16()
val relays = (0 until relayCount).mapNotNull { RelayUrlNormalizer.normalizeOrNull(reader.readOpaque2().decodeToString()) }
val origin = CoordinatorConfig.Origin.entries.firstOrNull { it.name == originName }
if (relays.isNotEmpty() && origin != null) {
out += CoordinatorConfig(pubKey, relays, origin, label.ifEmpty { null })
}
}
return out
}
}
@@ -0,0 +1,155 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* What the delivery operator learns, as data rather than as documentation.
*
* §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement:
* *"This section should be surfaced in the UI if we ship this, not buried. A
* Marmot group and a cordn group have materially different metadata exposure
* and users cannot infer that from either one looking like a group chat."*
*
* A comment cannot satisfy that; a screen can, and a screen needs a model. So
* the analysis lives here as [GroupExposure], computed from the group's actual
* state rather than written out per group — a group joined from a share link
* really does expose more than one whose members were added directly, and the
* difference is mechanical, not editorial.
*
* The one thing this deliberately does **not** do is rank the two bindings.
* cordn is weaker against the delivery operator and stronger against the
* network (§8.5: the coordinator never sees an IP). Which trade is right
* depends on who runs the coordinator, which is the user's call and not ours.
*/
enum class ExposureLevel {
/** The operator cannot learn this at all. */
NONE,
/** Learned, but tied to a throwaway key rather than to the account. */
PSEUDONYMOUS,
/** Learned and tied to the real npub. */
IDENTIFIED,
}
/** One thing worth telling the user about, with the rule it follows from. */
enum class ExposureNote {
/**
* §8.1 — admission names both ends. `join_request_store` rides the stable
* identity and carries the `gid`; `welcome_store` names the target's real
* pubkey. For a group joined from a share link the coordinator observes
* real-identity membership directly. Structural, not a bug.
*/
MEMBERSHIP_IS_IDENTIFIED,
/**
* §8.2 — the ephemeral identity is per-session, not per-message, so one
* pseudonym posts and fetches across every group on that coordinator. The
* set of `gid`s it touches links those groups together and leaks how many
* there are. This is the one the user cannot guess and the reason a plain
* "messages are pseudonymous" would be a lie by omission.
*/
GROUPS_LINKED_BY_SESSION,
/**
* §8.3 — one operator holds the complete ordered history of every group it
* serves, with timestamps and payload sizes, in one SQLite file. Relays
* are redundant and partitioned; a coordinator is neither.
*/
SINGLE_OPERATOR_HOLDS_HISTORY,
/**
* §8.4 — `kp_publish` rides the stable identity and the coordinator keeps
* the signed event, by design, to re-serve. That is a verifiable record
* that this account uses cordn, rotation cadence included.
*/
PUBLICATION_IS_A_SIGNED_RECORD,
/**
* §8.3 — nothing in `spec/03.md` pads the sealed payload, so message sizes
* are visible to the operator.
*/
MESSAGE_SIZES_UNPADDED,
/**
* §8.6 — the transport is NOT pinned to required encryption, so a
* coordinator that declines to announce `support_encryption` would receive
* plaintext JSON-RPC on public relays. Should be impossible in our client;
* if this ever appears, it is a bug, not a disclosure.
*/
ENCRYPTION_NOT_PINNED,
}
/**
* The exposure of one cordn group on one coordinator.
*
* @param linkedGroupCount how many groups share this coordinator's ephemeral
* session (§8.2). One is already a link between that group and the account's
* traffic; more is a graph.
*/
data class GroupExposure(
val coordinator: HexKey,
val linkedGroupCount: Int,
val joinedFromShareLink: Boolean,
val publishedKeyPackage: Boolean,
val encryptionPinned: Boolean,
) {
/** §8: double-sealed, and the coordinator is forbidden from parsing. Always. */
val content: ExposureLevel = ExposureLevel.NONE
/** §8.1. Admission names real keys on both ends; there is no other way in. */
val membership: ExposureLevel = ExposureLevel.IDENTIFIED
/** §8.2. The ephemeral identity covers the message path — and only that. */
val messaging: ExposureLevel = ExposureLevel.PSEUDONYMOUS
/**
* The notes worth showing, **most surprising first**.
*
* Order is part of the disclosure, not presentation trivia: a reader gives
* the first two lines real attention and skims the rest. So cross-group
* linkage (§8.2) — the one nobody predicts — comes before message sizes,
* which is the least consequential item here. A bug, if one ever appears,
* outranks everything.
*/
fun notes(): List<ExposureNote> =
buildList {
if (!encryptionPinned) add(ExposureNote.ENCRYPTION_NOT_PINNED)
add(ExposureNote.MEMBERSHIP_IS_IDENTIFIED)
// Only worth saying once there is actually something to link to.
if (linkedGroupCount > 1) add(ExposureNote.GROUPS_LINKED_BY_SESSION)
add(ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY)
if (publishedKeyPackage) add(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD)
add(ExposureNote.MESSAGE_SIZES_UNPADDED)
}
/**
* Whether this group's exposure differs from a Marmot group's in a way the
* user should be told before they treat the two the same.
*
* Always true today, and written as a function anyway: the interesting case
* is a self-hosted coordinator, where the answer changes without any of
* this code changing.
*/
fun differsFromMarmot(): Boolean = true
}
@@ -0,0 +1,384 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec
import com.vitorpamplona.quartz.cordn.sync.EchoState
import com.vitorpamplona.quartz.cordn.sync.GroupCursor
import com.vitorpamplona.quartz.cordn.sync.PendingEpochOperation
import com.vitorpamplona.quartz.mls.codec.TlsReader
import com.vitorpamplona.quartz.mls.codec.TlsWriter
/**
* Local storage for cordn group state, keyed by the delivery `gid`.
*
* **Implementations MUST encrypt at rest.** The blobs are
* `MlsGroupState.encodeTls()` output: private keys and epoch secrets.
*
* Separate from Marmot's `MlsGroupStateStore` rather than shared, and the key
* is the reason. That interface is keyed on the Nostr group id — the MIP-01 `h`
* tag — which cordn has no equivalent of; cordn's key is the coordinator's
* delivery `gid`, which is opaque, is not the MLS `group_id`, and is not even
* unique across coordinators. The two look alike and mean different things, so
* one interface serving both would be an invitation to hand the wrong id to the
* wrong store and silently find no group.
*
* The cursor lives here too because it is worthless apart from the state it
* belongs to: a cursor restored against a group at a different epoch replays
* messages the group can no longer decrypt.
*/
interface CordnGroupStore {
suspend fun saveGroup(
gid: String,
state: ByteArray,
)
suspend fun loadGroup(gid: String): ByteArray?
suspend fun deleteGroup(gid: String)
/** Every `gid` with saved state, for restoring memberships at startup. */
suspend fun listGroups(): List<String>
suspend fun saveCursor(
gid: String,
cursor: GroupCursor,
)
suspend fun loadCursor(gid: String): GroupCursor?
/**
* Saves the record of which stream entries are this client's own.
*
* Beside the cursor because it is only meaningful with it, and durable for
* the same reason: posting does not advance the cursor (a lower one may
* still hold somebody else's unprocessed message), so the next run
* re-reads what this one posted. Without this it cannot tell that it is
* its own — a Commit sealed under an epoch key it has since left, a
* message from a ratchet generation already consumed — and reports a gap
* in its own conversation. See `EchoState`.
*/
suspend fun saveEchoState(
gid: String,
state: EchoState,
)
/** The saved echo bookkeeping for [gid], or an empty one. */
suspend fun loadEchoState(gid: String): EchoState
/**
* Records that admission to [gid] went through `join_request_store`.
*
* Per-group side state beside the cursor, and durable for the same reason:
* it describes something the coordinator will not forget. §8.1 — a join
* request names the asker's real npub against a specific group, so once it
* has happened the coordinator can tie this account to this group forever.
* Whether *we* still remember changes nothing about what it knows, which
* is why this outlives the session that did it rather than resetting to a
* cheerful default at every launch.
*/
suspend fun saveJoinedViaRequest(gid: String)
/** Whether [gid] was admitted through a join request. */
suspend fun loadJoinedViaRequest(gid: String): Boolean
/**
* Saves the per-room state a screen needs but the protocol does not.
*
* An unsent draft and a read position are not MLS state and never leave
* the device — but they go through the same store, and so the same
* encryption, for one reason: a draft is the plaintext of a message that
* was about to be end-to-end encrypted. Writing it somewhere softer than
* the conversation it belongs to would make the composer the weakest point
* in the whole feature.
*/
suspend fun saveRoomState(
gid: String,
state: CordnRoomState,
)
/** The saved room state for [gid], or a blank one. */
suspend fun loadRoomState(gid: String): CordnRoomState
/**
* Records one delivered message, as the only copy there will ever be.
*
* Not a cache. Both of a cordn payload's seal keys are derived per epoch,
* so once the group ratchets forward the ciphertext the coordinator still
* holds cannot be opened again — and the cursor has advanced past it in any
* case, so it will not even be offered. Ingestion is the one moment the
* message is in the clear.
*
* MUST be idempotent on the envelope's id. A client that dies between this
* call and [saveCursor] re-fetches the message on the next catch-up, which
* is the crash window the append order is chosen to land in — see
* `commons/plans/2026-09-24-cordn-message-store.md`.
*/
suspend fun appendMessage(
gid: String,
message: CordnDeliveredMessage,
)
/** Every stored message for [gid], oldest first. Empty when there are none. */
suspend fun loadMessages(gid: String): List<CordnDeliveredMessage>
/**
* The newest message and the count, without reading the whole log.
*
* The inbox needs a preview line for every room before any room is opened,
* and reading every group's history at login is what
* `CordnRuntime.restoreRoomState` deliberately avoids. This is the small
* read that makes the preview possible: one key per group, written on the
* same beat as [appendMessage].
*/
suspend fun loadMessageSummary(gid: String): CordnMessageSummary?
}
/**
* What the inbox needs to know about a room without opening it.
*
* @param newest the last message delivered, for the preview line.
* @param count how many are stored, for anything that wants to say "empty" and
* mean it rather than meaning "not loaded yet".
*/
data class CordnMessageSummary(
val newest: CordnDeliveredMessage,
val count: Int,
)
/**
* What a cordn room remembers between visits.
*
* @param draft text typed and not sent.
* @param lastReadCursor the newest cursor this account has seen in the room.
* A cursor rather than a timestamp, because the coordinator's order is the
* only one every member agrees on (`spec/00.md` §4) and a sender's clock is
* a claim — the same reason the room itself sorts on it.
*/
data class CordnRoomState(
val draft: String = "",
val lastReadCursor: Long = 0L,
) {
val isBlank: Boolean get() = draft.isEmpty() && lastReadCursor == 0L
}
/**
* The on-disk form of a [CordnMessageSummary].
*
* The message itself rides as its own JSON entry rather than being taken apart
* into TLS fields, so there is exactly one definition of what a stored message
* looks like and the summary cannot drift from the log it summarises.
*/
object CordnMessageSummaryCodec {
const val VERSION = 1
fun encode(
newest: CordnDeliveredMessage,
count: Int,
): ByteArray {
val writer = TlsWriter()
writer.putUint16(VERSION)
writer.putUint32(count.toLong())
writer.putOpaque2(CordnDeliveredMessageCodec.encode(newest).encodeToByteArray())
return writer.toByteArray()
}
/** Null on anything unreadable: a preview line is not worth failing a login over. */
fun decode(bytes: ByteArray): CordnMessageSummary? =
try {
val reader = TlsReader(bytes)
if (reader.readUint16() != VERSION) {
null
} else {
val count = reader.readUint32().toInt()
val newest = CordnDeliveredMessageCodec.decode(reader.readOpaque2().decodeToString())
CordnMessageSummary(newest, count)
}
} catch (e: Exception) {
null
}
}
/** The on-disk layout of a [CordnRoomState]. */
object CordnRoomStateCodec {
const val VERSION = 1
fun encode(state: CordnRoomState): ByteArray {
val writer = TlsWriter()
writer.putUint16(VERSION)
writer.putOpaque2(state.draft.encodeToByteArray())
writer.putUint64(state.lastReadCursor)
return writer.toByteArray()
}
/** Blank on anything unreadable: a lost draft must not cost the room. */
fun decode(bytes: ByteArray): CordnRoomState =
try {
val reader = TlsReader(bytes)
if (reader.readUint16() != VERSION) {
CordnRoomState()
} else {
CordnRoomState(reader.readOpaque2().decodeToString(), reader.readUint64())
}
} catch (e: Exception) {
CordnRoomState()
}
}
/**
* The on-disk layout of an [EchoState].
*
* ```
* version:u16
* pending_commits: vector2 of { sealed:opaque2, applied:u8 }
* own_cursors: vector2 of u64
* ```
*
* Empty on anything unreadable, which is the safe direction: losing the record
* costs a client one run of reporting its own traffic as a gap, while
* accepting a half-decoded one could let it skip somebody else's message as
* though it were its own.
*/
object EchoStateCodec {
const val VERSION = 1
fun encode(state: EchoState): ByteArray {
val writer = TlsWriter()
writer.putUint16(VERSION)
val commits = TlsWriter()
state.pendingCommits.forEach {
commits.putOpaque2(it.sealedBase64.encodeToByteArray())
commits.putUint8(if (it.localStateApplied) 1 else 0)
}
writer.putOpaque2(commits.toByteArray())
val cursors = TlsWriter()
state.ownMessageCursors.forEach { cursors.putUint64(it) }
writer.putOpaque2(cursors.toByteArray())
return writer.toByteArray()
}
fun decode(bytes: ByteArray): EchoState =
try {
val reader = TlsReader(bytes)
if (reader.readUint16() != VERSION) {
EchoState()
} else {
val commits = TlsReader(reader.readOpaque2())
val pending = mutableListOf<PendingEpochOperation>()
while (commits.hasRemaining) {
val sealed = commits.readOpaque2().decodeToString()
pending += PendingEpochOperation(sealed, commits.readUint8() == 1)
}
val cursors = TlsReader(reader.readOpaque2())
val own = mutableListOf<Long>()
while (cursors.hasRemaining) {
own += cursors.readUint64()
}
EchoState(pending, own)
}
} catch (e: Exception) {
EchoState()
}
}
/** A [CordnGroupStore] that keeps everything in memory. Tests, and nothing else. */
class InMemoryCordnGroupStore : CordnGroupStore {
private val groups = mutableMapOf<String, ByteArray>()
private val cursors = mutableMapOf<String, GroupCursor>()
private val viaRequest = mutableSetOf<String>()
private val roomStates = mutableMapOf<String, CordnRoomState>()
private val echoStates = mutableMapOf<String, EchoState>()
private val messages = mutableMapOf<String, MutableList<CordnDeliveredMessage>>()
override suspend fun saveGroup(
gid: String,
state: ByteArray,
) {
groups[gid] = state
}
override suspend fun loadGroup(gid: String): ByteArray? = groups[gid]
override suspend fun deleteGroup(gid: String) {
groups.remove(gid)
cursors.remove(gid)
viaRequest.remove(gid)
roomStates.remove(gid)
echoStates.remove(gid)
// Leaving a group that left its history behind would keep the plaintext
// of an end-to-end encrypted conversation on disk after the one thing
// that could read it was thrown away.
messages.remove(gid)
}
override suspend fun listGroups(): List<String> = groups.keys.toList()
override suspend fun saveJoinedViaRequest(gid: String) {
viaRequest += gid
}
override suspend fun loadJoinedViaRequest(gid: String): Boolean = gid in viaRequest
override suspend fun saveRoomState(
gid: String,
state: CordnRoomState,
) {
roomStates[gid] = state
}
override suspend fun loadRoomState(gid: String): CordnRoomState = roomStates[gid] ?: CordnRoomState()
override suspend fun appendMessage(
gid: String,
message: CordnDeliveredMessage,
) {
val log = messages.getOrPut(gid) { mutableListOf() }
if (log.none { it.envelope.id == message.envelope.id }) log.add(message)
}
override suspend fun loadMessages(gid: String): List<CordnDeliveredMessage> = messages[gid]?.toList() ?: emptyList()
override suspend fun loadMessageSummary(gid: String): CordnMessageSummary? = messages[gid]?.lastOrNull()?.let { CordnMessageSummary(it, messages[gid]?.size ?: 0) }
override suspend fun saveEchoState(
gid: String,
state: EchoState,
) {
echoStates[gid] = state
}
override suspend fun loadEchoState(gid: String): EchoState = echoStates[gid] ?: EchoState()
override suspend fun saveCursor(
gid: String,
cursor: GroupCursor,
) {
cursors[gid] = cursor
}
override suspend fun loadCursor(gid: String): GroupCursor? = cursors[gid]
}
@@ -0,0 +1,115 @@
/*
* 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.commons.cordn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Whether this device has handed its cordn state to another one.
*
* ## The property that makes migration safe
*
* `multi-device.md` §10 has one unresolved case: two devices of an identity
* committing inside a single delivery round-trip reach epoch N+1 with different
* states, and §15 concedes *equal-epoch MLS states have no merge function*. The
* whole reason migration is buildable where continuous sync is not is that a
* handoff has one writer.
*
* That is only true if the old phone actually stops. Two phones holding one
* leaf and both committing fork the ratchet tree, and MLS does not recover —
* after the fork every message silently fails to decrypt for somebody. So the
* handoff is not finished when the code is scanned; it is finished when this
* device has stood down, and [handedOff] is what says so.
*
* ## Why it is reversible
*
* A migration can fail after the export: the other phone is out of battery,
* the QR does not scan, the user changes their mind. Locking irreversibly
* would strand an account on a device that still holds the only copy of its
* state. [resume] exists for that, and is safe exactly while the new device
* has not started committing — which is why the UI asks rather than assumes.
*
* ## What it does not do
*
* It does not tell the coordinator anything, and it does not delete anything.
* The state stays on disk so [resume] can work, and so a user who migrated by
* mistake has not lost their groups.
*/
class CordnHandoffState(
private val store: CordnHandoffStore,
) {
private val _handedOff = MutableStateFlow(false)
/** True once this device has exported and stood down. */
val handedOff: StateFlow<Boolean> = _handedOff.asStateFlow()
/** Reads the flag off disk. Call before the first sync loop starts. */
suspend fun restore() {
_handedOff.value = store.load()
}
/**
* Records that this device has handed off.
*
* The caller is responsible for having stopped the sync loops first: this
* records a decision, it does not enforce one.
*/
suspend fun markHandedOff() {
store.save(true)
_handedOff.value = true
}
/** Takes the device back, for a migration that did not complete. */
suspend fun resume() {
store.save(false)
_handedOff.value = false
}
/**
* Throws if this device has handed off.
*
* Called on every path that would advance an epoch. A read is still
* allowed: showing the user the conversations they had is harmless, and
* refusing it would make a failed migration look like data loss.
*/
fun requireNotHandedOff() {
if (_handedOff.value) {
throw CordnHandedOffException(
"this device handed its cordn groups to another one; " +
"sending from both would fork the group state",
)
}
}
}
/** An attempt to write from a device that has handed off. */
class CordnHandedOffException(
message: String,
) : Exception(message)
/** Where the handoff flag lives between runs. */
interface CordnHandoffStore {
suspend fun load(): Boolean
suspend fun save(handedOff: Boolean)
}
@@ -0,0 +1,396 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.groups.CordnCredential
import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy
import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.cancellation.CancellationException
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* The private halves of the KeyPackages this account has published.
*
* **Implementations MUST encrypt at rest.** A bundle is key material: whoever
* holds it can open the Welcome that admits its owner to a group.
*
* Separate from Marmot's `KeyPackageBundleStore` and not a refactor of it. That
* one persists a single snapshot carrying Marmot's rotation state alongside the
* bundles; cordn has no rotation state, no kind-443 event, and no publish
* obligations — its KeyPackages live entirely in coordinator calls
* (`spec/00.md` §4.2). The shapes differ because the protocols do.
*/
interface CordnKeyPackageStore {
suspend fun save(
keyPackageRef: String,
bundle: ByteArray,
)
suspend fun load(keyPackageRef: String): ByteArray?
suspend fun delete(keyPackageRef: String)
/** Every ref with a stored private half. */
suspend fun list(): List<String>
}
/** In-memory [CordnKeyPackageStore]. Tests, and nothing else. */
class InMemoryCordnKeyPackageStore : CordnKeyPackageStore {
private val bundles = mutableMapOf<String, ByteArray>()
override suspend fun save(
keyPackageRef: String,
bundle: ByteArray,
) {
bundles[keyPackageRef] = bundle
}
override suspend fun load(keyPackageRef: String): ByteArray? = bundles[keyPackageRef]
override suspend fun delete(keyPackageRef: String) {
bundles.remove(keyPackageRef)
}
override suspend fun list(): List<String> = bundles.keys.toList()
}
/**
* Publishing KeyPackages so other people can add this account to groups.
*
* ## Why this is not Marmot's rotation manager with the names changed
*
* Marmot publishes a KeyPackage as a kind-443 event to relays: a real Nostr
* event, replaceable, discoverable, with its own relay list and its own publish
* obligations. cordn has **no KeyPackage event kind at all** (`spec/00.md`
* §4.2). Publishing is a `kp_publish` call, and the "signed publication
* payload" §7 requires is the ContextVM request event the transport happens to
* sign — which the coordinator stores and serves back verbatim. There is no
* relay copy, nothing to rotate against, and nothing to re-publish if a relay
* forgets. None of Marmot's machinery transfers; all of it would be wrong here.
*
* ## `kp_ref` is the RFC 9420 KeyPackageRef and nothing else
*
* It is the coordinator's primary key for a KeyPackage and the address a
* Welcome is delivered to. Two implementations that compute it differently do
* not fail loudly: every `kp_take` misses, every Welcome lands at an address
* nobody is listening on, and the group simply never forms. This class owns the
* computation so no caller invents one — [MlsKeyPackage.reference] is checked
* against ts-mls's in `CordnLifecycleInteropTest`.
*/
@OptIn(ExperimentalEncodingApi::class)
class CordnKeyPackages(
private val accountPubKey: HexKey,
private val coordinator: ICoordinator,
private val store: CordnKeyPackageStore,
) {
private val _published = MutableStateFlow<Set<String>>(emptySet())
/** Refs this account has published and still holds the private half for. */
val published: StateFlow<Set<String>> = _published.asStateFlow()
/** One published KeyPackage, as this account sees it. */
data class Published(
val keyPackageRef: String,
val lastResort: Boolean,
val at: Long,
)
private val snapshotLock = Mutex()
private var snapshot: Snapshot? = null
/**
* What `kp_list` reported, the last time we asked.
*
* `kp_list` takes no arguments and returns every KeyPackage the coordinator
* holds for **every** identity (`spec/00.md` leaves retrieval scoping to the
* coordinator; the reference implementation answers with the whole table).
* So one call answers a question about anybody, and asking it once per
* person would be the same download repeated.
*
* [identities] keeps only pubkeys, not the entries. Another account's
* `kp_ref` is useless to us -- a Welcome is addressed to a ref we obtained
* by *taking* the package, never to one we read out of a listing -- and on
* a busy coordinator those refs are the bulk of the response. Dropping them
* bounds what we retain even though nothing bounds what arrives.
*/
class Snapshot(
/** Every identity the coordinator holds at least one KeyPackage for. */
val identities: Set<HexKey>,
/** The full entries for this account, which are the ones we act on. */
val mine: List<AvailableKeyPackage>,
/** When this was fetched, for [snapshot]'s age check. */
val at: Long,
)
/**
* Refetches unconditionally and replaces the snapshot.
*
* Every decision about whether to *publish* reads through here rather than
* through [snapshot]. Minting against a stale listing is the one place
* staleness does damage: the coordinator keeps a single last-resort package
* per identity, so publishing a second silently evicts the first and
* strands every invite that referenced it.
*/
suspend fun refresh(): Snapshot = snapshotLock.withLock { fetchLocked() }
/**
* The snapshot, refetched if it is older than [maxAgeSeconds].
*
* For reads that only *describe* what the coordinator holds. A minute of
* staleness costs at most a missing badge for somebody who published within
* it, and the act itself still fails loudly at the coordinator if the
* listing was wrong -- whereas every refresh is another full-table
* download, so asking less often is the cheap side of the trade.
*/
suspend fun snapshot(maxAgeSeconds: Long = SNAPSHOT_TTL_SECONDS): Snapshot =
snapshotLock.withLock {
snapshot?.takeIf { TimeUtils.now() - it.at < maxAgeSeconds } ?: fetchLocked()
}
/**
* Which identities the coordinator can deliver a Welcome to, or null.
*
* Null means **we do not know** -- the coordinator is unreachable, throttled
* us, or answered with more than the transport admits. It does not mean
* nobody has published. Callers must keep those apart: rendering a failed
* lookup as "this person cannot be added" states something about a person
* on the strength of a network error.
*/
suspend fun identitiesWithKeyPackages(): Set<HexKey>? =
try {
snapshot().identities
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
/** Must hold [snapshotLock]. */
private suspend fun fetchLocked(): Snapshot {
val all = coordinator.listKeyPackages()
return Snapshot(
identities = all.mapTo(mutableSetOf()) { it.pubKey },
mine = all.filter { it.pubKey == accountPubKey },
at = TimeUtils.now(),
).also { snapshot = it }
}
/** Forgets the snapshot after we changed what the coordinator holds. */
private suspend fun invalidate() = snapshotLock.withLock { snapshot = null }
/** Reloads which refs we hold private halves for. Call at startup. */
suspend fun restore() {
_published.value = store.list().toSet()
}
/**
* Whether this account has a KeyPackage published on this coordinator.
*
* Read from the store rather than from [published], so it is right before
* [restore] has run and right after a relaunch. It answers the §8.4 half
* of the exposure disclosure, which is about what the coordinator holds —
* not about what this process has done since it started.
*
* A private half with no published counterpart is possible (the publish
* failed and the store was not cleaned), and reporting that as "published"
* overstates the exposure by one KeyPackage rather than understating it by
* all of them — the direction a privacy surface should err in.
*/
suspend fun hasPublished(): Boolean = store.list().isNotEmpty()
/**
* What the coordinator currently serves for this account.
*
* The coordinator's answer, not ours. `kp_take` consumes a single-use
* package, so our own record of what we published drifts upward from what
* is actually available the moment anyone invites us — and it is what is
* available that decides whether the next invitation can happen.
*/
suspend fun listPublished(): List<AvailableKeyPackage> = refresh().mine
/**
* Generates a KeyPackage, keeps its private half, and publishes it.
*
* The private half is stored **before** the call, not after: a publish that
* succeeds on the coordinator and then fails locally would leave a
* KeyPackage other people can use to invite an account that can no longer
* open the Welcome. Storing first makes the failure the harmless direction —
* an unused bundle on disk.
*
* @param lastResort marks it reusable. `spec/00.md` lets a last-resort
* package back several Welcomes, which is why a Welcome record is keyed
* by `(kp_ref, at)` rather than by ref alone.
*/
suspend fun publishNew(lastResort: Boolean = false): Published {
val bundle = generate(lastResort)
val ref = refOf(bundle)
store.save(ref, KeyPackageBundleCodec.encode(bundle))
_published.value = _published.value + ref
val result =
try {
coordinator.publishKeyPackage(ref, Base64.encode(bundle.keyPackage.toTlsBytes()))
} catch (e: Exception) {
// The coordinator never took it, so nobody can invite us with
// it. Drop the local half rather than accumulate bundles that
// will never be used.
store.delete(ref)
_published.value = _published.value - ref
throw e
}
invalidate()
return Published(result.keyPackageRef, result.lastResort || lastResort, result.at)
}
/**
* The private half for [keyPackageRef], if this device published it.
*
* Hand this to [CordnGroupManager.joinPendingWelcomes]. Null is ordinary and
* not an error: a Welcome addressed to a KeyPackage another device of the
* same account published belongs to that device, and draining it here would
* destroy it.
*/
suspend fun bundleFor(keyPackageRef: String): KeyPackageBundle? =
store.load(keyPackageRef)?.let {
try {
KeyPackageBundleCodec.decode(it)
} catch (e: IllegalArgumentException) {
// A bundle we cannot read is a bundle we cannot use. Reporting
// null sends the Welcome back to the inbox, where another
// device — or a later version of this one — may manage it.
null
}
}
/**
* Withdraws [keyPackageRefs] from the coordinator and forgets their halves.
*
* Order matters the other way here: the coordinator is told first, because
* deleting locally while the coordinator still serves the package is the
* bad direction — somebody invites us and we cannot open the Welcome.
*/
suspend fun withdraw(keyPackageRefs: List<String>): List<String> {
if (keyPackageRefs.isEmpty()) return emptyList()
val removed = coordinator.removeKeyPackages(keyPackageRefs)
removed.forEach { store.delete(it) }
_published.value = _published.value - removed.toSet()
invalidate()
return removed
}
/**
* Tops up to [minimum] single-use KeyPackages on the coordinator.
*
* Counts what the coordinator actually holds for this account rather than
* what we think we published: `kp_take` consumes a single-use package, so
* the coordinator's count falls as people invite us and ours does not.
*
* @return the refs published by this call.
*/
suspend fun topUp(minimum: Int = DEFAULT_POOL): List<String> {
require(minimum >= 0) { "a KeyPackage pool cannot be negative" }
val theirs = refresh().mine.filter { !it.lastResort }
val missing = minimum - theirs.size
if (missing <= 0) return emptyList()
return List(missing) { publishNew().keyPackageRef }
}
/**
* Ensures exactly one last-resort KeyPackage is published.
*
* Its job is that an invite can always be made, even after the single-use
* pool is drained. More than one is not better — each is an extra reusable
* package, and `spec/00.md` treats a last-resort package as the fallback,
* not the norm.
*/
suspend fun ensureLastResort(): String? {
val existing = refresh().mine.filter { it.lastResort }
// Only one, and only one we can still open: a last-resort package whose
// private half this device never had is useless to it.
val usable = existing.firstOrNull { store.load(it.keyPackageRef) != null }
if (usable != null) return usable.keyPackageRef
return publishNew(lastResort = true).keyPackageRef
}
/** A fresh bundle carrying this account's cordn credential. */
private fun generate(lastResort: Boolean): KeyPackageBundle {
// A scratch group only to reach the engine's KeyPackage builder; it is
// never joined, committed to, or stored.
val identity = CordnCredential.of(accountPubKey).identity
val scratch = MlsGroup.create(identity, policy = CordnGroupPolicy)
return scratch.createKeyPackage(
identity = identity,
// Ignored by the engine, which generates the leaf signature keypair
// itself and signs with that. Every caller in the tree passes empty
// for the same reason; it is a dead parameter, not a key we are
// failing to supply.
signingKey = ByteArray(0),
keyPackageExtensions = if (lastResort) listOf(CordnGroupPolicy.lastResortExtension()) else emptyList(),
// A last-resort package carries its marker inside app_data_dictionary
// (0x0006), so its leaf has to say it understands that carrier.
// CordnGroupPolicy has had the right set for this all along and
// nothing was calling it, which left every last-resort KeyPackage
// advertising one type short of what it used.
capabilities =
if (lastResort) CordnGroupPolicy.lastResortLeafCapabilities() else CordnGroupPolicy.defaultLeafCapabilities,
)
}
/** The RFC 9420 KeyPackageRef, hex. See the class KDoc. */
fun refOf(bundle: KeyPackageBundle): String = bundle.keyPackage.reference().toHexKey()
companion object {
/**
* How many single-use KeyPackages to keep available.
*
* Each one is one invite somebody can make without falling back to the
* reusable package. Small because they cost a round trip each and the
* last-resort package is the safety net.
*/
const val DEFAULT_POOL = 5
/**
* How long a [Snapshot] may be reused by [snapshot].
*
* Bounded by what a refresh costs rather than by how fast the answer
* changes: `kp_list` is unpaginated, so each one downloads the
* coordinator's whole table. A minute is long enough that opening a
* screen repeatedly costs one call and short enough that somebody who
* has just published becomes visible while you are still looking.
*/
const val SNAPSHOT_TTL_SECONDS = 60L
}
}
@@ -0,0 +1,103 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef
/**
* What a pasted `cordn1…` link turns out to be.
*
* A group ref is the one cordn artifact a person handles directly — it arrives
* in a chat message or a QR code — and it is also the moment where §8 matters:
* before joining, not after. So the parse and the disclosure are computed
* together here, in headless code a test can drive, and the screen only draws
* the result.
*/
sealed interface CordnLinkInspection {
/** Not a cordn link, with the rule it broke, in the decoder's own words. */
data class Invalid(
val reason: String,
) : CordnLinkInspection
/**
* A well-formed link.
*
* [coordinator] and [exposure] are null together, and legitimately so:
* `spec/applications/group-ref.md` §2 makes the coordinator optional, and a
* ref carrying only a `gid` names no operator — so there is no one to
* disclose anything about, and pretending otherwise would be inventing a
* threat model for a server we cannot identify.
*/
data class Valid(
val ref: CordnGroupRef,
val coordinator: CoordinatorConfig?,
val exposure: GroupExposure?,
) : CordnLinkInspection {
/** Whether this link can be acted on without asking the sender for more. */
val isFollowable: Boolean get() = coordinator != null
}
companion object {
/**
* Inspects [input].
*
* @param existingGroupsOnCoordinator how many groups this account
* already has on that coordinator, so §8.2's linkage count describes
* what joining would actually create rather than a hypothetical. The
* default assumes none, which is the conservative reading — it
* under-reports linkage rather than inventing it.
*/
fun of(
input: String,
existingGroupsOnCoordinator: Int = 0,
publishedKeyPackage: Boolean = false,
): CordnLinkInspection {
val trimmed = input.trim()
if (trimmed.isEmpty()) return Invalid("empty")
val ref =
try {
CordnGroupRef.decode(trimmed)
} catch (e: IllegalArgumentException) {
return Invalid(e.message ?: "not a cordn group reference")
}
val coordinator = CoordinatorConfig.from(ref)
return Valid(
ref = ref,
coordinator = coordinator,
exposure =
coordinator?.let {
GroupExposure(
coordinator = it.pubKey,
// The group being inspected is the one that would be added.
linkedGroupCount = existingGroupsOnCoordinator + 1,
// A link is how a stranger joins, which is exactly the
// `join_request_store` path §8.1 describes.
joinedFromShareLink = true,
publishedKeyPackage = publishedKeyPackage,
encryptionPinned = true,
)
},
)
}
}
}
@@ -0,0 +1,113 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
/**
* Splitting a cordn message into the parts a screen renders differently.
*
* ## Why cordn needs its own, small version of this
*
* Amethyst's rich-text rendering is built on `Note`, and a `Note` comes from
* `LocalCache`. Nothing cordn receives may go in there — a cordn message is an
* MLS payload that never touched a relay, and giving it a `Note` would make
* private group chat searchable and notifiable alongside published events.
* So the parsing happens here, on the envelope's own content, and the result
* is plain data a composable can walk.
*
* Resolving a mentioned pubkey to a **display name** is a different question
* and a safe one: a profile is public relay data that the cache holds anyway,
* and reading one puts nothing of the conversation into it. The rule is about
* what goes in, not what is looked up.
*
* ## Scope
*
* Mentions only — `nostr:npub1…` and `nostr:nprofile1…`. Not URLs, not
* hashtags, not embedded events. Each of those renders as something, and
* something that renders is something that can leak: an auto-loaded preview
* would fetch a URL the moment a private message arrived, telling its host
* that a specific person opened a specific message. That belongs with the
* media work, behind the same decisions, not smuggled in with mentions.
*/
object CordnMentions {
/** One run of a message: either text to print, or a person to name. */
sealed interface Segment {
data class Text(
val value: String,
) : Segment
data class Mention(
val pubKey: HexKey,
/** The exact text matched, for a renderer that wants to fall back to it. */
val raw: String,
) : Segment
}
/**
* Splits [content] into text and mention runs, in order.
*
* Concatenating every segment's original text reproduces [content] exactly
* — there is a test for that. A renderer that drops a segment type would
* otherwise silently eat part of someone's message, which is worse than
* not rendering mentions at all.
*/
fun segment(content: String): List<Segment> {
if (content.isEmpty()) return emptyList()
val out = mutableListOf<Segment>()
var last = 0
MENTION.findAll(content).forEach { match ->
val pubKey = decodePublicKeyAsHexOrNull(match.value.removePrefix(NOSTR_PREFIX)) ?: return@forEach
if (match.range.first > last) {
out += Segment.Text(content.substring(last, match.range.first))
}
out += Segment.Mention(pubKey, match.value)
last = match.range.last + 1
}
if (last < content.length) out += Segment.Text(content.substring(last))
return out
}
/** Every account mentioned in [content], deduplicated, in first-seen order. */
fun mentioned(content: String): List<HexKey> =
segment(content)
.filterIsInstance<Segment.Mention>()
.map { it.pubKey }
.distinct()
private const val NOSTR_PREFIX = "nostr:"
/**
* `nostr:` is required, unlike NIP-19's looser scan.
*
* A bare `npub1…` in the middle of a sentence is as likely to be someone
* quoting a key as mentioning a person, and turning it into a name changes
* what they wrote. The bech32 alphabet excludes `1`, `b`, `i` and `o`,
* which is what keeps the match from running past the entity into ordinary
* words.
*/
private val MENTION = Regex("nostr:(npub1|nprofile1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+")
}
@@ -0,0 +1,342 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDeviceTip
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentException
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentSeal
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnGroupDocument
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnMetaDocument
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory
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.crypto.verify
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Moving one account's cordn state from the phone it is on to a new one.
*
* ## Why this is safe when continuous sync is not
*
* `multi-device.md` §10 leaves one case unresolved: two devices of one
* identity committing inside a single delivery round-trip both reach epoch N+1
* with different states, and §15 concedes that *equal-epoch MLS states have no
* merge function*. That race needs two live writers. A handoff has one — the
* old phone writes a snapshot and then stops (see [CordnMigrationSnapshot] and
* the handoff lock) — so the race is out of reach by construction rather than
* by mitigation.
*
* Everything here is therefore §9 seeding plus the §6/§7 transport. There are
* no `prev` chains (§8.5), no sibling-Commit convergence (§10) and no
* publish-on-every-Commit (§10.5): each of those exists to keep two *live*
* devices in step.
*
* ## The honest cost
*
* The sealed documents leave the device. They are NIP-44 v2 ciphertext under a
* DEK reachable only through a seal to the owner's own `npub`, so a storage
* server learns nothing but size and timing — but the group state, including
* leaf private keys, is on someone else's disk until the blobs are deleted.
* That is the trade the tip transport makes, and a UI should say so rather
* than let a user discover it.
*/
class CordnMigration(
private val client: INostrClient,
private val signer: NostrSigner,
private val blobs: CordnBlobStore,
) {
/**
* Seals, uploads and advertises [snapshot]; returns the code to scan.
*
* The ephemeral keypair and the `d` value are both minted here and both
* random. §6 forbids deriving the signing key from the owner identity: a
* public derivation would let anyone compute the tip's author from an
* `npub` and go looking for it, which is the linkage the opaque tip exists
* to prevent.
*
* @param relays where to publish the tip.
* @throws CordnMigrationException when nothing could be stored or published.
*/
suspend fun publish(
snapshot: CordnMigrationSnapshot,
relays: Set<NormalizedRelayUrl>,
): CordnHandoffCode {
require(relays.isNotEmpty()) { "a migration needs at least one relay to publish the tip on" }
val dek = CordnDocumentSeal.newKey()
val issuedAt = TimeUtils.now() * MILLIS_PER_SECOND
val hosts = mutableSetOf<String>()
val groupEntries =
snapshot.groups.map { group ->
val document =
CordnGroupDocument(
gid = group.gid,
coordinator = group.coordinatorPubKey,
clientState = group.clientStateBase64,
cursor = group.cursor,
issuedAt = issuedAt,
roomState = group.roomStateBase64,
echoState = group.echoStateBase64,
joinedViaRequest = group.joinedViaRequest,
messages = group.messages.takeIf { it.isNotEmpty() },
coordinatorRelays = group.coordinatorRelays,
)
val blob = CordnDocumentSeal.seal(document, dek)
val servers = blobs.put(blob)
if (servers.isEmpty()) {
throw CordnMigrationException("no server accepted the document for group ${group.gid}")
}
hosts += servers
CordnTipEntry(address = CordnDocumentSeal.address(blob), gid = group.gid)
}
val metaBlob =
CordnDocumentSeal.seal(
CordnMetaDocument(
lastResortKeyPackage = snapshot.lastResortKeyPackage,
keyPackages = snapshot.keyPackages,
issuedAt = issuedAt,
),
dek,
)
val metaServers = blobs.put(metaBlob)
if (metaServers.isEmpty()) throw CordnMigrationException("no server accepted the meta document")
hosts += metaServers
val inventory =
CordnTipInventory(
groups = groupEntries,
meta = CordnDocumentSeal.address(metaBlob),
dekPrivateKey = dek.privKey!!.toHexString(),
servers = hosts.toList(),
)
val ephemeral = KeyPair()
val dTag = RandomInstance.randomChars(D_TAG_LENGTH)
val inner = signer.signInner(inventory)
val sealedInner = signer.nip44Encrypt(JacksonMapper.toJson(inner), signer.pubKey)
val outer =
NostrSignerInternal(ephemeral).sign<Event>(
createdAt = TimeUtils.now(),
kind = CordnDeviceTip.OUTER_KIND,
tags = arrayOf(arrayOf(CordnDeviceTip.TAG_D, dTag)),
content = sealedInner,
)
if (!client.publishAndConfirm(outer, relays)) {
throw CordnMigrationException("no relay accepted the tip")
}
return CordnHandoffCode(
ephemeralPubKey = ephemeral.pubKey.toHexString(),
dTag = dTag,
relays = relays.map { it.url },
)
}
/**
* Reads back what [code] points at.
*
* The outer event is signed by a key anyone holding the code could hold, so
* it establishes nothing. Confidentiality and authorship both come from the
* seal: the content is NIP-44 to the owner's own pubkey, and only the owner
* can compute that conversation key — so an outsider can neither read the
* tip nor write one the owner will read.
*
* The inner signature check on top of that is defence in depth against a
* writer-side bug rather than an outsider: adopting MLS state whose
* credential names another account would leave the new phone making Commits
* the rest of every group rejects. Each blob's address is checked too, and
* before decryption, because that one IS an outsider's opening — the
* storage server chooses the bytes.
*/
suspend fun fetch(code: CordnHandoffCode): CordnMigrationSnapshot {
val relays = code.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isEmpty()) throw CordnMigrationException("the handoff code names no usable relay")
val outer =
client.fetchFirst(
filters =
relays.associateWith {
listOf(
Filter(
kinds = listOf(code.kind),
authors = listOf(code.ephemeralPubKey),
tags = mapOf(CordnDeviceTip.TAG_D to listOf(code.dTag)),
limit = 1,
),
)
},
) ?: throw CordnMigrationException("no relay had a tip for this code")
val innerJson =
try {
signer.nip44Decrypt(outer.content, signer.pubKey)
} catch (e: Exception) {
throw CordnMigrationException("the tip did not decrypt — is this the same account?")
}
val inner =
try {
JacksonMapper.fromJson(innerJson)
} catch (e: Exception) {
throw CordnMigrationException("the tip's inner event is not readable: ${e.message}")
}
// The authenticity root. Without it, anyone holding the code could
// repoint the tip at an inventory of their own choosing.
if (inner.pubKey != signer.pubKey) {
throw CordnMigrationException("the tip was signed by a different account")
}
if (!inner.verify()) {
throw CordnMigrationException("the tip's inner signature does not verify")
}
val inventory = CordnDeviceTip.parse(inner)
val dek = KeyPair(privKey = inventory.dekPrivateKey.hexToByteArray())
val groups =
inventory.groups.map { entry ->
val document = fetchDocument(entry.address, inventory.servers, dek)
if (document !is CordnGroupDocument) {
throw CordnMigrationException("the document for ${entry.gid} is not a group document")
}
if (!document.isReadableHere) {
throw CordnMigrationException(
"group ${entry.gid} was written by a different MLS engine " +
"(${document.clientStateFormat ?: "unmarked"}) and cannot be read here",
)
}
CordnMigrationGroup(
coordinatorPubKey = document.coordinator,
coordinatorRelays = document.coordinatorRelays,
gid = document.gid,
clientStateBase64 = document.clientState,
cursor = document.cursor,
roomStateBase64 = document.roomState,
echoStateBase64 = document.echoState,
joinedViaRequest = document.joinedViaRequest,
messages = document.messages.orEmpty(),
)
}
val meta =
inventory.meta?.let {
fetchDocument(it, inventory.servers, dek) as? CordnMetaDocument
?: throw CordnMigrationException("the meta address does not hold a meta document")
}
return CordnMigrationSnapshot(
accountPubKey = signer.pubKey,
groups = groups,
lastResortKeyPackage = meta?.lastResortKeyPackage,
keyPackages = meta?.keyPackages.orEmpty(),
)
}
private suspend fun fetchDocument(
address: String,
servers: List<String>,
dek: KeyPair,
) = run {
val blob =
blobs.get(address, servers)
?: throw CordnMigrationException("no server served the document at $address")
// §6: check the address BEFORE decrypting, so a store serving the
// wrong bytes never gets its plaintext in front of the parser.
if (!CordnDocumentSeal.verifyAddress(blob, address)) {
throw CordnMigrationException("the blob at $address does not hash to its address")
}
try {
CordnDocumentSeal.open(blob, dek)
} catch (e: CordnDocumentException) {
throw CordnMigrationException("the document at $address is unreadable: ${e.message}")
}
}
private suspend fun NostrSigner.signInner(inventory: CordnTipInventory): Event =
sign(
createdAt = TimeUtils.now(),
kind = CordnDeviceTip.INNER_KIND,
tags = CordnDeviceTip.tags(inventory),
content = "",
)
companion object {
private const val D_TAG_LENGTH = 16
private const val MILLIS_PER_SECOND = 1000L
}
}
/** A migration that could not be completed, with the step that stopped it. */
class CordnMigrationException(
message: String,
) : Exception(message)
/**
* Everything a replacement device needs, read from disk rather than memory.
*
* Reading from the stores is deliberate and copied from `exportArchive`: a
* coordinator whose session failed to open today is still migrated, because a
* handoff that silently omitted the groups the app could not reach would be
* wrong precisely when it matters.
*/
data class CordnMigrationSnapshot(
val accountPubKey: HexKey,
val groups: List<CordnMigrationGroup>,
val lastResortKeyPackage: CordnLastResortKeyPackage? = null,
val keyPackages: List<CordnCarriedKeyPackage> = emptyList(),
)
/** One group in a migration. All blobs are base64 of their on-disk encoding. */
data class CordnMigrationGroup(
val coordinatorPubKey: HexKey,
val coordinatorRelays: List<String>,
val gid: String,
val clientStateBase64: String,
val cursor: Long,
val roomStateBase64: String? = null,
val echoStateBase64: String? = null,
val joinedViaRequest: Boolean = false,
/** The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. */
val messages: List<String> = emptyList(),
)
@@ -0,0 +1,281 @@
/*
* 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.commons.cordn
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
/**
* The three things a sync loop needs from a group manager.
*
* [CordnGroupManager] implements it and is the only production implementation;
* the interface exists so the loop can be tested for what it actually does.
* Everything interesting about it — not spinning on an empty account, not
* dying on a coordinator outage, resetting its backoff, re-subscribing when a
* group is joined — is a reaction to a coordinator that is slow, failing or
* changing underfoot. Driving those through a real transport would mean
* provoking a network failure on a schedule, and the timing test that resulted
* would be the flakiest thing in the suite.
*/
interface CordnSyncSource {
/** The groups to sync. The loop re-subscribes when this changes. */
val gids: StateFlow<Set<String>>
suspend fun catchUp(onDelivery: (CordnGroupManager.Delivery) -> Unit): Int
/** Suspends until the coordinator closes the stream. */
suspend fun subscribe(
timeoutMs: Long,
onDelivery: (CordnGroupManager.Delivery) -> Unit,
)
}
/**
* Keeps one coordinator's groups up to date for as long as it runs.
*
* The shape is `spec/02.md`'s: drain history with `catch_up`, then hold a live
* `subscribe`, and when that stream closes, do it again. The cursor makes the
* seam safe — a subscription resumes from where the catch-up stopped, so the
* two phases cannot leave a hole between them.
*
* ## What this class is actually for
*
* `catchUp` and `subscribe` are one call each; wrapping them would be pointless
* if the happy path were the whole story. It is not. Three things make an
* unattended loop different from a call:
*
* - **A failure must not end the loop.** A coordinator that is down for a
* minute is ordinary. A loop that propagates that exception stops syncing for
* the rest of the session and looks, from the UI, exactly like a quiet group.
* So every attempt is caught, recorded on [CoordinatorHealth], and retried
* with a backoff that **resets on success** — without the reset, one bad
* stretch leaves the loop at its maximum delay forever.
* - **An account with no groups must not spin.** Both calls return immediately
* when the manager holds nothing, so the obvious `while (true)` burns a core
* on a brand-new account. This one parks on [CordnGroupManager.gids] until
* there is something to sync.
* - **A group joined mid-subscription must not wait.** The subscription is
* opened for a fixed set of `gid`s, so a group joined a moment later is not
* in it. Rather than leave the user staring at an empty room until the
* stream times out, a change to the group set cancels the subscription and
* re-opens it.
*
* Nothing here is Marmot's sync. Marmot subscribes to relays by filter and lets
* the relay decide what to send; cordn asks one coordinator for one group's
* stream from one cursor. They are different enough that a shared loop would be
* a conditional, not an abstraction.
*/
class CordnSyncLoop(
private val source: CordnSyncSource,
/**
* Where deliveries go.
*
* A callback rather than a `SharedFlow`, deliberately: a flow with no
* replay drops what it emits when nothing is collecting, and "the app was
* backgrounded so those messages are gone" is not a thing a chat client may
* do. The caller decides what durable place these land in.
*/
private val onDelivery: (CordnGroupManager.Delivery) -> Unit,
private val subscribeTimeoutMs: Long = DEFAULT_SUBSCRIBE_TIMEOUT_MS,
private val minBackoffMs: Long = DEFAULT_MIN_BACKOFF_MS,
private val maxBackoffMs: Long = DEFAULT_MAX_BACKOFF_MS,
) {
private var job: Job? = null
private val _state = MutableStateFlow<State>(State.Stopped)
/** What the loop is doing, for the UI to show without inventing it. */
val state: StateFlow<State> = _state.asStateFlow()
sealed interface State {
data object Stopped : State
/** Nothing to sync: this account holds no groups on this coordinator. */
data object NoGroups : State
data object CatchingUp : State
/** A subscription is open. */
data object Live : State
/**
* The last attempt failed and the next is [inMs] away.
*
* Carries the reason because the alternative is a UI that can only say
* "not syncing", which is the same thing it says when the loop is fine
* and the group is quiet.
*/
data class Retrying(
val attempt: Int,
val inMs: Long,
val reason: String?,
) : State
}
/**
* Starts the loop in [scope]; a second call while it runs does nothing.
*
* Two loops on one session would both `catch_up` the same groups and both
* feed [onDelivery], so every message would surface twice.
*/
fun start(scope: CoroutineScope) {
if (job?.isActive == true) return
job = scope.launch { run() }
}
/**
* Stops the loop and waits for it to actually be stopped. Safe to call
* when it is not running.
*
* `cancel()` alone returns while the loop's `finally` blocks are still
* running, and one of those persists the group state a subscription
* ingested. `CordnRuntime.importArchive` stops the loops, deletes the
* account's directory and restores from the archive — so a persist that
* landed a moment late wrote a group back onto disk after the delete, and
* the restore then read it in again. That is the merge importArchive
* exists to prevent.
*
* Nothing was noticed while the persist silently failed on cancellation;
* making it survive cancellation is what made the missing join matter.
*/
suspend fun stop() {
job?.cancelAndJoin()
job = null
_state.value = State.Stopped
}
private suspend fun run() =
coroutineScope {
var attempt = 0
while (isActive) {
try {
val gids = source.gids.value
if (gids.isEmpty()) {
// Park rather than spin: both calls below are no-ops
// with no groups, so looping here would be a busy wait.
_state.value = State.NoGroups
source.gids.first { it.isNotEmpty() }
continue
}
_state.value = State.CatchingUp
source.catchUp(onDelivery)
_state.value = State.Live
subscribeUntilGroupsChange(gids)
// Any completed pass is a working coordinator, including a
// subscription that simply timed out.
attempt = 0
} catch (e: CancellationException) {
// Only OUR cancellation may end the loop. A coordinator
// that does not answer inside the RPC budget surfaces as
// `TimeoutCancellationException`, which IS a
// `CancellationException` -- rethrowing it ended the loop
// for the rest of the session, so one 20-second hiccup
// turned into permanent silence with the UI still saying
// "catching up". Measured on device: the first
// `msg_fetch_many` timeout stopped cordn sync outright.
if (!isActive) throw e
attempt++
val wait = backoffFor(attempt)
_state.value = State.Retrying(attempt, wait, e.message)
delay(wait)
} catch (e: Exception) {
attempt++
val wait = backoffFor(attempt)
_state.value = State.Retrying(attempt, wait, e.message)
delay(wait)
}
}
}
/**
* Holds a subscription until it closes or the group set changes.
*
* The watcher is what makes a freshly joined group live immediately instead
* of at the next timeout.
*/
private suspend fun subscribeUntilGroupsChange(opened: Set<String>) =
coroutineScope {
val subscription =
async {
// `msg_sub_many` spends a total-time budget and throws when
// it runs out. That is the stream ending on schedule, not
// an outage -- the loop's job is to open the next one -- so
// it must not reach the backoff, and it must not reach the
// cancellation clause above either.
try {
source.subscribe(subscribeTimeoutMs, onDelivery)
} catch (e: TimeoutCancellationException) {
// budget spent; fall through and re-subscribe
}
}
val changed = async { source.gids.first { it != opened } }
select {
subscription.onAwait { changed.cancel() }
changed.onAwait { subscription.cancel() }
}
}
/** Exponential, capped, and reset by the caller on any success. */
private fun backoffFor(attempt: Int): Long {
var wait = minBackoffMs
repeat(attempt - 1) {
if (wait >= maxBackoffMs) return maxBackoffMs
wait *= 2
}
return wait.coerceAtMost(maxBackoffMs)
}
companion object {
/**
* How long one subscription stays open before the loop re-opens it.
*
* A ceiling on how long a silently dead stream goes unnoticed, not a
* polling interval — the coordinator pushes within it.
*/
const val DEFAULT_SUBSCRIBE_TIMEOUT_MS = 60_000L
const val DEFAULT_MIN_BACKOFF_MS = 1_000L
/**
* Long enough not to hammer a coordinator that is down, short enough
* that a user who reopens the app is not waiting on a dead timer.
*/
const val DEFAULT_MAX_BACKOFF_MS = 60_000L
}
}
@@ -0,0 +1,88 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A [CordnBlobCipher] under a key the caller supplies: ChaCha20-Poly1305 with
* a fresh random nonce per blob, framed `nonce:12 || ciphertext || tag:16`.
*
* For every platform that has no OS-held key store to hide the key in — the
* desktop app and `amy`. It is the honest half of "encrypted at rest": the
* cipher is real, and where the key lives decides what that buys. Android's
* [CordnBlobCipher] keeps its key in hardware, so a blob read off the device
* is useless; a caller that keeps the key in a file beside the blobs is
* protecting against a stolen backup, not against a process running as the
* same user. Say which one you are doing where the key is created, not here.
*
* ## The nonce is random, not a counter
*
* One key covers every blob this cipher writes, and the stores rewrite the
* same group's state on every epoch change, so a counter would have to be
* persisted and survive a crash to stay unique. A 96-bit random nonce does
* not: at the volumes a client writes (thousands of blobs, not billions) the
* collision probability is negligible, and a repeat would be a confidentiality
* break rather than a corrupted file. Nonce reuse under ChaCha20-Poly1305
* leaks the XOR of two plaintexts and the Poly1305 key — for an
* `MlsGroupState` that is epoch secrets, so this is the one parameter here
* worth being careful about.
*
* No associated data: the frame has no header worth binding, and the blob's
* path is not authenticated on purpose. The stores move a file into place
* atomically and a blob that lands at the wrong path fails to parse as the
* thing that path expects, which is where that belongs.
*
* Stateless, so safe to call from several coroutines at once.
*/
class KeyedCordnBlobCipher(
private val key: ByteArray,
) : CordnBlobCipher {
init {
require(key.size == KEY_LENGTH) { "a cordn blob key is $KEY_LENGTH bytes, got ${key.size}" }
}
override fun encrypt(bytes: ByteArray): ByteArray {
val nonce = RandomInstance.bytes(NONCE_LENGTH)
return nonce + ChaCha20Poly1305.encrypt(bytes, EMPTY, nonce, key)
}
override fun decrypt(bytes: ByteArray): ByteArray {
require(bytes.size > NONCE_LENGTH) { "not a cordn blob: ${bytes.size} bytes" }
return ChaCha20Poly1305.decrypt(
bytes.copyOfRange(NONCE_LENGTH, bytes.size),
EMPTY,
bytes.copyOfRange(0, NONCE_LENGTH),
key,
)
}
companion object {
const val KEY_LENGTH = 32
const val NONCE_LENGTH = 12
private val EMPTY = ByteArray(0)
/** A new key, for a caller about to persist one. */
fun newKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH)
}
}
@@ -0,0 +1,111 @@
/*
* 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.commons.cordn
import com.vitorpamplona.quartz.contextvm.transport.CvmRelayPool
import com.vitorpamplona.quartz.contextvm.transport.CvmSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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 com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A Nostr relay client, as the [CvmRelayPool] ContextVM expects.
*
* The adapter between the two halves: [INostrClient] is what every Amethyst
* front end already runs, and [CvmRelayPool] is the two operations ContextVM
* needs from one. Nothing here is platform-specific, so Android, the desktop
* app and `amy` share it.
*
* [relays] is the coordinator's own relay list, not the account's. A
* coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so the
* only place its traffic can be found is the relays whoever published the
* coordinator said to use. Sending it to the account's outbox relays instead
* would both miss the coordinator and tell relays that have no business
* knowing that this account talks to it.
*/
class NostrClientCvmRelayPool(
private val client: INostrClient,
private val relays: Set<NormalizedRelayUrl>,
) : CvmRelayPool {
override fun subscribe(
pubKey: HexKey,
kinds: IntArray,
onEvent: (Event) -> Unit,
): CvmSubscription {
// Random rather than a counter, which is how the rest of the
// relay client names subscriptions (`newSubId`). A shared `nextId++`
// is not atomic, and `CvmTransport` opens one subscription per request
// and closes it in the same call — the sync loop and the UI issue
// requests concurrently, so two coroutines reading the same value is
// ordinary. Two subscriptions sharing an id means the first `close()`
// unsubscribes both, and the other request waits out its full
// 20-second timeout for a response the relay stopped sending.
val subId = "cordn-${pubKey.take(8)}-${RandomInstance.randomChars(8)}"
val filter = Filter(kinds = kinds.toList(), tags = mapOf("p" to listOf(pubKey)))
client.subscribe(
subId = subId,
filters = relays.associateWith { listOf(filter) },
listener =
object : SubscriptionListener {
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Stored-vs-live is not a distinction kind 25910 has:
// it is ephemeral, so anything that arrives at all is
// live by definition. Filtering on isLive here would
// drop responses on a relay that replays its buffer.
onEvent(event)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
Log.d(TAG) { "coordinator subscription closed by $relay: $message" }
}
},
)
return object : CvmSubscription {
override fun close() {
client.unsubscribe(subId)
}
}
}
override suspend fun publish(event: Event) {
client.publish(event, relays)
}
companion object {
private const val TAG = "CordnRelayPool"
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
/**
@@ -51,6 +51,13 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff
import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret
import com.vitorpamplona.quartz.marmot.groups.currentGroupState
import com.vitorpamplona.quartz.marmot.groups.currentMarmotData
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
@@ -59,18 +66,15 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.marmot.protocolCore.PublishOutcome
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.mls.messages.CommitResult
import com.vitorpamplona.quartz.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -324,9 +328,11 @@ class MarmotManager(
): ByteArray? =
try {
val preCommitKey =
MlsGroup
.restore(obligation.priorState)
.exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
MarmotGroupPolicy.commitExporter.let { exporter ->
MlsGroup
.restore(obligation.priorState, MarmotGroupPolicy)
.exporterSecret(exporter.label, exporter.context, exporter.length)
}
GroupEventEncryption.decrypt(event.content, preCommitKey)
} catch (e: Exception) {
Log.w(

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