mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge origin/main into claude/relaxed-maxwell-hlc0a2
Main moved FavoriteAppsRegistry, BrowserHistoryRegistry and BrowserIconRegistry into commons as classes reached through AppModules (favoriteApps, browserHistory, browserIcons). The browser chrome, the broker and WebShortcuts now call those instances. The star keeps sending the state it wants, now on the favorites instance. WebSitePermissionRegistry, added on this branch, moves off the Context.preferencesDataStore delegate onto appStores like the other stores, keeping the same web_site_permissions file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
+48
-1
@@ -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`,
|
||||
@@ -280,6 +285,48 @@ Quartz uses expect/actual for platform-specific implementations (e.g. crypto
|
||||
backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on
|
||||
JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
|
||||
|
||||
## Strings
|
||||
|
||||
**MANDATORY: every new user-visible string goes in `commonsUI` Compose resources**,
|
||||
`commonsUI/src/commonMain/composeResources/values/strings.xml` — **never** in
|
||||
`amethyst/src/main/res/values/strings.xml`, even for an Android-only screen. A
|
||||
screen cannot move to `commonsUI` (or be rendered by Desktop) while its labels
|
||||
live in Android `res/`, and every key added there has to be migrated back out
|
||||
later. (The 2026-09-22 move emptied Android `res/` down to ~190 keys; the next two
|
||||
features put 447 back.)
|
||||
|
||||
- Reference it as `Res.string.my_key` / `Res.plurals.my_key`, importing
|
||||
`com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor
|
||||
`com.vitorpamplona.amethyst.commons.resources.my_key` (Compose resources generates
|
||||
each key as an extension property).
|
||||
- Read it with `stringRes(Res.string.x)` / `pluralStringRes(Res.plurals.x, n, …)`
|
||||
in composition — the app-side `ui.stringRes` overloads delegate to the
|
||||
commonsUI bridge (`commons/ui/StringRes.kt`), so one import serves both kinds.
|
||||
- Outside composition there is **no blocking accessor**: use `loadStringRes(…)`
|
||||
from a coroutine (or make the function `suspend`), or resolve the label in
|
||||
composition and pass it down as a `String`. A `runBlocking` bridge is ruled out.
|
||||
- Hold a string reference as `StringResource` / `PluralStringResource`, not a
|
||||
`@StringRes Int`.
|
||||
- Format arguments must be positional (`%1$s`, `%2$d`) — compose-resources does
|
||||
not format bare `%s`/`%d`.
|
||||
- Escaping is not Android's: write `'` and `"` bare (no `\'`), and never wrap a
|
||||
value in quotes to keep its whitespace. `.claude/hooks/compose_escaping_check.py`
|
||||
checks this before a push.
|
||||
|
||||
The **only** strings that stay in Android `res/` are the ones a platform API
|
||||
reads synchronously under a deadline, where a `suspend` read cannot run: a
|
||||
foreground service's `startForeground` notification, a notification channel
|
||||
created during service startup, PiP `RemoteAction`s, napplet capability labels
|
||||
read in the sandbox process, and `AndroidManifest.xml` / `res/xml` references.
|
||||
Most of those are *copies* of a key that also lives in `commonsUI`. See
|
||||
`commons/plans/2026-09-22-strings-to-compose-resources.md` ("the
|
||||
synchronous-platform tier"). If you think a new string belongs in that tier,
|
||||
say why in the PR.
|
||||
|
||||
To move keys that already landed in Android `res/`, use
|
||||
`tools/strings-migrate/migrate.py <keys…>`. It moves every locale byte-for-byte,
|
||||
so Crowdin sees a pure move. Then repoint `R.string.x` → `Res.string.x`.
|
||||
|
||||
## Icons
|
||||
|
||||
The Material Symbols font bundled at
|
||||
|
||||
@@ -27,6 +27,9 @@ Repair with:
|
||||
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
|
||||
quotes from values like `import_follows_tips`.
|
||||
|
||||
Raw line breaks and tabs inside a value are the whitespace half of the same
|
||||
mismatch: aapt collapsed them, Compose renders them (the same repair fixes them).
|
||||
|
||||
Only the Compose catalog is scanned. In an Android res tree the same escaping is
|
||||
correct and must be left alone.
|
||||
"""
|
||||
@@ -41,6 +44,10 @@ CATALOG = "*/src/*/composeResources/values*/strings.xml"
|
||||
# Compose resolves those itself.
|
||||
ANDROID_ESCAPE = re.compile(r"(?<!\\)\\(['\"?@])")
|
||||
TOOLS_ATTR = re.compile(r'tools:[\w.-]+="')
|
||||
# A value whose text holds a raw line break or tab. aapt collapsed that XML layout to
|
||||
# one space; Compose draws it, and a markdown value indented by it becomes a code block.
|
||||
STRING_TEXT = re.compile(r"<(string|item)\b[^>]*>(.*?)</\1>", re.S)
|
||||
LAYOUT_WS = re.compile(r"[\r\n\t]")
|
||||
|
||||
|
||||
def find_violations(root: Path):
|
||||
@@ -52,6 +59,13 @@ def find_violations(root: Path):
|
||||
n = len(TOOLS_ATTR.findall(text))
|
||||
if n:
|
||||
found[path]["tools: attribute"] += n
|
||||
n = sum(
|
||||
1
|
||||
for m in STRING_TEXT.finditer(text)
|
||||
if "<![CDATA[" not in m.group(2) and LAYOUT_WS.search(m.group(2))
|
||||
)
|
||||
if n:
|
||||
found[path]["raw line break/tab in a value"] += n
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
|
||||
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
|
||||
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
|
||||
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
|
||||
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
|
||||
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `bookmarkSets`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
|
||||
- Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
|
||||
- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`.
|
||||
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event.
|
||||
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `followSetDecryptionCache`, …) so NIP-44 decryption results are cached per event.
|
||||
- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
|
||||
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ been renamed, grep `Account.kt` for the class name.)
|
||||
| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` |
|
||||
| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` |
|
||||
| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` |
|
||||
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` |
|
||||
| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` |
|
||||
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `FollowSetsState` | NIP-51 people sets | `model/nip51Lists/followSets/` |
|
||||
| `followLists` | `StarterPacksState` | NIP-51 follow sets | `model/nip51Lists/followSets/` |
|
||||
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
|
||||
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
|
||||
|
||||
@@ -31,7 +31,7 @@ been renamed, grep `Account.kt` for the class name.)
|
||||
| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` |
|
||||
| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` |
|
||||
| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` |
|
||||
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
|
||||
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `favoriteRelayList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
|
||||
|
||||
Derived relay views (merge several of the above): `homeRelays`
|
||||
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
|
||||
@@ -43,10 +43,10 @@ Derived relay views (merge several of the above): `homeRelays`
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
|
||||
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
|
||||
| `bookmarkSets` | `BookmarkSetsState` | NIP-51 bookmark sets | `model/nip51Lists/bookmarkSets/` |
|
||||
| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` |
|
||||
| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` |
|
||||
| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` |
|
||||
| `hashtagList` / `geohashList` | `InterestListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/interestLists/`, `…/geohashLists/` |
|
||||
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
|
||||
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
|
||||
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
|
||||
|
||||
@@ -875,18 +875,33 @@ fun ProfileScreen(
|
||||
|
||||
### 3. Resource Access
|
||||
|
||||
**Strings do not go in `amethyst/src/main/res`.** New user-visible strings live in
|
||||
`commonsUI/src/commonMain/composeResources/values/strings.xml` and are read as
|
||||
`Res.string.x`, even on an Android-only screen. See "Strings" in `.claude/CLAUDE.md`
|
||||
for the rule and the small platform tier that is the only exception.
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.button_clicked
|
||||
import com.vitorpamplona.amethyst.commons.resources.button_label
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun LocalizedButton() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val message = context.getString(R.string.button_clicked)
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
// No blocking accessor outside composition: read it from a coroutine,
|
||||
// or resolve it in composition and capture the String.
|
||||
scope.launch {
|
||||
Toast.makeText(context, loadStringRes(Res.string.button_clicked), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.button_label))
|
||||
Text(stringRes(Res.string.button_label))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ abstract class NostrSigner(val pubKey: HexKey) {
|
||||
abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit)
|
||||
abstract fun nip44Encrypt(...)
|
||||
abstract fun nip44Decrypt(...)
|
||||
abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapRequestEvent) -> Unit)
|
||||
abstract fun decryptZapEvent(event: ZapRequestEvent, onReady: (ZapRequestEvent) -> Unit)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -26,6 +26,13 @@ There are two separate `strings.xml` trees, each with its own default `values/`
|
||||
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
|
||||
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
|
||||
|
||||
**New keys go in the `commonsUI` tree, always.** The amethyst tree is frozen at
|
||||
the small synchronous-platform tier (foreground-service and notification-channel
|
||||
text, PiP actions, napplet capability labels, manifest references). If you find
|
||||
a new feature key in it, it belongs in `commonsUI`. Move it with
|
||||
`tools/strings-migrate/migrate.py` rather than translating it where it is. See
|
||||
"Strings" in `.claude/CLAUDE.md`.
|
||||
|
||||
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
|
||||
|
||||
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
|
||||
|
||||
@@ -26,16 +26,16 @@ event signatures; the 10040→assertion link is **consumer-side convention** (se
|
||||
| Kind | Class | Kind class | d-tag = the subject | Content |
|
||||
|---|---|---|---|---|
|
||||
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
|
||||
| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
|
||||
| 30382 | `users/UserAssertionEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
|
||||
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
|
||||
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
|
||||
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
|
||||
|
||||
Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)`
|
||||
Addresses: `UserAssertionEvent.createAddress(owner, target)` → `Address(30382, owner, target)`
|
||||
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
|
||||
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
|
||||
|
||||
`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
|
||||
`UserAssertionEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
|
||||
tags plus topics; the encrypted card content is intentionally never indexed.
|
||||
|
||||
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
|
||||
@@ -91,9 +91,9 @@ The same kind serves two roles, distinguished **by author**:
|
||||
`followers`, `hops`, …); this is what 10040 discovery points at.
|
||||
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
|
||||
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
|
||||
the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/
|
||||
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
|
||||
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
|
||||
the NIP-44 encrypted content, never in public tags** (`UserAssertionEvent.build`/
|
||||
`updatePetNameAndSummary` strip stray public copies; asserted by `UserAssertionPetNameTest`).
|
||||
`commons/.../UserAssertionsState.kt` keys everything on `author == account` and ignores
|
||||
provider cards.
|
||||
|
||||
## Tag vocabulary and value semantics
|
||||
@@ -104,7 +104,7 @@ value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name
|
||||
validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) —
|
||||
consumers must defend.
|
||||
|
||||
**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in
|
||||
**On 30382** (`users/tags/`, accessors on `UserAssertionEvent` and as `TagArray` extensions in
|
||||
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
|
||||
|
||||
| Tag name | Accessor | Type | Semantics |
|
||||
@@ -138,7 +138,7 @@ val providers: List<ServiceProviderTag> = updated.serviceProviders() //
|
||||
val private = updated.privateTags(signer)?.serviceProviders() // private side
|
||||
|
||||
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
|
||||
val card = ContactCardEvent.create(
|
||||
val card = UserAssertionEvent.create(
|
||||
targetUser = subjectPubkey,
|
||||
signer = providerSigner,
|
||||
publicInitializer = {
|
||||
@@ -225,8 +225,8 @@ card's `a`-tag (`30382:<provider>:<target>`).
|
||||
|
||||
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
|
||||
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
|
||||
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
|
||||
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
|
||||
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`UserAssertionsState`,
|
||||
`UserCardsCache`, `UserAssertionDecryptionCache`, `TrustProviderListDecryptionCache`),
|
||||
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
|
||||
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
|
||||
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
|
||||
|
||||
@@ -79,30 +79,38 @@ companion object {
|
||||
|
||||
### Zap Request/Receipt (kinds 9734, 9735)
|
||||
```kotlin
|
||||
class LnZapRequestEvent(...) : Event(...)
|
||||
class ZapRequestEvent(...) : Event(...)
|
||||
// Created by client, sent to Lightning Address
|
||||
|
||||
class LnZapEvent(...) : Event(...)
|
||||
class ZapReceiptEvent(...) : Event(...)
|
||||
// Receipt from LSP, contains bolt11 + embedded zap request
|
||||
val zapRequest: LnZapRequestEvent? by lazy { containedPost() }
|
||||
val zapRequest: ZapRequestEvent? by lazy { containedPost() }
|
||||
val amount: BigDecimal? by lazy { /* parse from bolt11 */ }
|
||||
```
|
||||
|
||||
### Long-Form Content (kind 30023)
|
||||
```kotlin
|
||||
class LongTextNoteEvent(...) : BaseAddressableEvent(...)
|
||||
class LongFormContentEvent(...) : BaseAddressableEvent(...)
|
||||
// Blog posts, articles
|
||||
// Addressable via kind:pubkey:d-tag
|
||||
```
|
||||
|
||||
### Lists (kinds 10000-30004)
|
||||
### Lists (NIP-51)
|
||||
Each list kind is its own class under `nip51Lists/`, not a subtype of a shared list event.
|
||||
Lists with private (NIP-44 encrypted) items extend one of two bases:
|
||||
```kotlin
|
||||
sealed class PeopleListEvent : BaseAddressableEvent {
|
||||
object MuteList : PeopleListEvent(10000)
|
||||
object PinList : PeopleListEvent(10001)
|
||||
object BookmarkList : PeopleListEvent(10003)
|
||||
// ... 18 list types total
|
||||
}
|
||||
// Base for sets (kind 30000-39999); some replaceable lists use it too (MuteListEvent, InterestListEvent)
|
||||
abstract class PrivateTagArrayEvent(...) : BaseAddressableEvent(...)
|
||||
// Replaceable lists (kind 10000-19999)
|
||||
abstract class PrivateReplaceableTagArrayEvent(...) : BaseReplaceableEvent(...)
|
||||
|
||||
class MuteListEvent(...) : PrivateTagArrayEvent(...) // kind 10000
|
||||
class PinListEvent(...) : BaseReplaceableEvent(...) // kind 10001, public only
|
||||
class BookmarkListEvent(...) : PrivateReplaceableTagArrayEvent(...) // kind 10003
|
||||
class InterestListEvent(...) : PrivateTagArrayEvent(...) // kind 10015
|
||||
class FollowSetEvent(...) : PrivateTagArrayEvent(...) // kind 30000 follow sets
|
||||
class BookmarkSetEvent(...) : PrivateTagArrayEvent(...) // kind 30003 bookmark sets
|
||||
class StarterPackEvent(...) : BaseAddressableEvent(...) // kind 39089 starter packs
|
||||
```
|
||||
|
||||
## Event Interfaces
|
||||
@@ -279,7 +287,7 @@ val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2"
|
||||
### Event Deletion
|
||||
```kotlin
|
||||
// Delete events
|
||||
val deletion = DeletionEvent.create(
|
||||
val deletion = DeletionRequestEvent.create(
|
||||
deleteEvents = listOf(eventId1, eventId2),
|
||||
reason = "Spam",
|
||||
signer = signer
|
||||
|
||||
@@ -15,7 +15,7 @@ under `experimental/`**. The categorized list below may lag behind —
|
||||
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
|
||||
| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) |
|
||||
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
|
||||
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
|
||||
| 09 | `nip09Deletions/` | DeletionRequestEvent.kt | Event deletion requests (kind 5) |
|
||||
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
|
||||
| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work |
|
||||
| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes |
|
||||
@@ -33,7 +33,7 @@ ent for NIP-04) |
|
||||
| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) |
|
||||
| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) |
|
||||
| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) |
|
||||
| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) |
|
||||
| 23 | `nip23LongContent/` | LongFormContentEvent.kt | Long-form content (kind 30023) |
|
||||
| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) |
|
||||
| 31 | `nip31Alts/` | Alt tags | Alt description tags |
|
||||
| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags |
|
||||
@@ -62,7 +62,7 @@ ent for NIP-04) |
|
||||
| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) |
|
||||
| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol |
|
||||
| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) |
|
||||
| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
|
||||
| 57 | `nip57Zaps/` | ZapReceiptEvent.kt, ZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
|
||||
| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) |
|
||||
| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy |
|
||||
| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) |
|
||||
@@ -76,7 +76,7 @@ ent for NIP-04) |
|
||||
| 35 | `nip35Torrents/` | Torrent events | Torrent tracking |
|
||||
| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) |
|
||||
| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) |
|
||||
| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) |
|
||||
| 54 | `nip54Wiki/` | WikiArticleEvent.kt | Wiki pages (kind 30818) |
|
||||
| 68 | `nip68Picture/` | Picture metadata | Picture metadata |
|
||||
| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) |
|
||||
| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) |
|
||||
@@ -84,7 +84,7 @@ ent for NIP-04) |
|
||||
| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) |
|
||||
| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) |
|
||||
| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags |
|
||||
| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
|
||||
| 94 | `nip94FileMetadata/` | FileMetadataEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
|
||||
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
|
||||
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
|
||||
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
|
||||
@@ -93,7 +93,7 @@ ent for NIP-04) |
|
||||
### Web/Storage/Other
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) |
|
||||
| 38 | `nip38UserStatus/` | UserStatusEvent.kt | User status (kind 30315) |
|
||||
| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) |
|
||||
| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests |
|
||||
| 64 | `nip64Chess/` | Chess moves | Chess move events |
|
||||
|
||||
@@ -650,15 +650,15 @@ val custom = ReactionEvent.build("🤙", targetEvent)
|
||||
### NIP-57 — Zap request (kind 9734)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
|
||||
val template = LnZapRequestEvent.build(
|
||||
val template = ZapRequestEvent.build(
|
||||
message = "Great post!",
|
||||
relays = listOf("wss://relay.damus.io"),
|
||||
target = targetEvent,
|
||||
zapType = LnZapRequestEvent.ZapType.PUBLIC
|
||||
zapType = ZapRequestEvent.ZapType.PUBLIC
|
||||
)
|
||||
val zapRequest: LnZapRequestEvent = signer.sign(template)
|
||||
val zapRequest: ZapRequestEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)
|
||||
@@ -678,9 +678,9 @@ val (dmEvent, giftWrap) = NIP17Factory.create(
|
||||
### NIP-23 — Long-form article (kind 30023)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
|
||||
|
||||
val template = LongTextNoteEvent.build(
|
||||
val template = LongFormContentEvent.build(
|
||||
body = markdownContent,
|
||||
title = "My Article",
|
||||
image = "https://example.com/cover.jpg",
|
||||
@@ -882,16 +882,16 @@ use `Nip11RelayInformation.fromJson(json)`.
|
||||
| 0 | User metadata | 01 | `MetadataEvent` |
|
||||
| 1 | Text note | 10 | `TextNoteEvent` |
|
||||
| 3 | Follow list | 02 | `ContactListEvent` |
|
||||
| 4 | Legacy DM | 04 | `PrivateDmEvent` |
|
||||
| 5 | Deletion | 09 | `DeletionEvent` |
|
||||
| 4 | Legacy DM | 04 | `EncryptedDmEvent` |
|
||||
| 5 | Deletion | 09 | `DeletionRequestEvent` |
|
||||
| 6 | Repost | 18 | `RepostEvent` |
|
||||
| 7 | Reaction | 25 | `ReactionEvent` |
|
||||
| 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` |
|
||||
| 1059 | Gift wrap | 59 | `GiftWrapEvent` |
|
||||
| 9734 | Zap request | 57 | `LnZapRequestEvent` |
|
||||
| 9735 | Zap receipt | 57 | `LnZapEvent` |
|
||||
| 9734 | Zap request | 57 | `ZapRequestEvent` |
|
||||
| 9735 | Zap receipt | 57 | `ZapReceiptEvent` |
|
||||
| 10002 | Relay list | 65 | `AdvertisedRelayListEvent` |
|
||||
| 30023 | Long-form content | 23 | `LongTextNoteEvent` |
|
||||
| 30023 | Long-form content | 23 | `LongFormContentEvent` |
|
||||
|
||||
## Related Skills
|
||||
|
||||
|
||||
@@ -45,10 +45,9 @@ Notables that surprise people:
|
||||
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
|
||||
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
|
||||
website, picture URL, …).
|
||||
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
|
||||
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so
|
||||
`title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()`
|
||||
is dead on the store path.
|
||||
- **Kind 30063** (`ReleaseArtifactSetEvent`) serves both NIP-51 release artifact sets and NIP-82
|
||||
software releases: it indexes `title()\ndescription()`, plus the release notes in `content` only
|
||||
for NIP-82 releases (NIP-51 `content` can be encrypted private items).
|
||||
- Poll kinds (1068, 6969) append each option label on its own line.
|
||||
|
||||
## MANDATORY maintenance when you touch this surface
|
||||
|
||||
@@ -5,7 +5,7 @@ expression. **Update this file in the same PR as any change to the searchable se
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
|
||||
|
||||
Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
kind 30063 has a collision — see the footnote). File paths are under
|
||||
kind 30063 is shared by two NIPs — see the footnote). File paths are under
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
|
||||
|
||||
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
@@ -26,7 +26,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` |
|
||||
| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL |
|
||||
| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) |
|
||||
| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
|
||||
| 1063 | FileMetadataEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
|
||||
| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL |
|
||||
| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` |
|
||||
| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL |
|
||||
@@ -48,20 +48,20 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 2004 | TorrentCommentEvent | nip35Torrents | `content` |
|
||||
| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL |
|
||||
| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` |
|
||||
| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
|
||||
| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
|
||||
| 5050 | DvmTextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
|
||||
| 5100 | DvmImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
|
||||
| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
|
||||
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
|
||||
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
|
||||
| 5250 | DvmTextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
|
||||
| 5302 | DvmContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
|
||||
| 5303 | DvmPeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
|
||||
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
|
||||
| 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` |
|
||||
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
|
||||
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
|
||||
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
|
||||
| 9002 | GroupEditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
|
||||
| 9041 | ZapGoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
|
||||
| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` |
|
||||
| 9734 | LnZapRequestEvent | nip57Zaps | `content` |
|
||||
| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
|
||||
| 9734 | ZapRequestEvent | nip57Zaps | `content` |
|
||||
| 9735 | ZapReceiptEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
|
||||
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
|
||||
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
|
||||
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
|
||||
@@ -74,10 +74,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL |
|
||||
| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
|
||||
| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30000 | FollowSetEvent | nip51Lists/followSet | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
|
||||
| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL |
|
||||
| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30003 | BookmarkSetEvent | nip51Lists/bookmarkSet | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
@@ -87,11 +87,11 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` |
|
||||
| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` |
|
||||
| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` |
|
||||
| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30023 | LongFormContentEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL |
|
||||
| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL |
|
||||
| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL |
|
||||
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description(), content if NIP-82)` NL |
|
||||
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
|
||||
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
|
||||
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
|
||||
@@ -101,8 +101,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL |
|
||||
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
|
||||
| 30315 | StatusEvent | nip38UserStatus | `content` |
|
||||
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
|
||||
| 30315 | UserStatusEvent | nip38UserStatus | `content` |
|
||||
| 30382 | UserAssertionEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
|
||||
| 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership |
|
||||
| 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
@@ -111,7 +111,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
|
||||
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
|
||||
| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL |
|
||||
| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30818 | WikiArticleEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL |
|
||||
| 31871 | AttestationEvent | experimental/attestations/attestation | `content` |
|
||||
| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` |
|
||||
@@ -119,15 +119,15 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` |
|
||||
| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
|
||||
| 31924 | CalendarCollectionEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
|
||||
| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` |
|
||||
| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) |
|
||||
| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL |
|
||||
| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
|
||||
| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL |
|
||||
| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL |
|
||||
| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34235 | AddressableNormalVideoEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34236 | AddressableShortVideoEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL |
|
||||
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
|
||||
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
@@ -138,7 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
|
||||
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
|
||||
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
|
||||
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
|
||||
| 39089 | StarterPackEvent | nip51Lists/starterPack | `listOfNotNull(title(), description())` NL |
|
||||
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
|
||||
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
|
||||
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
|
||||
@@ -150,10 +150,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 45003 | ForumCommentEvent | buzz/forum | `content` |
|
||||
| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` |
|
||||
|
||||
† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also
|
||||
declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps
|
||||
30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes
|
||||
`title()\ndescription()`. If the factory mapping ever changes, this table changes with it.
|
||||
† **Kind 30063 is shared** by NIP-51 release artifact sets and NIP-82 software releases, and
|
||||
`ReleaseArtifactSetEvent` parses both. It indexes `title()` and `description()`, plus `content`
|
||||
(the release notes) only when the event carries the NIP-82 `i` + `version` tags — a NIP-51 set
|
||||
may hold encrypted private items in `content`, which must never be indexed.
|
||||
|
||||
## Abstract bases (no kind of their own)
|
||||
|
||||
|
||||
+114
-17
@@ -335,10 +335,29 @@ jobs:
|
||||
name: Quartz iOS Test Reports
|
||||
path: quartz/build/reports
|
||||
|
||||
test-and-build-android:
|
||||
# Android Lint, split out of test-and-build-android.
|
||||
#
|
||||
# That job died with "the runner has received a shutdown signal" (SIGTERM,
|
||||
# exit 143) seven times across this workflow's history — the OOM killer taking
|
||||
# the runner agent on a 16GB box. Two attempts to fix it by tuning numbers
|
||||
# have now been spent: capping both daemons to 4g traded the runner OOM for an
|
||||
# R8/lintAnalyze "Java heap space", and --max-workers=3 survives a warm cache
|
||||
# but still dies on a cold one.
|
||||
#
|
||||
# This is the structural fix rather than a third number. lintAnalyze is the
|
||||
# single heaviest step in that job — measured at 13 of its 35 minutes on one
|
||||
# cold run — and it holds a large analysis graph while the Kotlin daemon, a
|
||||
# forked test JVM and R8 are all still resident. Giving the two lint tasks
|
||||
# their own runner removes that peak from the critical job instead of trying
|
||||
# to squeeze everything under one ceiling, and the two now run concurrently.
|
||||
#
|
||||
# The cost is honest: both jobs restore the same read-only Gradle cache and so
|
||||
# repeat some module compilation. That buys back more than it spends here,
|
||||
# because the duplicated work is parallel while the memory pressure was not.
|
||||
lint-android:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
@@ -354,12 +373,61 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# Lint + focused unit tests + benchmark assembly in one Gradle invocation.
|
||||
# Previously: one invocation for lint, one for `test` (which compiled all
|
||||
# six amethyst variants × all flavors), one for `assembleBenchmark`
|
||||
# (re-walking the same task graph). Combining them keeps the daemon hot
|
||||
# across phases and lets task-level dedup (e.g. compileKotlin) only
|
||||
# happen once.
|
||||
# Same daemon cap as the sibling job: lintAnalyze draws on the Gradle
|
||||
# daemon's heap, which is why the earlier 4g experiment broke it. Only the
|
||||
# Kotlin daemon is trimmed.
|
||||
- name: Lint Android (gradle)
|
||||
run: |
|
||||
./gradlew \
|
||||
-Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \
|
||||
--max-workers=3 \
|
||||
:amethyst:lintFdroidBenchmark \
|
||||
:amethyst:lintPlayBenchmark
|
||||
|
||||
- name: Upload Android Lint Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: Android Lint Reports
|
||||
path: amethyst/build/reports/lint-results-*.html
|
||||
|
||||
test-and-build-android:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
# 90, not 60. On main this job's Gradle step takes ~46 minutes, which used
|
||||
# 76% of a 60-minute budget — fine for an incremental run, but PR runs set
|
||||
# `cache-read-only` (below), so they restore main's Gradle cache and never
|
||||
# save. A PR that touches `quartz` or `commons` invalidates most of what
|
||||
# that cache holds for everything downstream, and the job then rebuilds it
|
||||
# from cold: measured 2-3x the main-branch time across every job in the
|
||||
# workflow, which puts this one past the cap and gets it killed mid-step
|
||||
# with no test report. Raising the cap costs nothing on runs that finish
|
||||
# early — `timeout-minutes` bounds a job, it does not reserve the time.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# Focused unit tests + benchmark assembly in one Gradle invocation.
|
||||
# Previously: one invocation for `test` (which compiled all six amethyst
|
||||
# variants × all flavors) and one for `assembleBenchmark` (re-walking the
|
||||
# same task graph). Combining them keeps the daemon hot across phases and
|
||||
# lets task-level dedup (e.g. compileKotlin) only happen once.
|
||||
#
|
||||
# Lint used to run here too and now has its own job (lint-android above) —
|
||||
# see the note there for why. What remains is still the heaviest job in
|
||||
# the workflow, so the memory notes below continue to apply.
|
||||
#
|
||||
# `-PdisableAbiSplits=true` produces a single non-split APK per
|
||||
# (flavor, buildType) instead of 5 (4 ABIs + universal). The CI only
|
||||
@@ -371,11 +439,47 @@ jobs:
|
||||
# variants are compile-equivalent for unit-test purposes; running all six
|
||||
# adds ~5× the kotlinc work without catching new defects on PRs. Push to
|
||||
# main still gets the full test matrix via the production-build path.
|
||||
# Memory, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g
|
||||
# and 2g metaspace (Kotlin daemon) — about 16GB of ceiling on a 16GB
|
||||
# ubuntu-latest runner, before the launcher JVM, Lint's fork and the KSP
|
||||
# workers. This job is the only one heavy enough to reach it, and it died
|
||||
# five times mid-compile with "the runner has received a shutdown signal",
|
||||
# which is the OOM killer taking the runner agent.
|
||||
#
|
||||
# Only the Kotlin daemon is cut. An earlier attempt capped BOTH to 4g and
|
||||
# traded one OOM for another: the runner survived and the job ran to
|
||||
# completion, but R8 and lintAnalyze then failed with
|
||||
# "java.lang.OutOfMemoryError: Java heap space" — they draw on the Gradle
|
||||
# daemon's heap, and 4g is not enough for them on this app. 6g always was,
|
||||
# so it stays; 8g + 2g for kotlinc is the part that did not fit.
|
||||
#
|
||||
# Overridden here rather than in gradle.properties so local builds on
|
||||
# bigger machines keep the headroom.
|
||||
#
|
||||
# `--max-workers` is the second half, and it is about concurrency rather
|
||||
# than ceilings. The heap numbers above are per-JVM limits; what actually
|
||||
# tips a 16GB runner over is how many heavy JVMs are live at once. Gradle
|
||||
# defaults max-workers to the core count (4 on ubuntu-latest) and
|
||||
# org.gradle.parallel is on, so a cold run can have several kotlinc
|
||||
# workers, a lint fork and a forked test JVM resident alongside the two
|
||||
# daemons.
|
||||
#
|
||||
# Cold is the case that matters. The 4g cap was first validated on a run
|
||||
# that only changed this file, so it restored main's Gradle cache and
|
||||
# built almost nothing; the next PR run that touched `commons`
|
||||
# invalidated that cache, rebuilt from cold, and died the same way at
|
||||
# ~20 minutes. Fewer workers is what makes the cold path fit — dropping
|
||||
# heap further would start starving R8 again, which is the trade the
|
||||
# previous attempt already lost.
|
||||
#
|
||||
# 3 rather than 2: this job is mostly a chain of single-task module
|
||||
# compiles, so the parallelism it loses is small, and halving it risks
|
||||
# the timeout on a cold run. The cap goes to 120 for the same reason.
|
||||
- name: Test + Build Android (gradle)
|
||||
run: |
|
||||
./gradlew \
|
||||
:amethyst:lintFdroidBenchmark \
|
||||
:amethyst:lintPlayBenchmark \
|
||||
-Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \
|
||||
--max-workers=3 \
|
||||
:quartz:jvmTest \
|
||||
:commons:jvmTest \
|
||||
:commonsUI:jvmTest \
|
||||
@@ -386,13 +490,6 @@ jobs:
|
||||
:amethyst:assembleBenchmark \
|
||||
-PdisableAbiSplits=true
|
||||
|
||||
- name: Upload Android Lint Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: Android Lint Reports
|
||||
path: amethyst/build/reports/lint-results-*.html
|
||||
|
||||
# Publishes the JUnit XML produced by the unit-test tasks above as inline
|
||||
# annotations plus a job summary. Replaces asadmansr/android-test-report-action,
|
||||
# which was abandoned (last release 2020) and rebuilt an EOL Ubuntu 18.04 +
|
||||
|
||||
@@ -68,7 +68,7 @@ The NIP-42 plumbing and a first-cut permission gate are already in place:
|
||||
| Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` |
|
||||
| Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` |
|
||||
| Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) |
|
||||
| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` |
|
||||
| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `StarterPacksState.isUserInFollowSets(...)` |
|
||||
| DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) |
|
||||
|
||||
## The gap
|
||||
@@ -180,7 +180,7 @@ class RelayAuthContext(val relayUrl: String, val purposes: List<AuthPurpose>)
|
||||
- `IF_IN_MY_LIST` → `ALLOW` if relay ∈ my relay lists, else fall through
|
||||
- `TRUSTED_FOLLOWS` *(new — see idea A below)* → `ALLOW` if relay ∈ my lists
|
||||
**or** any counterparty in `ctx.purposes` is followed (`Account.allFollows`
|
||||
/ `FollowListsState.isUserInFollowSets`) and the purpose permits it; else
|
||||
/ `StarterPacksState.isUserInFollowSets`) and the purpose permits it; else
|
||||
fall through.
|
||||
4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason);
|
||||
otherwise `DENY` silently (don't prompt for anonymous stranger challenges).
|
||||
|
||||
@@ -392,11 +392,11 @@ can be a group event if it carries `h` and the relay accepts the kind."
|
||||
has:
|
||||
|
||||
- `metadata/` — `GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent`
|
||||
(39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003).
|
||||
- `moderation/` — `CreateGroupEvent` (9007), `EditMetadataEvent` (9002),
|
||||
`PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005),
|
||||
`DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers.
|
||||
- `request/` — `JoinRequestEvent` (9021), `LeaveRequestEvent` (9022).
|
||||
(39001), `GroupMembersEvent` (39002), `GroupRolesEvent` (39003).
|
||||
- `moderation/` — `CreateGroupEvent` (9007), `GroupEditMetadataEvent` (9002),
|
||||
`GroupPutUserEvent` (9000), `GroupRemoveUserEvent` (9001), `GroupDeleteEventEvent` (9005),
|
||||
`DeleteGroupEvent` (9008), `GroupCreateInviteEvent` (9009), plus tag helpers.
|
||||
- `request/` — `GroupJoinRequestEvent` (9021), `GroupLeaveRequestEvent` (9022).
|
||||
- `tags/` — `GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`,
|
||||
and even a `PreviousTag`.
|
||||
|
||||
@@ -427,7 +427,7 @@ NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under
|
||||
### 5.2 The main protocol gap
|
||||
|
||||
There is **no kind-9 group chat message event** in `nip29RelayGroups/`
|
||||
(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
|
||||
(`GroupCreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
|
||||
The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the
|
||||
actual message carrier does not. This is the first thing to build:
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Feed-level:
|
||||
marker update (only the newest visible timestamp matters; the marker is
|
||||
monotonic). One coroutine per scroll session instead of one per row.
|
||||
6. **Jumbo without a coroutine (#4)**: only launch the decrypt effect for
|
||||
encrypted kinds (`PrivateDmEvent`, sealed rumors not yet in the decrypt
|
||||
encrypted kinds (`EncryptedDmEvent`, sealed rumors not yet in the decrypt
|
||||
cache). Plaintext kinds (public chats, NIP-17 rumors already unwrapped —
|
||||
the vast majority) take the synchronous path only.
|
||||
7. **Retire settled delivery ticks (#6)**: once a message is fully accepted (or
|
||||
|
||||
@@ -55,7 +55,7 @@ Bitchat's Nostr side has two chat features. Amethyst is a pure-Nostr client
|
||||
|
||||
1. **Encrypted DMs (Phase 2).** Port the `bitchat1:` binary packet
|
||||
(`BitchatPacket` TLV + `NoisePayloadType`) into quartz, wrap/unwrap it in the
|
||||
existing NIP-17 stack (`GiftWrapEvent`/`SealedRumorEvent`/`ChatMessageEvent`),
|
||||
existing NIP-17 stack (`GiftWrapEvent`/`SealEvent`/`ChatMessageEvent`),
|
||||
handle geohash DMs (to a per-geohash pubkey) and delivery/read receipts.
|
||||
Add `amy geochat dm` for interop testing.
|
||||
2. **Desktop UI.** The shared pieces (quartz events, `GeoRelayDirectory`) are
|
||||
|
||||
@@ -178,10 +178,10 @@ This keeps the *policy* (who gets notified) exactly where it is and isolates the
|
||||
|
||||
| Kind(s) | Category | Style | Accent / icon | Title → Body | Actions |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `PrivateDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read |
|
||||
| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `EncryptedDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read |
|
||||
| `TextNoteEvent`(1)/`CommentEvent`(1111)/`ChannelMessageEvent`(42) **reply to me** | REPLY | `MessagingStyle` (parent as prior message) | purple / `reply` | "X replied" → excerpt (+ quoted parent) | Reply, View thread, Mute thread |
|
||||
| Same kinds, **mention/quote only** | MENTION | plain (BigText) | purple / `alternate_email` | "X mentioned you" → excerpt | View, Mute thread |
|
||||
| `LnZapEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View |
|
||||
| `ZapReceiptEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View |
|
||||
| `NutzapEvent`(9321) *(new)* | ZAP | colorized amount card | gold / `bolt` (cashu tint) | "🥜 X nutzapped you 2,100 sats" → … | Zap back, View |
|
||||
| `OnchainZapEvent`(8333) *(new)* | ZAP | colorized amount card | gold / `bolt` | "⛓ X sent an onchain zap" → … | View |
|
||||
| `ReactionEvent`(7) | REACTION | aggregated `InboxStyle` | heart-red / `favorite` | "🤙 X & N others liked your post" → note excerpt | Like back, View |
|
||||
@@ -189,7 +189,7 @@ This keeps the *policy* (who gets notified) exactly where it is and isolates the
|
||||
| `PictureEvent`(20)/`VideoNormal`/`Short`/`Vertical`/`Horizontal` | MEDIA | `BigPictureStyle` | purple / `image` | "X shared a photo/video" → caption, image inline | View |
|
||||
| `PollEvent`/`ZapPollEvent` | MENTION | plain | purple / `ballot` | "X asked a question" → poll title | Vote, View |
|
||||
| `HighlightEvent`(9802) | ARTICLE | plain | purple / `format_quote` | "X highlighted your article" → highlighted text | View |
|
||||
| `LongTextNoteEvent`(30023)/`WikiNoteEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View |
|
||||
| `LongFormContentEvent`(30023)/`WikiArticleEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View |
|
||||
| `GitIssueEvent`/`GitPatchEvent`/`GitPullRequestEvent`/`…Update` | CODE | plain | slate / `merge` | "X opened an issue/PR" → subject | View |
|
||||
| `BadgeAwardEvent`(8) *(new)* | BADGE | `BigPictureStyle` (badge art) | gold / `award_star` | "You earned a badge" → badge name, image | View |
|
||||
| `LiveChessGameAcceptEvent`/`LiveChessMoveEvent` | CHESS | plain | brown / `chess` | "X accepted your challenge" / "your turn" | Open board |
|
||||
|
||||
@@ -59,7 +59,7 @@ and dispatches the decrypted `Response`, so a new method needs no changes there:
|
||||
decrypt-on-arrival.
|
||||
- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the
|
||||
author-spoof gate.
|
||||
- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback.
|
||||
- `LocalCache.consume(NwcResponseEvent)` — routes 23195 back to the callback.
|
||||
- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`;
|
||||
`PaymentSourceResolver`.
|
||||
- Pay rail entry: `ZapPaymentHandler.zap()` → `payViaNWC()` (the `PaymentSource.Nwc`
|
||||
|
||||
@@ -41,9 +41,9 @@ storm.
|
||||
|---|---|
|
||||
| The chip row + "Add" chip | `ui/note/creators/notify/Notifying.kt` (`Notifying`, `NotifyUserChip`, `AddUserChip`) |
|
||||
| Audience state | `ShortNotePostViewModel.pTags: List<User>?`, `mutedNotifies: Set<HexKey>`, `activeNotifies()`, `addToReplyList(user)` |
|
||||
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`model/nip51Lists/peopleList/PeopleListsState.kt`) |
|
||||
| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/peopleList/FollowListsState.kt`) |
|
||||
| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set<User>`) | `model/nip51Lists/peopleList/PeopleList.kt` |
|
||||
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`model/nip51Lists/followSets/FollowSetsState.kt`) |
|
||||
| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/followSets/StarterPacksState.kt`) |
|
||||
| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set<User>`) | `model/nip51Lists/followSets/PeopleList.kt` |
|
||||
| Two-column list catalog rendering | `ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt` — same "Follow sets" + "Discover follows" sectioning |
|
||||
| Multi-select member review (count header, select-all checkbox, per-user row, confirm button) | `ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt` (`PreviewList` / `FollowEntryRow`) |
|
||||
| Bottom-sheet picker shell w/ search field | `ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt` |
|
||||
|
||||
@@ -281,7 +281,7 @@ In `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt`
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
```
|
||||
2. Add a dispatch branch inside the `when (kind)` near the other NIP-90 kinds (next to line 736 `NIP90StatusEvent.KIND -> ...`):
|
||||
2. Add a dispatch branch inside the `when (kind)` near the other NIP-90 kinds (next to line 736 `DvmStatusEvent.KIND -> ...`):
|
||||
```kotlin
|
||||
DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
```
|
||||
@@ -304,7 +304,7 @@ private val knownDTagReaders =
|
||||
|
||||
- [ ] **Step 6: Update the design doc §2**
|
||||
|
||||
In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, replace the §2 bullet that says the class "extends `BaseReplaceableEvent`" / "Overrides `dTag()`" with the as-built wording: extends `BaseAddressableEvent` (the codebase convention for 10xxx events with real `d` tags, e.g. `FollowListEvent`), so `dTag()`/`address()`/`addressTag()` come from the base; note `MAX_AGE_SECONDS`, `isFreshAt`, and the `knownDTagReaders` entry live in quartz too (commons imports them).
|
||||
In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, replace the §2 bullet that says the class "extends `BaseReplaceableEvent`" / "Overrides `dTag()`" with the as-built wording: extends `BaseAddressableEvent` (the codebase convention for 10xxx events with real `d` tags, e.g. `StarterPackEvent`), so `dTag()`/`address()`/`addressTag()` come from the base; note `MAX_AGE_SECONDS`, `isFreshAt`, and the `knownDTagReaders` entry live in quartz too (commons imports them).
|
||||
|
||||
- [ ] **Step 7: Run the tests to verify they pass**
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ feeds and the detail surface show an offline state instead.
|
||||
New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
|
||||
|
||||
- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase
|
||||
convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` /
|
||||
convention for 10xxx events with real `d` tags (e.g. `StarterPackEvent`), so `dTag()` /
|
||||
`address()` / `addressTag()` come from the base. The cache address is
|
||||
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
|
||||
`Address(31990, dvmPubkey, dTag)`.
|
||||
|
||||
@@ -83,7 +83,7 @@ The listing event is mostly existing tags wearing a new kind number:
|
||||
| `r` | `nip51Lists/tags/RelayTag` (`"r"`) |
|
||||
| `a` | `nip01Core/tags/aTag/ATag` (curation list + found log) |
|
||||
| `title`, `description` (37517) | `nip51Lists/tags/TitleTag`, `.../DescriptionTag` |
|
||||
| embedded 7517 | `Event.fromJson(...)` (same trick `LnZapEvent.zapRequest()` uses) |
|
||||
| embedded 7517 | `Event.fromJson(...)` (same trick `ZapReceiptEvent.zapRequest()` uses) |
|
||||
| signature check | `Event.verifySignature()` in `nip01Core/crypto/EventExt.kt` |
|
||||
| kind 1111 logs | `nip22Comments/CommentEvent` **as-is** — it already models `A/K/P` + `a/k/p` addressable root+parent (`rootAddress()`, `replyAddress()`, `hasRootAddress()`). Only the `t` log-type vocabulary is new. |
|
||||
| required-tag gate | `containsAllTagNamesWithValues(REQUIRED_FIELDS)`, as `ClassifiedsEvent.isWellFormed()` does |
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Retiring EncryptedStorage
|
||||
|
||||
Status: **migrated, not yet deleted.** Every key has a home in the new stores.
|
||||
The legacy files are still written, so they are still there — and the reader
|
||||
can never go.
|
||||
|
||||
## The constraint
|
||||
|
||||
Every migration in the preference layer is *lazy*: it reads the legacy store
|
||||
when it runs, not when the app is installed. Ten come through
|
||||
`EncryptedStorage` — the eight `LegacyKeyTable` copies on the per-account
|
||||
DataStore plus the key, secret and roster stores. Only the Cashu counters and
|
||||
calendar reminders read a plain (non-encrypted) source and would survive its
|
||||
removal.
|
||||
|
||||
So deleting `EncryptedStorage` does not merely affect installs that have not
|
||||
upgraded yet. It strands anyone who **skips** the release introducing the new
|
||||
stores: a pre-migration build upgrading straight to a post-deletion build runs
|
||||
its migration against a reader that no longer exists. Keys, accounts, wallets
|
||||
and settings stay encrypted on disk with nothing able to read them, and the app
|
||||
opens as a fresh install. Auto-update off, the F-Droid cadence and restoring
|
||||
from a backup all skip releases.
|
||||
|
||||
**The reader is permanent.** What a later release can retire is the legacy
|
||||
*write*, which stops new data landing there while old data stays readable.
|
||||
`androidx.security.crypto` has to stay for as long as the reader does. That is
|
||||
an unmaintained-library risk, not an active vulnerability, and a much smaller
|
||||
cost than stranding users.
|
||||
|
||||
## Where each key went
|
||||
|
||||
`LegacyKeyCoverageTest` holds this to being exhaustive: every constant in
|
||||
`PrefKeys` is either claimed by a migration table, one of the secrets, on the
|
||||
accepted-loss list, or a key of the global file. A key added to `PrefKeys` and
|
||||
to none of those fails that test at the commit that adds it.
|
||||
|
||||
| group | destination | legacy write |
|
||||
|---|---|---|
|
||||
| follow lists, cached events, upload, dialogs, relay auth, feed visibility, notifications | the account's plain DataStore | already retired |
|
||||
| identity — pubkey, signer, local relays, backup conflicts, backup flag | the account's plain DataStore | **kept** |
|
||||
| private key | `SecureKeyStorage` | **kept** |
|
||||
| NIP-46 material, wallets, payment source | the account's encrypted DataStore | **kept** |
|
||||
| current account, saved accounts | the encrypted roster store | **kept** |
|
||||
| UI settings (`shared_settings`) | `UiSharedPreferences`' own DataStore | none left |
|
||||
| location-chat identity — seed, nickname | the account's encrypted DataStore, as its own `GeohashIdentitySecrets` group | **kept** |
|
||||
|
||||
The stores that still mirror are the ones whose loss is not an annoyance: an
|
||||
account that cannot be listed, signed with, or paid from. They keep the
|
||||
rollback window open until the device pass below has happened.
|
||||
|
||||
The location-chat identity is the odd one, in two ways worth knowing before
|
||||
step 4. It is its **own** group rather than fields on `AccountSecrets`: every
|
||||
account save mirrors a whole `AccountSecrets` built from `AccountSettings`,
|
||||
which does not hold these, and that group save removes keys whose value is
|
||||
null — folded in, the seed would be deleted by the next unrelated save and
|
||||
every geohash identity the user has would silently change. And its legacy home
|
||||
is a **different file**, `secret_keeper_<pubkey hex>`, because its writer
|
||||
passed `signer.pubKey` where every other caller passes an npub.
|
||||
|
||||
The UI settings copy is **guarded** where the others are not. That store has
|
||||
been the real home of these settings for a while, so most installs already
|
||||
have a populated one and copying the old blob over it would undo every UI
|
||||
change since. The copy only runs into a store that has never been saved
|
||||
(`ui.theme` absent, which `save` always writes).
|
||||
|
||||
## Deliberately not migrated
|
||||
|
||||
| key | what is lost |
|
||||
|---|---|
|
||||
| `PENDING_ATTESTATIONS` | queued OTS attestations are not published |
|
||||
| `NOTIF_GLOBAL_TO_CURATED_MIGRATED` | the one-shot notification filter migration runs once more |
|
||||
| `LAST_READ_PER_ROUTE` | every feed reads as unread once |
|
||||
| `USE_PROXY`, `PROXY_PORT` | nothing — only ever removed, never read |
|
||||
| `TOR_SETTINGS` | nothing — no reader left anywhere |
|
||||
|
||||
These are listed in `LegacyAccountKeys.accepted`, which is what lets the
|
||||
cleanup treat any *other* unclaimed key as a reason to keep the file.
|
||||
|
||||
## Deleting a legacy file
|
||||
|
||||
`LegacyPreferenceCleanup` runs after every successful account load and deletes
|
||||
that account's files — `secret_keeper_<npub>` **and** the location-chat
|
||||
identity's `secret_keeper_<pubkey hex>` — only when it can prove nothing would
|
||||
be lost. Both, because nothing else would ever remove the second one: the
|
||||
cleanup enumerates npub-keyed files, so left out of this it would sit on disk
|
||||
holding a seed forever.
|
||||
|
||||
1. **Every key in the file is accounted for** — claimed by a table, one of the
|
||||
secrets, or on the accepted list. Driven from the file's own keys, not from
|
||||
a checklist, because a checklist fails silently in the one direction that
|
||||
matters.
|
||||
2. **Every copy that had something to copy has run.** Marker-based, not a value
|
||||
comparison: those groups stopped being legacy-written when they moved, so
|
||||
the file is a frozen snapshot and the two are *expected* to diverge as soon
|
||||
as the user changes a setting. `CopyOnceMigration` commits the values and
|
||||
its marker as one `Preferences`, so the marker cannot be set without them.
|
||||
3. **The secrets and the private key read back identical** from the current
|
||||
stores. Those *are* still dual-written, so the stronger question is
|
||||
available and is asked.
|
||||
4. **The location-chat identity has been copied**, when its file holds one.
|
||||
Read from the hex-keyed file, not the npub one — these two keys were never
|
||||
in that one, so a check pointed at it would never fire. An account that
|
||||
never opened a location chat holds neither key, which is a real answer and
|
||||
must not hold the file hostage.
|
||||
5. A store that cannot be read is a reason, never a pass.
|
||||
|
||||
It refuses today, and says so, because of the last condition:
|
||||
`LEGACY_WRITES_RETIRED` is false. While the app still mirrors into the file,
|
||||
deleting it achieves nothing — the next save recreates it — and would look
|
||||
like it had worked.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. ~~Migrate the remaining keys.~~ Done.
|
||||
2. ~~Gate deletion on a per-account read-back.~~ Done.
|
||||
3. Do the device pass below.
|
||||
4. Flip `LEGACY_WRITES_RETIRED` and drop the legacy writes for the identity,
|
||||
key, secret and roster stores. This ends the rollback window, so it is a
|
||||
release of its own. The flag is `internal`, not private, so the
|
||||
location-chat mirror in `GeohashChatIdentityState` reads the same switch —
|
||||
flipping it stops that write too, and the cleanup then removes both of the
|
||||
account's legacy files. One flip, nothing left behind.
|
||||
5. Keep the reader, and `androidx.security.crypto`, indefinitely.
|
||||
|
||||
## Verification this needs and has not had
|
||||
|
||||
None of the AndroidKeyStore paths have executed: this environment has no device
|
||||
or emulator, and `commons` has no Robolectric. What is tested is the decision
|
||||
logic against fakes — which is why step 3 is not optional, and why deletion is
|
||||
the one irreversible step in the whole series.
|
||||
|
||||
On a real device, before step 4 ships: upgrade an install holding accounts and
|
||||
confirm they all list; open one and sign; force-stop and relaunch; add and
|
||||
remove an account; pair a NIP-46 signer; pay from a wallet; check the
|
||||
key-backup nudge stays dismissed; confirm UI settings survive the upgrade;
|
||||
open a location chat under a bunker or external signer and confirm the
|
||||
throwaway identity and nickname are the same ones as before the upgrade — the
|
||||
seed is the one migrated value whose loss is silent rather than visible.
|
||||
Then let the cleanup run with the flag flipped, and confirm the files are gone
|
||||
and everything above still holds on the next cold start.
|
||||
@@ -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. |
|
||||
|
||||
@@ -94,7 +94,7 @@ Section labels: "Bitcoin" and "Lightning".
|
||||
## Subscription edits — extend existing kind lists
|
||||
|
||||
No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing
|
||||
`LnZapEvent.KIND` (9735) sites:
|
||||
`ZapReceiptEvent.KIND` (9735) sites:
|
||||
|
||||
| File | Edit |
|
||||
|---|---|
|
||||
@@ -109,7 +109,7 @@ No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing
|
||||
|
||||
## Display path — fold into `Note.zapsAmount`
|
||||
|
||||
Today: `LocalCache.consume(LnZapEvent)` → `Note.addZap()` → `Note.updateZapTotal()`
|
||||
Today: `LocalCache.consume(ZapReceiptEvent)` → `Note.addZap()` → `Note.updateZapTotal()`
|
||||
sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` /
|
||||
`ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap
|
||||
sats to the same `Note.zapsAmount` — no UI changes required.
|
||||
@@ -118,7 +118,7 @@ sats to the same `Note.zapsAmount` — no UI changes required.
|
||||
- Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`).
|
||||
- Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or
|
||||
pending tx amounts are NOT counted.
|
||||
- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667)
|
||||
- `amethyst/.../model/LocalCache.kt` (after the `consume(ZapReceiptEvent)` block ~line 1667)
|
||||
- New `consume(event: OnchainZapEvent)` handler.
|
||||
- Reject self-zap.
|
||||
- Enqueue verification against the configured `OnchainBackend`.
|
||||
|
||||
@@ -34,7 +34,7 @@ and what remains as future work.
|
||||
2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read
|
||||
`muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries.
|
||||
Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`,
|
||||
`PeopleListEvent.publicUsersIdSet()`).
|
||||
`FollowSetEvent.publicUsersIdSet()`).
|
||||
|
||||
3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome**
|
||||
(a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can
|
||||
|
||||
+1
-1
@@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
|
||||
+1
-1
@@ -24,11 +24,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* The narrow slice of a key store this needs.
|
||||
*
|
||||
* An interface rather than [SecureKeyStorage] directly so the decision logic
|
||||
* below — which store wins, what happens when one fails — is testable without
|
||||
* an AndroidKeyStore, which no unit test can reach.
|
||||
*/
|
||||
interface PrivateKeyVault {
|
||||
/** The stored key, or null only when genuinely absent. Throws when the store cannot be read. */
|
||||
suspend fun get(npub: String): String?
|
||||
|
||||
suspend fun save(
|
||||
npub: String,
|
||||
privKeyHex: String,
|
||||
)
|
||||
|
||||
suspend fun delete(npub: String)
|
||||
}
|
||||
|
||||
/** [PrivateKeyVault] over the real [SecureKeyStorage]. */
|
||||
class SecureKeyStorageVault(
|
||||
private val storage: SecureKeyStorage,
|
||||
) : PrivateKeyVault {
|
||||
override suspend fun get(npub: String): String? = storage.getPrivateKeyOrThrow(npub)
|
||||
|
||||
override suspend fun save(
|
||||
npub: String,
|
||||
privKeyHex: String,
|
||||
) = storage.savePrivateKey(npub, privKeyHex)
|
||||
|
||||
override suspend fun delete(npub: String) {
|
||||
storage.deletePrivateKey(npub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves account private keys off `androidx.security.crypto` without ever
|
||||
* leaving one only in a place the running build cannot read.
|
||||
*
|
||||
* The old home is `secret_keeper_<npub>`, an EncryptedSharedPreferences file.
|
||||
* The new one is [SecureKeyStorage], which on Android is now an encrypted
|
||||
* DataStore sealed by the AndroidKeyStore directly. Both are written on every
|
||||
* save, and reads prefer the new store but fall back to the old one, so:
|
||||
*
|
||||
* - an install that has never run this build still finds its key, and is
|
||||
* migrated the first time the account loads;
|
||||
* - a build rolled back to reading only the old store still finds every key,
|
||||
* including ones added after the upgrade;
|
||||
* - a new store that cannot be read — a wiped AndroidKeyStore after a device
|
||||
* credential reset, say — falls back rather than presenting the account as
|
||||
* having no key, which would silently demote it to read-only.
|
||||
*
|
||||
* Nothing is deleted here, and the legacy *reader* is permanent — see
|
||||
* [EncryptedStorage]. The migration is lazy, so an install that skips the
|
||||
* release introducing this store still needs the old file readable when it
|
||||
* finally arrives. What a later release can drop is the legacy **write**, once
|
||||
* every key in that file has a new home; several still do not.
|
||||
*
|
||||
* The two stores cannot legitimately disagree: an npub is derived from its
|
||||
* private key, so the key for a given npub never changes. A mismatch means
|
||||
* corruption, and is resolved in favour of the older, proven store.
|
||||
*/
|
||||
class AccountKeyStore(
|
||||
private val vault: PrivateKeyVault,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AccountKeyStore"
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's private key, or null when it genuinely has none — an
|
||||
* external-signer account, or a watch-only npub.
|
||||
*
|
||||
* @param legacyValue what the legacy store holds, read by the caller that
|
||||
* already has the file open.
|
||||
*/
|
||||
suspend fun read(
|
||||
npub: String,
|
||||
legacyValue: String?,
|
||||
): String? {
|
||||
val fromSecure =
|
||||
try {
|
||||
vault.get(npub)
|
||||
} catch (e: Exception) {
|
||||
// Unreadable, not absent. Fall back, and do not migrate into a
|
||||
// store that just failed.
|
||||
Log.w(TAG, "Could not read the key store for $npub; using the legacy store", e)
|
||||
return legacyValue
|
||||
}
|
||||
|
||||
if (fromSecure != null) {
|
||||
if (legacyValue != null && legacyValue != fromSecure) {
|
||||
Log.e(TAG, "Key mismatch for $npub between the legacy and current stores; keeping the legacy value", null)
|
||||
return legacyValue
|
||||
}
|
||||
return fromSecure
|
||||
}
|
||||
|
||||
// Absent from the new store: first load since the upgrade.
|
||||
if (legacyValue != null) migrate(npub, legacyValue)
|
||||
return legacyValue
|
||||
}
|
||||
|
||||
private suspend fun migrate(
|
||||
npub: String,
|
||||
privKeyHex: String,
|
||||
) {
|
||||
try {
|
||||
vault.save(npub, privKeyHex)
|
||||
Log.i(TAG) { "Migrated the private key for $npub into the current store" }
|
||||
} catch (e: Exception) {
|
||||
// The legacy store still has it and is still read, so this is
|
||||
// recoverable — the next load tries again.
|
||||
Log.w(TAG, "Could not migrate the private key for $npub; it stays in the legacy store", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a save into the new store. The legacy write stays where it is,
|
||||
* inside the caller's existing edit block, so a rollback keeps working.
|
||||
*
|
||||
* The three cases match the legacy write exactly, including the one that is
|
||||
* easy to get wrong: with no external signer and no private key in hand,
|
||||
* the legacy store *leaves the stored key alone* rather than clearing it,
|
||||
* so this must not clear it either. Deleting here would drop the key on
|
||||
* every save from a session that never decrypted it.
|
||||
*/
|
||||
suspend fun mirrorSave(
|
||||
npub: String,
|
||||
usesExternalSigner: Boolean,
|
||||
privKeyHex: String?,
|
||||
) {
|
||||
try {
|
||||
when {
|
||||
usesExternalSigner -> vault.delete(npub)
|
||||
privKeyHex != null -> vault.save(npub, privKeyHex)
|
||||
else -> Unit
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Never fatal: the legacy store still loads the account, and the
|
||||
// next save or load repairs this one.
|
||||
Log.w(TAG, "Could not write the private key for $npub to the current store", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the current store holds, with no fallback to the legacy value.
|
||||
*
|
||||
* For [LegacyPreferenceCleanup]; [read] deliberately hides this distinction.
|
||||
*/
|
||||
suspend fun stored(npub: String): String? = vault.get(npub)
|
||||
|
||||
/** Drops the key from the new store; the caller clears the legacy file itself. */
|
||||
suspend fun delete(npub: String) {
|
||||
try {
|
||||
vault.delete(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not delete the private key for $npub from the current store", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The production instance, over the app's [SecureKeyStorage]. */
|
||||
val accountKeyStore: AccountKeyStore by lazy {
|
||||
AccountKeyStore(SecureKeyStorageVault(SecureKeyStorage.create(Amethyst.instance.appContext)))
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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.commons.model.preferences.AccountRosterStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* The slice of the roster store this needs.
|
||||
*
|
||||
* An interface so the fallback decisions below are testable without an
|
||||
* AndroidKeyStore, which no unit test can reach.
|
||||
*/
|
||||
interface RosterStorage {
|
||||
suspend fun hasMigrated(): Boolean
|
||||
|
||||
suspend fun markMigrated()
|
||||
|
||||
suspend fun currentAccount(): String?
|
||||
|
||||
suspend fun setCurrentAccount(npub: String?)
|
||||
|
||||
suspend fun allAccountInfoJson(): String?
|
||||
|
||||
suspend fun setAllAccountInfoJson(json: String?)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/** [RosterStorage] over the real encrypted store. */
|
||||
class EncryptedRosterStorage(
|
||||
private val store: AccountRosterStore,
|
||||
) : RosterStorage {
|
||||
override suspend fun hasMigrated() = store.hasMigrated()
|
||||
|
||||
override suspend fun markMigrated() = store.markMigrated()
|
||||
|
||||
override suspend fun currentAccount() = store.currentAccount()
|
||||
|
||||
override suspend fun setCurrentAccount(npub: String?) = store.setCurrentAccount(npub)
|
||||
|
||||
override suspend fun allAccountInfoJson() = store.allAccountInfoJson()
|
||||
|
||||
override suspend fun setAllAccountInfoJson(json: String?) = store.setAllAccountInfoJson(json)
|
||||
|
||||
override suspend fun clear() = store.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the account index — which accounts exist, which one is in front — out
|
||||
* of the global `secret_keeper` EncryptedSharedPreferences file, on the same
|
||||
* terms as the keys and secrets before it: both stores written, new store
|
||||
* preferred on read, nothing deleted.
|
||||
*
|
||||
* This one is the most consequential to get wrong. Every private key can be
|
||||
* perfectly intact and, if the roster reads empty, the app still opens as a
|
||||
* fresh install with no way back to the accounts that are sitting on disk.
|
||||
* So a read that fails or comes back empty falls through to the legacy file
|
||||
* rather than being taken at face value.
|
||||
*
|
||||
* The legacy reader stays for good; see [EncryptedStorage] for why a lazy
|
||||
* migration cannot have its source deleted.
|
||||
*/
|
||||
class AccountRoster(
|
||||
private val store: RosterStorage,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AccountRoster"
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the one-off copy if it has not run, and reports whether the new
|
||||
* store can be trusted for this read.
|
||||
*
|
||||
* Returns false when anything goes wrong, which sends the caller to the
|
||||
* legacy file.
|
||||
*/
|
||||
private suspend fun ready(
|
||||
legacyCurrent: () -> String?,
|
||||
legacyAll: () -> String?,
|
||||
): Boolean =
|
||||
try {
|
||||
if (!store.hasMigrated()) {
|
||||
store.setCurrentAccount(legacyCurrent())
|
||||
store.setAllAccountInfoJson(legacyAll())
|
||||
// Marker last: a crash midway leaves this unmigrated, so the
|
||||
// next read copies again rather than trusting a half-written
|
||||
// roster.
|
||||
store.markMigrated()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not prepare the roster store; using the legacy file", e)
|
||||
false
|
||||
}
|
||||
|
||||
suspend fun currentAccount(
|
||||
legacyCurrent: () -> String?,
|
||||
legacyAll: () -> String?,
|
||||
): String? {
|
||||
if (!ready(legacyCurrent, legacyAll)) return legacyCurrent()
|
||||
|
||||
return try {
|
||||
store.currentAccount() ?: legacyCurrent()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the current account; using the legacy file", e)
|
||||
legacyCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved-account list as JSON.
|
||||
*
|
||||
* An empty or absent value falls through to the legacy file rather than
|
||||
* being reported as "no accounts": the two are indistinguishable here, and
|
||||
* only one of them is safe to act on.
|
||||
*/
|
||||
suspend fun allAccountInfoJson(
|
||||
legacyCurrent: () -> String?,
|
||||
legacyAll: () -> String?,
|
||||
): String? {
|
||||
if (!ready(legacyCurrent, legacyAll)) return legacyAll()
|
||||
|
||||
return try {
|
||||
store.allAccountInfoJson()?.takeIf { it.isNotBlank() && it != "[]" } ?: legacyAll()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the saved accounts; using the legacy file", e)
|
||||
legacyAll()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mirrorCurrentAccount(npub: String?) = guard { store.setCurrentAccount(npub) }
|
||||
|
||||
suspend fun mirrorAllAccountInfoJson(json: String?) = guard { store.setAllAccountInfoJson(json) }
|
||||
|
||||
/** Matches the legacy `clear()` on the global file when the last account goes. */
|
||||
suspend fun clear() = guard { store.clear() }
|
||||
|
||||
private suspend fun guard(block: suspend () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
// Never fatal: the legacy file is still written and still read.
|
||||
Log.w(TAG, "Could not write the roster store", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val accountRoster: AccountRoster by lazy {
|
||||
AccountRoster(
|
||||
EncryptedRosterStorage(
|
||||
AccountRosterStore(
|
||||
// Through the holder rather than a DataStore built here: it is the
|
||||
// one registry that knows which files already have a live store,
|
||||
// and `roster` sits in the same directory as every other one.
|
||||
Amethyst.instance.appStores.getDataStore(ROSTER_FILE_NAME),
|
||||
SecretEncryption(),
|
||||
Amethyst.instance.applicationIOScope,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private const val ROSTER_FILE_NAME = "roster"
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.commons.model.preferences.AccountSecrets
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecretsEncryptedStores
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okio.Path.Companion.toOkioPath
|
||||
|
||||
/**
|
||||
* Moves the per-account secrets — NIP-46 bunker material, wallet connection
|
||||
* strings — out of the `secret_keeper_<npub>` EncryptedSharedPreferences file
|
||||
* and into an encrypted DataStore, on the same terms as the private key: both
|
||||
* stores written, new store preferred on read, nothing deleted.
|
||||
*
|
||||
* The copy is lazy rather than a DataMigration, and that is not a style
|
||||
* choice. A DataMigration writes values as-is, while this store decrypts on
|
||||
* read, so plaintext placed there by one cannot be read back — the attempt
|
||||
* raises. `EncryptedDataStoreTest` pins that behaviour. Copying through the
|
||||
* store's own `save` is what keeps the values readable.
|
||||
*
|
||||
* Losing these is recoverable — the user re-pairs a signer or re-adds a wallet
|
||||
* — but it is not something to spend, so a read that fails falls back to the
|
||||
* legacy values rather than reporting the account as having none.
|
||||
*
|
||||
* The legacy reader stays for good; see [EncryptedStorage] for why a lazy
|
||||
* migration cannot have its source deleted.
|
||||
*/
|
||||
class AccountSecretsStore(
|
||||
private val stores: AccountSecretsEncryptedStores,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AccountSecretsStore"
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's secrets, migrating out of the legacy file on first use.
|
||||
*
|
||||
* @param legacy what the legacy encrypted file holds, read by the caller
|
||||
* that already has it open.
|
||||
*/
|
||||
suspend fun read(
|
||||
npub: String,
|
||||
legacy: AccountSecrets,
|
||||
): AccountSecrets {
|
||||
val stored =
|
||||
try {
|
||||
stores.loadSecrets(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the secrets store for $npub; using the legacy file", e)
|
||||
return legacy
|
||||
}
|
||||
|
||||
if (stored != null) return stored
|
||||
|
||||
// Not migrated yet: copy the legacy values across and use them.
|
||||
mirror(npub, legacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
/** Mirrors a save into the new store. The legacy write stays where it is. */
|
||||
suspend fun mirror(
|
||||
npub: String,
|
||||
value: AccountSecrets,
|
||||
) {
|
||||
try {
|
||||
stores.saveSecrets(npub, value)
|
||||
} catch (e: Exception) {
|
||||
// Never fatal: the legacy file still has them, and the next save or
|
||||
// load tries again.
|
||||
Log.w(TAG, "Could not write the secrets for $npub to the current store", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the current store holds, with no fallback to the legacy file.
|
||||
*
|
||||
* For [LegacyPreferenceCleanup], which has to tell "migrated" from
|
||||
* "falling back and looking migrated" — the read above deliberately cannot.
|
||||
*/
|
||||
suspend fun stored(npub: String): AccountSecrets? = stores.loadSecrets(npub)
|
||||
|
||||
// ── the location-chat identity ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The account's location-chat identity, migrating out of the legacy file on
|
||||
* first use, on the same terms as [read].
|
||||
*
|
||||
* @param legacy opens and reads `secret_keeper_<pubkey hex>`. Note the
|
||||
* *hex*: this group's legacy file is keyed by the signer's pubkey rather
|
||||
* than the npub every other group uses, so it is a different file.
|
||||
*
|
||||
* A lambda, not a value, because opening that file **creates** it — an
|
||||
* `EncryptedSharedPreferences` writes its Tink keyset on construction. An
|
||||
* eager read would resurrect the file on the load after the cleanup
|
||||
* deleted it, and would cost a Keystore-backed open per account on every
|
||||
* cold start. Called only when the store has nothing yet.
|
||||
*/
|
||||
suspend fun readGeohashIdentity(
|
||||
npub: String,
|
||||
legacy: suspend () -> GeohashIdentitySecrets,
|
||||
): GeohashIdentitySecrets {
|
||||
val stored =
|
||||
try {
|
||||
stores.loadGeohashIdentity(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the location-chat identity for $npub; using the legacy file", e)
|
||||
return legacy()
|
||||
}
|
||||
|
||||
if (stored != null) return stored
|
||||
|
||||
val fromLegacy = legacy()
|
||||
mirrorGeohashIdentity(npub, fromLegacy)
|
||||
return fromLegacy
|
||||
}
|
||||
|
||||
/**
|
||||
* What the current store holds for the location-chat identity, with no
|
||||
* fallback to the legacy file — the same distinction [stored] draws, and
|
||||
* for the same reader: [LegacyPreferenceCleanup] has to tell "migrated"
|
||||
* from "falling back and looking migrated".
|
||||
*/
|
||||
suspend fun storedGeohashIdentity(npub: String): GeohashIdentitySecrets? = stores.loadGeohashIdentity(npub)
|
||||
|
||||
/** Mirrors a save into the new store. The legacy write stays where it is. */
|
||||
suspend fun mirrorGeohashIdentity(
|
||||
npub: String,
|
||||
value: GeohashIdentitySecrets,
|
||||
) {
|
||||
try {
|
||||
stores.saveGeohashIdentity(npub, value)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not write the location-chat identity for $npub to the current store", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(npub: String) {
|
||||
try {
|
||||
stores.removeAccount(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not drop the secrets store for $npub", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val accountSecretsStore: AccountSecretsStore by lazy {
|
||||
AccountSecretsStore(
|
||||
AccountSecretsEncryptedStores(
|
||||
rootFilesDir = {
|
||||
Amethyst.instance.appContext.filesDir
|
||||
.toOkioPath()
|
||||
},
|
||||
scope = Amethyst.instance.applicationIOScope,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -26,9 +26,6 @@ import android.os.Build
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
|
||||
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.WebShortcuts
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry
|
||||
@@ -148,24 +145,24 @@ class Amethyst : Application() {
|
||||
WorkerThreadPriorityGovernor.start(this)
|
||||
|
||||
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
|
||||
FavoriteAppsRegistry.init(this)
|
||||
instance.favoriteApps.init()
|
||||
|
||||
// Mirror web-app favorites into the launcher's long-press shortcuts (open in Amethyst's browser).
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
FavoriteAppsRegistry.favorites
|
||||
instance.favoriteApps.favorites
|
||||
.map { apps -> apps.filterIsInstance<FavoriteApp.WebApp>().map { it.url to it.label } }
|
||||
.distinctUntilChanged()
|
||||
.collect { WebShortcuts.publishFavorites(this@Amethyst, FavoriteAppsRegistry.favorites.value) }
|
||||
.collect { WebShortcuts.publishFavorites(this@Amethyst, instance.favoriteApps.favorites.value) }
|
||||
}
|
||||
|
||||
// Hydrate the per-site camera/microphone/location answers the browser asks about.
|
||||
WebSitePermissionRegistry.init(this)
|
||||
|
||||
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
|
||||
BrowserHistoryRegistry.init(this)
|
||||
instance.browserHistory.init()
|
||||
|
||||
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
|
||||
BrowserIconRegistry.init(this)
|
||||
instance.browserIcons.init()
|
||||
|
||||
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
|
||||
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
|
||||
|
||||
@@ -27,12 +27,27 @@ import android.os.SystemClock
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
|
||||
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics
|
||||
@@ -40,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker
|
||||
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
@@ -52,33 +68,28 @@ import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
|
||||
import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
|
||||
import com.vitorpamplona.amethyst.commons.state.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayState
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
|
||||
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE
|
||||
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
import com.vitorpamplona.amethyst.service.calendar.calendarReminderLogMigrations
|
||||
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings
|
||||
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettingsMigrations
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
|
||||
@@ -93,13 +104,11 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import com.vitorpamplona.amethyst.service.pow.PowJobRestorer
|
||||
import com.vitorpamplona.amethyst.service.pow.PowJobStore
|
||||
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
|
||||
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
@@ -198,6 +207,7 @@ import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
|
||||
class AppModules(
|
||||
@@ -229,19 +239,55 @@ class AppModules(
|
||||
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* The app-wide DataStore files — the ones that belong to the install rather
|
||||
* than to an account. [AccountPreferenceStores] is the same idea keyed by
|
||||
* npub.
|
||||
*
|
||||
* This replaces the `Context.preferencesDataStore` delegate these stores
|
||||
* used to share. Same paths — `AppPreferenceStores.file` reproduces
|
||||
* `filesDir/datastore/<name>.preferences_pb` exactly — so nothing migrates
|
||||
* and a rollback finds its data where it left it. What it buys is that the
|
||||
* stores themselves live in `commonMain`, where a desktop or CLI front end
|
||||
* can say where its data lives instead of needing a `Context`.
|
||||
*
|
||||
* The shared_settings migration is attached here, to the file, because
|
||||
* eight stores share it and DataStore runs a file's migrations once, on
|
||||
* whichever store opens it first.
|
||||
*/
|
||||
val appStores by lazy {
|
||||
AppPreferenceStores(
|
||||
rootFilesDir = { appContext.filesDir.toOkioPath() },
|
||||
migrations = { name ->
|
||||
when {
|
||||
name == AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() }
|
||||
// One file per account, so the migration is per name rather than a constant.
|
||||
name.startsWith(CashuPreferences.FILE_PREFIX) ->
|
||||
listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX)))
|
||||
name == CALENDAR_REMINDER_SETTINGS_STORE -> calendarReminderSettingsMigrations(appContext)
|
||||
name == CALENDAR_REMINDER_LOG_STORE -> calendarReminderLogMigrations(appContext)
|
||||
else -> emptyList()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The file UI, Tor, Namecoin, OTS and the Buzz stores all share. */
|
||||
val sharedSettingsStore get() = appStores.sharedSettings()
|
||||
|
||||
// Pre-load both preference DataStores in parallel on IO threads.
|
||||
// Both constructors use runBlocking internally, so starting them concurrently
|
||||
// reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs).
|
||||
private val uiPrefsDeferred =
|
||||
applicationIOScope.async {
|
||||
val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings()
|
||||
UiSharedPreferences(prefs, appContext, applicationIOScope)
|
||||
val prefs = UiSharedPreferences.uiPreferences(sharedSettingsStore) ?: UiSettings()
|
||||
UiSharedPreferences(prefs, sharedSettingsStore, appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
private val torPrefsDeferred =
|
||||
applicationIOScope.async {
|
||||
val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings()
|
||||
TorSharedPreferences(prefs, appContext, applicationIOScope)
|
||||
val prefs = TorSettingsStore.torPreferences(sharedSettingsStore) ?: TorSettings()
|
||||
TorSettingsStore(prefs, sharedSettingsStore, applicationIOScope)
|
||||
}
|
||||
|
||||
// Blocking load of UI Preferences to avoid theme/language blinking
|
||||
@@ -252,20 +298,26 @@ class AppModules(
|
||||
|
||||
// Blocking load of Tor Settings to avoid connection leaks
|
||||
val torPrefs by lazy {
|
||||
Log.d("AppModules", "TorSharedPreferences Init")
|
||||
Log.d("AppModules", "TorSettingsStore Init")
|
||||
runBlocking { torPrefsDeferred.await() }
|
||||
}
|
||||
|
||||
// Namecoin ElectrumX server preferences (global, like Tor settings)
|
||||
val namecoinPrefs by lazy {
|
||||
Log.d("AppModules", "NamecoinSharedPreferences Init")
|
||||
NamecoinSharedPreferences(appContext, applicationIOScope)
|
||||
Log.d("AppModules", "NamecoinSettingsStore Init")
|
||||
NamecoinSettingsStore(sharedSettingsStore, applicationIOScope)
|
||||
}
|
||||
|
||||
// OTS blockchain explorer preferences (global, like Tor settings)
|
||||
//
|
||||
// The blocking load is the one the store used to do inside its own
|
||||
// constructor: `current` has to answer synchronously for the resolver
|
||||
// builder, so somebody has to wait. It is explicit here rather than hidden
|
||||
// in commonMain, which has no runBlocking to hide it behind.
|
||||
val otsPrefs by lazy {
|
||||
Log.d("AppModules", "OtsSharedPreferences Init")
|
||||
OtsSharedPreferences(appContext, applicationIOScope)
|
||||
Log.d("AppModules", "OtsSettingsStore Init")
|
||||
val store = sharedSettingsStore
|
||||
OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) })
|
||||
}
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their
|
||||
@@ -308,12 +360,13 @@ class AppModules(
|
||||
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
|
||||
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
|
||||
val relayGroupDeletionPrefs =
|
||||
RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope)
|
||||
|
||||
// Restore + persist which drawer section headings the user has folded away, so the side menu
|
||||
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
|
||||
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
|
||||
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope)
|
||||
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(sharedSettingsStore, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
@@ -811,8 +864,10 @@ class AppModules(
|
||||
*/
|
||||
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
|
||||
|
||||
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
|
||||
// Singleton stores for napplet permissions. The holder is what enforces
|
||||
// DataStore's one-instance-per-file rule now; this stays a lazy val so the
|
||||
// ledger below and the broker share one object.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appStores.getDataStore("napplet_permissions"), nappletAccountScope) }
|
||||
|
||||
/**
|
||||
* The one napplet permission ledger for the main process. Its persistent half is just the store
|
||||
@@ -830,10 +885,26 @@ class AppModules(
|
||||
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
|
||||
// account rather than the active one. The napplet path namespaces its own coordinate the same way
|
||||
// (see NappletBroker.signerCoordinateFor) instead.
|
||||
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
|
||||
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appStores) }
|
||||
|
||||
// Display + relay info for connected NIP-46 remote-signer clients.
|
||||
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
|
||||
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) }
|
||||
|
||||
// The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the
|
||||
// browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in
|
||||
// commons and take their store and scope from here — that is the whole of their Android binding.
|
||||
//
|
||||
// Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds
|
||||
// these either. One instance each, so DataStore only ever sees one live reader per file.
|
||||
val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) }
|
||||
|
||||
val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) }
|
||||
|
||||
// Favicons captured by the browser host, one PNG per host. Not a DataStore — it takes the directory
|
||||
// to keep them in, the same way AppPreferenceStores takes rootFilesDir.
|
||||
val browserIcons by lazy {
|
||||
BrowserIconRegistry({ appContext.filesDir.toOkioPath() / BrowserIconRegistry.DIR }, applicationIOScope)
|
||||
}
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
@@ -912,7 +983,7 @@ class AppModules(
|
||||
// and every enqueue raises the shortService shield so backgrounding
|
||||
// doesn't freeze a miner.
|
||||
val powJobStore by lazy {
|
||||
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
|
||||
PoWJobStore(File(appContext.filesDir, PoWJobStore.FILE_NAME), applicationIOScope)
|
||||
}
|
||||
|
||||
val powPublishQueue by lazy {
|
||||
@@ -969,11 +1040,11 @@ class AppModules(
|
||||
// start — Buzz membership is server-side) and the starred channels. Per account: the
|
||||
// joined set makes a relay first-party for NIP-42, and a star is personal.
|
||||
startBuzzPersistence = { account ->
|
||||
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
|
||||
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
|
||||
BuzzWorkspaceStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzWorkspaces)
|
||||
BuzzChannelStarStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzChannelStars)
|
||||
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
|
||||
// first Buzz-relay AUTH rather than after it.
|
||||
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
|
||||
BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1083,7 +1154,7 @@ class AppModules(
|
||||
// Local store for posts the user has scheduled to publish later. Backed by a
|
||||
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
|
||||
val scheduledPostStore =
|
||||
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
|
||||
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path)
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService by
|
||||
@@ -1303,7 +1374,7 @@ class AppModules(
|
||||
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
|
||||
).conflate()
|
||||
.collect {
|
||||
if (CalendarReminderPrefs(appContext).isEnabled() &&
|
||||
if (calendarReminderSettings().load().enabled &&
|
||||
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
|
||||
) {
|
||||
CalendarReminderWorker.schedule(appContext)
|
||||
|
||||
@@ -24,6 +24,39 @@ import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* The legacy encrypted preference files, and a permanent read-only migration
|
||||
* source.
|
||||
*
|
||||
* # This class cannot be deleted
|
||||
*
|
||||
* Every migration in the preference layer is *lazy*: it reads the legacy store
|
||||
* at the moment it runs, not when the app is installed. Nine of them come
|
||||
* through here — the seven CopyOnceMigrations under LocalPreferences plus the
|
||||
* key, secret and roster stores.
|
||||
*
|
||||
* So deleting this class does not only affect installs that have not upgraded
|
||||
* yet. It strands anyone who **skips** the release that introduced the new
|
||||
* stores: they move from a pre-migration build straight to a post-deletion one,
|
||||
* the migration runs against a reader that no longer exists, and their keys,
|
||||
* accounts, wallets and settings sit encrypted on disk with nothing able to
|
||||
* read them. The app opens as a fresh install. That is not rare — auto-update
|
||||
* off, the F-Droid cadence, or a restore from backup all skip releases.
|
||||
*
|
||||
* What *can* go, once the new path has shipped and held, is the legacy
|
||||
* **writes**. Dropping those stops new data landing here while this stays able
|
||||
* to read what is already here. The `androidx.security.crypto` dependency has
|
||||
* to stay for as long as this does; it is an unmaintained-library risk rather
|
||||
* than an active vulnerability, and a far smaller cost than stranding users.
|
||||
*
|
||||
* # Before any legacy file is deleted
|
||||
*
|
||||
* Deletion is only safe for an account whose every key has been migrated, and
|
||||
* that is not yet true — see `amethyst/plans/2026-09-23-encrypted-storage-retirement.md`
|
||||
* for what is still outstanding. NOSTR_PUBKEY is the one to watch: without it
|
||||
* `loadAccountConfigFromEncryptedStorage` returns null and the account
|
||||
* disappears whether or not its private key survived.
|
||||
*/
|
||||
class EncryptedStorage {
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* How every key that can appear in a `secret_keeper_<npub>` file is accounted
|
||||
* for.
|
||||
*
|
||||
* Together with [LegacyAccountSecretNames], these two lists are what let
|
||||
* [LegacyPreferenceCleanup] treat any *other* key in the file as a reason not
|
||||
* to delete it. `LegacyKeyCoverageTest` holds them to covering all of
|
||||
* `PrefKeys`, so a key added later cannot quietly fall outside both.
|
||||
*/
|
||||
internal object LegacyAccountKeys {
|
||||
/**
|
||||
* The one-shot copies out of the account's legacy file.
|
||||
*
|
||||
* Each store owns the table of legacy names it came from, so the copy and
|
||||
* the check that the copy happened read the same list — see [LegacyKeyTable].
|
||||
*/
|
||||
val tables =
|
||||
listOf(
|
||||
TopNavFollowListStore.legacyTable,
|
||||
LatestEventCacheStore.legacyTable,
|
||||
UploadSettingsStore.legacyTable,
|
||||
DialogDismissalStore.legacyTable,
|
||||
RelayAuthStore.legacyTable,
|
||||
FeedVisibilityStore.legacyTable,
|
||||
NotificationPrefsStore.legacyTable,
|
||||
AccountIdentityStore.legacyTable,
|
||||
)
|
||||
|
||||
/**
|
||||
* Keys that are deliberately not carried across.
|
||||
*
|
||||
* Each costs something once and nothing after, and none is worth the code
|
||||
* to move it: queued attestations go unpublished, the one-shot
|
||||
* Global -> Curated notification rewrite runs one more time, and every feed
|
||||
* reads as unread once. `use_proxy` and `proxy_port` are only ever removed,
|
||||
* never read, and `tor_settings` has no reader left at all.
|
||||
*/
|
||||
val accepted =
|
||||
setOf(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED,
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
PrefKeys.USE_PROXY,
|
||||
PrefKeys.PROXY_PORT,
|
||||
PrefKeys.TOR_SETTINGS,
|
||||
)
|
||||
}
|
||||
|
||||
/** What [LegacyPreferenceCleanup] did, and why. */
|
||||
sealed interface LegacyCleanupResult {
|
||||
/** There was no legacy file for this account. */
|
||||
data object NothingToDelete : LegacyCleanupResult
|
||||
|
||||
data object Deleted : LegacyCleanupResult
|
||||
|
||||
/** Nothing was touched. Each reason names one thing that would have been lost. */
|
||||
data class Kept(
|
||||
val reasons: List<String>,
|
||||
) : LegacyCleanupResult
|
||||
}
|
||||
|
||||
/** The per-account legacy file, as this needs it. */
|
||||
interface LegacyAccountFiles {
|
||||
fun source(npub: String): LegacyPreferenceSource
|
||||
|
||||
/**
|
||||
* The account's OTHER legacy file: the location-chat identity, which lives
|
||||
* in `secret_keeper_<pubkey hex>` rather than `secret_keeper_<npub>`
|
||||
* because that is the key its writer passed.
|
||||
*
|
||||
* Separate from [source] because [delete] removes both, and a check that
|
||||
* read the npub file for these keys would never find them — they are not
|
||||
* in it. That mistake was made once already.
|
||||
*/
|
||||
fun geohashSource(npub: String): LegacyPreferenceSource
|
||||
|
||||
fun exists(npub: String): Boolean
|
||||
|
||||
/** Returns false when there was nothing to delete. */
|
||||
suspend fun delete(npub: String): Boolean
|
||||
}
|
||||
|
||||
/** What the current, encrypted stores hold for an account. */
|
||||
interface MigratedSecrets {
|
||||
/** Null when this account has not been copied across yet. */
|
||||
suspend fun secrets(npub: String): AccountSecrets?
|
||||
|
||||
/** Null only when the account genuinely has no private key. Throws when the store is unreadable. */
|
||||
suspend fun privateKey(npub: String): String?
|
||||
|
||||
/**
|
||||
* The location-chat identity, or null when it has not been copied across.
|
||||
*
|
||||
* Its own question because it migrates out of its own file: [AccountSecrets]
|
||||
* being present says nothing about whether this was carried over.
|
||||
*/
|
||||
suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets?
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account's `secret_keeper_<npub>` file, but only once it can prove
|
||||
* nothing in it would be lost.
|
||||
*
|
||||
* # Why the check is not one rule
|
||||
*
|
||||
* The two halves of the migration are in different states, and asking the same
|
||||
* question of both would give the wrong answer for one of them.
|
||||
*
|
||||
* The plain per-account groups — settings, dialogs, feeds, cached events —
|
||||
* stopped being written to the legacy file when they moved, so that file is a
|
||||
* frozen snapshot of the day they migrated. Comparing values would flag every
|
||||
* setting the user has changed since. What is actually being asked of them is
|
||||
* "did the copy run", and [LegacyKeyTable.hasRun] answers it exactly:
|
||||
* `CopyOnceMigration` writes the values and its marker as a single
|
||||
* `Preferences`, committed atomically, so the marker cannot be set without them.
|
||||
*
|
||||
* The private key takes the strongest form: read it back and require it to
|
||||
* equal the legacy one. That comparison stays valid forever, because an npub is
|
||||
* derived from its private key, so the key for a given npub can never change.
|
||||
*
|
||||
* The secrets cannot be compared, and the reason is worth stating because the
|
||||
* obvious reading is wrong. They *are* dual-written today — but this whole
|
||||
* check only runs once [legacyWritesRetired] is true, and from that release on
|
||||
* the legacy copy is frozen while the live one keeps moving. An account that
|
||||
* re-pairs a bunker or adds a wallet after upgrading would then differ from the
|
||||
* file forever and never have it deleted. So they are gated the same way as the
|
||||
* plain groups: on the copy having run, which
|
||||
* [AccountSecretsEncryptedStores.loadSecrets] reports by returning non-null
|
||||
* only once its marker is set, and it writes that marker last.
|
||||
*
|
||||
* # Why an unrecognised key blocks
|
||||
*
|
||||
* A list of keys to check, maintained by hand, fails silently in the one
|
||||
* direction that matters: a key added later that no migration carries. So the
|
||||
* check runs the other way round — every key *in the file* must be claimed by
|
||||
* a table, be one of the secrets, or be on [accepted], the short list of
|
||||
* deliberate losses. Anything else stops the deletion and says so by name.
|
||||
*
|
||||
* # Cost
|
||||
*
|
||||
* [LegacyPreferenceSource.keys] goes through `EncryptedSharedPreferences.all`,
|
||||
* which decrypts every value in the file — there is no keys-only API. It is
|
||||
* called from the one place an account load is not already cached, so it costs
|
||||
* at most once per account per process, and nothing at all while
|
||||
* [legacyWritesRetired] is false.
|
||||
*
|
||||
* # Why deletion also waits on the legacy writes
|
||||
*
|
||||
* [legacyWritesRetired] is the other half. While the app still mirrors into
|
||||
* this file on every save, deleting it achieves nothing — the next save
|
||||
* recreates it, with a subset of what was there. Worse, it would look like it
|
||||
* had worked. So the file is only removed once it is no longer being written,
|
||||
* which is a separate release from this one.
|
||||
*/
|
||||
class LegacyPreferenceCleanup(
|
||||
private val tables: List<LegacyKeyTable>,
|
||||
private val accepted: Set<String>,
|
||||
private val files: LegacyAccountFiles,
|
||||
private val currentStore: suspend (String) -> Preferences,
|
||||
private val secrets: MigratedSecrets,
|
||||
private val legacyWritesRetired: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "LegacyPreferenceCleanup"
|
||||
|
||||
const val STILL_WRITTEN = "the legacy file is still written on every save"
|
||||
}
|
||||
|
||||
private val claimed: Set<String> = tables.flatMapTo(mutableSetOf()) { it.legacyNames } + LegacyAccountSecretNames.all
|
||||
|
||||
/**
|
||||
* Everything that would be lost by deleting this account's legacy file.
|
||||
* Empty means nothing would be.
|
||||
*
|
||||
* A store that cannot be read is a reason, never a pass: the whole point is
|
||||
* to be sure, and "the check itself failed" is not sure.
|
||||
*/
|
||||
suspend fun verify(npub: String): List<String> {
|
||||
val legacy =
|
||||
try {
|
||||
files.source(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not open the legacy file for $npub", e)
|
||||
return listOf("the legacy file could not be read")
|
||||
}
|
||||
|
||||
val reasons = mutableListOf<String>()
|
||||
|
||||
val present = legacy.keys()
|
||||
(present - claimed - accepted).sorted().forEach {
|
||||
reasons += "no migration claims '$it'"
|
||||
}
|
||||
|
||||
val current =
|
||||
try {
|
||||
currentStore(npub)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the current store for $npub", e)
|
||||
return reasons + "the current store could not be read"
|
||||
}
|
||||
|
||||
tables.forEach { table ->
|
||||
// A table whose keys the file never held has nothing to prove.
|
||||
if (present.none { it in table.legacyNames }) return@forEach
|
||||
if (!table.hasRun(current)) reasons += "the '${table.markerName}' copy has not run"
|
||||
}
|
||||
|
||||
reasons += secretMismatches(npub, legacy)
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
private suspend fun secretMismatches(
|
||||
npub: String,
|
||||
legacy: LegacyPreferenceSource,
|
||||
): List<String> {
|
||||
val reasons = mutableListOf<String>()
|
||||
|
||||
try {
|
||||
if (secrets.secrets(npub) == null) reasons += "the secrets have not been copied across"
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the secrets store for $npub", e)
|
||||
reasons += "the secrets store could not be read"
|
||||
}
|
||||
|
||||
val legacyKey = legacy.getString(LegacyAccountSecretNames.NOSTR_PRIVKEY)
|
||||
if (legacyKey != null) {
|
||||
try {
|
||||
when (secrets.privateKey(npub)) {
|
||||
null -> reasons += "the private key has not been copied across"
|
||||
legacyKey -> Unit
|
||||
else -> reasons += "the stored private key differs from the legacy file"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the key store for $npub", e)
|
||||
reasons += "the key store could not be read"
|
||||
}
|
||||
}
|
||||
|
||||
reasons += geohashMismatches(npub)
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether deleting this account's `secret_keeper_<pubkey hex>` file would
|
||||
* lose its location-chat identity.
|
||||
*
|
||||
* Read from [LegacyAccountFiles.geohashSource], not from the npub file the
|
||||
* rest of [verify] walks: these two keys were never in that one. An account
|
||||
* that never opened a location chat holds neither, and needs no copy.
|
||||
*/
|
||||
private suspend fun geohashMismatches(npub: String): List<String> {
|
||||
val legacy =
|
||||
try {
|
||||
readLegacyGeohashIdentity(files.geohashSource(npub))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the location-chat identity file for $npub", e)
|
||||
return listOf("the location-chat identity file could not be read")
|
||||
}
|
||||
|
||||
if (legacy == GeohashIdentitySecrets()) return emptyList()
|
||||
|
||||
return try {
|
||||
if (secrets.geohashIdentity(npub) == null) {
|
||||
listOf("the location-chat identity has not been copied across")
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the location-chat identity store for $npub", e)
|
||||
listOf("the location-chat identity store could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the account's legacy file if — and only if — [verify] comes back
|
||||
* empty and the app has stopped writing to it.
|
||||
*/
|
||||
suspend fun deleteIfVerified(npub: String): LegacyCleanupResult {
|
||||
if (!files.exists(npub)) return LegacyCleanupResult.NothingToDelete
|
||||
|
||||
if (!legacyWritesRetired) return LegacyCleanupResult.Kept(listOf(STILL_WRITTEN))
|
||||
|
||||
val reasons = verify(npub)
|
||||
if (reasons.isNotEmpty()) {
|
||||
Log.i(TAG) { "Keeping the legacy file for $npub: ${reasons.joinToString("; ")}" }
|
||||
return LegacyCleanupResult.Kept(reasons)
|
||||
}
|
||||
|
||||
return try {
|
||||
if (files.delete(npub)) {
|
||||
Log.i(TAG) { "Deleted the migrated legacy file for $npub" }
|
||||
LegacyCleanupResult.Deleted
|
||||
} else {
|
||||
LegacyCleanupResult.NothingToDelete
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not delete the legacy file for $npub", e)
|
||||
LegacyCleanupResult.Kept(listOf("the legacy file could not be deleted"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-25
@@ -18,34 +18,28 @@
|
||||
* 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.nip64Chess
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import java.util.prefs.Preferences
|
||||
import android.content.SharedPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource
|
||||
|
||||
actual class ChessDismissedGamesStorage private actual constructor() {
|
||||
private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java)
|
||||
/**
|
||||
* [LegacyPreferenceSource] over the `secret_keeper` files.
|
||||
*
|
||||
* Every getter reports absence as null rather than as a default, which
|
||||
* `SharedPreferences` itself cannot do — that distinction is what keeps a
|
||||
* migration from writing "false" over a key the user never set.
|
||||
*/
|
||||
class LegacySharedPreferences(
|
||||
private val prefs: SharedPreferences,
|
||||
) : LegacyPreferenceSource {
|
||||
override fun keys(): Set<String> = prefs.all.keys
|
||||
|
||||
actual companion object {
|
||||
private const val NODE_PREFIX = "chess_dismissed_"
|
||||
private const val DELIMITER = ","
|
||||
override fun getBoolean(name: String): Boolean? = if (prefs.contains(name)) prefs.getBoolean(name, false) else null
|
||||
|
||||
actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
|
||||
}
|
||||
override fun getString(name: String): String? = prefs.getString(name, null)
|
||||
|
||||
actual fun load(userPubkey: String): Set<String> {
|
||||
val raw = prefs.get("$NODE_PREFIX$userPubkey", "")
|
||||
if (raw.isEmpty()) return emptySet()
|
||||
return raw.split(DELIMITER).toSet()
|
||||
}
|
||||
|
||||
actual fun save(
|
||||
userPubkey: String,
|
||||
ids: Set<String>,
|
||||
) {
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove("$NODE_PREFIX$userPubkey")
|
||||
} else {
|
||||
prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER))
|
||||
}
|
||||
}
|
||||
// SharedPreferences hands back the live set and documents that mutating it
|
||||
// corrupts the file, so this copies before anything downstream can hold it.
|
||||
override fun getStringSet(name: String): Set<String>? = prefs.getStringSet(name, null)?.toSet()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* 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.favorites
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
|
||||
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
|
||||
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
|
||||
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
|
||||
* visit. Used to decorate favorite cards and omnibox suggestion rows.
|
||||
*
|
||||
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
|
||||
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
|
||||
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
|
||||
* `File.exists()` disk checks out of composition).
|
||||
*/
|
||||
object BrowserIconRegistry {
|
||||
private const val DIR = "browser_icons"
|
||||
|
||||
private val _keys = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
|
||||
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
|
||||
|
||||
@Volatile private var iconDir: File? = null
|
||||
|
||||
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
|
||||
// that must not block: init() from app startup and record() from the broker's IPC handler, which
|
||||
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
|
||||
// UI while a favicon was saved.
|
||||
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Binds the app context and indexes already-stored icons. Idempotent.
|
||||
*
|
||||
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
|
||||
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
|
||||
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
|
||||
* drives recomposition.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR)
|
||||
iconDir = dir
|
||||
io.launch {
|
||||
dir.mkdirs()
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
|
||||
fun record(
|
||||
host: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val dir = iconDir ?: return
|
||||
if (host.isBlank() || bytes.isEmpty()) return
|
||||
val key = sanitize(host)
|
||||
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
|
||||
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
|
||||
// icon exists before the file backing it does.
|
||||
io.launch {
|
||||
try {
|
||||
dir.mkdirs()
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
|
||||
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
|
||||
*/
|
||||
fun iconModelFor(host: String): String? {
|
||||
val dir = iconDir ?: return null
|
||||
val key = sanitize(host)
|
||||
if (key !in _keys.value) return null
|
||||
return "file://" + File(dir, key + PNG).absolutePath
|
||||
}
|
||||
|
||||
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
|
||||
// icon file, which is harmless for a decoration.
|
||||
private fun sanitize(host: String): String =
|
||||
host
|
||||
.lowercase()
|
||||
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
|
||||
.joinToString("")
|
||||
.take(120)
|
||||
|
||||
private const val PNG = ".png"
|
||||
}
|
||||
@@ -97,7 +97,7 @@ object FavoriteAppLauncher {
|
||||
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
||||
}
|
||||
}
|
||||
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
|
||||
val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url")
|
||||
val intent =
|
||||
NappletBrowserActivity
|
||||
.intent(
|
||||
|
||||
@@ -111,7 +111,7 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
|
||||
|
||||
/**
|
||||
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
|
||||
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
|
||||
* has been captured yet. The favicon is stored by [com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry] at browse time (the
|
||||
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
|
||||
*
|
||||
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
|
||||
@@ -121,8 +121,9 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
|
||||
@Composable
|
||||
fun rememberWebAppIconModel(url: String): String? {
|
||||
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
|
||||
val iconKeys by Amethyst.instance.browserIcons.keys
|
||||
.collectAsStateWithLifecycle()
|
||||
return remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.graphics.scale
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
@@ -107,7 +108,7 @@ object WebShortcuts {
|
||||
url: String,
|
||||
): IconCompat {
|
||||
val bitmap =
|
||||
BrowserIconRegistry
|
||||
Amethyst.instance.browserIcons
|
||||
.iconModelFor(BrowserChrome.displayHost(url))
|
||||
?.removePrefix("file://")
|
||||
?.let { path -> runCatching { BitmapFactory.decodeFile(File(path).absolutePath) }.getOrNull() }
|
||||
|
||||
@@ -75,21 +75,21 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.Block
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists.InterestListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip62Vanish.VanishRequestsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserAssertionDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserAssertionsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps.OnchainWalletState
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
@@ -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
|
||||
@@ -144,18 +145,18 @@ import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets.BookmarkSetsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.interestLists.InterestListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
@@ -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
|
||||
@@ -237,8 +238,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
|
||||
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
@@ -280,10 +281,10 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
@@ -319,10 +320,10 @@ import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.blurhash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.dimension
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.fileSize
|
||||
@@ -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
|
||||
@@ -457,17 +467,17 @@ class Account(
|
||||
// redeemed by this key and the relay grants membership to it alone — and this set makes the
|
||||
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
|
||||
// hand every other logged-in account an automatic login on a workspace it never joined.
|
||||
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
|
||||
// Restored/persisted per account by BuzzWorkspaceStore (see AccountCacheState).
|
||||
val buzzWorkspaces = BuzzWorkspaces()
|
||||
|
||||
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
|
||||
// of the community view, so a shared set let one account reorder and badge every other one's
|
||||
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
|
||||
// channel list. Restored/persisted per account by BuzzChannelStarStore.
|
||||
val buzzChannelStars = BuzzChannelStars()
|
||||
|
||||
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
|
||||
// AUTH so the relay grants virtual membership. Restored/persisted per account by
|
||||
// BuzzAttestationPreferences.
|
||||
// BuzzAttestationStore.
|
||||
val buzzAttestation = BuzzHeldAttestations(pubKey)
|
||||
|
||||
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
|
||||
@@ -629,8 +639,8 @@ class Account(
|
||||
val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer)
|
||||
val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings)
|
||||
|
||||
val relayFeedsListDecryptionCache = RelayFeedsListDecryptionCache(signer)
|
||||
val relayFeedsList = RelayFeedListState(signer, cache, relayFeedsListDecryptionCache, scope, settings)
|
||||
val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer)
|
||||
val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings)
|
||||
|
||||
val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer)
|
||||
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
|
||||
@@ -748,8 +758,8 @@ class Account(
|
||||
val communityListDecryptionCache = CommunityListDecryptionCache(signer)
|
||||
val communityList = CommunityListState(signer, cache, communityListDecryptionCache, scope, settings)
|
||||
|
||||
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
|
||||
val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings)
|
||||
val interestListDecryptionCache = InterestListDecryptionCache(signer)
|
||||
val interestList = InterestListState(signer, cache, interestListDecryptionCache, scope, settings)
|
||||
|
||||
val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer)
|
||||
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
|
||||
@@ -759,7 +769,7 @@ class Account(
|
||||
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
|
||||
|
||||
// Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats.
|
||||
val geohashIdentity = GeohashChatIdentityState(signer)
|
||||
val geohashIdentity = GeohashChatIdentityState(signer, scope)
|
||||
|
||||
val muteListDecryptionCache = MuteListDecryptionCache(signer)
|
||||
val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings)
|
||||
@@ -767,14 +777,14 @@ class Account(
|
||||
val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer)
|
||||
val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings)
|
||||
|
||||
val peopleListDecryptionCache = PeopleListDecryptionCache(signer)
|
||||
val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope)
|
||||
val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope)
|
||||
val followLists = FollowListsState(signer, cache, scope)
|
||||
val followSetDecryptionCache = FollowSetDecryptionCache(signer)
|
||||
val blockPeopleList = BlockPeopleListState(signer, cache, followSetDecryptionCache, scope)
|
||||
val followSets = FollowSetsState(signer, cache, followSetDecryptionCache, scope)
|
||||
val starterPacks = StarterPacksState(signer, cache, scope)
|
||||
|
||||
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
|
||||
|
||||
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
|
||||
val bookmarkSets = BookmarkSetsState(signer, cache, scope)
|
||||
val interestSets = InterestSetsState(signer, cache, scope)
|
||||
val appRecommendations = AppRecommendationsState(signer, cache, scope)
|
||||
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
|
||||
@@ -785,8 +795,8 @@ class Account(
|
||||
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
|
||||
|
||||
// needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs
|
||||
val contactCardDecryptionCache = ContactCardDecryptionCache(signer)
|
||||
val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji)
|
||||
val userAssertionDecryptionCache = UserAssertionDecryptionCache(signer)
|
||||
val userAssertions = UserAssertionsState(signer, cache, userAssertionDecryptionCache, emoji)
|
||||
|
||||
val vanish = VanishRequestsState(signer, cache, client, scope)
|
||||
|
||||
@@ -883,7 +893,7 @@ class Account(
|
||||
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
|
||||
|
||||
// Merges all follow lists to create a single All Follows feed.
|
||||
val allFollows = MergedFollowListsState(kind3FollowList, peopleLists, followLists, hashtagList, geohashList, communityList, scope)
|
||||
val allFollows = MergedFollowListsState(kind3FollowList, followSets, starterPacks, interestList, geohashList, communityList, scope)
|
||||
|
||||
val privateDMDecryptionCache = PrivateDMCache(signer)
|
||||
override val privateZapsDecryptionCache = PrivateZapCache(signer)
|
||||
@@ -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(
|
||||
@@ -1002,10 +1030,10 @@ class Account(
|
||||
|
||||
val feedDecryptionCaches =
|
||||
FeedDecryptionCaches(
|
||||
peopleListCache = peopleListDecryptionCache,
|
||||
peopleListCache = followSetDecryptionCache,
|
||||
muteListCache = muteListDecryptionCache,
|
||||
communityListCache = communityListDecryptionCache,
|
||||
hashtagCache = hashtagListDecryptionCache,
|
||||
hashtagCache = interestListDecryptionCache,
|
||||
geohashCache = geohashListDecryptionCache,
|
||||
)
|
||||
|
||||
@@ -1019,7 +1047,7 @@ class Account(
|
||||
blockedRelays = blockedRelayList.flow,
|
||||
proxyRelays = proxyRelayList.flow,
|
||||
mineRelays = mineRelays.flow,
|
||||
relayFeeds = relayFeedsList.flow,
|
||||
relayFeeds = favoriteRelayList.flow,
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
@@ -1255,7 +1283,7 @@ class Account(
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
selectedZapType: ZapReceiptEvent.ZapType,
|
||||
nip47Update: Nip47WalletConnect.Nip47URINorm?,
|
||||
) {
|
||||
var changed = false
|
||||
@@ -1679,7 +1707,7 @@ class Account(
|
||||
if (myNotes.isNotEmpty()) {
|
||||
// chunks in 200 elements to avoid going over the 65KB limit for events.
|
||||
myNotes.chunked(200).forEach { chunkedList ->
|
||||
val template = DeletionEvent.build(chunkedList.mapNotNull { it.event })
|
||||
val template = DeletionRequestEvent.build(chunkedList.mapNotNull { it.event })
|
||||
val deletionEvent = signer.sign(template)
|
||||
val myRelayList = outboxRelays.flow.value.toMutableSet()
|
||||
chunkedList.forEach {
|
||||
@@ -1710,7 +1738,7 @@ class Account(
|
||||
|
||||
val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey)
|
||||
broadcastPrivately(
|
||||
NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer),
|
||||
NIP17Factory().createDeletionNIP17(DeletionRequestEvent.build(myRumors), recipients, signer),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1721,7 +1749,7 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
if (event.pubKey != signer.pubKey) return
|
||||
|
||||
val deletionEvent = signer.sign(DeletionEvent.build(listOf(event)))
|
||||
val deletionEvent = signer.sign(DeletionRequestEvent.build(listOf(event)))
|
||||
client.publish(deletionEvent, outboxRelays.flow.value + additionalRelays)
|
||||
cache.justConsumeMyOwnEvent(deletionEvent)
|
||||
}
|
||||
@@ -2098,9 +2126,9 @@ class Account(
|
||||
|
||||
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
|
||||
|
||||
suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.follow(tag))
|
||||
suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(interestList.follow(tag))
|
||||
|
||||
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag))
|
||||
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(interestList.unfollow(tag))
|
||||
|
||||
suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm))
|
||||
|
||||
@@ -2148,7 +2176,7 @@ class Account(
|
||||
suspend fun deleteWebBookmark(event: WebBookmarkEvent) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val template = DeletionEvent.build(listOf(event))
|
||||
val template = DeletionRequestEvent.build(listOf(event))
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
@@ -2184,7 +2212,7 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
if (event.pubKey != signer.pubKey) return
|
||||
|
||||
val template = DeletionEvent.build(listOf(event))
|
||||
val template = DeletionRequestEvent.build(listOf(event))
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
@@ -2569,7 +2597,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileHeaderEvent.build(url, alt) {
|
||||
FileMetadataEvent.build(url, alt) {
|
||||
hash(headerInfo.hash)
|
||||
fileSize(headerInfo.size)
|
||||
|
||||
@@ -2683,7 +2711,7 @@ class Account(
|
||||
val extraRelays = draftNote.relays
|
||||
|
||||
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
|
||||
val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft)))
|
||||
val deletionEvent = signer.sign(DeletionRequestEvent.build(listOf(deletedDraft)))
|
||||
|
||||
val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet()
|
||||
|
||||
@@ -2909,7 +2937,7 @@ class Account(
|
||||
broadcast.forEach { client.publish(it, relayList) }
|
||||
}
|
||||
|
||||
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
|
||||
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<EncryptedDmEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val newEvent = signer.sign(eventTemplate)
|
||||
@@ -3412,11 +3440,11 @@ class Account(
|
||||
* encrypted in the content. `null` clears a field. Goes out through the
|
||||
* account's extended outbox relays.
|
||||
*/
|
||||
suspend fun updateContactCardPetName(
|
||||
suspend fun updateUserAssertionPetName(
|
||||
pubkeyHex: HexKey,
|
||||
petName: String?,
|
||||
summary: String?,
|
||||
) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary))
|
||||
) = sendMyPublicAndPrivateOutbox(userAssertions.updatePetNameAndSummary(pubkeyHex, petName, summary))
|
||||
|
||||
suspend fun showUser(pubkeyHex: HexKey) {
|
||||
sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex))
|
||||
@@ -3453,10 +3481,10 @@ class Account(
|
||||
|
||||
suspend fun requestDVMContentDiscovery(
|
||||
dvmPublicKey: User,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
|
||||
onReady: (event: DvmContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
|
||||
) {
|
||||
val relays = nip65RelayList.inboxFlow.value.toSet()
|
||||
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
|
||||
val request = signer.sign<DvmContentDiscoveryRequestEvent>(DvmContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
|
||||
|
||||
val relayList =
|
||||
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
|
||||
@@ -3475,8 +3503,8 @@ class Account(
|
||||
|
||||
return if (isWriteable()) {
|
||||
when {
|
||||
event is PrivateDmEvent -> privateDMDecryptionCache.cachedDM(event)
|
||||
event is LnZapRequestEvent && event.isPrivateZap() -> privateZapsDecryptionCache.cachedPrivateZap(event)?.content
|
||||
event is EncryptedDmEvent -> privateDMDecryptionCache.cachedDM(event)
|
||||
event is ZapRequestEvent && event.isPrivateZap() -> privateZapsDecryptionCache.cachedPrivateZap(event)?.content
|
||||
event is DraftWrapEvent -> draftsDecryptionCache.preCachedDraft(event)?.content
|
||||
else -> event.content
|
||||
}
|
||||
@@ -3491,11 +3519,11 @@ class Account(
|
||||
suspend fun decryptContent(note: Note): String? {
|
||||
val event = note.event
|
||||
return when {
|
||||
event is PrivateDmEvent && isWriteable() -> {
|
||||
event is EncryptedDmEvent && isWriteable() -> {
|
||||
privateDMDecryptionCache.decryptDM(event)
|
||||
}
|
||||
|
||||
event is LnZapRequestEvent && isWriteable() -> {
|
||||
event is ZapRequestEvent && isWriteable() -> {
|
||||
if (event.isPrivateZap()) {
|
||||
if (isWriteable()) {
|
||||
privateZapsDecryptionCache.decryptPrivateZap(event)?.content
|
||||
@@ -3522,7 +3550,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null
|
||||
suspend fun decryptZapOrNull(event: ZapRequestEvent): PrivateZapEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean = users.all { isHidden(it) }
|
||||
|
||||
@@ -3574,7 +3602,7 @@ class Account(
|
||||
note.countReportAuthorsBy(followingKeySet()) < reportWarningThreshold
|
||||
}
|
||||
|
||||
fun isDecryptedContentHidden(noteEvent: PrivateDmEvent): Boolean =
|
||||
fun isDecryptedContentHidden(noteEvent: EncryptedDmEvent): Boolean =
|
||||
if (hiddenUsers.flow.value.hiddenWordsCase
|
||||
.isNotEmpty()
|
||||
) {
|
||||
@@ -3719,11 +3747,11 @@ class Account(
|
||||
|
||||
suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays))
|
||||
|
||||
suspend fun saveRelayFeedsList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(relayFeedsList.saveRelayList(trustedRelays))
|
||||
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays))
|
||||
|
||||
suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.addRelay(url))
|
||||
suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url))
|
||||
|
||||
suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.removeRelay(url))
|
||||
suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url))
|
||||
|
||||
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
||||
|
||||
@@ -3755,7 +3783,7 @@ class Account(
|
||||
proxyRelayList.getProxyRelayList(),
|
||||
broadcastRelayList.getBroadcastRelayList(),
|
||||
indexerRelayList.getIndexerRelayList(),
|
||||
relayFeedsList.getRelayFeedsList(),
|
||||
favoriteRelayList.getFavoriteRelayList(),
|
||||
blockedRelayList.getBlockedRelayList(),
|
||||
muteList.getMuteList(),
|
||||
bookmarkState.getBookmarkList(),
|
||||
@@ -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
|
||||
@@ -4015,9 +4056,9 @@ class Account(
|
||||
logTime("Account ${userProfile().toBestDisplayName()} newEventBundle Update with ${newNotes.size} new notes") {
|
||||
upgradeAttestations()
|
||||
newNotesPreProcessor.runNew(newNotes)
|
||||
peopleLists.newNotes(newNotes)
|
||||
followLists.newNotes(newNotes)
|
||||
labeledBookmarkLists.newNotes(newNotes)
|
||||
followSets.newNotes(newNotes)
|
||||
starterPacks.newNotes(newNotes)
|
||||
bookmarkSets.newNotes(newNotes)
|
||||
interestSets.newNotes(newNotes)
|
||||
ownedEmojiPacks.newNotes(newNotes)
|
||||
}
|
||||
@@ -4028,9 +4069,9 @@ class Account(
|
||||
cache.live.deletedEventBundles.collect { deletedNotes ->
|
||||
logTime("Account ${userProfile().toBestDisplayName()} deletedEventBundle Update with ${deletedNotes.size} new notes") {
|
||||
newNotesPreProcessor.runDeleted(deletedNotes)
|
||||
peopleLists.deletedNotes(deletedNotes)
|
||||
followLists.deletedNotes(deletedNotes)
|
||||
labeledBookmarkLists.deletedNotes(deletedNotes)
|
||||
followSets.deletedNotes(deletedNotes)
|
||||
starterPacks.deletedNotes(deletedNotes)
|
||||
bookmarkSets.deletedNotes(deletedNotes)
|
||||
interestSets.deletedNotes(deletedNotes)
|
||||
ownedEmojiPacks.deletedNotes(deletedNotes)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+16
-16
@@ -58,15 +58,15 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -92,7 +92,7 @@ class AccountRelayGroupActions(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) {
|
||||
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
|
||||
val template = GroupJoinRequestEvent.build(channel.groupId.id, inviteCode = code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.follow(channel)
|
||||
}
|
||||
@@ -295,7 +295,7 @@ class AccountRelayGroupActions(
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
val template = GroupLeaveRequestEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.unfollow(channel)
|
||||
}
|
||||
@@ -351,7 +351,7 @@ class AccountRelayGroupActions(
|
||||
) { listOf(relay) }
|
||||
|
||||
val edit =
|
||||
EditMetadataEvent.build(
|
||||
GroupEditMetadataEvent.build(
|
||||
groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
@@ -405,7 +405,7 @@ class AccountRelayGroupActions(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) {
|
||||
val template = CreateInviteEvent.build(channel.groupId.id, code)
|
||||
val template = GroupCreateInviteEvent.build(channel.groupId.id, code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ class AccountRelayGroupActions(
|
||||
channel: RelayGroupChannel,
|
||||
pinnedEventIds: List<HexKey>,
|
||||
) {
|
||||
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
|
||||
val template = GroupUpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ class AccountRelayGroupActions(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
|
||||
val template = GroupRemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ class AccountRelayGroupActions(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
|
||||
val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ class AccountRelayGroupActions(
|
||||
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
|
||||
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
|
||||
val template =
|
||||
EditMetadataEvent.build(
|
||||
GroupEditMetadataEvent.build(
|
||||
channel.groupId.id,
|
||||
name = name,
|
||||
about = about,
|
||||
@@ -549,7 +549,7 @@ class AccountRelayGroupActions(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) {
|
||||
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
|
||||
val template = GroupEditMetadataEvent.build(channel.groupId.id, archived = archived)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
@@ -68,16 +68,16 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
@@ -203,20 +203,20 @@ class AccountSettings(
|
||||
val showMessagesInNotifications: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
var backupUserMetadata: MetadataEvent? = null,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||
var backupDMRelayList: DmRelayListEvent? = null,
|
||||
var backupKeyPackageRelayList: KeyPackageRelayListEvent? = null,
|
||||
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupIndexRelayList: IndexerRelayListEvent? = null,
|
||||
var backupRelayFeedsList: RelayFeedsListEvent? = null,
|
||||
var backupFavoriteRelayList: FavoriteRelayListEvent? = null,
|
||||
var backupBlockedRelayList: BlockedRelayListEvent? = null,
|
||||
var backupTrustedRelayList: TrustedRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
var backupAppSpecificData: AppSpecificDataEvent? = null,
|
||||
var backupChannelList: ChannelListEvent? = null,
|
||||
var backupPublicChatList: PublicChatListEvent? = null,
|
||||
var backupCommunityList: CommunityListEvent? = null,
|
||||
var backupHashtagList: HashtagListEvent? = null,
|
||||
var backupInterestList: InterestListEvent? = null,
|
||||
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
@@ -375,7 +375,7 @@ class AccountSettings(
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean {
|
||||
fun changeDefaultZapType(zapType: ZapReceiptEvent.ZapType): Boolean {
|
||||
if (syncedSettings.zaps.defaultZapType.value != zapType) {
|
||||
syncedSettings.zaps.defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
@@ -1075,7 +1075,7 @@ class AccountSettings(
|
||||
when (incoming) {
|
||||
is MetadataEvent -> updateUserMetadata(incoming)
|
||||
is ContactListEvent -> updateContactListTo(incoming)
|
||||
is ChatMessageRelayListEvent -> updateDMRelayList(incoming)
|
||||
is DmRelayListEvent -> updateDMRelayList(incoming)
|
||||
is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming)
|
||||
is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming)
|
||||
is CashuWalletEvent -> updateCashuWallet(incoming)
|
||||
@@ -1084,13 +1084,13 @@ class AccountSettings(
|
||||
is Bolt12OfferListEvent -> updateBolt12Offers(incoming)
|
||||
is SearchRelayListEvent -> updateSearchRelayList(incoming)
|
||||
is IndexerRelayListEvent -> updateIndexRelayList(incoming)
|
||||
is RelayFeedsListEvent -> updateRelayFeedList(incoming)
|
||||
is FavoriteRelayListEvent -> updateFavoriteRelayList(incoming)
|
||||
is BlockedRelayListEvent -> updateBlockedRelayList(incoming)
|
||||
is TrustedRelayListEvent -> updateTrustedRelayList(incoming)
|
||||
is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming)
|
||||
is ChannelListEvent -> updateChannelListTo(incoming)
|
||||
is PublicChatListEvent -> updatePublicChatListTo(incoming)
|
||||
is GeohashListEvent -> updateGeohashListTo(incoming)
|
||||
is HashtagListEvent -> updateHashtagListTo(incoming)
|
||||
is InterestListEvent -> updateInterestListTo(incoming)
|
||||
is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming)
|
||||
is CommunityListEvent -> updateCommunityListTo(incoming)
|
||||
is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming)
|
||||
@@ -1140,7 +1140,7 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
fun updateDMRelayList(newDMRelayList: DmRelayListEvent?) {
|
||||
if (newDMRelayList == null) return
|
||||
|
||||
if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) {
|
||||
@@ -1221,7 +1221,8 @@ class AccountSettings(
|
||||
* Reserve [count] consecutive NUT-13 counters for [keysetId],
|
||||
* returning the first one. Caller derives `(secret, r)` from
|
||||
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
|
||||
* Persisted synchronously before returning — see [CashuKeysetCounterStore].
|
||||
* Persisted before returning — see [CashuKeysetCounterStore]. Suspends
|
||||
* because that write is what stands between a crash and a reused counter.
|
||||
*
|
||||
* One-time migration: when this keyset has a non-zero value in the
|
||||
* legacy [cashuKeysetCounters] map (from a build that persisted
|
||||
@@ -1229,7 +1230,7 @@ class AccountSettings(
|
||||
* still at zero, the legacy value is copied over before we reserve
|
||||
* so an upgrade doesn't reset the counter.
|
||||
*/
|
||||
fun reserveCashuCounters(
|
||||
suspend fun reserveCashuCounters(
|
||||
keysetId: String,
|
||||
count: Int,
|
||||
): Long {
|
||||
@@ -1238,12 +1239,12 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
/** Inspect the next counter for [keysetId] without consuming any. */
|
||||
fun peekCashuCounter(keysetId: String): Long {
|
||||
suspend fun peekCashuCounter(keysetId: String): Long {
|
||||
migrateLegacyCashuCounter(keysetId)
|
||||
return cashuCounters.peek(keysetId)
|
||||
}
|
||||
|
||||
private fun migrateLegacyCashuCounter(keysetId: String) {
|
||||
private suspend fun migrateLegacyCashuCounter(keysetId: String) {
|
||||
val legacy = cashuKeysetCounters[keysetId] ?: return
|
||||
cashuCounters.seedIfMissing(keysetId, legacy)
|
||||
}
|
||||
@@ -1284,11 +1285,11 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
|
||||
if (newRelayFeedList == null) return
|
||||
fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) {
|
||||
if (newFavoriteRelayList == null) return
|
||||
|
||||
if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) {
|
||||
backupRelayFeedsList = newRelayFeedList
|
||||
if (backupGuard.accept(backupFavoriteRelayList, newFavoriteRelayList, isEmpty = newFavoriteRelayList.tags.isEmpty()) { updateFavoriteRelayList(newFavoriteRelayList) }) {
|
||||
backupFavoriteRelayList = newFavoriteRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
@@ -1320,13 +1321,13 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
override fun channelList() = backupChannelList
|
||||
override fun publicChatList() = backupPublicChatList
|
||||
|
||||
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
if (newChannelList == null) return
|
||||
override fun updatePublicChatListTo(newPublicChatList: PublicChatListEvent?) {
|
||||
if (newPublicChatList == null) return
|
||||
|
||||
if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) {
|
||||
backupChannelList = newChannelList
|
||||
if (backupGuard.accept(backupPublicChatList, newPublicChatList, isEmpty = newPublicChatList.tags.isEmpty()) { updatePublicChatListTo(newPublicChatList) }) {
|
||||
backupPublicChatList = newPublicChatList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
@@ -1340,11 +1341,11 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
|
||||
if (newHashtagList == null) return
|
||||
fun updateInterestListTo(newInterestList: InterestListEvent?) {
|
||||
if (newInterestList == null) return
|
||||
|
||||
if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) {
|
||||
backupHashtagList = newHashtagList
|
||||
if (backupGuard.accept(backupInterestList, newInterestList, isEmpty = newInterestList.tags.isEmpty()) { updateInterestListTo(newInterestList) }) {
|
||||
backupInterestList = newInterestList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -265,7 +265,7 @@ class AccountVideoPlayerPreferences(
|
||||
@Stable
|
||||
class AccountZapPreferences(
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
|
||||
val defaultZapType: MutableStateFlow<ZapReceiptEvent.ZapType>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
|
||||
@@ -207,7 +207,7 @@ class AccountZapPreferencesInternal(
|
||||
// and so the on-chain-eligible subset round-trips back for them; on load it
|
||||
// is unioned into [zapAmountChoices]. See AccountSyncedSettings.
|
||||
var onchainZapAmountChoices: List<Long> = DefaultOnchainZapAmounts,
|
||||
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
val defaultZapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -48,8 +48,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -74,12 +74,12 @@ class AccountZapActions(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
toUser: User?,
|
||||
additionalRelays: Set<NormalizedRelayUrl>? = null,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
) = LnZapRequestEvent.create(
|
||||
) = ZapRequestEvent.create(
|
||||
zappedEvent = event,
|
||||
// Where the provider should publish the receipt. Zapping group content pins that to the room's
|
||||
// host relay: the receipt belongs where the message it pays for lives, so the room can show it
|
||||
@@ -205,7 +205,7 @@ class AccountZapActions(
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
|
||||
onError: (StringResource, String?) -> Unit,
|
||||
// (code, detail) — the wallet refused or failed the payment; no funds moved.
|
||||
@@ -215,7 +215,7 @@ class AccountZapActions(
|
||||
) {
|
||||
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
|
||||
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
|
||||
if (zapType == LnZapEvent.ZapType.NONZAP) {
|
||||
if (zapType == ZapReceiptEvent.ZapType.NONZAP) {
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response ->
|
||||
account.scope.launch {
|
||||
try {
|
||||
@@ -228,7 +228,7 @@ class AccountZapActions(
|
||||
return
|
||||
}
|
||||
|
||||
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
|
||||
val anonymous = zapType == ZapReceiptEvent.ZapType.ANONYMOUS
|
||||
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
|
||||
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
|
||||
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
|
||||
@@ -284,12 +284,12 @@ class AccountZapActions(
|
||||
suspend fun createZapRequestFor(
|
||||
user: User,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent {
|
||||
): ZapRequestEvent {
|
||||
val zapRequest =
|
||||
LnZapRequestEvent.create(
|
||||
ZapRequestEvent.create(
|
||||
userHex = user.pubkeyHex,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
|
||||
signer = account.signer,
|
||||
|
||||
@@ -40,16 +40,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
@@ -121,7 +121,7 @@ class EventBroadcaster(
|
||||
event is AppSpecificDataEvent ||
|
||||
event is BookmarkListEvent ||
|
||||
event is OldBookmarkListEvent ||
|
||||
event is LabeledBookmarkListEvent
|
||||
event is BookmarkSetEvent
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class EventBroadcaster(
|
||||
}
|
||||
// Seals, inner DM messages, and unsigned rumors never get broadcast
|
||||
// relays: they only travel inside gift wraps.
|
||||
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
|
||||
if (event is SealEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ class EventBroadcaster(
|
||||
.toList()
|
||||
}
|
||||
val filter =
|
||||
if (host.kind == SealedRumorEvent.KIND) {
|
||||
if (host.kind == SealEvent.KIND) {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
ids = listOf(host.id),
|
||||
@@ -383,7 +383,7 @@ class EventBroadcaster(
|
||||
fun sendRestoredVersion(event: Event) {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl })
|
||||
is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is DmRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is CashuWalletEvent -> sendLiterallyEverywhere(event)
|
||||
// Profiles and nutzap info are normally saved everywhere so others can find them;
|
||||
|
||||
+105
-40
@@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.core.content.edit
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LegacySharedPreferences
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.accountSecretsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The account's anonymous, per-geohash chat identities.
|
||||
@@ -52,68 +63,122 @@ import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
*/
|
||||
class GeohashChatIdentityState(
|
||||
private val signer: NostrSigner,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val cache = HashMap<String, KeyPair>()
|
||||
/**
|
||||
* Guards seed creation as well as the key cache: two callers racing into
|
||||
* [deviceSeed] must not mint two different seeds, or the loser's cells get
|
||||
* identities the next launch cannot reproduce. A [Mutex] rather than
|
||||
* `synchronized`, because the store reads it protects are suspending.
|
||||
*/
|
||||
private val mutex = Mutex()
|
||||
private val cache = ConcurrentHashMap<String, KeyPair>()
|
||||
|
||||
@Volatile private var cachedDeviceSeed: ByteArray? = null
|
||||
/**
|
||||
* The npub the current store is keyed by.
|
||||
*
|
||||
* The legacy file is keyed by the pubkey *hex* — the old code passed
|
||||
* `signer.pubKey` where every other caller passes an npub, so the identity
|
||||
* lived in `secret_keeper_<hex>`, a different file from the account's own
|
||||
* `secret_keeper_<npub>`. The copy below reads that file and writes the
|
||||
* npub-keyed store, which is what folds this orphan back in with the rest.
|
||||
*/
|
||||
private val npub by lazy { signer.pubKey.hexToByteArray().toNpub() }
|
||||
|
||||
@Volatile private var cachedNickname: String? = null
|
||||
@Volatile private var loaded: GeohashIdentitySecrets? = null
|
||||
|
||||
/**
|
||||
* What `secret_keeper_<pubkey hex>` holds.
|
||||
*
|
||||
* Only called when the store has nothing yet: opening this file creates it,
|
||||
* so reading it unconditionally would resurrect it after the cleanup has
|
||||
* deleted it. Touches disk; callers are off the main thread.
|
||||
*/
|
||||
private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey)))
|
||||
|
||||
/**
|
||||
* The stored identity, copying it out of the legacy file the first time.
|
||||
*
|
||||
* **Call under [mutex].** Not self-locking, because [keyPair] already holds
|
||||
* the lock when it reaches here and [Mutex] is not reentrant.
|
||||
*/
|
||||
private suspend fun current(): GeohashIdentitySecrets {
|
||||
loaded?.let { return it }
|
||||
return accountSecretsStore.readGeohashIdentity(npub) { legacy() }.also { loaded = it }
|
||||
}
|
||||
|
||||
/** Call under [mutex], for the reason [current] gives. */
|
||||
private suspend fun persist(value: GeohashIdentitySecrets) {
|
||||
loaded = value
|
||||
accountSecretsStore.mirrorGeohashIdentity(npub, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's display handle for location chats: a single global nickname, persisted per account.
|
||||
* Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000
|
||||
* messages are ephemeral (relays needn't store them), so the only durable home for it is the device.
|
||||
* Kept in this account's encrypted storage, so it survives restarts and switches with the account.
|
||||
* Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread.
|
||||
* Empty string means "no nickname set".
|
||||
*/
|
||||
fun nickname(): String {
|
||||
cachedNickname?.let { return it }
|
||||
synchronized(lock) {
|
||||
cachedNickname?.let { return it }
|
||||
val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: ""
|
||||
cachedNickname = value
|
||||
return value
|
||||
}
|
||||
}
|
||||
suspend fun nickname(): String = mutex.withLock { current().nickname ?: "" }
|
||||
|
||||
/** Persists the global location-chat nickname (trimmed) for this account. */
|
||||
/**
|
||||
* Persists the global location-chat nickname (trimmed) for this account.
|
||||
*
|
||||
* Fire-and-forget on the account scope, which is what the SharedPreferences
|
||||
* `edit {}` this replaced already did — the caller is a click handler on the
|
||||
* main thread and the write is not something it waits for.
|
||||
*/
|
||||
fun setNickname(value: String) {
|
||||
val trimmed = value.trim()
|
||||
synchronized(lock) {
|
||||
cachedNickname = trimmed
|
||||
scope.launch {
|
||||
// Under the lock: this is a read-modify-write of the same group
|
||||
// deviceSeed() writes. Racing the first seed mint, an unlocked copy
|
||||
// would persist the nickname over a null deviceSeed, putOrRemove
|
||||
// would delete the seed, and every per-cell identity minted that
|
||||
// session would be unreproducible on the next launch.
|
||||
mutex.withLock {
|
||||
persist(current().copy(nickname = trimmed))
|
||||
// Mirrored, not moved: the legacy file stays readable until the
|
||||
// legacy writes are retired app-wide, so a rollback keeps the handle.
|
||||
// Gated on the same switch as every other mirror — otherwise flipping
|
||||
// it would retire the documented four and leave this one writing.
|
||||
if (!LocalPreferences.LEGACY_WRITES_RETIRED) {
|
||||
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */
|
||||
fun keyPair(geohash: String): KeyPair =
|
||||
synchronized(lock) {
|
||||
cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed()
|
||||
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached. */
|
||||
suspend fun keyPair(geohash: String): KeyPair {
|
||||
cache[geohash]?.let { return it }
|
||||
|
||||
return mutex.withLock {
|
||||
cache[geohash] ?: GeohashKeyDerivation.deriveKeyPair(seed(), geohash).also { cache[geohash] = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Call under [mutex]. */
|
||||
private suspend fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed()
|
||||
|
||||
private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey
|
||||
|
||||
/** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */
|
||||
private fun deviceSeed(): ByteArray {
|
||||
cachedDeviceSeed?.let { return it }
|
||||
synchronized(lock) {
|
||||
cachedDeviceSeed?.let { return it }
|
||||
val prefs = Amethyst.instance.encryptedStorage(signer.pubKey)
|
||||
val existing = prefs.getString(PREF_KEY, null)
|
||||
val seed =
|
||||
if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) {
|
||||
existing.hexToByteArray()
|
||||
} else {
|
||||
/**
|
||||
* Random per-account seed, used only when the account key is unreachable (bunker / external signer).
|
||||
*
|
||||
* Call under [mutex]: minting a second seed for an account that already has
|
||||
* one would change every throwaway identity it has ever used.
|
||||
*/
|
||||
private suspend fun deviceSeed(): ByteArray {
|
||||
val stored = current().deviceSeed
|
||||
if (stored != null && stored.length == GeohashKeyDerivation.SEED_SIZE * 2) return stored.hexToByteArray()
|
||||
|
||||
val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
|
||||
prefs.edit { putString(PREF_KEY, fresh.toHexKey()) }
|
||||
fresh
|
||||
}
|
||||
cachedDeviceSeed = seed
|
||||
return seed
|
||||
persist(current().copy(deviceSeed = fresh.toHexKey()))
|
||||
if (!LocalPreferences.LEGACY_WRITES_RETIRED) {
|
||||
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) }
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+42
-15
@@ -26,20 +26,21 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedKeyPackageBundleStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore
|
||||
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
|
||||
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
|
||||
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -49,6 +50,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -57,6 +59,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
|
||||
class AccountCacheState(
|
||||
@@ -89,6 +92,23 @@ class AccountCacheState(
|
||||
/** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */
|
||||
private val loadLock = Any()
|
||||
|
||||
/**
|
||||
* One [AppPreferenceStores] per account directory, kept for the life of the
|
||||
* process.
|
||||
*
|
||||
* [buildAccount] runs again for the same account on re-login and on cache
|
||||
* races, and DataStore throws if a second instance is ever live on a file
|
||||
* that already has one. Caching the holder — rather than the store — keeps
|
||||
* that guarantee for every per-account store that gets added here later,
|
||||
* not just the relay-auth one.
|
||||
*/
|
||||
private val accountStoreHolders = LargeCache<String, AppPreferenceStores>()
|
||||
|
||||
private fun storesFor(accountDir: File): AppPreferenceStores =
|
||||
accountStoreHolders.getOrCreate(accountDir.absolutePath) {
|
||||
AppPreferenceStores(rootFilesDir = { accountDir.toOkioPath() })
|
||||
}
|
||||
|
||||
fun removeAccount(pubkey: HexKey) {
|
||||
accounts.update { existingAccounts ->
|
||||
val oldValue = existingAccounts[pubkey]
|
||||
@@ -230,13 +250,13 @@ class AccountCacheState(
|
||||
val mlsStore =
|
||||
try {
|
||||
Log.d("AccountCacheState") {
|
||||
"Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}"
|
||||
"Initializing EncryptedMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}"
|
||||
}
|
||||
AndroidMlsGroupStateStore(accountDir)
|
||||
EncryptedMlsGroupStateStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)",
|
||||
"Failed to initialize EncryptedMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)",
|
||||
e,
|
||||
)
|
||||
InMemoryMlsGroupStateStore()
|
||||
@@ -247,11 +267,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotMessageStore =
|
||||
try {
|
||||
AndroidMarmotMessageStore(accountDir)
|
||||
EncryptedMarmotMessageStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)",
|
||||
"Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)",
|
||||
e,
|
||||
)
|
||||
null
|
||||
@@ -259,11 +279,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotKeyPackageStore =
|
||||
try {
|
||||
AndroidKeyPackageBundleStore(accountDir)
|
||||
EncryptedKeyPackageBundleStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
|
||||
"Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
|
||||
e,
|
||||
)
|
||||
null
|
||||
@@ -271,11 +291,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotPublishObligationStore =
|
||||
try {
|
||||
AndroidPublishObligationStore(accountDir)
|
||||
EncryptedPublishObligationStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidPublishObligationStore " +
|
||||
"Failed to initialize EncryptedPublishObligationStore " +
|
||||
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
|
||||
e,
|
||||
)
|
||||
@@ -310,7 +330,10 @@ class AccountCacheState(
|
||||
|
||||
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
|
||||
// account never leaks into another (the store used to be a single app-wide file).
|
||||
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
|
||||
val relayAuthPermissionStore =
|
||||
DataStoreRelayAuthPermissionStore(
|
||||
storesFor(accountDir).getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME),
|
||||
)
|
||||
|
||||
return Account(
|
||||
settings = accountSettings,
|
||||
@@ -330,6 +353,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,
|
||||
|
||||
+7
-7
@@ -25,8 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -60,7 +60,7 @@ data class FavoriteAlgoFeedsSnapshot(
|
||||
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
val ids: Set<HexKey> = emptySet(),
|
||||
val addresses: Set<String> = emptySet(),
|
||||
val latestStatus: NIP90StatusEvent? = null,
|
||||
val latestStatus: DvmStatusEvent? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@@ -139,9 +139,9 @@ class FavoriteAlgoFeedsOrchestrator(
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
|
||||
.observeLatestEvent<DvmContentDiscoveryResponseEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
|
||||
kinds = listOf(DvmContentDiscoveryResponseEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
@@ -159,9 +159,9 @@ class FavoriteAlgoFeedsOrchestrator(
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90StatusEvent>(
|
||||
.observeLatestEvent<DvmStatusEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90StatusEvent.KIND),
|
||||
kinds = listOf(DvmStatusEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
|
||||
+114
@@ -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
+1
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
+1
-34
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
|
||||
/**
|
||||
* Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g.
|
||||
@@ -43,38 +43,5 @@ fun relayAdvertisesNip(
|
||||
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
|
||||
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
|
||||
|
||||
/**
|
||||
* Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc
|
||||
* resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field
|
||||
* NIP-29 relays publish so clients can verify their relay-signed group metadata — see
|
||||
* [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading,
|
||||
* or the fetch failed), so it never triggers the warning.
|
||||
*/
|
||||
fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null
|
||||
|
||||
/**
|
||||
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
|
||||
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
|
||||
* `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else".
|
||||
*
|
||||
* So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self`
|
||||
* key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29
|
||||
* relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we
|
||||
* fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self`
|
||||
* still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers
|
||||
* driving a live surface should warm it first and re-evaluate as it resolves.
|
||||
*/
|
||||
fun isRelaySignedRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
relayInfo: Nip11RelayInformation,
|
||||
): Boolean {
|
||||
val self = relayInfo.self
|
||||
return if (self != null) {
|
||||
channel.event?.pubKey == self
|
||||
} else {
|
||||
relayInfo.supported_nips?.any { it == "29" } == true
|
||||
}
|
||||
}
|
||||
|
||||
/** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */
|
||||
fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl))
|
||||
|
||||
+8
-8
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -48,14 +48,14 @@ class DmRelayListState(
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val dmListNote = cache.getOrCreateAddressableNote(getDMRelayListAddress())
|
||||
|
||||
fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey)
|
||||
fun getDMRelayListAddress() = DmRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getDMRelayListFlow(): StateFlow<NoteState> = dmListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getDMRelayList(): ChatMessageRelayListEvent? = dmListNote.event as? ChatMessageRelayListEvent
|
||||
fun getDMRelayList(): DmRelayListEvent? = dmListNote.event as? DmRelayListEvent
|
||||
|
||||
fun normalizeDMRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList
|
||||
val event = note.event as? DmRelayListEvent ?: settings.backupDMRelayList
|
||||
return event?.relays()?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
@@ -70,16 +70,16 @@ class DmRelayListState(
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(dmRelays: List<NormalizedRelayUrl>): ChatMessageRelayListEvent {
|
||||
suspend fun saveRelayList(dmRelays: List<NormalizedRelayUrl>): DmRelayListEvent {
|
||||
val relayListForDMs = getDMRelayList()
|
||||
return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
|
||||
ChatMessageRelayListEvent.updateRelayList(
|
||||
DmRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForDMs,
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
ChatMessageRelayListEvent.create(
|
||||
DmRelayListEvent.create(
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
)
|
||||
@@ -99,7 +99,7 @@ class DmRelayListState(
|
||||
Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start")
|
||||
getDMRelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Updating DM Relay List for ${signer.pubKey}" }
|
||||
(it.note.event as? ChatMessageRelayListEvent)?.let {
|
||||
(it.note.event as? DmRelayListEvent)?.let {
|
||||
settings.updateDMRelayList(it)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
@@ -129,12 +129,12 @@ class OwnedEmojiPacksState(
|
||||
.onStart { emit(listFeedFlow.value.getPack(dTag)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun DeletionEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value)
|
||||
fun DeletionRequestEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is EmojiPackEvent || (event is DeletionEvent && event.hasAnyDeletedOwnedEmojiPacks())
|
||||
event is EmojiPackEvent || (event is DeletionRequestEvent && event.hasAnyDeletedOwnedEmojiPacks())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -269,7 +269,7 @@ class OwnedEmojiPacksState(
|
||||
account: Account,
|
||||
) {
|
||||
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
|
||||
val deletionEventTemplate = DeletionEvent.build(listOf(packEvent))
|
||||
val deletionEventTemplate = DeletionRequestEvent.build(listOf(packEvent))
|
||||
val deletionEvent = signer.sign(deletionEventTemplate)
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
}
|
||||
|
||||
+9
-9
@@ -36,10 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
|
||||
@@ -199,12 +199,12 @@ class NwcSignerState(
|
||||
|
||||
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
|
||||
|
||||
override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? {
|
||||
override suspend fun decryptRequest(event: NwcRequestEvent): Request? {
|
||||
if (!hasWalletConnectSetup()) return null
|
||||
return zapPaymentRequestDecryptionCache.value.decryptRequest(event)
|
||||
}
|
||||
|
||||
override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? {
|
||||
override suspend fun decryptResponse(event: NwcResponseEvent): Response? {
|
||||
if (!hasWalletConnectSetup()) return null
|
||||
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
|
||||
}
|
||||
@@ -252,7 +252,7 @@ class NwcSignerState(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
|
||||
): Pair<NwcRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
|
||||
|
||||
/**
|
||||
* Sends a generic NIP-47 request to a specific wallet.
|
||||
@@ -266,14 +266,14 @@ class NwcSignerState(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
): Pair<NwcRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
val walletSigner = buildSigner(walletService) ?: signer
|
||||
|
||||
val info = walletInfo(walletService)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
|
||||
val event = NwcRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -314,7 +314,7 @@ class NwcSignerState(
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
): Pair<NwcRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val info = walletInfo(walletService)
|
||||
@@ -322,7 +322,7 @@ class NwcSignerState(
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event =
|
||||
LnZapPaymentRequestEvent.createRequest(
|
||||
NwcRequestEvent.createRequest(
|
||||
request,
|
||||
walletService.pubKeyHex,
|
||||
nip47Signer.value,
|
||||
|
||||
+56
-56
@@ -18,7 +18,7 @@
|
||||
* 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.nip51Lists.labeledBookmarkLists
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
@@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.amethyst.commons.model.eventIdSet
|
||||
import com.vitorpamplona.amethyst.commons.model.events
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet
|
||||
import com.vitorpamplona.amethyst.commons.model.updateFlow
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.title
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -55,41 +55,41 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class LabeledBookmarkListsState(
|
||||
class BookmarkSetsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun existingLabeledBookmarkNotes() = cache.addressables.filter(LabeledBookmarkListEvent.KIND, user.pubkeyHex)
|
||||
fun existingBookmarkSetNotes() = cache.addressables.filter(BookmarkSetEvent.KIND, user.pubkeyHex)
|
||||
|
||||
val labeledBookmarkListVersions = MutableStateFlow(0)
|
||||
val bookmarkSetVersions = MutableStateFlow(0)
|
||||
|
||||
val labeledBookmarkListNotes =
|
||||
labeledBookmarkListVersions
|
||||
.map { existingLabeledBookmarkNotes() }
|
||||
.onStart { emit(existingLabeledBookmarkNotes()) }
|
||||
val bookmarkSetNotes =
|
||||
bookmarkSetVersions
|
||||
.map { existingBookmarkSetNotes() }
|
||||
.onStart { emit(existingBookmarkSetNotes()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val labeledBookmarkListEventIds =
|
||||
labeledBookmarkListNotes
|
||||
val bookmarkSetEventIds =
|
||||
bookmarkSetNotes
|
||||
.map { it.eventIdSet() }
|
||||
.onStart { emit(labeledBookmarkListNotes.value.eventIdSet()) }
|
||||
.onStart { emit(bookmarkSetNotes.value.eventIdSet()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val latestBookmarkLists: StateFlow<List<LabeledBookmarkListEvent>> =
|
||||
labeledBookmarkListNotes
|
||||
.transformLatest { emitAll(it.updateFlow<LabeledBookmarkListEvent>()) }
|
||||
.onStart { emit(labeledBookmarkListNotes.value.events()) }
|
||||
val latestBookmarkLists: StateFlow<List<BookmarkSetEvent>> =
|
||||
bookmarkSetNotes
|
||||
.transformLatest { emitAll(it.updateFlow<BookmarkSetEvent>()) }
|
||||
.onStart { emit(bookmarkSetNotes.value.events()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() =
|
||||
LabeledBookmarkList(
|
||||
suspend fun BookmarkSetEvent.toBookmarkSet() =
|
||||
BookmarkSet(
|
||||
identifier = dTag(),
|
||||
title = titleOrName() ?: dTag(),
|
||||
description = description(),
|
||||
@@ -98,28 +98,28 @@ class LabeledBookmarkListsState(
|
||||
publicBookmarks = publicBookmarks().toSet(),
|
||||
)
|
||||
|
||||
suspend fun List<LabeledBookmarkListEvent>.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title }
|
||||
suspend fun List<BookmarkSetEvent>.toBookmarkSetsFeed() = map { it.toBookmarkSet() }.sortedBy { it.title }
|
||||
|
||||
val listFeedFlow =
|
||||
latestBookmarkLists
|
||||
.map { it.toLabeledBookmarkListsFeed() }
|
||||
.onStart { emit(latestBookmarkLists.value.toLabeledBookmarkListsFeed()) }
|
||||
.map { it.toBookmarkSetsFeed() }
|
||||
.onStart { emit(latestBookmarkLists.value.toBookmarkSetsFeed()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun List<LabeledBookmarkList>.getList(bookmarkListId: String) =
|
||||
fun List<BookmarkSet>.getList(bookmarkListId: String) =
|
||||
this.firstOrNull {
|
||||
it.identifier == bookmarkListId
|
||||
}
|
||||
|
||||
fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag)
|
||||
|
||||
fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value)
|
||||
fun DeletionRequestEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(BookmarkSetEvent.KIND) || deletesAnyEventIn(bookmarkSetEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists())
|
||||
event is BookmarkSetEvent || (event is DeletionRequestEvent && event.hasAnyDeletedBookmarkLists())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -138,14 +138,14 @@ class LabeledBookmarkListsState(
|
||||
}
|
||||
|
||||
fun forceRefresh() {
|
||||
labeledBookmarkListVersions.update { it + 1 }
|
||||
bookmarkSetVersions.update { it + 1 }
|
||||
}
|
||||
|
||||
fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier }
|
||||
fun getBookmarkSetNote(bookmarkIdentifier: String): AddressableNote? = existingBookmarkSetNotes().find { it.dTag() == bookmarkIdentifier }
|
||||
|
||||
fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent
|
||||
fun getBookmarkSetEvent(bookmarkIdentifier: String): BookmarkSetEvent = getBookmarkSetNote(bookmarkIdentifier)?.event as BookmarkSetEvent
|
||||
|
||||
fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) =
|
||||
fun getBookmarkSetFlow(bookmarkIdentifier: String) =
|
||||
listFeedFlow
|
||||
.map { it.getList(bookmarkIdentifier) }
|
||||
.onStart {
|
||||
@@ -154,7 +154,7 @@ class LabeledBookmarkListsState(
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun addLabeledBookmarkList(
|
||||
suspend fun addBookmarkSet(
|
||||
listName: String,
|
||||
listDescription: String? = null,
|
||||
listImage: String? = null,
|
||||
@@ -163,7 +163,7 @@ class LabeledBookmarkListsState(
|
||||
account: Account,
|
||||
) {
|
||||
val newList =
|
||||
LabeledBookmarkListEvent.create(
|
||||
BookmarkSetEvent.create(
|
||||
title = listName,
|
||||
description = listDescription,
|
||||
image = listImage,
|
||||
@@ -178,10 +178,10 @@ class LabeledBookmarkListsState(
|
||||
listName: String?,
|
||||
listDescription: String?,
|
||||
listImage: String?,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
bookmarkList: BookmarkSet,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
val listEvent = getBookmarkSetEvent(bookmarkList.identifier)
|
||||
|
||||
val template =
|
||||
listEvent.update {
|
||||
@@ -197,12 +197,12 @@ class LabeledBookmarkListsState(
|
||||
|
||||
suspend fun renameBookmarkList(
|
||||
newName: String,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
bookmarkList: BookmarkSet,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
val listEvent = getBookmarkSetEvent(bookmarkList.identifier)
|
||||
val renamedList =
|
||||
LabeledBookmarkListEvent.modifyName(
|
||||
BookmarkSetEvent.modifyName(
|
||||
earlierVersion = listEvent,
|
||||
newTitle = newName,
|
||||
signer = account.signer,
|
||||
@@ -212,12 +212,12 @@ class LabeledBookmarkListsState(
|
||||
|
||||
suspend fun modifyListDescription(
|
||||
newDescription: String?,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
bookmarkList: BookmarkSet,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
val listEvent = getBookmarkSetEvent(bookmarkList.identifier)
|
||||
val modifiedList =
|
||||
LabeledBookmarkListEvent.modifyDescription(
|
||||
BookmarkSetEvent.modifyDescription(
|
||||
earlierVersion = listEvent,
|
||||
newDescription = newDescription,
|
||||
signer = account.signer,
|
||||
@@ -226,13 +226,13 @@ class LabeledBookmarkListsState(
|
||||
}
|
||||
|
||||
suspend fun cloneBookmarkList(
|
||||
currentBookmarkList: LabeledBookmarkList,
|
||||
currentBookmarkList: BookmarkSet,
|
||||
customCloneName: String?,
|
||||
customCloneDescription: String?,
|
||||
account: Account,
|
||||
) {
|
||||
val clonedList =
|
||||
LabeledBookmarkListEvent.create(
|
||||
BookmarkSetEvent.create(
|
||||
title = customCloneName ?: currentBookmarkList.title,
|
||||
description = customCloneDescription ?: currentBookmarkList.description,
|
||||
publicBookmarks = currentBookmarkList.publicBookmarks.toList(),
|
||||
@@ -246,8 +246,8 @@ class LabeledBookmarkListsState(
|
||||
bookmarkListIdentifier: String,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val deletionEventTemplate = DeletionEvent.build(listOf(listEvent))
|
||||
val listEvent = getBookmarkSetEvent(bookmarkListIdentifier)
|
||||
val deletionEventTemplate = DeletionRequestEvent.build(listOf(listEvent))
|
||||
val deletionEvent = account.signer.sign(deletionEventTemplate)
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
}
|
||||
@@ -258,9 +258,9 @@ class LabeledBookmarkListsState(
|
||||
isBookmarkPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val currentBookmarkList = getBookmarkSetEvent(bookmarkListIdentifier)
|
||||
val updatedList =
|
||||
LabeledBookmarkListEvent.addBookmark(
|
||||
BookmarkSetEvent.addBookmark(
|
||||
earlierVersion = currentBookmarkList,
|
||||
bookmarkIdTag = bookmark,
|
||||
isPrivate = isBookmarkPrivate,
|
||||
@@ -275,9 +275,9 @@ class LabeledBookmarkListsState(
|
||||
isBookmarkCurrentlyPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val bookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val bookmarkList = getBookmarkSetEvent(bookmarkListIdentifier)
|
||||
val updatedList =
|
||||
LabeledBookmarkListEvent.moveBookmark(
|
||||
BookmarkSetEvent.moveBookmark(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = bookmark,
|
||||
isCurrentlyPrivate = isBookmarkCurrentlyPrivate,
|
||||
@@ -292,9 +292,9 @@ class LabeledBookmarkListsState(
|
||||
isBookmarkPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val currentBookmarkList = getBookmarkSetEvent(bookmarkListIdentifier)
|
||||
val updatedList =
|
||||
LabeledBookmarkListEvent.removeBookmark(
|
||||
BookmarkSetEvent.removeBookmark(
|
||||
earlierVersion = currentBookmarkList,
|
||||
bookmarkIdTag = bookmark,
|
||||
isPrivate = isBookmarkPrivate,
|
||||
@@ -311,7 +311,7 @@ class LabeledBookmarkListsState(
|
||||
) {
|
||||
if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return
|
||||
|
||||
val currentList = getLabeledBookmarkListNote(bookmarkListIdentifier)?.event as? LabeledBookmarkListEvent ?: return
|
||||
val currentList = getBookmarkSetNote(bookmarkListIdentifier)?.event as? BookmarkSetEvent ?: return
|
||||
|
||||
val newPublicTags =
|
||||
currentList.tags
|
||||
@@ -328,7 +328,7 @@ class LabeledBookmarkListsState(
|
||||
val updatedList =
|
||||
if (oldPrivateTags == null) {
|
||||
if (newPublicTags.size == currentList.tags.size) return
|
||||
LabeledBookmarkListEvent.resign(
|
||||
BookmarkSetEvent.resign(
|
||||
content = currentList.content,
|
||||
tags = newPublicTags,
|
||||
signer = account.signer,
|
||||
@@ -344,7 +344,7 @@ class LabeledBookmarkListsState(
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return
|
||||
LabeledBookmarkListEvent.resign(
|
||||
BookmarkSetEvent.resign(
|
||||
tags = newPublicTags,
|
||||
privateTags = newPrivateTags,
|
||||
signer = account.signer,
|
||||
+32
-32
@@ -18,16 +18,16 @@
|
||||
* 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.nip51Lists.relayFeeds
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -40,32 +40,32 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RelayFeedListState(
|
||||
class FavoriteRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: RelayFeedsListDecryptionCache,
|
||||
val decryptionCache: FavoriteRelayListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val relayFeedListNote = cache.getOrCreateAddressableNote(getRelayFeedsListAddress())
|
||||
val favoriteRelayListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress())
|
||||
|
||||
fun getRelayFeedsListAddress() = RelayFeedsListEvent.createAddress(signer.pubKey)
|
||||
fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getRelayFeedsListFlow(): StateFlow<NoteState> = relayFeedListNote.flow().metadata.stateFlow
|
||||
fun getFavoriteRelayListFlow(): StateFlow<NoteState> = favoriteRelayListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getRelayFeedsList(): RelayFeedsListEvent? = relayFeedListNote.event as? RelayFeedsListEvent
|
||||
fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteRelayListNote.event as? FavoriteRelayListEvent
|
||||
|
||||
fun relayFeedsListEvent(note: Note) = note.event as? RelayFeedsListEvent ?: settings.backupRelayFeedsList
|
||||
fun favoriteRelayListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList
|
||||
|
||||
suspend fun normalizeRelayFeedsListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
|
||||
suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = favoriteRelayListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
|
||||
|
||||
suspend fun normalizeRelayFeedsListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = favoriteRelayListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
val flow =
|
||||
getRelayFeedsListFlow()
|
||||
.map { normalizeRelayFeedsListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeRelayFeedsListWithBackup(relayFeedListNote)) }
|
||||
getFavoriteRelayListFlow()
|
||||
.map { normalizeFavoriteRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteRelayListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
@@ -74,9 +74,9 @@ class RelayFeedListState(
|
||||
)
|
||||
|
||||
val flowNoDefaults =
|
||||
getRelayFeedsListFlow()
|
||||
.map { normalizeRelayFeedsListWithBackupNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote)) }
|
||||
getFavoriteRelayListFlow()
|
||||
.map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
@@ -84,30 +84,30 @@ class RelayFeedListState(
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun addRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent {
|
||||
val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList()
|
||||
suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent {
|
||||
val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote).toMutableList()
|
||||
if (relay !in current) current.add(relay)
|
||||
return saveRelayList(current)
|
||||
}
|
||||
|
||||
suspend fun removeRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent? {
|
||||
val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList()
|
||||
suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? {
|
||||
val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote).toMutableList()
|
||||
if (relay !in current) return null
|
||||
current.remove(relay)
|
||||
return saveRelayList(current)
|
||||
}
|
||||
|
||||
suspend fun saveRelayList(relayFeeds: List<NormalizedRelayUrl>): RelayFeedsListEvent {
|
||||
val relayFeedsList = getRelayFeedsList()
|
||||
suspend fun saveRelayList(relayFeeds: List<NormalizedRelayUrl>): FavoriteRelayListEvent {
|
||||
val favoriteRelayList = getFavoriteRelayList()
|
||||
|
||||
return if (relayFeedsList != null && relayFeedsList.tags.isNotEmpty()) {
|
||||
RelayFeedsListEvent.updateRelayList(
|
||||
earlierVersion = relayFeedsList,
|
||||
return if (favoriteRelayList != null && favoriteRelayList.tags.isNotEmpty()) {
|
||||
FavoriteRelayListEvent.updateRelayList(
|
||||
earlierVersion = favoriteRelayList,
|
||||
relays = relayFeeds,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
RelayFeedsListEvent.create(
|
||||
FavoriteRelayListEvent.create(
|
||||
relays = relayFeeds,
|
||||
signer = signer,
|
||||
)
|
||||
@@ -115,7 +115,7 @@ class RelayFeedListState(
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupRelayFeedsList?.let {
|
||||
settings.backupFavoriteRelayList?.let {
|
||||
Log.d("AccountRegisterObservers") { "Loading saved relay feeds list ${it.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
|
||||
@@ -123,10 +123,10 @@ class RelayFeedListState(
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Relay feeds list Collector Start")
|
||||
getRelayFeedsListFlow().collect {
|
||||
getFavoriteRelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Updating Relay feeds list for ${signer.pubKey}" }
|
||||
(it.note.event as? RelayFeedsListEvent)?.let {
|
||||
settings.updateRelayFeedList(it)
|
||||
(it.note.event as? FavoriteRelayListEvent)?.let {
|
||||
settings.updateFavoriteRelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-29
@@ -18,7 +18,7 @@
|
||||
* 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.nip51Lists.peopleList
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
@@ -28,19 +28,19 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.amethyst.commons.model.eventIdSet
|
||||
import com.vitorpamplona.amethyst.commons.model.events
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList
|
||||
import com.vitorpamplona.amethyst.commons.model.updateFlow
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followSet.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.followSet.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.followSet.title
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.title
|
||||
import com.vitorpamplona.quartz.utils.flattenToSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -62,10 +62,10 @@ import java.util.UUID
|
||||
*
|
||||
* This class must receive updates from Large Cache as it receives new events.
|
||||
*/
|
||||
class PeopleListsState(
|
||||
class FollowSetsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: PeopleListDecryptionCache,
|
||||
val decryptionCache: FollowSetDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
@@ -78,8 +78,8 @@ class PeopleListsState(
|
||||
// can subscribe and fetch them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(PeopleListEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null }
|
||||
.filter(FollowSetEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.dTag() != FollowSetEvent.BLOCK_LIST_D_TAG || it.event != null }
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val peopleListVersions = MutableStateFlow(0)
|
||||
@@ -99,21 +99,21 @@ class PeopleListsState(
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val latestLists: StateFlow<List<PeopleListEvent>> =
|
||||
val latestLists: StateFlow<List<FollowSetEvent>> =
|
||||
peopleListNotes
|
||||
.transformLatest { emitAll(it.updateFlow<PeopleListEvent>()) }
|
||||
.transformLatest { emitAll(it.updateFlow<FollowSetEvent>()) }
|
||||
.onStart { emit(peopleListNotes.value.events()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
suspend fun PeopleListEvent.userIdSet() = decryptionCache.userIdSet(this)
|
||||
suspend fun FollowSetEvent.userIdSet() = decryptionCache.userIdSet(this)
|
||||
|
||||
suspend fun List<PeopleListEvent>.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet()
|
||||
suspend fun List<FollowSetEvent>.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet()
|
||||
|
||||
suspend fun List<PeopleListEvent>.mapGoodUsersToIdSet() =
|
||||
suspend fun List<FollowSetEvent>.mapGoodUsersToIdSet() =
|
||||
this
|
||||
.mapNotNull {
|
||||
if (it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG) {
|
||||
if (it.dTag() != FollowSetEvent.BLOCK_LIST_D_TAG) {
|
||||
it.userIdSet()
|
||||
} else {
|
||||
null
|
||||
@@ -127,7 +127,7 @@ class PeopleListsState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
suspend fun PeopleListEvent.toUI() =
|
||||
suspend fun FollowSetEvent.toUI() =
|
||||
PeopleList(
|
||||
identifierTag = this.dTag(),
|
||||
title = this.titleOrName() ?: this.dTag(),
|
||||
@@ -137,7 +137,7 @@ class PeopleListsState(
|
||||
publicMembers = cache.load(this.publicUsersIdSet()),
|
||||
)
|
||||
|
||||
suspend fun List<PeopleListEvent>.toUI() = this.map { it.toUI() }.sortedBy { it.title }
|
||||
suspend fun List<FollowSetEvent>.toUI() = this.map { it.toUI() }.sortedBy { it.title }
|
||||
|
||||
val uiListFlow =
|
||||
latestLists
|
||||
@@ -158,12 +158,12 @@ class PeopleListsState(
|
||||
.map { it.select(dTag) }
|
||||
.onStart { emit(selectList(dTag)) }
|
||||
|
||||
fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value)
|
||||
fun DeletionRequestEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(FollowSetEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is PeopleListEvent || (event is DeletionEvent && event.hasDeletedAnyPeopleList())
|
||||
event is FollowSetEvent || (event is DeletionRequestEvent && event.hasDeletedAnyPeopleList())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -191,7 +191,7 @@ class PeopleListsState(
|
||||
|
||||
fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier }
|
||||
|
||||
fun getPeopleList(noteIdentifier: String): PeopleListEvent = getPeopleListNote(noteIdentifier)?.event as PeopleListEvent
|
||||
fun getPeopleList(noteIdentifier: String): FollowSetEvent = getPeopleListNote(noteIdentifier)?.event as FollowSetEvent
|
||||
|
||||
fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint())
|
||||
|
||||
@@ -207,7 +207,7 @@ class PeopleListsState(
|
||||
): String {
|
||||
val dTag = UUID.randomUUID().toString()
|
||||
val newListTemplate =
|
||||
PeopleListEvent.build(
|
||||
FollowSetEvent.build(
|
||||
dTag = dTag,
|
||||
title = listName,
|
||||
publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
|
||||
@@ -252,7 +252,7 @@ class PeopleListsState(
|
||||
account: Account,
|
||||
) {
|
||||
val newList =
|
||||
PeopleListEvent.createListWithDescription(
|
||||
FollowSetEvent.createListWithDescription(
|
||||
dTag = UUID.randomUUID().toString(),
|
||||
title = customCloneName ?: currentPeopleList.title,
|
||||
description = customCloneDescription ?: currentPeopleList.description,
|
||||
@@ -268,7 +268,7 @@ class PeopleListsState(
|
||||
account: Account,
|
||||
) {
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
val deletionEvent = account.signer.sign(DeletionRequestEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this list would keep
|
||||
// re-creating an empty shell for its address (and render the dTag/UUID in the
|
||||
@@ -284,7 +284,7 @@ class PeopleListsState(
|
||||
) {
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val newList =
|
||||
PeopleListEvent.addUser(
|
||||
FollowSetEvent.addUser(
|
||||
earlierVersion = followListEvent,
|
||||
pubKeyHex = user.pubkeyHex,
|
||||
relayHint = user.bestRelayHint(),
|
||||
@@ -302,7 +302,7 @@ class PeopleListsState(
|
||||
) {
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val newList =
|
||||
PeopleListEvent.addUserFirst(
|
||||
FollowSetEvent.addUserFirst(
|
||||
earlierVersion = followListEvent,
|
||||
pubKeyHex = user.pubkeyHex,
|
||||
relayHint = user.bestRelayHint(),
|
||||
@@ -320,7 +320,7 @@ class PeopleListsState(
|
||||
) {
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val newList =
|
||||
PeopleListEvent.removeUser(
|
||||
FollowSetEvent.removeUser(
|
||||
earlierVersion = followListEvent,
|
||||
pubKeyHex = user.pubkeyHex,
|
||||
isUserPrivate = isPrivate,
|
||||
+28
-28
@@ -18,7 +18,7 @@
|
||||
* 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.nip51Lists.peopleList
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
@@ -28,22 +28,22 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.amethyst.commons.model.eventIdSet
|
||||
import com.vitorpamplona.amethyst.commons.model.events
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList
|
||||
import com.vitorpamplona.amethyst.commons.model.updateFlow
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.person
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.title
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.person
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.personFirst
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.removePerson
|
||||
import com.vitorpamplona.quartz.nip51Lists.starterPack.title
|
||||
import com.vitorpamplona.quartz.utils.flattenToSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -65,7 +65,7 @@ import java.util.UUID
|
||||
*
|
||||
* This class must receive updates from Large Cache as it receives new events.
|
||||
*/
|
||||
class FollowListsState(
|
||||
class StarterPacksState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
@@ -79,34 +79,34 @@ class FollowListsState(
|
||||
// them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(FollowListEvent.KIND, user.pubkeyHex)
|
||||
.filter(StarterPackEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val followListVersions = MutableStateFlow(0)
|
||||
|
||||
val followListNotes =
|
||||
val starterPackNotes =
|
||||
followListVersions
|
||||
.map { existingPeopleListNotes() }
|
||||
.onStart { emit(existingPeopleListNotes()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val followListsEventIds =
|
||||
followListNotes
|
||||
val starterPacksEventIds =
|
||||
starterPackNotes
|
||||
.map { it.eventIdSet() }
|
||||
.onStart { emit(followListNotes.value.eventIdSet()) }
|
||||
.onStart { emit(starterPackNotes.value.eventIdSet()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val latestLists: StateFlow<List<FollowListEvent>> =
|
||||
followListNotes
|
||||
.transformLatest { emitAll(it.updateFlow<FollowListEvent>()) }
|
||||
.onStart { emit(followListNotes.value.events()) }
|
||||
val latestLists: StateFlow<List<StarterPackEvent>> =
|
||||
starterPackNotes
|
||||
.transformLatest { emitAll(it.updateFlow<StarterPackEvent>()) }
|
||||
.onStart { emit(starterPackNotes.value.events()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun List<FollowListEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
|
||||
fun List<StarterPackEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
|
||||
|
||||
val allPeopleListProfiles: StateFlow<Set<HexKey>> =
|
||||
latestLists
|
||||
@@ -115,7 +115,7 @@ class FollowListsState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
fun FollowListEvent.toUI() =
|
||||
fun StarterPackEvent.toUI() =
|
||||
PeopleList(
|
||||
identifierTag = this.dTag(),
|
||||
title = this.title() ?: this.dTag(),
|
||||
@@ -125,7 +125,7 @@ class FollowListsState(
|
||||
publicMembers = cache.load(this.followIdSet()),
|
||||
)
|
||||
|
||||
fun List<FollowListEvent>.toUI() = this.map { it.toUI() }
|
||||
fun List<StarterPackEvent>.toUI() = this.map { it.toUI() }
|
||||
|
||||
val uiListFlow =
|
||||
latestLists
|
||||
@@ -148,12 +148,12 @@ class FollowListsState(
|
||||
|
||||
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
|
||||
|
||||
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value)
|
||||
fun DeletionRequestEvent.hasDeletedAnyStarterPack() = deleteAddressesWithKind(StarterPackEvent.KIND) || deletesAnyEventIn(starterPacksEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is FollowListEvent || (event is DeletionEvent && event.hasDeletedAnyFollowList())
|
||||
event is StarterPackEvent || (event is DeletionRequestEvent && event.hasDeletedAnyStarterPack())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -181,7 +181,7 @@ class FollowListsState(
|
||||
|
||||
fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier }
|
||||
|
||||
fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent
|
||||
fun getPeopleList(noteIdentifier: String): StarterPackEvent = getPeopleListNote(noteIdentifier)?.event as StarterPackEvent
|
||||
|
||||
fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint())
|
||||
|
||||
@@ -198,7 +198,7 @@ class FollowListsState(
|
||||
val dTag = UUID.randomUUID().toString()
|
||||
|
||||
val newListTemplate =
|
||||
FollowListEvent.build(
|
||||
StarterPackEvent.build(
|
||||
name = name,
|
||||
people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
|
||||
dTag = dTag,
|
||||
@@ -262,7 +262,7 @@ class FollowListsState(
|
||||
account: Account,
|
||||
) {
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
val deletionEvent = account.signer.sign(DeletionRequestEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this follow pack would
|
||||
// keep re-creating an empty shell for its address (and render the dTag/UUID in
|
||||
+26
-26
@@ -18,15 +18,15 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.interestLists
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists.InterestListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -40,30 +40,30 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HashtagListState(
|
||||
class InterestListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: HashtagListDecryptionCache,
|
||||
val decryptionCache: InterestListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val hashtagListNote = cache.getOrCreateAddressableNote(getHashtagListAddress())
|
||||
val hashtagListNote = cache.getOrCreateAddressableNote(getInterestListAddress())
|
||||
|
||||
fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey)
|
||||
fun getInterestListAddress() = InterestListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getHashtagListFlow(): StateFlow<NoteState> = hashtagListNote.flow().metadata.stateFlow
|
||||
fun getInterestListFlow(): StateFlow<NoteState> = hashtagListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getHashtagList(): HashtagListEvent? = hashtagListNote.event as? HashtagListEvent
|
||||
fun getInterestList(): InterestListEvent? = hashtagListNote.event as? InterestListEvent
|
||||
|
||||
suspend fun hashtagListWithBackup(note: Note): Set<String> {
|
||||
val event = note.event as? HashtagListEvent ?: settings.backupHashtagList
|
||||
val event = note.event as? InterestListEvent ?: settings.backupInterestList
|
||||
return event?.let { decryptionCache.hashtags(it).mapTo(mutableSetOf()) { it.lowercase() } } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<String>> =
|
||||
getHashtagListFlow()
|
||||
getInterestListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(hashtagListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
@@ -75,38 +75,38 @@ class HashtagListState(
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun follow(hashtags: List<String>): HashtagListEvent {
|
||||
val hashtagList = getHashtagList()
|
||||
suspend fun follow(hashtags: List<String>): InterestListEvent {
|
||||
val hashtagList = getInterestList()
|
||||
|
||||
return if (hashtagList == null) {
|
||||
HashtagListEvent.create(hashtags, true, signer)
|
||||
InterestListEvent.create(hashtags, true, signer)
|
||||
} else {
|
||||
HashtagListEvent.add(hashtagList, hashtags, true, signer)
|
||||
InterestListEvent.add(hashtagList, hashtags, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(hashtag: String): HashtagListEvent {
|
||||
val hashtagList = getHashtagList()
|
||||
suspend fun follow(hashtag: String): InterestListEvent {
|
||||
val hashtagList = getInterestList()
|
||||
|
||||
return if (hashtagList == null) {
|
||||
HashtagListEvent.create(hashtag.lowercase(), true, signer)
|
||||
InterestListEvent.create(hashtag.lowercase(), true, signer)
|
||||
} else {
|
||||
HashtagListEvent.add(hashtagList, hashtag.lowercase(), true, signer)
|
||||
InterestListEvent.add(hashtagList, hashtag.lowercase(), true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(hashtag: String): HashtagListEvent? {
|
||||
val hashtagList = getHashtagList()
|
||||
suspend fun unfollow(hashtag: String): InterestListEvent? {
|
||||
val hashtagList = getInterestList()
|
||||
|
||||
return if (hashtagList != null) {
|
||||
HashtagListEvent.remove(hashtagList, hashtag, signer)
|
||||
InterestListEvent.remove(hashtagList, hashtag, signer)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupHashtagList?.let { event ->
|
||||
settings.backupInterestList?.let { event ->
|
||||
Log.d("AccountRegisterObservers") { "Loading saved Hashtag list ${event.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -116,10 +116,10 @@ class HashtagListState(
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Hashtag List Collector Start")
|
||||
getHashtagListFlow().collect {
|
||||
getInterestListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Hashtag List for ${signer.pubKey}" }
|
||||
(it.note.event as? HashtagListEvent)?.let {
|
||||
settings.updateHashtagListTo(it)
|
||||
(it.note.event as? InterestListEvent)?.let {
|
||||
settings.updateInterestListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.Interest
|
||||
import com.vitorpamplona.amethyst.commons.model.updateFlow
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -106,12 +106,12 @@ class InterestSetsState(
|
||||
|
||||
fun getInterestSet(dTag: String) = listFeedFlow.value.getSet(dTag)
|
||||
|
||||
fun DeletionEvent.hasAnyDeletedInterestSets() = deleteAddressesWithKind(InterestSetEvent.KIND) || deletesAnyEventIn(interestSetEventIds.value)
|
||||
fun DeletionRequestEvent.hasAnyDeletedInterestSets() = deleteAddressesWithKind(InterestSetEvent.KIND) || deletesAnyEventIn(interestSetEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is InterestSetEvent || (event is DeletionEvent && event.hasAnyDeletedInterestSets())
|
||||
event is InterestSetEvent || (event is DeletionRequestEvent && event.hasAnyDeletedInterestSets())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -178,7 +178,7 @@ class InterestSetsState(
|
||||
account: Account,
|
||||
) {
|
||||
val event = getInterestSetEvent(identifier)
|
||||
val template = DeletionEvent.build(listOf(event))
|
||||
val template = DeletionRequestEvent.build(listOf(event))
|
||||
val deletionEvent = account.signer.sign(template)
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
}
|
||||
|
||||
+45
-87
@@ -20,110 +20,68 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip60Cashu
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
|
||||
/**
|
||||
* Per-account Cashu state that needs durable, synchronous persistence —
|
||||
* separate from [com.vitorpamplona.amethyst.model.AccountSettings] which
|
||||
* batches writes through a 1-second debounced StateFlow.
|
||||
* Android's per-account NUT-13 counter store: the shared
|
||||
* [DataStoreCashuCounterStore] over a file in the app's data directory.
|
||||
*
|
||||
* # Why a separate store
|
||||
* Two older layers feed into it, and neither may move a counter backwards:
|
||||
*
|
||||
* The NUT-13 keyset counter is the critical bit. Every mint / swap /
|
||||
* melt reserves counter slots, derives deterministic blinded outputs at
|
||||
* those slots, sends them to the mint, and the mint signs them. The
|
||||
* mint persists which (keyset, blind_message) pairs it has ever signed;
|
||||
* a second request to sign the same blind_message returns HTTP 400
|
||||
* "outputs already signed". So once the wallet hands a counter to the
|
||||
* mint, the local counter advance MUST survive a crash — otherwise the
|
||||
* next reservation pulls the same slot and the mint rejects it.
|
||||
* - `cashu_prefs_<npub>` SharedPreferences, copied in full on first read by
|
||||
* [CopyOnceMigration]. The copy happens inside the same atomic DataStore
|
||||
* write that records it happened, so a crash cannot leave the marker set
|
||||
* with the counters missing. It is a copy, not a move: the old file stays
|
||||
* intact, so a rolled-back build still finds its counters.
|
||||
* - `AccountSettings.cashuKeysetCounters`, an older in-settings map, still
|
||||
* applied per keyset through `seedIfMissing` on every read.
|
||||
*
|
||||
* The default settings save path debounces writes by 1000 ms, which is
|
||||
* exactly the race window between "we asked the mint to sign" and "the
|
||||
* mint replied". A crash inside that window (OOM, signer dialog dismiss,
|
||||
* unexpected process death) loses the counter advance and makes the
|
||||
* wallet unusable. This store writes via `commit = true` so each
|
||||
* reservation is durable before the function returns.
|
||||
*
|
||||
* # Layout
|
||||
*
|
||||
* One SharedPreferences file per account, named
|
||||
* `cashu_prefs_<npub>.xml`. Keys are flat:
|
||||
* - `counter_<keysetId>` → Long, the next free NUT-13 counter
|
||||
*
|
||||
* Plain (non-encrypted) prefs because keyset counters aren't secret —
|
||||
* they're not the seed, they don't carry value, and a leak would only
|
||||
* tell an attacker how many proofs the wallet has minted at each
|
||||
* keyset (a privacy signal at most).
|
||||
*
|
||||
* # Migration
|
||||
*
|
||||
* Older builds stored counters inside `AccountSettings.cashuKeysetCounters`.
|
||||
* On first read of a given keyset, callers should pre-seed the store
|
||||
* from the legacy map (one-time copy) so an upgrade doesn't reset the
|
||||
* counter to zero. See `AccountSettings.migrateCashuCountersTo` for
|
||||
* the helper.
|
||||
* Losing a counter here means restarting a keyset at zero and reusing
|
||||
* indices, which costs real ecash — so nothing on this path is best-effort.
|
||||
*/
|
||||
class CashuPreferences(
|
||||
private val prefs: SharedPreferences,
|
||||
) : CashuKeysetCounterStore {
|
||||
/** Inspect the next free counter for [keysetId] without advancing it. */
|
||||
@Synchronized
|
||||
override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
|
||||
object CashuPreferences {
|
||||
private const val LEGACY_FILE_PREFIX = "cashu_prefs_"
|
||||
|
||||
/** The store file name for [npub], as AppPreferenceStores takes it. */
|
||||
const val FILE_PREFIX = "cashu_"
|
||||
|
||||
fun fileName(npub: String) = FILE_PREFIX + npub
|
||||
|
||||
private val stores = LargeCache<String, CashuKeysetCounterStore>()
|
||||
|
||||
/**
|
||||
* Atomically reserve [count] consecutive NUT-13 counters for
|
||||
* [keysetId] and return the first reserved index. The write is
|
||||
* forced to disk with `commit = true` BEFORE returning — see the
|
||||
* class header for why this isn't optional.
|
||||
* Per-account instance, cached: DataStore refuses two live instances over
|
||||
* one file, and a second instance would defeat the single-writer
|
||||
* serialisation that `reserve` depends on.
|
||||
*/
|
||||
@Synchronized
|
||||
@SuppressLint("ApplySharedPref")
|
||||
override fun reserve(
|
||||
keysetId: String,
|
||||
count: Int,
|
||||
): Long {
|
||||
require(count > 0) { "Counter reservation must be positive" }
|
||||
val current = peek(keysetId)
|
||||
val next = current + count.toLong()
|
||||
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
|
||||
return current
|
||||
fun forAccount(npub: String): CashuKeysetCounterStore =
|
||||
stores.getOrCreate(npub) {
|
||||
DataStoreCashuCounterStore(Amethyst.instance.appStores.getDataStore(fileName(npub)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed [keysetId]'s counter from a legacy value found in
|
||||
* [AccountSettings.cashuKeysetCounters]. No-op when the store
|
||||
* already has a value at or above [legacyValue] — never moves the
|
||||
* counter backwards. Called once at wallet load to carry forward
|
||||
* pre-migration state.
|
||||
* The copy out of `cashu_prefs_<npub>`, wired to the file by AppModules
|
||||
* rather than attached here.
|
||||
*
|
||||
* DataStore runs a file's migrations when that file is first opened, and
|
||||
* the holder is what opens it, so the migration has to be registered with
|
||||
* the holder or it would never run.
|
||||
*/
|
||||
@Synchronized
|
||||
@SuppressLint("ApplySharedPref")
|
||||
override fun seedIfMissing(
|
||||
keysetId: String,
|
||||
legacyValue: Long,
|
||||
) {
|
||||
if (legacyValue <= 0L) return
|
||||
val current = peek(keysetId)
|
||||
if (current >= legacyValue) return
|
||||
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
|
||||
fun legacyMigration(
|
||||
context: Context,
|
||||
npub: String,
|
||||
) = CopyOnceMigration("migrated.cashuCounters") { out ->
|
||||
val legacy = context.getSharedPreferences("$LEGACY_FILE_PREFIX$npub", Context.MODE_PRIVATE)
|
||||
legacy.all.forEach { (key, value) ->
|
||||
if (key.startsWith(DataStoreCashuCounterStore.COUNTER_PREFIX) && value is Long) {
|
||||
out[longPreferencesKey(key)] = value
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_PREFIX = "cashu_prefs_"
|
||||
|
||||
private fun counterKey(keysetId: String) = "counter_$keysetId"
|
||||
|
||||
/** Per-account instance. [npub] keys the on-disk file so each account is isolated. */
|
||||
fun forAccount(npub: String): CashuPreferences {
|
||||
val context = Amethyst.instance.appContext
|
||||
val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE)
|
||||
return CashuPreferences(prefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
|
||||
@@ -486,7 +486,7 @@ class CashuWalletState(
|
||||
|
||||
// Process our own NIP-09 deletions inline rather than
|
||||
// relying on LocalCache's `deletedEventBundles`. That
|
||||
// path only fires when `consume(DeletionEvent)` finds
|
||||
// path only fires when `consume(DeletionRequestEvent)` finds
|
||||
// the target Note still resident in `notes` — a
|
||||
// LargeSoftCache backed by WeakReferences, which can
|
||||
// be cleared on any GC cycle. When the weak ref is
|
||||
@@ -503,7 +503,7 @@ class CashuWalletState(
|
||||
val ourDeleteIds =
|
||||
all
|
||||
.asSequence()
|
||||
.filterIsInstance<DeletionEvent>()
|
||||
.filterIsInstance<DeletionRequestEvent>()
|
||||
.filter { it.pubKey == pubKey }
|
||||
.flatMap { it.deleteEventIds().asSequence() }
|
||||
.toSet()
|
||||
@@ -1447,7 +1447,7 @@ class CashuWalletState(
|
||||
}
|
||||
val redundantIds = redundant.map { it.event.id }.toSet()
|
||||
runCatching {
|
||||
val template = DeletionEvent.build(redundant.map { it.event })
|
||||
val template = DeletionRequestEvent.build(redundant.map { it.event })
|
||||
val signed = signer.sign(template)
|
||||
publishEvent(signed)
|
||||
}.onFailure {
|
||||
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import java.io.File
|
||||
|
||||
class AccountPreferenceStores(
|
||||
val rootFilesDir: () -> File,
|
||||
) {
|
||||
companion object {
|
||||
val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList")
|
||||
val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList")
|
||||
val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList")
|
||||
val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList")
|
||||
|
||||
val localRelayServers = stringPreferencesKey("localRelayServers")
|
||||
val defaultFileServer = stringPreferencesKey("defaultFileServer")
|
||||
|
||||
val latestUserMetadata = stringPreferencesKey("latestUserMetadata")
|
||||
val latestContactList = stringPreferencesKey("latestContactList")
|
||||
val latestDMRelayList = stringPreferencesKey("latestDMRelayList")
|
||||
val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList")
|
||||
val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList")
|
||||
val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList")
|
||||
val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList")
|
||||
val latestMuteList = stringPreferencesKey("latestMuteList")
|
||||
val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList")
|
||||
val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData")
|
||||
val latestChannelList = stringPreferencesKey("latestChannelList")
|
||||
val latestCommunityList = stringPreferencesKey("latestCommunityList")
|
||||
val latestHashtagList = stringPreferencesKey("latestHashtagList")
|
||||
val latestGeohashList = stringPreferencesKey("latestGeohashList")
|
||||
val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList")
|
||||
|
||||
val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog")
|
||||
val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog")
|
||||
val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog")
|
||||
|
||||
val torSettings = stringPreferencesKey("tor_settings")
|
||||
|
||||
val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion")
|
||||
}
|
||||
|
||||
private val storeCache = LargeCache<String, DataStore<Preferences>>()
|
||||
|
||||
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences")
|
||||
|
||||
private fun getDataStore(npub: String): DataStore<Preferences> =
|
||||
storeCache.getOrCreate(npub) {
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { file(npub) },
|
||||
)
|
||||
}
|
||||
|
||||
fun removeAccount(npub: String): Boolean {
|
||||
val deleted = file(npub).delete()
|
||||
storeCache.remove(npub)
|
||||
return deleted
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.io.File
|
||||
|
||||
class AccountSecretsEncryptedStores(
|
||||
val rootFilesDir: () -> File,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
companion object Companion {
|
||||
val encryption = KeyStoreEncryption()
|
||||
val key = stringPreferencesKey("privKey")
|
||||
val nwc = stringPreferencesKey("nwc")
|
||||
}
|
||||
|
||||
private val storeCache = LargeCache<String, EncryptedDataStore>()
|
||||
|
||||
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets")
|
||||
|
||||
private fun getDataStore(npub: String): EncryptedDataStore =
|
||||
storeCache.getOrCreate(npub) {
|
||||
EncryptedDataStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { file(npub) },
|
||||
),
|
||||
encryption,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key)
|
||||
|
||||
suspend fun savePrivateKey(
|
||||
npub: String,
|
||||
value: HexKey,
|
||||
) {
|
||||
getDataStore(npub).save(key, value)
|
||||
}
|
||||
|
||||
suspend fun nwc(npub: String): UpdatablePropertyFlow<Nip47WalletConnect.Nip47URI> =
|
||||
getDataStore(npub).getProperty(
|
||||
key = nwc,
|
||||
parser = Nip47WalletConnect.Nip47URI::parser,
|
||||
serializer = Nip47WalletConnect.Nip47URI::serializer,
|
||||
)
|
||||
|
||||
fun removeAccount(npub: String): Boolean {
|
||||
val deleted = file(npub).delete()
|
||||
storeCache.remove(npub)
|
||||
return deleted
|
||||
}
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
class EncryptedDataStore(
|
||||
private val store: DataStore<Preferences>,
|
||||
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private fun decode(str: String): ByteArray = Base64.decode(str)
|
||||
|
||||
private fun encode(bytes: ByteArray): String = Base64.encode(bytes)
|
||||
|
||||
private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray()))
|
||||
|
||||
private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString()
|
||||
|
||||
suspend fun remove(key: Preferences.Key<String>) {
|
||||
store.edit { prefs ->
|
||||
prefs.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
key: Preferences.Key<String>,
|
||||
value: String,
|
||||
) {
|
||||
store.edit { prefs ->
|
||||
prefs[key] = encrypt(value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(key: Preferences.Key<String>): String? =
|
||||
store.data
|
||||
.catch { e ->
|
||||
if (e is IOException) emit(emptyPreferences()) else throw e
|
||||
}.firstOrNull()
|
||||
?.get(key)
|
||||
?.let { decrypt(it) }
|
||||
|
||||
fun <T> getProperty(
|
||||
key: Preferences.Key<String>,
|
||||
parser: (String) -> T,
|
||||
serializer: (T) -> String,
|
||||
): UpdatablePropertyFlow<T> =
|
||||
UpdatablePropertyFlow(
|
||||
flow =
|
||||
store.data
|
||||
.catch { e ->
|
||||
if (e is IOException) emit(emptyPreferences()) else throw e
|
||||
}.map { prefs ->
|
||||
val value = prefs[key]
|
||||
if (value != null) {
|
||||
val decrypted = decrypt(value)
|
||||
if (decrypted.isNotBlank()) {
|
||||
parser(decrypted)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
update = { newValue ->
|
||||
if (newValue != null) {
|
||||
val serialized = serializer(newValue)
|
||||
if (serialized.isNotBlank()) {
|
||||
save(key, serialized)
|
||||
} else {
|
||||
remove(key)
|
||||
}
|
||||
} else {
|
||||
remove(key)
|
||||
}
|
||||
},
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
-322
@@ -1,322 +0,0 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.AccentColorType
|
||||
import com.vitorpamplona.amethyst.commons.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.commons.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.commons.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.commons.model.FontFamilyType
|
||||
import com.vitorpamplona.amethyst.commons.model.FontSizeType
|
||||
import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType
|
||||
import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
|
||||
|
||||
@Stable
|
||||
class UiSharedPreferences(
|
||||
prefs: UiSettings,
|
||||
val context: Context,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
|
||||
val value = UiSettingsFlow.build(prefs)
|
||||
|
||||
val languageUpdate =
|
||||
value.preferredLanguage
|
||||
.onEach { language -> applyLanguage(language) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
value.toSettings(),
|
||||
)
|
||||
|
||||
val nightModeUpdate =
|
||||
value.theme
|
||||
.onEach { theme -> applyNightMode(theme) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
prefs.theme,
|
||||
)
|
||||
|
||||
/**
|
||||
* Mirrors the in-app theme choice into the system's *per-application* night mode, so the
|
||||
* launch splash agrees with a theme that is pinned against the phone's own light/dark setting.
|
||||
*
|
||||
* The system composites the splash from the manifest theme before the process starts, resolving
|
||||
* it against this app's configuration -- so day/night resource qualifiers alone can only ever
|
||||
* follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package
|
||||
* configuration override* (UiModeManagerService hands it to
|
||||
* ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when
|
||||
* it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from
|
||||
* the next cold start onwards -- the current launch is already painted.
|
||||
*
|
||||
* This is deliberately [UiModeManager.setApplicationNightMode] and not
|
||||
* [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device
|
||||
* and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a
|
||||
* silent no-op and was removed. The per-application setter is the documented app-local
|
||||
* alternative and is not permission-checked; UiModeManagerService only validates the argument.
|
||||
*
|
||||
* MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other
|
||||
* than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets
|
||||
* the app fall back to the device configuration.
|
||||
*
|
||||
* Not deduplicated, deliberately. There is no public getter for the per-application override,
|
||||
* so the only way to skip a repeat call would be to shadow it in our own store -- a cache of
|
||||
* state we do not own, which goes stale silently and takes the splash with it. Re-sending the
|
||||
* value on every launch is self-healing instead, and the platform already no-ops the expensive
|
||||
* half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is
|
||||
* unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on
|
||||
* having actually changed. What remains is one Binder round trip per launch, off the main
|
||||
* thread. (This is why the deduplication in applyLanguage below does not generalise here: it
|
||||
* compares against getApplicationLocales(), the authoritative value, not a private copy.)
|
||||
*
|
||||
* MainActivity declares `uiMode` in its `configChanges`, so any change that does result is
|
||||
* delivered to `onConfigurationChanged` rather than recreating the activity.
|
||||
*/
|
||||
private suspend fun applyNightMode(theme: ThemeType) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
|
||||
val mode =
|
||||
when (theme) {
|
||||
ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES
|
||||
ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO
|
||||
ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
|
||||
try {
|
||||
context.getSystemService<UiModeManager>()?.setApplicationNightMode(mode)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the preferred language into AppCompat, skipping the call when the app already
|
||||
* runs in that locale.
|
||||
*
|
||||
* On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call
|
||||
* is a blocking Binder round trip into the system's LocaleManagerService, which commits a
|
||||
* SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning.
|
||||
* That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since
|
||||
* this flow starts eagerly, the app paid it on the main thread on every launch, even when
|
||||
* the locale had not changed since the previous run -- and StrictMode reported it as a
|
||||
* DiskReadViolation via the Binder call.
|
||||
*
|
||||
* [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the
|
||||
* comparison runs off the main thread. Actual changes still hop to the main thread:
|
||||
* below API 33 AppCompat applies them in process by reconfiguring (and possibly
|
||||
* recreating) the active activities.
|
||||
*/
|
||||
private suspend fun applyLanguage(language: String?) {
|
||||
val newLocales = LocaleListCompat.forLanguageTags(language)
|
||||
if (newLocales == AppCompatDelegate.getApplicationLocales()) return
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(newLocales)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val saving =
|
||||
value.propertyWatchFlow
|
||||
.debounce(1000)
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
save(it, context)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
value.toSettings(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
// loads faster when individualized
|
||||
val UI_THEME = stringPreferencesKey("ui.theme")
|
||||
val UI_LANGUAGE = stringPreferencesKey("ui.language")
|
||||
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
|
||||
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
|
||||
val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos")
|
||||
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
|
||||
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
|
||||
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
|
||||
val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector")
|
||||
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
|
||||
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
|
||||
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
|
||||
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
|
||||
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
|
||||
val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts")
|
||||
val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab")
|
||||
val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab")
|
||||
val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab")
|
||||
val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges")
|
||||
val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations")
|
||||
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
|
||||
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
|
||||
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
|
||||
val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect")
|
||||
val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color")
|
||||
val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family")
|
||||
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
|
||||
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
|
||||
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
|
||||
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
|
||||
|
||||
suspend fun uiPreferences(context: Context): UiSettings? =
|
||||
try {
|
||||
// Get the preference flow and take the first value.
|
||||
val preferences = context.sharedPreferencesDataStore.data.first()
|
||||
|
||||
val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED
|
||||
|
||||
UiSettings(
|
||||
theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM,
|
||||
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
|
||||
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
|
||||
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
|
||||
automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
|
||||
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
|
||||
dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false,
|
||||
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
|
||||
featureSet = featureSet,
|
||||
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
|
||||
automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||
useTrackedBroadcasts =
|
||||
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
|
||||
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
|
||||
automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||
showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true,
|
||||
showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true,
|
||||
showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false,
|
||||
showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true,
|
||||
showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true,
|
||||
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
|
||||
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
|
||||
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
|
||||
suggestWorkoutsFromHealthConnect =
|
||||
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||
accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE,
|
||||
fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM,
|
||||
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
|
||||
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
|
||||
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
|
||||
showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Log any errors that occur while reading the DataStore.
|
||||
Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" }
|
||||
|
||||
try {
|
||||
val oldVersion = LocalPreferences.loadSharedSettings()
|
||||
if (oldVersion != null) {
|
||||
save(oldVersion, context)
|
||||
}
|
||||
oldVersion
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
sharedSettings: UiSettings,
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { preferences ->
|
||||
preferences[UI_THEME] = sharedSettings.theme.name
|
||||
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
|
||||
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
|
||||
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
|
||||
preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name
|
||||
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
|
||||
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
|
||||
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
|
||||
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
|
||||
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
|
||||
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
|
||||
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
|
||||
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
||||
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
|
||||
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
|
||||
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
|
||||
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
|
||||
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
|
||||
preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges
|
||||
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
|
||||
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
|
||||
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
|
||||
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
|
||||
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
|
||||
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
|
||||
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
|
||||
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
|
||||
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
||||
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
||||
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Log any errors that occur while reading the DataStore.
|
||||
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* The Android half of the UI settings: the flows the app observes, and the two
|
||||
* platform side effects that a theme or language change has to perform.
|
||||
*
|
||||
* Persistence is [UiSettingsStore] in `commons`, which every front end shares.
|
||||
* What stays here is the part that has no desktop equivalent — the per-app night
|
||||
* mode override that the launch splash reads, and AppCompat's locale list.
|
||||
*/
|
||||
@Stable
|
||||
class UiSharedPreferences(
|
||||
prefs: UiSettings,
|
||||
dataStore: DataStore<Preferences>,
|
||||
val context: Context,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private val store = UiSettingsStore(dataStore)
|
||||
|
||||
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
|
||||
val value = UiSettingsFlow.build(prefs)
|
||||
|
||||
val languageUpdate =
|
||||
value.preferredLanguage
|
||||
.onEach { language -> applyLanguage(language) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
value.toSettings(),
|
||||
)
|
||||
|
||||
val nightModeUpdate =
|
||||
value.theme
|
||||
.onEach { theme -> applyNightMode(theme) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
prefs.theme,
|
||||
)
|
||||
|
||||
/**
|
||||
* Mirrors the in-app theme choice into the system's *per-application* night mode, so the
|
||||
* launch splash agrees with a theme that is pinned against the phone's own light/dark setting.
|
||||
*
|
||||
* The system composites the splash from the manifest theme before the process starts, resolving
|
||||
* it against this app's configuration -- so day/night resource qualifiers alone can only ever
|
||||
* follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package
|
||||
* configuration override* (UiModeManagerService hands it to
|
||||
* ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when
|
||||
* it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from
|
||||
* the next cold start onwards -- the current launch is already painted.
|
||||
*
|
||||
* This is deliberately [UiModeManager.setApplicationNightMode] and not
|
||||
* [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device
|
||||
* and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a
|
||||
* silent no-op and was removed. The per-application setter is the documented app-local
|
||||
* alternative and is not permission-checked; UiModeManagerService only validates the argument.
|
||||
*
|
||||
* MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other
|
||||
* than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets
|
||||
* the app fall back to the device configuration.
|
||||
*
|
||||
* Not deduplicated, deliberately. There is no public getter for the per-application override,
|
||||
* so the only way to skip a repeat call would be to shadow it in our own store -- a cache of
|
||||
* state we do not own, which goes stale silently and takes the splash with it. Re-sending the
|
||||
* value on every launch is self-healing instead, and the platform already no-ops the expensive
|
||||
* half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is
|
||||
* unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on
|
||||
* having actually changed. What remains is one Binder round trip per launch, off the main
|
||||
* thread. (This is why the deduplication in applyLanguage below does not generalise here: it
|
||||
* compares against getApplicationLocales(), the authoritative value, not a private copy.)
|
||||
*
|
||||
* MainActivity declares `uiMode` in its `configChanges`, so any change that does result is
|
||||
* delivered to `onConfigurationChanged` rather than recreating the activity.
|
||||
*/
|
||||
private suspend fun applyNightMode(theme: ThemeType) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
|
||||
val mode =
|
||||
when (theme) {
|
||||
ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES
|
||||
ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO
|
||||
ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
|
||||
try {
|
||||
context.getSystemService<UiModeManager>()?.setApplicationNightMode(mode)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the preferred language into AppCompat, skipping the call when the app already
|
||||
* runs in that locale.
|
||||
*
|
||||
* On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call
|
||||
* is a blocking Binder round trip into the system's LocaleManagerService, which commits a
|
||||
* SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning.
|
||||
* That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since
|
||||
* this flow starts eagerly, the app paid it on the main thread on every launch, even when
|
||||
* the locale had not changed since the previous run -- and StrictMode reported it as a
|
||||
* DiskReadViolation via the Binder call.
|
||||
*
|
||||
* [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the
|
||||
* comparison runs off the main thread. Actual changes still hop to the main thread:
|
||||
* below API 33 AppCompat applies them in process by reconfiguring (and possibly
|
||||
* recreating) the active activities.
|
||||
*/
|
||||
private suspend fun applyLanguage(language: String?) {
|
||||
val newLocales = LocaleListCompat.forLanguageTags(language)
|
||||
if (newLocales == AppCompatDelegate.getApplicationLocales()) return
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(newLocales)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val saving =
|
||||
value.propertyWatchFlow
|
||||
.debounce(1000)
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
store.save(it)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
value.toSettings(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
suspend fun uiPreferences(dataStore: DataStore<Preferences>): UiSettings? = UiSettingsStore(dataStore).load()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.commons.service.http.ProxiedSocketFactory
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
+15
-15
@@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.model.serverList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.interestLists.InterestListState
|
||||
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
|
||||
@@ -44,9 +44,9 @@ import kotlinx.coroutines.flow.stateIn
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class MergedFollowListsState(
|
||||
val kind3List: Kind3FollowListState,
|
||||
val peopleList: PeopleListsState,
|
||||
val followList: FollowListsState,
|
||||
val hashtagList: HashtagListState,
|
||||
val followSets: FollowSetsState,
|
||||
val starterPacks: StarterPacksState,
|
||||
val interestList: InterestListState,
|
||||
val geohashList: GeohashListState,
|
||||
val communityList: CommunityListState,
|
||||
val scope: CoroutineScope,
|
||||
@@ -85,9 +85,9 @@ class MergedFollowListsState(
|
||||
combine(
|
||||
listOf(
|
||||
kind3List.flow,
|
||||
peopleList.allGoodPeopleListProfiles,
|
||||
followList.allPeopleListProfiles,
|
||||
hashtagList.flow,
|
||||
followSets.allGoodPeopleListProfiles,
|
||||
starterPacks.allPeopleListProfiles,
|
||||
interestList.flow,
|
||||
geohashList.flow,
|
||||
communityList.flow,
|
||||
),
|
||||
@@ -104,9 +104,9 @@ class MergedFollowListsState(
|
||||
emit(
|
||||
mergeLists(
|
||||
kind3List.flow.value,
|
||||
peopleList.allGoodPeopleListProfiles.value,
|
||||
followList.allPeopleListProfiles.value,
|
||||
hashtagList.flow.value,
|
||||
followSets.allGoodPeopleListProfiles.value,
|
||||
starterPacks.allPeopleListProfiles.value,
|
||||
interestList.flow.value,
|
||||
geohashList.flow.value,
|
||||
communityList.flow.value,
|
||||
),
|
||||
@@ -118,9 +118,9 @@ class MergedFollowListsState(
|
||||
SharingStarted.Eagerly,
|
||||
mergeLists(
|
||||
kind3List.flow.value,
|
||||
peopleList.allGoodPeopleListProfiles.value,
|
||||
followList.allPeopleListProfiles.value,
|
||||
hashtagList.flow.value,
|
||||
followSets.allGoodPeopleListProfiles.value,
|
||||
starterPacks.allPeopleListProfiles.value,
|
||||
interestList.flow.value,
|
||||
geohashList.flow.value,
|
||||
communityList.flow.value,
|
||||
),
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
-7
@@ -20,19 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions")
|
||||
|
||||
/**
|
||||
* Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a
|
||||
* dedicated DataStore. Keyed by `"<coordinate>\u0000<capability>"` so a coordinate's grants can
|
||||
@@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletPermissionStore {
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
|
||||
|
||||
/**
|
||||
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
|
||||
* every read and write to that account's namespace with no rebuild — a grant made by one account
|
||||
|
||||
@@ -20,17 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletStorage
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
|
||||
|
||||
/**
|
||||
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
|
||||
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
|
||||
@@ -44,9 +40,6 @@ class DataStoreNappletStorage(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletStorage {
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
|
||||
|
||||
override suspend fun get(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
|
||||
@@ -43,9 +43,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.WebShortcuts
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
@@ -92,7 +89,7 @@ class NappletBrokerService : Service() {
|
||||
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
|
||||
private val storage by lazy { DataStoreNappletStorage(Amethyst.instance.appStores.getDataStore("napplet_storage"), Amethyst.instance.nappletAccountScope) }
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
@@ -205,8 +202,9 @@ class NappletBrokerService : Service() {
|
||||
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
BrowserHistoryRegistry.init(applicationContext)
|
||||
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
|
||||
val history = Amethyst.instance.browserHistory
|
||||
history.init()
|
||||
history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -215,8 +213,9 @@ class NappletBrokerService : Service() {
|
||||
val data = msg.data ?: return true
|
||||
val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true
|
||||
BrowserIconRegistry.init(applicationContext)
|
||||
BrowserIconRegistry.record(host, bytes)
|
||||
val icons = Amethyst.instance.browserIcons
|
||||
icons.init()
|
||||
icons.record(host, bytes)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -225,18 +224,20 @@ class NappletBrokerService : Service() {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
|
||||
FavoriteAppsRegistry.init(applicationContext)
|
||||
val favorites = Amethyst.instance.favoriteApps
|
||||
favorites.init()
|
||||
val id = "url:$url"
|
||||
// The star sends the state it wants (it flips what it shows); an older client sends none: toggle.
|
||||
val target =
|
||||
if (data.containsKey(NappletIpc.KEY_FAVORITE_IS_FAVORITE)) {
|
||||
data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE)
|
||||
} else {
|
||||
!FavoriteAppsRegistry.isFavorite(id)
|
||||
!favorites.isFavorite(id)
|
||||
}
|
||||
if (target) {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
|
||||
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
|
||||
} else {
|
||||
FavoriteAppsRegistry.remove(id)
|
||||
favorites.remove(id)
|
||||
}
|
||||
msg.replyTo?.let { replyWebFavoriteState(it, url) }
|
||||
return true
|
||||
@@ -246,7 +247,7 @@ class NappletBrokerService : Service() {
|
||||
if (msg.what == NappletIpc.MSG_QUERY_WEB_FAVORITE) {
|
||||
val replyTo = msg.replyTo ?: return true
|
||||
val url = msg.data?.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
FavoriteAppsRegistry.init(applicationContext)
|
||||
Amethyst.instance.favoriteApps.init()
|
||||
replyWebFavoriteState(replyTo, url)
|
||||
return true
|
||||
}
|
||||
@@ -287,7 +288,7 @@ class NappletBrokerService : Service() {
|
||||
if (msg.what == NappletIpc.MSG_ADD_TO_HOME_SCREEN) {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") } ?: return true
|
||||
BrowserIconRegistry.init(applicationContext)
|
||||
Amethyst.instance.browserIcons.init()
|
||||
WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty())
|
||||
return true
|
||||
}
|
||||
@@ -529,7 +530,7 @@ class NappletBrokerService : Service() {
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_FAVORITE_URL, url)
|
||||
putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, FavoriteAppsRegistry.isFavorite("url:$url"))
|
||||
putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, Amethyst.instance.favoriteApps.isFavorite("url:$url"))
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
@@ -62,12 +63,11 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import org.jetbrains.compose.resources.PluralStringResource
|
||||
@@ -95,7 +95,7 @@ class NappletConsentSummary(
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
host to Amethyst.instance.browserIcons.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ class NappletConsentSummary(
|
||||
oneRemoved = Res.string.napplet_consent_diff_unmute_one,
|
||||
)
|
||||
// Deletions have no prior version to compare against — the tags are the whole request.
|
||||
DeletionEvent.KIND ->
|
||||
DeletionRequestEvent.KIND ->
|
||||
pluralFor(Res.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
|
||||
?.let { Consequence(it) }
|
||||
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
|
||||
|
||||
+15
-5
@@ -21,9 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -32,7 +34,15 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network")
|
||||
/**
|
||||
* The per-napplet routing file, on the app-wide holder rather than a `Context` delegate.
|
||||
* Same path the delegate resolved to, so nothing migrates.
|
||||
*
|
||||
* Main process only: [Amethyst.instance] is deliberately unset in the
|
||||
* `:napplet` sandbox.
|
||||
*/
|
||||
private val nappletNetworkDataStore: DataStore<Preferences>
|
||||
get() = Amethyst.instance.appStores.getDataStore("napplet_network")
|
||||
|
||||
/**
|
||||
* Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default)
|
||||
@@ -69,7 +79,7 @@ object NappletNetworkRegistry {
|
||||
appContext = ctx
|
||||
hydration =
|
||||
scope.launch {
|
||||
ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
// putIfAbsent: never clobber a choice made in this session before hydration finished.
|
||||
modes.putIfAbsent(key.name, value != OPEN_WEB)
|
||||
}
|
||||
@@ -95,9 +105,9 @@ object NappletNetworkRegistry {
|
||||
useTor: Boolean,
|
||||
) {
|
||||
modes[coordinate] = useTor
|
||||
val ctx = appContext ?: return
|
||||
appContext ?: return
|
||||
scope.launch {
|
||||
ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
|
||||
nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
@@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_fo
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -83,7 +83,7 @@ suspend fun buildSignerConsentInfo(
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
host to Amethyst.instance.browserIcons.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ suspend fun buildConnectInfo(
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
host to Amethyst.instance.browserIcons.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,7 +35,16 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network")
|
||||
/**
|
||||
* The per-site routing file, on the app-wide holder rather than a `Context`
|
||||
* delegate. Same path the delegate resolved to, so nothing migrates.
|
||||
*
|
||||
* Main process only: [Amethyst.instance] is deliberately unset in the
|
||||
* `:napplet` sandbox, and this registry is only touched from the browser
|
||||
* chrome that runs in the main process.
|
||||
*/
|
||||
private val webUrlNetworkDataStore: DataStore<Preferences>
|
||||
get() = Amethyst.instance.appStores.getDataStore("weburl_network")
|
||||
|
||||
/**
|
||||
* Per-web-client network-routing preference: whether a favorited URL / browsed site routes through
|
||||
@@ -67,7 +78,7 @@ object WebAppNetworkRegistry {
|
||||
appContext = ctx
|
||||
hydration =
|
||||
scope.launch {
|
||||
ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
// putIfAbsent: never clobber a choice made in this session before hydration finished.
|
||||
modes.putIfAbsent(key.name, value != OPEN_WEB)
|
||||
}
|
||||
@@ -98,9 +109,9 @@ object WebAppNetworkRegistry {
|
||||
) {
|
||||
val host = hostKeyOf(url)
|
||||
modes[host] = useTor
|
||||
val ctx = appContext ?: return
|
||||
appContext ?: return
|
||||
scope.launch {
|
||||
ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
|
||||
webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -21,9 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -36,7 +38,15 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val Context.webSitePermissionDataStore by preferencesDataStore(name = "web_site_permissions")
|
||||
/**
|
||||
* The per-site permission file, on the app-wide holder rather than a `Context` delegate (the same
|
||||
* `filesDir/datastore/web_site_permissions.preferences_pb` path the delegate used).
|
||||
*
|
||||
* Main process only: [Amethyst.instance] is deliberately unset in the `:napplet` sandbox, which reaches
|
||||
* these answers through the broker.
|
||||
*/
|
||||
private val webSitePermissionDataStore: DataStore<Preferences>
|
||||
get() = Amethyst.instance.appStores.getDataStore("web_site_permissions")
|
||||
|
||||
/**
|
||||
* The user's answers to web sites' camera / microphone / location requests, per **origin**
|
||||
@@ -66,7 +76,7 @@ object WebSitePermissionRegistry {
|
||||
appContext = ctx
|
||||
scope.launch {
|
||||
val loaded = mutableMapOf<String, MutableMap<BrowserSitePermission, Decision>>()
|
||||
ctx.webSitePermissionDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
webSitePermissionDataStore.data.first().asMap().forEach { (key, value) ->
|
||||
val origin = key.name.substringBeforeLast('|')
|
||||
val permission = BrowserSitePermission.fromKey(key.name.substringAfterLast('|')) ?: return@forEach
|
||||
val decision = runCatching { Decision.valueOf(value.toString()) }.getOrNull() ?: return@forEach
|
||||
@@ -94,9 +104,9 @@ object WebSitePermissionRegistry {
|
||||
if (decision == Decision.ASK) forOrigin.remove(permission) else forOrigin[permission] = decision
|
||||
if (forOrigin.isEmpty()) current - origin else current + (origin to forOrigin)
|
||||
}
|
||||
val ctx = appContext ?: return
|
||||
if (appContext == null) return
|
||||
scope.launch {
|
||||
ctx.webSitePermissionDataStore.edit { prefs ->
|
||||
webSitePermissionDataStore.edit { prefs ->
|
||||
val key = stringPreferencesKey("$origin|${permission.key}")
|
||||
if (decision == Decision.ASK) prefs.remove(key) else prefs[key] = decision.name
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import kotlinx.serialization.json.add
|
||||
@@ -137,7 +137,7 @@ class AccountIdentityReader(
|
||||
val receipts =
|
||||
cache
|
||||
.filter(Filter(kinds = listOf(9735), tags = mapOf("p" to listOf(pubkey))))
|
||||
.mapNotNull { it.event as? LnZapEvent }
|
||||
.mapNotNull { it.event as? ZapReceiptEvent }
|
||||
return buildJsonArray {
|
||||
receipts.forEach { zap ->
|
||||
addJsonObject {
|
||||
|
||||
@@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
@@ -91,7 +91,7 @@ class V4VPaymentHandler(
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoicesViaIntent: (invoices: List<String>) -> Unit,
|
||||
asZap: Boolean = false,
|
||||
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val shares = value.computeShares(totalMilliSats)
|
||||
if (shares.isEmpty()) {
|
||||
@@ -190,7 +190,7 @@ class V4VPaymentHandler(
|
||||
shares: List<PodcastValueShare>,
|
||||
message: String,
|
||||
asZap: Boolean,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
zappedNote: Note?,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
context: Context,
|
||||
|
||||
@@ -48,8 +48,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
@@ -79,7 +79,7 @@ class ZapPaymentHandler(
|
||||
// The signed kind 9734 this invoice was fetched with, and the message on it.
|
||||
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
|
||||
// a NONZAP split, which has no zap request to send.
|
||||
val zapRequest: LnZapRequestEvent? = null,
|
||||
val zapRequest: ZapRequestEvent? = null,
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ class ZapPaymentHandler(
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val noteEvent = note.event
|
||||
val zapSplitSetup = noteEvent?.zapSplitSetup()
|
||||
@@ -297,7 +297,7 @@ class ZapPaymentHandler(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
totalAmountMilliSats: Long,
|
||||
totalWeight: Double,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
@@ -360,14 +360,14 @@ class ZapPaymentHandler(
|
||||
|
||||
class ZapRequestReady(
|
||||
val inputSetup: MyZapSplitSetup,
|
||||
val zapRequest: LnZapRequestEvent?,
|
||||
val zapRequest: ZapRequestEvent?,
|
||||
)
|
||||
|
||||
suspend fun signAllZapRequests(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
zapsToSend: List<MyZapSplitSetup>,
|
||||
totalAmountMilliSats: Long,
|
||||
// Shared across the lightning + BOLT12 lanes so a mixed split stays proportional.
|
||||
@@ -389,7 +389,7 @@ class ZapPaymentHandler(
|
||||
val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32)
|
||||
|
||||
val zapRequest =
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
if (zapType != ZapReceiptEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
account.zaps.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = pollOption,
|
||||
@@ -535,7 +535,7 @@ class ZapPaymentHandler(
|
||||
totalAmountMilliSats: Long,
|
||||
totalWeight: Double,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapType: ZapReceiptEvent.ZapType,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
@@ -672,7 +672,7 @@ class ZapPaymentHandler(
|
||||
private suspend fun assembleInvoice(
|
||||
lud16: String,
|
||||
splitSetup: MyZapSplitSetup,
|
||||
nostrZapRequest: LnZapRequestEvent?,
|
||||
nostrZapRequest: ZapRequestEvent?,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
@@ -683,7 +683,7 @@ class ZapPaymentHandler(
|
||||
|
||||
// Only the request the provider actually accepted may be claimed as bound to
|
||||
// this invoice; see lnAddressInvoice's onZapRequestSent.
|
||||
var sentZapRequest: LnZapRequestEvent? = null
|
||||
var sentZapRequest: ZapRequestEvent? = null
|
||||
|
||||
val invoice =
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Device-wide preferences for the calendar reminder worker.
|
||||
*
|
||||
* Stored at device scope (rather than per-account) because the worker that consults them runs
|
||||
* globally — multiplexing per-account preferences would require account-context plumbing into
|
||||
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
|
||||
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
|
||||
* a follow-up if anyone asks.
|
||||
*/
|
||||
class CalendarReminderPrefs(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
}
|
||||
|
||||
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
|
||||
|
||||
fun setLeadMinutes(minutes: Int) {
|
||||
prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LEAD_MINUTES = 15
|
||||
const val DEFAULT_ENABLED = true
|
||||
|
||||
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
|
||||
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
|
||||
// UX shape supports without an extra "hours" picker.
|
||||
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
|
||||
|
||||
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_LEAD_MINUTES = "lead_minutes"
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
|
||||
* the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred
|
||||
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
|
||||
* same upcoming event until it started, since LocalCache has no memory of past reminders.
|
||||
*
|
||||
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
|
||||
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
|
||||
* fired so the store doesn't grow unbounded over time.
|
||||
*/
|
||||
class CalendarReminderStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Returns true when we've previously notified for this exact event-start pairing. If the
|
||||
* author updates the appointment to a new start time, the stored value won't match and
|
||||
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
|
||||
* meeting shouldn't be silently skipped.
|
||||
*/
|
||||
fun wasNotified(
|
||||
eventId: String,
|
||||
eventStartSeconds: Long,
|
||||
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
|
||||
|
||||
fun markNotified(
|
||||
eventId: String,
|
||||
eventStartSeconds: Long,
|
||||
) {
|
||||
prefs.edit { putLong(keyFor(eventId), eventStartSeconds) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
|
||||
* after each worker run so the store stays bounded — events that have long since ended
|
||||
* can't fire a second reminder, so their entries are dead weight.
|
||||
*/
|
||||
fun forgetBefore(cutoffSeconds: Long) {
|
||||
val editor = prefs.edit()
|
||||
var changed = false
|
||||
prefs.all.forEach { (key, value) ->
|
||||
if (value is Long && value < cutoffSeconds) {
|
||||
editor.remove(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) editor.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_NAME = "amethyst_calendar_reminders"
|
||||
private const val KEY_PREFIX = "notified:"
|
||||
|
||||
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataMigration
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
|
||||
|
||||
/**
|
||||
* Android wiring for the two calendar-reminder stores.
|
||||
*
|
||||
* The store classes live in commons; only the file location and the one-off
|
||||
* lift out of the legacy SharedPreferences are Android's business.
|
||||
*
|
||||
* Both files sit on `AppPreferenceStores` rather than a `Context` delegate.
|
||||
* The names below are the delegate's names, and the holder reproduces the path
|
||||
* it resolved to, so nothing migrates. The migrations move with them: DataStore
|
||||
* runs a file's migrations once, when that file is first opened, so they have
|
||||
* to be attached to the file by the holder rather than by whoever opens it.
|
||||
*/
|
||||
private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs"
|
||||
private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders"
|
||||
|
||||
/** Store (and file) names, as [Amethyst.appStores] keys them. */
|
||||
const val CALENDAR_REMINDER_SETTINGS_STORE = "calendar_reminder_settings"
|
||||
const val CALENDAR_REMINDER_LOG_STORE = "calendar_reminder_log"
|
||||
|
||||
/** The one-off copy of the reminder settings out of the legacy prefs file. */
|
||||
fun calendarReminderSettingsMigrations(context: Context): List<DataMigration<Preferences>> =
|
||||
listOf(
|
||||
CopyOnceMigration("migrated.calendarReminderSettings") { out ->
|
||||
val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE)
|
||||
if (legacy.contains("enabled")) {
|
||||
out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED)
|
||||
}
|
||||
if (legacy.contains("lead_minutes")) {
|
||||
out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** The one-off copy of the fired-reminder log out of the legacy prefs file. */
|
||||
fun calendarReminderLogMigrations(context: Context): List<DataMigration<Preferences>> =
|
||||
listOf(
|
||||
CopyOnceMigration("migrated.calendarReminderLog") { out ->
|
||||
val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE)
|
||||
// Values are the event-start times the reminders fired for; anything
|
||||
// else in the file is not ours and is left behind.
|
||||
legacy.all.forEach { (key, value) ->
|
||||
if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun calendarReminderSettings() = CalendarReminderSettingsStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_SETTINGS_STORE))
|
||||
|
||||
fun calendarReminderLog() = CalendarReminderLogStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_LOG_STORE))
|
||||
+6
-6
@@ -48,7 +48,7 @@ import java.util.concurrent.TimeUnit
|
||||
* to as ACCEPTED.
|
||||
*
|
||||
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* consults [CalendarReminderLogStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*
|
||||
@@ -65,8 +65,8 @@ class CalendarReminderWorker(
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
val settings = calendarReminderSettings().load()
|
||||
if (!settings.enabled) {
|
||||
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
|
||||
// The settings toggle re-schedules on enable; no reason to keep
|
||||
// waking the process while the feature is off.
|
||||
@@ -74,8 +74,8 @@ class CalendarReminderWorker(
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
val windowEnd = now + prefs.leadMinutes() * 60L
|
||||
val store = CalendarReminderStore(applicationContext)
|
||||
val windowEnd = now + settings.leadMinutes * 60L
|
||||
val store = calendarReminderLog()
|
||||
|
||||
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
@@ -83,7 +83,7 @@ class CalendarReminderWorker(
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps = acceptedRsvpsInCache()
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${settings.leadMinutes}m)" }
|
||||
|
||||
acceptedRsvps.forEach { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
|
||||
|
||||
+4
-4
@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -128,7 +128,7 @@ class LightningAddressResolver {
|
||||
lnCallback: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: LnZapRequestEvent? = null,
|
||||
nostrRequest: ZapRequestEvent? = null,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
context: Context,
|
||||
): String {
|
||||
@@ -219,11 +219,11 @@ class LightningAddressResolver {
|
||||
lnAddress: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: LnZapRequestEvent? = null,
|
||||
nostrRequest: ZapRequestEvent? = null,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
|
||||
onZapRequestSent: (ZapRequestEvent?) -> Unit = {},
|
||||
): String {
|
||||
val mapper = ObjectMapper()
|
||||
|
||||
|
||||
+13
-13
@@ -64,7 +64,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
@@ -72,7 +72,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
@@ -84,17 +84,17 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
@@ -234,7 +234,7 @@ class EventNotificationConsumer(
|
||||
// public chat the user has silenced (matches the in-app feed, which mutes all four).
|
||||
// Without the second check, muting a channel still let a like on your own message
|
||||
// there notify you — the row's glyph promises silence, so it has to mean it.
|
||||
if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) {
|
||||
if (event is ReactionEvent || event is ZapReceiptEvent || event is RepostEvent || event is GenericRepostEvent) {
|
||||
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
|
||||
if (target != null &&
|
||||
(
|
||||
@@ -247,7 +247,7 @@ class EventNotificationConsumer(
|
||||
}
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> DirectMessageNotification.notify(applicationContext, account, event)
|
||||
is EncryptedDmEvent -> DirectMessageNotification.notify(applicationContext, account, event)
|
||||
is ChatMessageEvent -> DirectMessageNotification.notify(applicationContext, account, event)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> DirectMessageNotification.notify(applicationContext, account, event)
|
||||
|
||||
@@ -256,7 +256,7 @@ class EventNotificationConsumer(
|
||||
is StreamMessageV2Event -> BuzzDmNotification.notify(applicationContext, account, event)
|
||||
is ChatEvent -> BuzzDmNotification.notify(applicationContext, account, event)
|
||||
|
||||
is LnZapEvent -> ZapNotification.notify(applicationContext, account, event)
|
||||
is ZapReceiptEvent -> ZapNotification.notify(applicationContext, account, event)
|
||||
is NutzapEvent -> ZapNotification.notify(applicationContext, account, event)
|
||||
is OnchainZapEvent -> ZapNotification.notify(applicationContext, account, event)
|
||||
|
||||
@@ -274,15 +274,15 @@ class EventNotificationConsumer(
|
||||
is PictureEvent,
|
||||
is VideoNormalEvent,
|
||||
is VideoShortEvent,
|
||||
is VideoHorizontalEvent,
|
||||
is VideoVerticalEvent,
|
||||
is AddressableNormalVideoEvent,
|
||||
is AddressableShortVideoEvent,
|
||||
-> MediaNotification.notify(applicationContext, account, event)
|
||||
|
||||
is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = Res.string.app_notification_poll_channel_message)
|
||||
|
||||
is HighlightEvent,
|
||||
is LongTextNoteEvent,
|
||||
is WikiNoteEvent,
|
||||
is LongFormContentEvent,
|
||||
is WikiArticleEvent,
|
||||
-> ArticleNotification.notify(applicationContext, account, event)
|
||||
|
||||
is GitIssueEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
|
||||
+5
-5
@@ -27,11 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.nip71Video.selectVideoTrack
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
|
||||
@@ -177,7 +177,7 @@ object NotificationContent {
|
||||
}
|
||||
|
||||
suspend fun decryptZapContentAuthor(
|
||||
event: LnZapRequestEvent,
|
||||
event: ZapRequestEvent,
|
||||
signer: NostrSigner,
|
||||
): Event? =
|
||||
if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) {
|
||||
@@ -191,8 +191,8 @@ object NotificationContent {
|
||||
signer: NostrSigner,
|
||||
): String? =
|
||||
when (val event = note.event) {
|
||||
is PrivateDmEvent -> event.decryptContent(signer)
|
||||
is LnZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content
|
||||
is EncryptedDmEvent -> event.decryptContent(signer)
|
||||
is ZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content
|
||||
else -> event?.content
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
@@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
@@ -52,17 +52,17 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
@@ -101,7 +101,7 @@ class NotificationDispatcher(
|
||||
private const val TAG = "NotificationDispatcher"
|
||||
|
||||
// The dispatcher observes the *final* notification payload kinds.
|
||||
// GiftWrap/EphemeralGiftWrap (1059/21059) and SealedRumor (13) are NOT
|
||||
// GiftWrap/EphemeralGiftWrap (1059/21059) and Seal (13) are NOT
|
||||
// listed here — by the time we care, Account.newNotesPreProcessor has
|
||||
// already unwrapped them and inserted the inner payload into LocalCache,
|
||||
// which fires the observer a second time on the inner event.
|
||||
@@ -118,8 +118,8 @@ class NotificationDispatcher(
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
EncryptedDmEvent.KIND,
|
||||
ZapReceiptEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
@@ -133,8 +133,8 @@ class NotificationDispatcher(
|
||||
PictureEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VideoHorizontalEvent.KIND,
|
||||
VideoVerticalEvent.KIND,
|
||||
AddressableNormalVideoEvent.KIND,
|
||||
AddressableShortVideoEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
@@ -150,8 +150,8 @@ class NotificationDispatcher(
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
LongFormContentEvent.KIND,
|
||||
WikiArticleEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
LiveChessMoveEvent.KIND,
|
||||
WakeUpEvent.KIND,
|
||||
@@ -235,8 +235,8 @@ class NotificationDispatcher(
|
||||
if (event is WakeUpEvent) return@predicate true
|
||||
|
||||
// getNoteIfExists(event) — not (event.id) — so
|
||||
// AddressableEvent kinds (LongTextNote, WikiNote,
|
||||
// LiveChess*, VideoHorizontal/Vertical) resolve to
|
||||
// AddressableEvent kinds (LongFormContent, WikiArticle,
|
||||
// LiveChess*, AddressableNormalVideo/AddressableShortVideo) resolve to
|
||||
// their address-keyed replaceable note. The id-keyed
|
||||
// version note has its replyTo moved away during
|
||||
// insertion (LocalCache.consumeBaseReplaceable), so
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ import kotlinx.coroutines.CancellationException
|
||||
*
|
||||
* Once decrypted, the inner event is fed into [LocalCache]. From there the
|
||||
* normal `Account.newNotesPreProcessor` → `GiftWrapEventHandler` →
|
||||
* `SealedRumorEventHandler` chain unwraps any remaining layers, and
|
||||
* `SealEventHandler` chain unwraps any remaining layers, and
|
||||
* [NotificationDispatcher] picks up the final payload and notifies.
|
||||
*/
|
||||
object PushWrapDecryptor {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user