mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge upstream/main into feat/desktop-hashtag-spam-filter
This commit is contained in:
+87
-44
@@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart.
|
||||
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
|
||||
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
|
||||
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
|
||||
exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53
|
||||
audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
|
||||
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
|
||||
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
|
||||
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
|
||||
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
|
||||
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
|
||||
production listener AND speaker paths both run on moq-lite to interop with the
|
||||
nostrnests reference relay. The IETF code is kept as a reference + unit-test
|
||||
@@ -23,27 +25,13 @@ implementation for any future IETF target; see
|
||||
Canonical NIP specs live at <https://github.com/nostr-protocol/nips> — use
|
||||
`/nip <number>` to pull a specific one (it fetches the spec file directly).
|
||||
|
||||
## Verify, Don't Guess (standing instruction)
|
||||
## Verify, Don't Guess
|
||||
|
||||
A plausible-sounding explanation is cheap; being right is not. Before
|
||||
asserting what a problem is or how something behaves:
|
||||
|
||||
1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm
|
||||
guessing" or "haven't verified" — never dress an untested guess up as a
|
||||
diagnosis. Use "I verified X by running Y" only when you actually did.
|
||||
2. **Reproduce before diagnosing.** If a claim is checkable in under a
|
||||
minute, check it before stating it. This repo gives you the means:
|
||||
`./gradlew test`, the per-module tests, and `amy` (the CLI exists partly
|
||||
to drive `quartz`/`commons` for interop checks). Write the failing case
|
||||
first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix`
|
||||
(reproduce-first) or `/investigate` (competing hypotheses + refutation).
|
||||
3. **Predict, then run.** Before running a command, state the output you
|
||||
expect. A mismatch is the cheapest signal that your model is wrong.
|
||||
4. **Don't commit to one cause.** A single immediate explanation stops you
|
||||
from looking. Hold 2–3 candidates and a discriminating test for each.
|
||||
|
||||
If you find yourself writing paragraphs to defend a theory, that effort
|
||||
almost always should have been one test.
|
||||
Don't assert a diagnosis you haven't reproduced. This repo gives you cheap
|
||||
verification tools: `./gradlew test`, per-module test suites, and the `amy`
|
||||
CLI (built partly to drive `quartz`/`commons` for interop checks). If a
|
||||
claim is checkable in under a minute, check it before stating it — write
|
||||
the failing case first, watch it fail, then explain.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -69,6 +57,7 @@ amethyst/
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
|
||||
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
|
||||
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
|
||||
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
||||
├── amethyst/ # Android app (layouts, navigation)
|
||||
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
|
||||
@@ -87,12 +76,44 @@ amethyst/
|
||||
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
|
||||
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
|
||||
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
|
||||
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed)
|
||||
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
|
||||
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
|
||||
standalone relay); never on `:amethyst` or `:desktopApp`.
|
||||
|
||||
**Plans per module:** design docs for new subsystems live in the owning
|
||||
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
|
||||
The global `docs/plans/` folder is frozen — don't add new plans there.
|
||||
|
||||
## Android Runtime Processes (IMPORTANT — the app runs in TWO processes)
|
||||
|
||||
The Android app is **not single-process**. Android instantiates the one `Amethyst`
|
||||
Application class (there is no per-process Application in the manifest) in **both**:
|
||||
|
||||
- **main** — the normal app: UI, account, signer, `LocalCache`, relay client.
|
||||
`Amethyst.instance` (`AppModules`) is built here.
|
||||
- **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
|
||||
(`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no**
|
||||
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
|
||||
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
|
||||
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
|
||||
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
|
||||
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
|
||||
stays in `:amethyst` and the two halves talk over Messenger IPC.
|
||||
|
||||
Consequences — don't get caught assuming one process:
|
||||
|
||||
- **Processes don't share memory.** Every `object`/companion/`static` is a
|
||||
*separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`,
|
||||
etc. The populated `LocalCache` lives only in **main**; the sandbox neither
|
||||
builds nor should reference it (a stray reference would lazily create a second,
|
||||
empty cache there).
|
||||
- **Don't assume `Amethyst.instance` exists.** Any code reachable from `:napplet`
|
||||
(the host activity, content server, or an Application lifecycle callback like
|
||||
`onTrimMemory`) must guard on the process and never reach for `instance`.
|
||||
- **Cross-process state goes over Messenger IPC**, never a shared singleton — this
|
||||
is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays
|
||||
an opaque token. See `amethyst/plans/2026-06-22-napplet-nsite-security.md`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Exact versions live in `gradle/libs.versions.toml` (the source of truth — check
|
||||
@@ -123,16 +144,6 @@ to be used together:
|
||||
skills: `compose-expert` tells you where shared composables live;
|
||||
`compose-slot-api-pattern` tells you how to shape their public API.
|
||||
|
||||
## Workflow
|
||||
|
||||
**When you ask for a feature:**
|
||||
|
||||
1. **Quick skill assessment** - I identify which skills are relevant
|
||||
2. **Propose which skills** - I present which skills I'll use for the task
|
||||
3. **Get approval** - You review and approve (or adjust) the skill selection
|
||||
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
|
||||
5. **Execute with skills** - Skills collaborate to implement the feature
|
||||
|
||||
## Feature Workflow
|
||||
|
||||
**CRITICAL: Check existing implementations first — most logic already exists.**
|
||||
@@ -142,17 +153,9 @@ job is usually to **reuse** (`quartz` protocol/business logic), **extract**
|
||||
(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav —
|
||||
not to duplicate existing managers, caches, or state.
|
||||
|
||||
Capture the survey as a matrix in your plan:
|
||||
|
||||
| File/Component | Status | Location | Action |
|
||||
|----------------|--------|----------|--------|
|
||||
| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is |
|
||||
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
|
||||
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
|
||||
|
||||
**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move
|
||||
to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid
|
||||
(duplicate; use existing pattern).
|
||||
Summarize the survey in your plan: for each component, note whether it's
|
||||
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
|
||||
(platform-specific only), or a duplicate of an existing pattern to avoid.
|
||||
|
||||
**Share vs keep platform-native:**
|
||||
|
||||
@@ -189,6 +192,37 @@ version. `quartz/` is protocol-only — no composables.
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
## Dependency Licensing
|
||||
|
||||
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
|
||||
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`,
|
||||
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a
|
||||
module's `build.gradle.kts`: determine its license **before** wiring it in.
|
||||
Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
|
||||
distributed artifact (APK, desktop binary) can force that artifact's
|
||||
combined-work terms onto the whole project.
|
||||
|
||||
Verify against the dependency's actual `LICENSE`/`COPYING` file or its published
|
||||
POM — **not from memory**. Then classify and act:
|
||||
|
||||
- **Permissive** (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → **OK**,
|
||||
proceed.
|
||||
- **LGPL, or GPL/EPL with a linking / Classpath exception** → **WARN.**
|
||||
Acceptable to link (the exception keeps our own code MIT), but call it out in
|
||||
your summary so the human knows. Confirm the exception actually exists in the
|
||||
LICENSE text — don't assume it does.
|
||||
- **Stricter than LGPL** — GPL/AGPL **without** a linking exception, SSPL,
|
||||
proprietary/commercial-only, or anything where the linking-exception check is
|
||||
"no" → **STRONGLY WARN and STOP.** Do not add it silently. Surface it
|
||||
prominently and **require an explicit call-out in the PR description** so a
|
||||
maintainer makes the decision. Prefer a permissive alternative, a clean-room
|
||||
implementation, or dropping the feature.
|
||||
|
||||
For any GPL-family hit the decisive question is always **"is there a linking
|
||||
(LGPL/Classpath) exception?"** — that is what separates a WARN from a STOP.
|
||||
(Example: TarsosDSP, GPLv3 with no exception, was removed from `amethyst` and
|
||||
replaced with an in-house pitch shifter for exactly this reason.)
|
||||
|
||||
## Quartz KMP Structure
|
||||
|
||||
Quartz uses expect/actual for platform-specific implementations (e.g. crypto
|
||||
@@ -243,3 +277,12 @@ Do this before considering the task complete.
|
||||
|
||||
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
|
||||
- Never use `--no-verify`
|
||||
|
||||
### Remotes & pull requests
|
||||
|
||||
A PR can be published two ways, via two **kinds** of remote — identify them by **URL** (`git remote -v`), because the names vary per clone and **a collaborator may have only one**:
|
||||
|
||||
- a **GitHub** remote (`github.com/vitorpamplona/amethyst`) — the **canonical** `main`; moves constantly. Standard `gh` PR flow.
|
||||
- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev** — *not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, but don't rely on that.)
|
||||
|
||||
Before opening, revising, or merging a PR by **either** path, use the **`ngit-pr`** skill. It covers when to use which, identifying your remotes by URL, the `gh` and `ngit` commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr `main` vs local `main`) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions.
|
||||
|
||||
@@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application:
|
||||
|
||||
If the build fails, check:
|
||||
|
||||
1. **JDK Version**: Requires JDK 17+
|
||||
1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`)
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
@@ -39,7 +39,7 @@ If the build fails, check:
|
||||
./gradlew :desktopApp:packageMsi
|
||||
|
||||
# Linux
|
||||
./gradlew :desktopApp:packageDeb
|
||||
./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm
|
||||
```
|
||||
|
||||
Outputs will be in `desktopApp/build/compose/binaries/`
|
||||
Outputs will be in `desktopApp/build/compose/binaries/main/`
|
||||
|
||||
@@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
|
||||
1. **Locate the component** in the amethyst module:
|
||||
```bash
|
||||
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
|
||||
find amethyst/src -name "*$ARGUMENTS*"
|
||||
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
- Android Compose specifics vs standard Compose
|
||||
|
||||
3. **Identify what can be shared**:
|
||||
- Pure Composable functions → `shared-ui/commonMain/`
|
||||
- Pure Composable functions → `commons/commonMain/`
|
||||
- Business logic → `quartz/commonMain/`
|
||||
- Platform-specific → create expect/actual
|
||||
|
||||
|
||||
+64
-355
@@ -1,339 +1,37 @@
|
||||
# AmethystMultiplatform Skills Creation Plan
|
||||
|
||||
## Overview
|
||||
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
|
||||
|
||||
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
|
||||
|
||||
## Skills to Implement
|
||||
|
||||
### 1. kotlin-multiplatform ✅ COMPLETED
|
||||
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Mental model: KMP hierarchy as dependency graph
|
||||
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
|
||||
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
|
||||
- expect/actual mechanics with 24+ examples from codebase
|
||||
- iOS framework setup for Quartz distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/source-set-hierarchy.md` - Visual diagram + examples
|
||||
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
|
||||
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
|
||||
- `assets/kmp-hierarchy-diagram.png` - Visual graph
|
||||
|
||||
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
|
||||
|
||||
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
|
||||
|
||||
---
|
||||
|
||||
### 2. gradle-expert ✅ COMPLETED
|
||||
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Build architecture: 4 modules, dependency flow
|
||||
- Version catalog mastery (libs.versions.toml)
|
||||
- Module dependency patterns (api vs implementation)
|
||||
- Android-specific: compileSdk, proguard
|
||||
- Desktop packaging: TargetFormat, distributions
|
||||
- Build performance: daemon, parallel, caching
|
||||
- Common errors: compose version conflicts, secp256k1 JNI variants
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/build-commands.md` - Common gradle tasks
|
||||
- `references/dependency-graph.md` - Module visualization
|
||||
- `references/version-catalog-guide.md` - Version catalog patterns
|
||||
- `references/common-errors.md` - Troubleshooting guide
|
||||
- `scripts/analyze-build-time.sh` - Performance report
|
||||
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
|
||||
|
||||
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
|
||||
|
||||
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 3. kotlin-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
|
||||
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
|
||||
- Immutability: @Immutable for Compose performance (173+ event classes)
|
||||
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
|
||||
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
|
||||
- Value classes: Zero-cost wrappers (optimization opportunity)
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
|
||||
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
|
||||
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
|
||||
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
|
||||
|
||||
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
|
||||
|
||||
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
|
||||
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
|
||||
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
|
||||
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 4. compose-expert ✅ COMPLETED
|
||||
**Focus:** Shared composables, state management, animations, Material3
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Shared composables philosophy (100+ already shared in commons/commonMain)
|
||||
- State management: remember, derivedStateOf, produceState (visual patterns)
|
||||
- Recomposition optimization: @Stable/@Immutable (visual usage)
|
||||
- Material3 conventions: theming
|
||||
- Custom icons: ImageVector builders (robohash pattern)
|
||||
- Platform differences: Desktop vs Android UI
|
||||
- Performance: lazy lists, image loading
|
||||
- Decision framework: share by default in commonMain
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/shared-composables-catalog.md` - Complete catalog with patterns
|
||||
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
|
||||
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
|
||||
- `scripts/find-composables.sh` - Grep @Composable utility
|
||||
|
||||
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 5. ios-expert
|
||||
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
|
||||
|
||||
**SKILL.md sections:**
|
||||
- iOS source sets: iosMain, iosArm64Main
|
||||
- Swift interop: type mapping, nullability
|
||||
- expect/actual iOS: 10+ examples from quartz/iosMain
|
||||
- XCFramework setup: baseName = "quartz-kmpKit"
|
||||
- Platform APIs: platform.posix, CFNetwork, Security
|
||||
- CocoaPods integration
|
||||
- XCode project setup
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/ios-actual-implementations.md` - 10 iosMain actuals
|
||||
- `references/swift-interop-guide.md` - Type mapping
|
||||
- `references/xcode-integration.md` - XCode setup
|
||||
- `scripts/generate-xcframework.sh` - Build all iOS targets
|
||||
|
||||
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
|
||||
|
||||
---
|
||||
|
||||
### 6. desktop-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Desktop entry point: application {} DSL
|
||||
- Window management: WindowState, positioning, multi-window
|
||||
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
|
||||
- System tray: minimize to tray
|
||||
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
|
||||
- File system: Desktop.getDesktop(), file pickers, drag-drop
|
||||
- Desktop UX principles: keyboard-first, native feel, tooltips
|
||||
- OS-specific behavior: macOS vs Windows vs Linux
|
||||
- Platform detection: PlatformDetector utility
|
||||
- Packaging: DMG, MSI, DEB distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
|
||||
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
|
||||
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
|
||||
- `references/os-detection.md` - Platform detection, file paths, system integration
|
||||
|
||||
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined desktop usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
|
||||
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
|
||||
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 7. android-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Android platform APIs, navigation, permissions, Material Design
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Android module structure: amethyst/ layout
|
||||
- Navigation: Navigation Compose, bottom nav
|
||||
- Permissions: runtime (camera, biometric)
|
||||
- Platform APIs: Intent, Context, ContentResolver
|
||||
- Lifecycle: Lifecycle-aware, ViewModel
|
||||
- Material Design: Android Material 3
|
||||
- Build config: Proguard, R8
|
||||
- Android UX: mobile-first patterns
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/android-navigation.md` - Navigation Compose
|
||||
- `references/android-permissions.md` - Permission handling
|
||||
- `references/proguard-rules.md` - Proguard explanation
|
||||
- `scripts/analyze-apk-size.sh` - APK optimization
|
||||
|
||||
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
|
||||
|
||||
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined Android usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
|
||||
3. ✅ RESEARCH - Android best practices + KMP Android patterns
|
||||
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
|
||||
5. ✅ DRAFT - Initialized skill, created resources
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 8. nostr-expert ✅ COMPLETED
|
||||
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Quartz architecture: package structure by NIP (57 NIPs implemented)
|
||||
- Event anatomy: IEvent, Event, kinds, tags
|
||||
- EventTemplate & TagArrayBuilder DSL patterns
|
||||
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
|
||||
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
|
||||
- Threading (NIP-10): reply/root markers
|
||||
- Cryptography: secp256k1 signing, NIP-44 encryption
|
||||
- Bech32 encoding: npub, nsec, note, nevent
|
||||
- Event validation & verification
|
||||
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
|
||||
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
|
||||
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
|
||||
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
|
||||
|
||||
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
|
||||
|
||||
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
Using skill-creator 10-step methodology per skill:
|
||||
|
||||
**Overall Plan:**
|
||||
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
|
||||
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
|
||||
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
|
||||
4. **SYNTHESIZE** ✅ - Skills designed above
|
||||
|
||||
**Per-Skill Implementation:**
|
||||
- kotlin-multiplatform: ✅ COMPLETED
|
||||
- gradle-expert: ✅ COMPLETED
|
||||
- kotlin-expert: ✅ COMPLETED
|
||||
- compose-expert: ✅ COMPLETED
|
||||
- desktop-expert: ✅ COMPLETED
|
||||
- android-expert: ✅ COMPLETED
|
||||
- nostr-expert: ✅ COMPLETED
|
||||
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
|
||||
|
||||
## Critical Files Referenced
|
||||
|
||||
**Build patterns:**
|
||||
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
|
||||
- `/commons/build.gradle.kts` - Shared UI setup
|
||||
|
||||
**Code patterns:**
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
|
||||
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
|
||||
|
||||
**Documentation:**
|
||||
- `/docs/shared-ui-analysis.md` - UI migration strategy
|
||||
|
||||
## Output Location
|
||||
`.claude/skills/<skill-name>/` for each skill
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
|
||||
2. ✅ Completed kotlin-multiplatform skill
|
||||
3. ✅ Completed gradle-expert skill
|
||||
4. ✅ Completed kotlin-expert skill
|
||||
5. ✅ Completed compose-expert skill
|
||||
6. ✅ Completed desktop-expert skill
|
||||
7. ✅ Completed android-expert skill
|
||||
8. ✅ Completed nostr-expert skill
|
||||
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
|
||||
|
||||
## Current Status: 7/8 Skills Completed
|
||||
|
||||
**Completed Skills (Auto-loaded from `.claude/skills/`):**
|
||||
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
|
||||
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
|
||||
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
|
||||
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
|
||||
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
|
||||
6. ✅ android-expert (Android platform APIs, navigation, permissions)
|
||||
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
|
||||
|
||||
**Deferred:**
|
||||
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
|
||||
|
||||
## Skill Loading
|
||||
|
||||
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
|
||||
|
||||
To manually verify skills are loaded:
|
||||
```bash
|
||||
ls -1 .claude/skills/
|
||||
```
|
||||
|
||||
Should show:
|
||||
- android-expert/
|
||||
- compose-expert/
|
||||
- desktop-expert/
|
||||
- gradle-expert/
|
||||
- kotlin-expert/
|
||||
- kotlin-multiplatform/
|
||||
- nostr-expert/
|
||||
|
||||
---
|
||||
# Amethyst Skill Library — History & Changelog
|
||||
|
||||
> Historical record of how the `.claude/skills/` library was built and audited.
|
||||
> The 8 original skills were created in 2025 using the skill-creator 10-step
|
||||
> methodology (detailed per-skill progress logs pruned in 2026-06 — see git
|
||||
> history of this file if you need them). For the current skill list and how
|
||||
> the two skill layers (codebase-oriented vs technique-oriented) fit together,
|
||||
> see the Skills section of `.claude/CLAUDE.md`.
|
||||
|
||||
## Phase 1 (2025): Core skills created
|
||||
|
||||
Eight hybrid domain skills (general expertise + Amethyst-specific patterns),
|
||||
each with a SKILL.md plus bundled `references/` and `scripts/`:
|
||||
|
||||
1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog
|
||||
2. **gradle-expert** — build system, version catalog, dependency troubleshooting
|
||||
3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders
|
||||
4. **compose-expert** — shared composables, state management, Material3, ImageVector
|
||||
5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs
|
||||
6. **android-expert** — Android navigation, permissions, platform APIs
|
||||
7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags
|
||||
8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet)
|
||||
|
||||
## Phase 2 (2026-04): Audit & Expansion
|
||||
|
||||
After a full audit of the skill library, the following changes were made:
|
||||
|
||||
### Stale references fixed
|
||||
- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20.
|
||||
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes.
|
||||
- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
|
||||
- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth.
|
||||
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes.
|
||||
- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
|
||||
|
||||
### Redundant files removed
|
||||
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer.
|
||||
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`).
|
||||
|
||||
### New references added to existing skills
|
||||
- `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities.
|
||||
@@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made:
|
||||
|
||||
### New skills created
|
||||
- **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store.
|
||||
- `references/account-state-flow.md`, `references/local-cache.md`
|
||||
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`).
|
||||
- `references/filter-assemblers.md`, `references/preloaders.md`
|
||||
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders.
|
||||
- **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`.
|
||||
- `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md`
|
||||
- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55).
|
||||
- `references/nip46-remote-signer.md`, `references/nip55-android-signer.md`
|
||||
- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers.
|
||||
|
||||
### Updated skills directory (Phase 2)
|
||||
```
|
||||
- android-expert/
|
||||
- auth-signers/ (new)
|
||||
- account-state/ (new)
|
||||
- compose-expert/
|
||||
- desktop-expert/
|
||||
- feed-patterns/ (new)
|
||||
- find-missing-translations/
|
||||
- find-non-lambda-logs/
|
||||
- gradle-expert/
|
||||
- kotlin-coroutines/
|
||||
- kotlin-expert/
|
||||
- kotlin-multiplatform/
|
||||
- nostr-expert/
|
||||
- quartz-integration/
|
||||
- relay-client/ (new)
|
||||
- quartz-kmp.md (breadcrumb pointer)
|
||||
```
|
||||
## Phase 3 (2026-06): Fable 5 config review
|
||||
|
||||
### Still deferred
|
||||
- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo.
|
||||
Instructions written to coach older models were removed now that the model
|
||||
handles them natively; stale references fixed:
|
||||
|
||||
- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section
|
||||
(skills auto-trigger; the approval loop blocked autonomous sessions);
|
||||
condensed "Verify, Don't Guess" to the repo-specific tooling pointers and
|
||||
dropped references to `/bugfix` / `/investigate` (never committed to this
|
||||
repo); replaced the mandated emoji survey matrix with one-line guidance.
|
||||
- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter —
|
||||
without it they were listed without trigger descriptions and never
|
||||
auto-invoked.
|
||||
- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`.
|
||||
- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete;
|
||||
`quartz-integration` and `nostr-expert` cover its pointers).
|
||||
- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified
|
||||
Kotlin files, so Q&A-only turns no longer pay a Gradle invocation.
|
||||
|
||||
Second audit pass (every concrete claim checked against the code; `amy-expert`,
|
||||
`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique
|
||||
skills verified clean):
|
||||
|
||||
- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)`
|
||||
+ `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`.
|
||||
- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt`
|
||||
static-object snippet replaced with the real `Nip44` facade
|
||||
(returns `EncryptedInfo`, `encodePayload()` for event content); invented
|
||||
`Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray`
|
||||
extensions (`toNpub()`, …), entity `create()` helpers, and
|
||||
`Nip19Parser.uriToRoute()?.entity`.
|
||||
- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with
|
||||
actual package counts (87 + 23 experimental) and a ground-truth pointer.
|
||||
- `quartz-integration`: NIP-19 decode example rewritten for
|
||||
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
|
||||
Event Store section corrected from "Android only" to commonMain/all platforms
|
||||
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
|
||||
|
||||
@@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens
|
||||
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
|
||||
|
||||
# Create local.properties if missing
|
||||
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
|
||||
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")"
|
||||
LOCAL_PROPS="$REPO_ROOT/local.properties"
|
||||
if [ ! -f "$LOCAL_PROPS" ]; then
|
||||
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Stop hook: format Kotlin sources, but only when the working tree actually
|
||||
# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns.
|
||||
set -uo pipefail
|
||||
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
|
||||
if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then
|
||||
./gradlew spotlessApply 2>/dev/null
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: account-state
|
||||
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
|
||||
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
|
||||
---
|
||||
|
||||
# Account & Local Cache State
|
||||
@@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus
|
||||
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
|
||||
│
|
||||
▼
|
||||
Account observes relevant kinds (3, 10002, 10000, …)
|
||||
Account state objects pin the relevant addressable notes
|
||||
│
|
||||
▼
|
||||
Account StateFlow updates (followList, relays, mutes, …)
|
||||
State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …)
|
||||
│
|
||||
▼
|
||||
ViewModels collect
|
||||
@@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
|
||||
Composables render
|
||||
```
|
||||
|
||||
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering.
|
||||
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering.
|
||||
|
||||
## Key Files
|
||||
|
||||
### `Account.kt` (singleton-per-session)
|
||||
|
||||
- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind:
|
||||
- `followListFlow` ← NIP-02 ContactList (kind 3)
|
||||
- `relayListFlow` ← NIP-65 RelayList (kind 10002)
|
||||
- `muteListFlow` ← NIP-51 Lists (kind 10000)
|
||||
- `bookmarkListFlow` ← NIP-51 Lists (kind 10003)
|
||||
- `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc.
|
||||
- Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, …
|
||||
- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event.
|
||||
- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
|
||||
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
|
||||
- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind:
|
||||
- `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`, …
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### `LocalCache.kt`
|
||||
|
||||
@@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
|
||||
Typical recipe:
|
||||
|
||||
1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list).
|
||||
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed.
|
||||
3. In `Account.kt`:
|
||||
- Add a private `MutableStateFlow<T>`.
|
||||
- Expose a `StateFlow<T>` read view.
|
||||
- Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill).
|
||||
- On event arrival, parse with the quartz event class and update the flow.
|
||||
- Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes.
|
||||
4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
|
||||
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one):
|
||||
- Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`.
|
||||
- Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`.
|
||||
- Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed.
|
||||
3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill).
|
||||
4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
|
||||
|
||||
## `LocalCache` vs `Account` Flow — Which to Read?
|
||||
|
||||
- **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`.
|
||||
- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.<featureFlow>`.
|
||||
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`).
|
||||
- **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache.
|
||||
- **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object.
|
||||
- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it.
|
||||
- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it.
|
||||
- **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state.
|
||||
- **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident.
|
||||
|
||||
## References
|
||||
|
||||
- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds.
|
||||
- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds.
|
||||
- `references/local-cache.md` — `LocalCache` internals, insertion path, indexes.
|
||||
- Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events).
|
||||
|
||||
@@ -1,93 +1,94 @@
|
||||
# Account StateFlow Catalog
|
||||
# Account State-Object Catalog
|
||||
|
||||
`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package.
|
||||
`Account.kt` composes ~50 **feature state objects** (not raw StateFlow
|
||||
properties). Each object pins its backing addressable note in `LocalCache`,
|
||||
exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and
|
||||
offers suspend mutation helpers that return signed events. Consumers read
|
||||
`account.<property>.flow`.
|
||||
|
||||
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.)
|
||||
(Property and class names are exact as of the current `Account.kt`; if one has
|
||||
been renamed, grep `Account.kt` for the class name.)
|
||||
|
||||
## Identity & Contacts
|
||||
|
||||
| Flow | Kind(s) | Source | Model package |
|
||||
|------|---------|--------|---------------|
|
||||
| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` |
|
||||
| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` |
|
||||
| `followersFlow` | derived | LocalCache scan | — |
|
||||
| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` |
|
||||
| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `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/` |
|
||||
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
|
||||
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
|
||||
|
||||
## Relays & Connectivity
|
||||
## Relay Lists
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` |
|
||||
| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` |
|
||||
| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` |
|
||||
| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` |
|
||||
| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` |
|
||||
| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` |
|
||||
| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` |
|
||||
| `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/…` |
|
||||
|
||||
Derived relay views (merge several of the above): `homeRelays`
|
||||
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
|
||||
`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`,
|
||||
`defaultGlobalRelays`.
|
||||
|
||||
## Content Lists
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `bookmarkListFlow` | 10003 | `model/nip51Lists/` |
|
||||
| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` |
|
||||
| `topNavFeedsFlow` | custom | `model/topNavFeeds/` |
|
||||
| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` |
|
||||
| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` |
|
||||
| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` |
|
||||
| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
|
||||
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
|
||||
| `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/` |
|
||||
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
|
||||
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
|
||||
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
|
||||
| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` |
|
||||
| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` |
|
||||
| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` |
|
||||
|
||||
## Messaging
|
||||
## Other Feature State
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` |
|
||||
| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` |
|
||||
| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` |
|
||||
| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` |
|
||||
| Account property | State class | Purpose | Package |
|
||||
|------------------|-------------|---------|---------|
|
||||
| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` |
|
||||
| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` |
|
||||
| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` |
|
||||
| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` |
|
||||
| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — |
|
||||
|
||||
## Settings & UI
|
||||
|
||||
| Flow | Source | Package |
|
||||
|------|--------|---------|
|
||||
| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` |
|
||||
| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` |
|
||||
| `privacyOptionsFlow` | local | `model/privacyOptions/` |
|
||||
| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` |
|
||||
| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` |
|
||||
|
||||
## Advanced / Derived
|
||||
|
||||
| Flow | Purpose | Package |
|
||||
|------|---------|---------|
|
||||
| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` |
|
||||
| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` |
|
||||
| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` |
|
||||
| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` |
|
||||
| `serverListFlow` | media/upload servers | `model/serverList/` |
|
||||
Note the migration direction: newer/extracted state classes live in
|
||||
`commons/src/commonMain/.../commons/model/`, the rest still in
|
||||
`amethyst/src/main/java/.../model/`. Check both when looking for one.
|
||||
|
||||
## Publishing Mutations
|
||||
|
||||
Every flow has a corresponding mutation method on `Account` that:
|
||||
State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`,
|
||||
`BookmarkListState` add/remove) **build and sign** the updated replaceable
|
||||
event via the quartz event class (`XEvent.add / remove / create`) and return
|
||||
it. The caller (usually a method on `Account`) is responsible for sending it
|
||||
through the client. Decryption results are cached in the paired
|
||||
`…DecryptionCache` so re-renders don't re-decrypt.
|
||||
|
||||
1. Constructs the updated event using a `TagArrayBuilder`.
|
||||
2. Signs through the injected `NostrSigner` (see `auth-signers` skill).
|
||||
3. Publishes to the appropriate relay set.
|
||||
4. Updates the local StateFlow *before* relay round-trip (optimistic).
|
||||
5. Rolls back / reconciles on failure.
|
||||
## When a State Object Doesn't Exist Yet
|
||||
|
||||
Examples of mutation methods (names may vary slightly in current code):
|
||||
- `follow(pubKey)` / `unfollow(pubKey)`
|
||||
- `addBookmark(noteId)` / `removeBookmark(noteId)`
|
||||
- `mute(pubKey)` / `unmute(pubKey)`
|
||||
- `updateRelayList(...)`, `updateDmRelayList(...)`
|
||||
- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)`
|
||||
If you're adding a new NIP that's user-scoped, follow the pattern (full recipe
|
||||
in `SKILL.md`):
|
||||
|
||||
## When a Flow Doesn't Exist Yet
|
||||
|
||||
If you're adding a new NIP that's user-scoped, follow the pattern:
|
||||
|
||||
1. Create `model/nipXX…/` with an optional `ExtState`/builder class.
|
||||
2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow<T> = _xFlow.asStateFlow()` to `Account`.
|
||||
3. Wire the relay subscription (see `relay-client` skill).
|
||||
4. Add the mutation method that builds, signs, and publishes.
|
||||
5. Update persistence if the setting is local-only (`AccountSettings.kt`).
|
||||
1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
|
||||
`BookmarkListState` (plain).
|
||||
2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose
|
||||
`val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`.
|
||||
3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if
|
||||
private), and wire the relay subscription (see `relay-client` skill).
|
||||
4. Add mutation helpers that build, sign, and return the event; publish from
|
||||
the calling site.
|
||||
5. Use `AccountSettings` for the local backup copy if the list must survive
|
||||
relay loss.
|
||||
|
||||
@@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`;
|
||||
copy it rather than re-deriving it.
|
||||
|
||||
Wire-up checklist:
|
||||
1. New file in `cli/commands/` with the `object` pattern.
|
||||
2. Add a branch in `Commands.kt`.
|
||||
3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch`
|
||||
/ a new group dispatcher).
|
||||
1. New file in `cli/commands/` with the `object` pattern. Sub-verb
|
||||
`dispatch` functions use the shared `route(...)` helper in
|
||||
`Router.kt` rather than a hand-rolled `when (tail[0])`.
|
||||
2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the
|
||||
command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`;
|
||||
`marmot` sub-verbs go through `marmotDispatch`'s `route` map).
|
||||
4. Extend `printUsage()` in `Main.kt`.
|
||||
5. Add the row to `cli/README.md`'s command table.
|
||||
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
|
||||
@@ -173,6 +175,7 @@ cli/
|
||||
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
|
||||
└── commands/ # one file (or group) per top-level verb
|
||||
├── UseCommand.kt # `amy use NAME`
|
||||
├── Router.kt # `route(...)` shared sub-verb dispatcher
|
||||
├── InitCommands.kt # init, whoami
|
||||
├── CreateCommand.kt + LoginCommand.kt
|
||||
├── RelayCommands.kt
|
||||
@@ -186,15 +189,30 @@ cli/
|
||||
├── MessageCommands.kt
|
||||
├── MarmotResetCommand.kt
|
||||
├── AwaitCommands.kt
|
||||
└── StoreCommands.kt
|
||||
├── StoreCommands.kt
|
||||
├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86)
|
||||
├── ServeCommand.kt # `amy serve` (embeds :geode)
|
||||
└── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers
|
||||
├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader
|
||||
├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt
|
||||
└── CashuReceiveCommands.kt + CashuSendCommands.kt
|
||||
+ CashuMaintenanceCommands.kt + CashuMintRecCommands.kt
|
||||
```
|
||||
|
||||
Shared logic consumed by Amy lives in `commons/`:
|
||||
- `commons/account/` — account bootstrap
|
||||
- `commons/marmot/` — MLS / group state
|
||||
- `commons/cashu/` — `ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader`
|
||||
+ `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android.
|
||||
- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with
|
||||
the Android relay-management screen.
|
||||
- `commons/defaults/` — default relays, kinds
|
||||
- Consult `commons/plans/` for cross-cutting design work in flight.
|
||||
|
||||
A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve`
|
||||
depends on `:geode` (the standalone relay) — the one allowed extra module
|
||||
dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5).
|
||||
|
||||
## Common mistakes to refuse
|
||||
|
||||
- **Adding protocol logic to `cli/`.** Push back, offer to extract.
|
||||
|
||||
@@ -18,8 +18,7 @@ object NotePublishCommand {
|
||||
val args = Args(rest)
|
||||
val text = args.positional(0, "text")
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val event = com.vitorpamplona.amethyst.commons.note
|
||||
@@ -33,13 +32,15 @@ object NotePublishCommand {
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed
|
||||
(RunState flushed, relays disconnected) on every exit path — never a
|
||||
hand-rolled `try { } finally { ctx.close() }`.
|
||||
|
||||
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
|
||||
result map IS the `--json` shape; the human-readable text default is
|
||||
derived from the same map by `Output.kt`'s renderer.
|
||||
@@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`,
|
||||
|
||||
```kotlin
|
||||
object NoteCommands {
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"publish" -> NotePublishCommand.run(dataDir, rest)
|
||||
"show" -> NoteShowCommand.run(dataDir, rest)
|
||||
"react" -> NoteReactCommand.run(dataDir, rest)
|
||||
else -> Output.error("bad_args", "note ${tail[0]}")
|
||||
}
|
||||
}
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
|
||||
route("note", tail, "note <publish|show|react>", mapOf(
|
||||
"publish" to { rest -> NotePublishCommand.run(dataDir, rest) },
|
||||
"show" to { rest -> NoteShowCommand.run(dataDir, rest) },
|
||||
"react" to { rest -> NoteReactCommand.run(dataDir, rest) },
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
Each verb gets its own file. Once a single file crosses ~200 lines,
|
||||
split it — see `GroupCommands.kt` and its siblings as the reference.
|
||||
The shared `route(name, tail, usage, routes)` helper (`Router.kt`)
|
||||
handles the empty-input and unknown-verb `bad_args` branches, so the
|
||||
`dispatch` body is just the verb→handler map. Each verb gets its own
|
||||
file. Once a single file crosses ~200 lines, split it — see
|
||||
`GroupCommands.kt` and its siblings as the reference.
|
||||
|
||||
## Wire-up checklist
|
||||
|
||||
For every new command:
|
||||
|
||||
1. File under `cli/commands/`.
|
||||
2. Branch in `Commands.kt`:
|
||||
2. Branch in `Main.kt`'s top-level `dispatch`, calling the command
|
||||
object directly:
|
||||
```kotlin
|
||||
suspend fun note(dataDir: DataDir, tail: Array<String>): Int =
|
||||
NoteCommands.dispatch(dataDir, tail)
|
||||
"note" -> NoteCommands.dispatch(dataDir, tail)
|
||||
```
|
||||
3. Branch in `Main.kt`'s top-level `dispatch`:
|
||||
```kotlin
|
||||
"note" -> Commands.note(dataDir, tail)
|
||||
```
|
||||
4. Line in `printUsage()` explaining the verb.
|
||||
5. Row in `cli/README.md`'s command table.
|
||||
6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
|
||||
3. Line in `printUsage()` explaining the verb.
|
||||
4. Row in `cli/README.md`'s command table.
|
||||
5. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
|
||||
|
||||
## What not to do
|
||||
|
||||
@@ -95,7 +91,7 @@ For every new command:
|
||||
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
|
||||
right exit code.
|
||||
- No holding a connection open across invocations — every run opens
|
||||
a fresh `Context` and closes it in `finally`.
|
||||
a fresh `Context` inside `use { }` so it closes on every exit path.
|
||||
- No blocking reads for user input — take a flag.
|
||||
- No global flags that collide with subcommand flags. `--name` is
|
||||
reserved for subcommand use (group/profile name); the global
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: android-expert
|
||||
description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform.
|
||||
---
|
||||
|
||||
# android-expert
|
||||
|
||||
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
|
||||
@@ -733,125 +738,29 @@ fun SignerIntegration(accountViewModel: AccountViewModel) {
|
||||
|
||||
## 6. Build Configuration
|
||||
|
||||
### Android Block
|
||||
Build files — the `android {}` block, the version catalog, dependencies,
|
||||
Proguard/R8, and Desktop packaging — are **gradle-expert's** domain. Use
|
||||
`/gradle-expert` instead of duplicating that guidance here. In particular, the
|
||||
app version and the Android `versionCode` both live in
|
||||
`gradle/libs.versions.toml` (`app` / `appCode`); `amethyst/build.gradle.kts`
|
||||
reads both from the catalog, so a release bump is a single-file edit.
|
||||
|
||||
The one build detail that is genuinely Android-specific — not generic Gradle —
|
||||
is the **product-flavor split** that ships two channels from one codebase:
|
||||
|
||||
**build.gradle (Amethyst pattern):**
|
||||
```gradle
|
||||
android {
|
||||
namespace = 'com.vitorpamplona.amethyst'
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = 26 // Android 8.0 (Oreo)
|
||||
targetSdk = 36 // Android 15
|
||||
versionCode = 447
|
||||
versionName = "1.11.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true // Enable BuildConfig access
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
|
||||
// Product flavors for Play Store vs F-Droid
|
||||
flavorDimensions = ["channel"]
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "channel"
|
||||
// Firebase, Google services
|
||||
}
|
||||
create("fdroid") {
|
||||
dimension = "channel"
|
||||
// UnifiedPush, open-source alternatives
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
}
|
||||
flavorDimensions = ["channel"]
|
||||
productFlavors {
|
||||
create("play") { dimension = "channel" } // Firebase, Google services
|
||||
create("fdroid") { dimension = "channel" } // UnifiedPush, open-source only
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
`play` carries Firebase/Google services; `fdroid` swaps them for UnifiedPush and
|
||||
open-source alternatives so the F-Droid build stays proprietary-free.
|
||||
|
||||
**Key Android Dependencies:**
|
||||
```gradle
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Activity
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Accompanist
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Shared module
|
||||
implementation(project(":commons"))
|
||||
implementation(project(":quartz"))
|
||||
}
|
||||
```
|
||||
|
||||
### Proguard Rules
|
||||
|
||||
**Common Rules for Amethyst:**
|
||||
```proguard
|
||||
# Keep Kotlin metadata
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep Nostr event classes
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
|
||||
# Keep serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
```
|
||||
|
||||
**Reference:** See `references/proguard-rules.md` for complete Proguard configuration.
|
||||
|
||||
### APK Optimization
|
||||
|
||||
**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis.
|
||||
Proguard/R8 rules: see `references/proguard-rules.md`. APK size analysis:
|
||||
`scripts/analyze-apk-size.sh`.
|
||||
|
||||
## 7. KMP Android Source Sets
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR
|
||||
Entry points:
|
||||
|
||||
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
|
||||
- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
|
||||
- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
|
||||
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
|
||||
|
||||
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: desktop-expert
|
||||
description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
|
||||
---
|
||||
|
||||
# Desktop Expert
|
||||
|
||||
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
|
||||
@@ -69,7 +74,7 @@ fun main() = application {
|
||||
- `rememberWindowState()` manages size/position
|
||||
- `onCloseRequest` handles window close
|
||||
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234.
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol).
|
||||
|
||||
---
|
||||
|
||||
@@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) {
|
||||
}
|
||||
```
|
||||
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces.
|
||||
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
|
||||
|
||||
**Why NavigationRail?**
|
||||
**Why a left sidebar?**
|
||||
- Desktop has horizontal space (1200+ dp width)
|
||||
- Vertical sidebar is standard desktop pattern
|
||||
- Always visible (no tabs hidden)
|
||||
@@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) {
|
||||
|
||||
**Android comparison:**
|
||||
- Android: `BottomNavigationBar` (horizontal, bottom)
|
||||
- Desktop: `NavigationRail` (vertical, left)
|
||||
- Desktop: left vertical sidebar (`MainSidebar`)
|
||||
|
||||
### Multi-Pane Layouts
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
|
||||
|
||||
---
|
||||
|
||||
## Desktop: NavigationRail
|
||||
## Desktop: Left Sidebar
|
||||
|
||||
### Current Implementation
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: feed-patterns
|
||||
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
|
||||
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
|
||||
---
|
||||
|
||||
# Feed Patterns
|
||||
@@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
│ ◄── ChatroomFeedViewModel │
|
||||
│ ◄── MarmotGroupFeedViewModel │
|
||||
│ │
|
||||
│ FeedContentState — the flow the UI collects │
|
||||
│ │
|
||||
│ commons/.../ui/feeds/ (shared, KMP) │
|
||||
│ IFeedFilter / FeedFilter<T> (abstract base) │
|
||||
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
|
||||
│ ChangesFlowFilter │
|
||||
│ FeedContentState, FeedState — the flow the UI collects │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ uses
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │
|
||||
│ FeedFilter<T> (abstract) │
|
||||
│ amethyst/.../ui/dal/ (Android-only additions) │
|
||||
│ AdditiveComplexFeedFilter<T, U> │
|
||||
│ ChangesFlowFilter │
|
||||
│ FilterByListParams │
|
||||
│ DefaultFeedOrder │
|
||||
│ DefaultFeedOrder (Note/Event/Card comparators) │
|
||||
│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │
|
||||
│ back-compat typealiases re-exporting commons) │
|
||||
│ │
|
||||
│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │
|
||||
│ BookmarkListFeedFilter, NotificationFeedFilter, … │
|
||||
│ Concrete feeds: HomeNewThreadFeedFilter, │
|
||||
│ HashtagFeedFilter, NotificationFeedFilter, … live in │
|
||||
│ feature folders under ui/screen/loggedIn/*/dal/ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ reads
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ model/LocalCache.kt + Account.<featureFlow> │
|
||||
│ model/LocalCache.kt + account.<feature>.flow │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
- **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed.
|
||||
- **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing.
|
||||
|
||||
### Android DAL (the filters)
|
||||
### Shared filter bases (commons)
|
||||
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
|
||||
|
||||
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
|
||||
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
|
||||
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query.
|
||||
- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects.
|
||||
|
||||
### Android DAL (additions on top)
|
||||
|
||||
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`:
|
||||
|
||||
- **`FeedFilters.kt`** — `abstract class FeedFilter<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
|
||||
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything.
|
||||
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query.
|
||||
- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters.
|
||||
- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging).
|
||||
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) are a different type than the list rows (`T`).
|
||||
- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters.
|
||||
- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`.
|
||||
- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here.
|
||||
|
||||
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
|
||||
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`.
|
||||
|
||||
## Adding a New Feed
|
||||
|
||||
1. **Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
|
||||
1. **Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` if incoming items differ in type from list rows). Implement:
|
||||
- `feedKey()` — stable identity (e.g. hashtag name, account pubkey).
|
||||
- `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list.
|
||||
- `limit()` — pagination hint.
|
||||
- If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
|
||||
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
|
||||
2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`.
|
||||
3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes.
|
||||
4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`.
|
||||
@@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
|
||||
|
||||
## Filter Sharing (Android vs Desktop)
|
||||
|
||||
- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`.
|
||||
- ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared.
|
||||
- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it.
|
||||
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
|
||||
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
|
||||
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
|
||||
- **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates.
|
||||
- **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts.
|
||||
- **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan.
|
||||
- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc.
|
||||
- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
|
||||
| If… | Use |
|
||||
|-----|-----|
|
||||
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
|
||||
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
|
||||
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
|
||||
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
|
||||
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
|
||||
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
|
||||
|
||||
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
|
||||
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
|
||||
|
||||
## 2. Write the Filter
|
||||
|
||||
@@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
|
||||
class HashtagFeedFilter(
|
||||
private val accountViewModel: AccountViewModel,
|
||||
private val hashtag: String,
|
||||
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String = "Hashtag-$hashtag"
|
||||
|
||||
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
|
||||
override fun feed(): List<Note> {
|
||||
val params = FilterByListParams.create(
|
||||
excludeMuted = true,
|
||||
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
|
||||
hiddenUsers = account.hiddenUsers.flow.value,
|
||||
)
|
||||
return LocalCache.hashtagIndex[hashtag]
|
||||
.orEmpty()
|
||||
@@ -69,7 +70,7 @@ class HashtagFeedViewModel(
|
||||
)
|
||||
```
|
||||
|
||||
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
|
||||
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`.
|
||||
|
||||
## 4. Wire Invalidation
|
||||
|
||||
@@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan
|
||||
```kotlin
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
accountViewModel.muteListFlow.collect { invalidateAll() }
|
||||
account.muteList.flow.collect { invalidateAll() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro
|
||||
|
||||
# Gradle Expert
|
||||
|
||||
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
|
||||
## Build Architecture Mental Model
|
||||
|
||||
Think of this project as **4 layers**:
|
||||
The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses):
|
||||
|
||||
```
|
||||
┌─────────────┬─────────────┐
|
||||
@@ -165,11 +165,11 @@ implementation(libs.jna)
|
||||
|
||||
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
|
||||
|
||||
**Current project config:**
|
||||
**Current project config** (always re-check `gradle/libs.versions.toml` — these drift):
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3" # Plugin + runtime
|
||||
composeBom = "2025.12.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.0"
|
||||
composeMultiplatform = "1.11.1" # Plugin + runtime
|
||||
composeBom = "2026.05.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.21"
|
||||
```
|
||||
|
||||
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
@@ -3,46 +3,50 @@
|
||||
## Visual Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Root Project │
|
||||
│ (Amethyst) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │
|
||||
│ (KMP UI) │
|
||||
│ │
|
||||
│ jvmAndroid │
|
||||
│ / \ │
|
||||
│ jvm android│
|
||||
└─────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │
|
||||
│(KMP Library)│
|
||||
│ │
|
||||
│ commonMain │
|
||||
│ │ │
|
||||
│ jvmAndroid │
|
||||
│ / | \ │
|
||||
│jvm and ios │
|
||||
└─────────────┘
|
||||
Apps / harnesses Libraries
|
||||
┌─────────────┐ ┌─────────────┐ ┌────────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │
|
||||
└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘
|
||||
│ │ └───────┼────┐ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ ▼ ▼ │ │ ▼ │
|
||||
│ ┌────────────────┐ │ │ (androidTest │
|
||||
│ │ :commons │◄┼──┼──only) │
|
||||
│ │ (KMP UI) │ │ │ │
|
||||
│ └───────┬────────┘ │ │ │
|
||||
│ │ ▲ │ │ │
|
||||
▼ │ │ │ │ │
|
||||
┌──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │
|
||||
│ :nestsClient │ │ │ :cli │ │:geode │ │
|
||||
│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │
|
||||
└──┬────────┬──┘ │ └──┬───┘ │relay) │ │
|
||||
│ │ │ │ └───┬───┘ │
|
||||
▼ │ │ │ │ │
|
||||
┌────────┐ │ │ │ │ │
|
||||
│ :quic │ │ │ │ │ │
|
||||
│ (KMP) │ │ │ │ │ │
|
||||
└───┬────┘ │ │ │ │ │
|
||||
│ ▲ │ │ │ │ │
|
||||
│ └── :quic-interop │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼
|
||||
┌──────────────────────────────┐
|
||||
│ :quartz │
|
||||
│ (KMP Library) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Verified edges (from each module's `build.gradle.kts`):
|
||||
|
||||
- `:amethyst` → `:quartz`, `:commons`, `:nestsClient`
|
||||
- `:desktopApp` → `:quartz`, `:commons`
|
||||
- `:benchmark` → `:quartz`, `:commons` (androidTest only)
|
||||
- `:cli` → `:quartz`, `:commons`
|
||||
- `:geode` → `:quartz` (api + testFixtures)
|
||||
- `:nestsClient` → `:quartz` (api), `:quic`
|
||||
- `:quic` → `:quartz` (api)
|
||||
- `:quic-interop` → `:quic` (project dir: `quic/interop`)
|
||||
|
||||
## Module Details
|
||||
|
||||
### :quartz (KMP Nostr Library)
|
||||
@@ -86,11 +90,41 @@
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- Modules: `:commons`, `:quartz` (androidTest only)
|
||||
- External: AndroidX Benchmark
|
||||
|
||||
**Role:** Performance benchmarking for Android builds
|
||||
|
||||
### :cli (Amy CLI)
|
||||
**Type:** JVM Application (no Compose)
|
||||
**Dependencies:** `:quartz`, `:commons`
|
||||
|
||||
**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill)
|
||||
|
||||
### :geode (Relay Server)
|
||||
**Type:** JVM Application (Ktor)
|
||||
**Dependencies:** `:quartz` (api + testFixtures)
|
||||
|
||||
**Role:** Standalone Nostr relay built on quartz's relay-server code
|
||||
|
||||
### :quic (QUIC Transport)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Dependencies:** `:quartz` (api)
|
||||
|
||||
**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ
|
||||
|
||||
### :nestsClient (Audio Rooms)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Dependencies:** `:quartz` (api), `:quic`
|
||||
|
||||
**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature
|
||||
|
||||
### :quic-interop (Interop Harness)
|
||||
**Type:** JVM Application (project dir `quic/interop`)
|
||||
**Dependencies:** `:quic`
|
||||
|
||||
**Role:** QUIC interop-runner test client
|
||||
|
||||
## Dependency Flow Patterns
|
||||
|
||||
### Desktop Build Chain
|
||||
@@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant
|
||||
implementation(compose.ui) // Compose Multiplatform BOM
|
||||
implementation(compose.material3)
|
||||
|
||||
// Version catalog alignment
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01" // AndroidX Compose
|
||||
// Version catalog alignment (re-check libs.versions.toml — these drift)
|
||||
composeMultiplatform = "1.11.1"
|
||||
composeBom = "2026.05.01" // AndroidX Compose
|
||||
```
|
||||
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
|
||||
|
||||
|
||||
@@ -807,6 +807,5 @@ Passing lambda to function?
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-12-30
|
||||
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
|
||||
**Version:** 1.0.1
|
||||
**Last Updated:** 2026-06-10
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: ngit-pr
|
||||
description: How to create, review, revise, and merge pull requests in this repo, which can be published TWO ways — GitHub (the `gh` CLI) and git-over-nostr (the `ngit` CLI, where PRs are nostr **proposals** reviewed on gitworkshop.dev). Use whenever a task involves opening/updating/merging a PR by either mechanism, the `pr/feat/*` branches, the `ngit` or `gh` CLIs, gitworkshop.dev, or a `nostr://` remote. Remote names vary per clone (and a collaborator may have only one) — this skill identifies remotes by URL, and covers the three-mains alignment gate the nostr flow depends on.
|
||||
---
|
||||
|
||||
# Pull requests: GitHub **and** git-over-nostr
|
||||
|
||||
This repo can be contributed to **two** ways, via **two kinds** of remote. Both end up in GitHub `main`.
|
||||
|
||||
| Remote kind | URL pattern | Role |
|
||||
|--|--|--|
|
||||
| **GitHub** | `github.com/vitorpamplona/amethyst` | **Canonical** `main`. Moves constantly (bots merge often) — a moving target. |
|
||||
| **git-over-nostr** | `nostr://…/relay.ngit.dev/amethyst` | `ngit`. A push fans out to GitHub **and** the GRASP git servers and publishes nostr events. PRs are **proposals**, reviewed on **gitworkshop.dev**. |
|
||||
|
||||
## Step 0 — identify YOUR remotes (names are not universal)
|
||||
|
||||
Remote **names are per-clone**. In the maintainer's checkout the GitHub remote is `upstream` and the nostr remote is `origin`, but yours may differ, and **you may have only one of them** (e.g. cloned straight from `nostr://…`, so the nostr remote is your `origin` and there is no separate GitHub remote — pushing it still reaches GitHub via fan-out). Detect by **URL**, never assume a name:
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
GH_REMOTE=$(git remote -v | awk '/github\.com/ {print $1; exit}') # GitHub remote (may be empty)
|
||||
NOSTR_REMOTE=$(git remote -v | awk '/nostr:\/\// {print $1; exit}') # git-over-nostr remote (may be empty)
|
||||
echo "github=$GH_REMOTE nostr=$NOSTR_REMOTE"
|
||||
```
|
||||
|
||||
The examples below use `$GH_REMOTE` / `$NOSTR_REMOTE` — substitute whichever you have.
|
||||
|
||||
## Which path?
|
||||
|
||||
| | **GitHub path** (`gh`) | **nostr path** (`ngit`) |
|
||||
|--|--|--|
|
||||
| Needs | a GitHub remote + `gh auth status` | a `nostr://` remote + `ngit` ≥ 2.5.0 |
|
||||
| PR lives on | GitHub only | nostr + GitHub + GRASP (fans out) |
|
||||
| Use when | Default; PR only needs to be on GitHub. Simplest, no alignment gate. | The PR must be visible/reviewable over nostr (gitworkshop), or you're revising/merging an existing **proposal** (a `pr/feat/*`). |
|
||||
|
||||
**Default to GitHub** unless the task is specifically about a nostr proposal (e.g. "the PRs on origin", a gitworkshop link, a `pr/feat/*` branch). If you only have one remote, that decides the path for you. Revise/merge a PR on **the same path it was created** — don't revise a GitHub PR via ngit or vice-versa.
|
||||
|
||||
---
|
||||
|
||||
# GitHub path (`gh`)
|
||||
|
||||
The normal flow most of this repo's history uses ("Merge pull request #NNNN …"). Requires a GitHub remote (`$GH_REMOTE`) and `gh auth status` OK.
|
||||
|
||||
```bash
|
||||
# create — branch off main, push, open the PR
|
||||
git checkout -b feat/<slug> main
|
||||
git push -u "$GH_REMOTE" feat/<slug>
|
||||
gh pr create --repo vitorpamplona/amethyst --base main --head feat/<slug> \
|
||||
--title "feat: …" --body "…"
|
||||
|
||||
# review / list
|
||||
gh pr list --repo vitorpamplona/amethyst
|
||||
gh pr view <number> --repo vitorpamplona/amethyst # --comments for the thread
|
||||
|
||||
# revise — push more commits to the same branch
|
||||
git push "$GH_REMOTE" feat/<slug>
|
||||
|
||||
# merge (maintainer)
|
||||
gh pr merge <number> --repo vitorpamplona/amethyst --merge # or --squash
|
||||
```
|
||||
|
||||
GitHub is the source of truth for this path — no three-mains gate. Standard Git Workflow rules from CLAUDE.md still apply (conventional commits, never `--no-verify`).
|
||||
|
||||
---
|
||||
|
||||
# nostr path (`ngit`)
|
||||
|
||||
Requires a `nostr://` remote (`$NOSTR_REMOTE`) and `ngit` ≥ 2.5.0 (`ngit --version`).
|
||||
|
||||
**Mental model:** an ngit PR ("proposal") is a *linear patch series off `main`*, published as nostr events. A "revision" is a new version of that proposal. Merging applies the series to `main` and publishes a merged-status event. There is **no** GitHub PR number; `ngit pr merge` makes a plain merge commit (amend it to a readable message).
|
||||
|
||||
## ⚠ The three-mains alignment gate (the thing that breaks everything)
|
||||
|
||||
Up to **three** `main` heads drift apart:
|
||||
|
||||
- GitHub main (`$GH_REMOTE/main` if you have it) — newest, moves every few minutes
|
||||
- nostr main (`$NOSTR_REMOTE/main` tracking ref) — **lags**, often far behind
|
||||
- local `main`
|
||||
|
||||
**Every create/revise/merge requires the proposal's base to equal the nostr `main`, and pushing `main` to the nostr remote requires GitHub's main to be an ancestor of what you push.** When misaligned, ngit **rejects pre-flight and publishes nothing** (safe — nothing half-breaks; realign and retry). Don't `--force` past it.
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
echo "github=$([ -n "$GH_REMOTE" ] && git rev-parse "$GH_REMOTE/main") \
|
||||
nostr=$(git ls-remote "$NOSTR_REMOTE" -h refs/heads/main | awk '{print $1}') \
|
||||
local=$(git rev-parse main)"
|
||||
# all present heads equal → proceed.
|
||||
# local behind GitHub? git merge --ff-only "$GH_REMOTE/main" (or "$NOSTR_REMOTE/main" if that's all you have)
|
||||
# nostr behind local? git push "$NOSTR_REMOTE" main (clean fast-forward only)
|
||||
```
|
||||
|
||||
If you have **only** the nostr remote: align local `main` to `$NOSTR_REMOTE/main`; GitHub is handled by fan-out, and any GitHub/GRASP disagreement surfaces as an ngit rejection on push. If GitHub diverged from nostr (`out of sync with nostr` on push), that's a maintainer `ngit sync --ref-name refs/heads/main --force` situation — **stop and ask the human**, don't run a forced sync unprompted.
|
||||
|
||||
## Pushes are slow — run them in the background
|
||||
|
||||
`git push "$NOSTR_REMOTE" …`, `ngit send`, and `ngit pr merge` fan out to relays + GRASP servers and **routinely exceed 2 minutes**. Run with `run_in_background: true` and poll (e.g. `git ls-remote "$NOSTR_REMOTE"` for the expected ref). A foreground call hits the 2-minute tool timeout even while the push is actually succeeding.
|
||||
|
||||
## Identity
|
||||
|
||||
`ngit account whoami` shows the signing key; `ngit send`/`ngit pr merge` sign with **that** key regardless of original author. The maintainer (`VitorPamplona`, `_@vitorpamplona.com`) revising/merging a contributor's proposal with their own key is expected.
|
||||
|
||||
## List / view
|
||||
|
||||
```bash
|
||||
ngit pr list # open + draft
|
||||
ngit pr list --status open,draft,closed,merged,applied
|
||||
ngit pr view <FULL-hex-event-id | nevent> # FULL id, not the short prefix
|
||||
```
|
||||
|
||||
In `git branch -r`, proposals show as `<nostr-remote>/pr/feat/<slug>(<short-id>)` — the `(...)` is an ngit annotation; the real ref is `pr/feat/<slug>`. Status `applied` == merged.
|
||||
|
||||
## Create
|
||||
|
||||
Push a `pr/`-prefixed branch (linear, off current `main`):
|
||||
|
||||
```bash
|
||||
git push -o 'title=My title' -o 'description=line1\n\nline2' -u "$NOSTR_REMOTE" pr/feat/<slug>
|
||||
```
|
||||
|
||||
Advanced (cover letter, labels): `ngit send` — see `ngit send --help`.
|
||||
|
||||
## Revise (publish a new version)
|
||||
|
||||
The revision must be a **linear series off the current `main`** (a merge commit is the wrong shape).
|
||||
|
||||
```bash
|
||||
# 1. align (gate above), then build the linear series:
|
||||
git checkout -b <work> main
|
||||
git cherry-pick <original-pr-tip> # existing PR commits
|
||||
git cherry-pick <your-new-commits…> # yours on top (or: git rebase main)
|
||||
|
||||
# 2. verify it compiles + tests pass; tree == your intended change.
|
||||
|
||||
# 3. publish as a new version, linked to the proposal:
|
||||
ngit send --in-reply-to <proposal-nevent> \
|
||||
--subject "<keep or update title>" \
|
||||
--description "<what changed>" \
|
||||
-d main # SINCE_OR_RANGE "main" → commits in main..HEAD
|
||||
```
|
||||
|
||||
`--in-reply-to` threads it under the same proposal on gitworkshop. **No `--force`** once the base equals the nostr `main`. `proposal builds on a commit N ahead of 'origin/main'` ⇒ gate unmet — realign and re-rebase, don't force. Verify: `git ls-remote "$NOSTR_REMOTE" | grep <short-id>` shows `refs/pr/<full-id>/head` at your new tip.
|
||||
|
||||
## Merge into main
|
||||
|
||||
The merge is **local**; the merged-status event publishes on the subsequent push.
|
||||
|
||||
```bash
|
||||
# 1. ALIGN (mandatory) — all present mains equal.
|
||||
|
||||
git checkout main
|
||||
ngit pr merge <FULL-hex-event-id> -d # local merge commit; marks proposal "applied"
|
||||
# --squash for a squash merge
|
||||
|
||||
# 2. amend the generic merge message to something readable:
|
||||
git commit --amend -F - <<'MSG'
|
||||
Merge PR: <title>
|
||||
|
||||
Merges nostr proposal <short-id> into main:
|
||||
- <commit summaries>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
MSG
|
||||
|
||||
# 3. sanity-check, then publish (background — slow):
|
||||
[ -n "$GH_REMOTE" ] && { git merge-base --is-ancestor "$GH_REMOTE/main" HEAD && echo "clean FF push" || echo "github moved; realign"; }
|
||||
git push "$NOSTR_REMOTE" main
|
||||
```
|
||||
|
||||
Confirm: `ngit pr list --status applied` shows it `applied`, and (if you have it) `git fetch "$GH_REMOTE"` fast-forwards GitHub's main to your merge.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Delete throwaway branches pushed to the nostr remote (e.g. a `merge/*` used before switching to the proper flow): `git push "$NOSTR_REMOTE" --delete <branch>` (the per-GRASP "non-existent ref" warnings are idempotent fan-out). Delete the local merged branches ngit creates (`pr/feat/<slug>(...)`) and your work branch.
|
||||
|
||||
## Failure modes — quick reference
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `proposal builds on a commit N ahead of 'origin/main'` | base ≠ stale nostr `main` | realign `main`, rebase series, resend (no `--force`) |
|
||||
| `! [remote rejected] main … out of sync with nostr` | GitHub main diverged from nostr | maintainer `ngit sync … --force` — **ask the human** |
|
||||
| push "succeeds" but nothing on gitworkshop | pushed a plain branch, not a proposal | use `pr/`-prefix or `ngit send --in-reply-to` |
|
||||
| `ngit pr view`/`merge` "failed to parse event id" | used the short prefix | pass the **full** hex id or `nevent` |
|
||||
| push hangs / times out at 2 min | normal GRASP fan-out latency | run in background; verify via `git ls-remote` |
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: nostr-expert
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
---
|
||||
|
||||
# Nostr Protocol Expert (Quartz Implementation)
|
||||
@@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner {
|
||||
### Encryption (NIP-44)
|
||||
|
||||
```kotlin
|
||||
// Modern encryption (ChaCha20-Poly1305)
|
||||
object Nip44v2 {
|
||||
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade
|
||||
// (nip44Encryption/Nip44.kt — picks the current version, decrypts any)
|
||||
object Nip44 {
|
||||
fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo
|
||||
fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String
|
||||
}
|
||||
|
||||
// Usage
|
||||
val encrypted = Nip44v2.encrypt(
|
||||
plaintext = "Secret message",
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = recipientPubKey
|
||||
)
|
||||
val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey)
|
||||
val payload = encrypted.encodePayload() // base64 string for event content
|
||||
|
||||
val decrypted = Nip44v2.decrypt(
|
||||
ciphertext = encrypted,
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = senderPubKey
|
||||
)
|
||||
val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey)
|
||||
```
|
||||
|
||||
Most code should not call `Nip44` directly — go through
|
||||
`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)`
|
||||
so remote/external signers keep working.
|
||||
|
||||
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
|
||||
|
||||
### NIP-04 (Deprecated)
|
||||
@@ -345,44 +343,34 @@ object Nip04 {
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
|
||||
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
|
||||
|
||||
## Bech32 Encoding (NIP-19)
|
||||
|
||||
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
|
||||
TLV entities carry relay hints via `create()` helpers on the entity classes in
|
||||
`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose
|
||||
`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`.
|
||||
|
||||
```kotlin
|
||||
object Nip19 {
|
||||
// Encode
|
||||
fun npubEncode(pubkey: HexKey): String // npub1...
|
||||
fun nsecEncode(privateKey: ByteArray): String // nsec1...
|
||||
fun noteEncode(eventId: HexKey): String // note1...
|
||||
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
|
||||
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
|
||||
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
|
||||
// Encode simple entities: ByteArray extensions
|
||||
val npub = pubkeyBytes.toNpub() // "npub1..."
|
||||
val nsec = privKeyBytes.toNsec() // "nsec1..."
|
||||
val note = eventIdBytes.toNote() // "note1..."
|
||||
|
||||
// Decode
|
||||
fun decode(bech32: String): Nip19Result
|
||||
}
|
||||
|
||||
sealed class Nip19Result {
|
||||
data class NPub(val hex: HexKey) : Nip19Result()
|
||||
data class NSec(val hex: HexKey) : Nip19Result()
|
||||
data class Note(val hex: HexKey) : Nip19Result()
|
||||
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
|
||||
}
|
||||
// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
|
||||
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
|
||||
val nprofile = NProfile.create(pubkeyHex, relays)
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
// Encode
|
||||
val npub = Nip19.npubEncode(pubkeyHex)
|
||||
// Output: "npub1..."
|
||||
|
||||
// Decode
|
||||
when (val result = Nip19.decode(npub)) {
|
||||
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
|
||||
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
|
||||
// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities
|
||||
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
|
||||
is NPub -> println("Pubkey: ${entity.hex}")
|
||||
is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}")
|
||||
is NAddress -> println("Address: ${entity.aTag()}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> println("Other type")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
|
||||
# NIP Catalog: Quartz NIP Packages
|
||||
|
||||
As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages
|
||||
under `experimental/`**. The categorized list below may lag behind —
|
||||
`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth.
|
||||
|
||||
## Standard NIPs by Category
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
|
||||
|
||||
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
|
||||
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.11.0` (Maven Central)
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.6` (Maven Central)
|
||||
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
||||
**License**: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.11.0"
|
||||
quartz = "1.12.6"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -41,7 +41,7 @@ kotlin {
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
|
||||
// Decode any bech32 entity
|
||||
val result = Nip19Parser.uriToRoute("npub1abc...")
|
||||
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
|
||||
|
||||
when (val r = Nip19Parser.uriToRoute(input)) {
|
||||
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
|
||||
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
|
||||
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
|
||||
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
|
||||
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> {}
|
||||
// Decode any bech32 entity (plain or nostr:-prefixed).
|
||||
// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity
|
||||
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
|
||||
is NPub -> println("pubkey: ${entity.hex}")
|
||||
is NNote -> println("event id: ${entity.hex}")
|
||||
is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}")
|
||||
is NProfile -> println("profile: ${entity.hex}")
|
||||
is NAddress -> println("address: ${entity.aTag()}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// The parser also handles nostr: URI scheme
|
||||
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
|
||||
// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt
|
||||
val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ...
|
||||
// TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
|
||||
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift
|
||||
|
||||
---
|
||||
|
||||
## 14. Event Store (Android only)
|
||||
## 14. Event Store (SQLite, all platforms)
|
||||
|
||||
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
|
||||
SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled
|
||||
androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50,
|
||||
NIP-62). All operations are `suspend`:
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.store.EventStore
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
|
||||
val store = EventStore()
|
||||
val store = EventStore() // default DB file "events.db"
|
||||
|
||||
// Insert
|
||||
store.insert(event)
|
||||
|
||||
// Query
|
||||
val events = store.query(
|
||||
val events = store.query<Event>(
|
||||
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
|
||||
)
|
||||
|
||||
@@ -623,7 +629,7 @@ val events = store.query(
|
||||
val count = store.count(Filter(kinds = listOf(1)))
|
||||
|
||||
// Full-text search (NIP-50)
|
||||
val results = store.query(Filter(search = "bitcoin"))
|
||||
val results = store.query<Event>(Filter(search = "bitcoin"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.11.0
|
||||
com.vitorpamplona.quartz:quartz:1.12.6
|
||||
```
|
||||
|
||||
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
|
||||
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.11.0"
|
||||
quartz = "1.12.6"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -55,7 +55,7 @@ kotlin {
|
||||
```kotlin
|
||||
// build.gradle.kts (app module)
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Quartz KMP (Legacy Skill — Migration Complete)
|
||||
|
||||
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
|
||||
>
|
||||
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
|
||||
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
|
||||
|
||||
## What was migrated
|
||||
|
||||
The Quartz library was successfully converted from Android-only to full KMP supporting:
|
||||
- **commonMain** — All Nostr protocol logic, events, filters, tags
|
||||
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
|
||||
- **androidMain** — SQLite event store, NIP-55 Android signer
|
||||
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
|
||||
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
|
||||
|
||||
## Current artifact
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.11.0
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
@@ -24,17 +24,23 @@ relayClient/
|
||||
├── assemblers/ # "Given these inputs, build this relay Filter"
|
||||
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
|
||||
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
|
||||
│ └── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
|
||||
│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
|
||||
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
|
||||
├── composeSubscriptionManagers/
|
||||
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
|
||||
│ ├── MutableComposeSubscriptionManager.kt # reference impl
|
||||
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
|
||||
├── eoseManagers/ # EOSE tracking per subscription
|
||||
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
|
||||
├── nip17Dm/ # gift-wrap DM plumbing
|
||||
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
|
||||
├── preload/
|
||||
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
|
||||
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
|
||||
└── subscriptions/
|
||||
└── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
|
||||
├── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
|
||||
├── LifecycleAwareKeyDataSourceSubscription.kt
|
||||
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
|
||||
```
|
||||
|
||||
## Core Concept: `Subscribable<T>`
|
||||
|
||||
@@ -6,7 +6,7 @@ description: >-
|
||||
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to validate (e.g. v1.08.0)"
|
||||
description: "Tag to validate (e.g. vX.YY.Z)"
|
||||
required: true
|
||||
is_prerelease:
|
||||
description: "Whether the release is marked as prerelease (empty string treated as false)"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Import macOS Developer ID certificate
|
||||
description: >
|
||||
Import a Developer ID Application certificate (base64-encoded .p12) into a
|
||||
throwaway keychain so codesign/jpackage can find it during the job. Soft:
|
||||
when no certificate is supplied it is a no-op and reports signing=false, so
|
||||
callers build UNSIGNED artifacts exactly as before.
|
||||
|
||||
inputs:
|
||||
certificate-p12-base64:
|
||||
description: Base64 of the Developer ID Application .p12 (cert + private key)
|
||||
required: true
|
||||
certificate-password:
|
||||
description: Password used when the .p12 was exported
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
signing:
|
||||
description: "'true' if a certificate was imported, else 'false'"
|
||||
value: ${{ steps.import.outputs.signing }}
|
||||
keychain:
|
||||
description: >
|
||||
Absolute path of the throwaway keychain holding the imported cert (empty
|
||||
when signing=false). Pass to tools that need an explicit keychain — e.g.
|
||||
Compose's macOS signing, whose certificate lookup doesn't resolve the
|
||||
search list reliably on CI runners the way bare `codesign` does.
|
||||
value: ${{ steps.import.outputs.keychain }}
|
||||
identity:
|
||||
description: >
|
||||
The imported certificate's full "Developer ID Application: NAME (TEAMID)"
|
||||
common name, resolved from the keychain (empty when signing=false or if it
|
||||
could not be parsed). `codesign` accepts a SHA-1 hash or a CN substring,
|
||||
but Compose's MacSigner runs `security find-certificate -c <identity>`
|
||||
after prepending "Developer ID Application: " — so it only works with the
|
||||
exact common name. Feed this to Compose's `signing.identity`.
|
||||
value: ${{ steps.import.outputs.identity }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: import
|
||||
shell: bash
|
||||
env:
|
||||
CERT_P12: ${{ inputs.certificate-p12-base64 }}
|
||||
CERT_PASSWORD: ${{ inputs.certificate-password }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CERT_P12:-}" ]]; then
|
||||
echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED."
|
||||
echo "signing=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db"
|
||||
KEYCHAIN_PWD="$(openssl rand -base64 24)"
|
||||
CERT_PATH="$RUNNER_TEMP/developer_id.p12"
|
||||
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
echo "$CERT_P12" | base64 --decode > "$CERT_PATH"
|
||||
security import "$CERT_PATH" -P "$CERT_PASSWORD" \
|
||||
-k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign
|
||||
# Let codesign use the private key without an interactive UI prompt.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
|
||||
# Prepend our keychain to the user search list so codesign sees it.
|
||||
security list-keychains -d user -s "$KEYCHAIN" \
|
||||
$(security list-keychains -d user | sed -e 's/[\"[:space:]]//g')
|
||||
# Also make it the default keychain. Bare `codesign` resolves identities
|
||||
# via the search list, but some tools (Compose's MacSigner runs
|
||||
# `security find-certificate` to map the identity to a cert) don't find
|
||||
# it on the search list alone on these runners. Callers that need an
|
||||
# explicit keychain can read the `keychain` output below.
|
||||
security default-keychain -d user -s "$KEYCHAIN"
|
||||
rm -f "$CERT_PATH"
|
||||
# Resolve the certificate's exact common name. Compose's MacSigner maps
|
||||
# its identity to a cert via `security find-certificate -c <name>` (after
|
||||
# prepending "Developer ID Application: " when absent), so it needs the
|
||||
# full CN — a SHA-1 hash or partial name in the secret signs fine with
|
||||
# bare `codesign` but yields "Could not find certificate ...". Pull the
|
||||
# canonical name straight from the keychain so the caller is independent
|
||||
# of whatever form the MAC_SIGN_IDENTITY secret takes.
|
||||
IDENTITY_NAME="$(security find-identity -v -p codesigning "$KEYCHAIN" \
|
||||
| grep -o '"Developer ID Application:[^"]*"' | head -1 | tr -d '"' || true)"
|
||||
echo "signing=true" >> "$GITHUB_OUTPUT"
|
||||
echo "keychain=$KEYCHAIN" >> "$GITHUB_OUTPUT"
|
||||
echo "identity=$IDENTITY_NAME" >> "$GITHUB_OUTPUT"
|
||||
if [[ -n "$IDENTITY_NAME" ]]; then
|
||||
echo "::notice::Resolved signing identity: $IDENTITY_NAME"
|
||||
else
|
||||
echo "::warning::Could not resolve a 'Developer ID Application' identity from the keychain; callers will fall back to the MAC_SIGN_IDENTITY secret."
|
||||
fi
|
||||
+14
-84
@@ -79,82 +79,6 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# Cache vlc-setup plugin downloads (VLC + UPX archives) keyed on the
|
||||
# versions pinned in desktopApp/build.gradle.kts. Each OS gets its own
|
||||
# cache namespace because the plugin downloads platform-specific archives.
|
||||
# On a hit the vlcDownload / upxDownload tasks are up-to-date and we
|
||||
# never touch get.videolan.org; on a miss (version bump or new runner)
|
||||
# we fall back to fetching, which is what the in-build retry budget
|
||||
# exists for.
|
||||
- name: Cache vlc-setup downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/vlcSetup
|
||||
key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
vlcsetup-${{ runner.os }}-
|
||||
|
||||
# Pre-fetch VLC + UPX archives into ~/.gradle/vlcSetup before invoking
|
||||
# Gradle. The vlc-setup plugin (ir.mahozad.vlc-setup 0.1.0) writes its
|
||||
# downloads to ${gradleUserHomeDir}/vlcSetup/ and sets overwrite(false),
|
||||
# so an existing file there makes vlcDownload / upxDownload up-to-date
|
||||
# and Gradle never opens a socket to videolan.org.
|
||||
#
|
||||
# Why curl instead of relying on de.undercouch.gradle.tasks.download:
|
||||
# curl --retry-all-errors with a long --retry-max-time tolerates a
|
||||
# sustained get.videolan.org outage far better than the plugin's inner
|
||||
# retry budget (retries(4) + 5min readTimeout in build.gradle.kts),
|
||||
# which has been hitting SocketTimeoutException on Windows runners.
|
||||
#
|
||||
# Cache hit: the file is already on disk, fetch() short-circuits, this
|
||||
# step takes <1s. Cache miss: curl downloads with aggressive retries,
|
||||
# populating the cache for the next run.
|
||||
#
|
||||
# Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion
|
||||
# = 3.0.20) and the vlc-setup extension default (upxVersion = 4.2.4).
|
||||
# NOTE: vlcVersion lags behind upstream VLC because the Linux plugins on
|
||||
# Maven Central (ir.mahozad:vlc-plugins-linux) are only published for
|
||||
# 3.0.20 / 3.0.20-2. Bump only after the Maven artifact is republished.
|
||||
# macOS does not download UPX — UPX cannot compress .dylib files.
|
||||
- name: Pre-fetch VLC + UPX archives
|
||||
env:
|
||||
VLC_VERSION: "3.0.20"
|
||||
UPX_VERSION: "4.2.4"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$HOME/.gradle/vlcSetup"
|
||||
mkdir -p "$DEST"
|
||||
fetch() {
|
||||
local url="$1" out="$2"
|
||||
if [[ -s "$out" ]]; then
|
||||
echo "cached: $out"
|
||||
return 0
|
||||
fi
|
||||
echo "fetching: $url"
|
||||
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
|
||||
--retry-max-time 900 --connect-timeout 30 \
|
||||
-o "$out.part" "$url"
|
||||
mv "$out.part" "$out"
|
||||
}
|
||||
case "${{ runner.os }}" in
|
||||
Windows)
|
||||
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/win64/vlc-${VLC_VERSION}-win64.zip" \
|
||||
"$DEST/vlc-${VLC_VERSION}.zip"
|
||||
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" \
|
||||
"$DEST/upx-${UPX_VERSION}.zip"
|
||||
;;
|
||||
Linux)
|
||||
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
|
||||
"$DEST/vlc-${VLC_VERSION}.jar"
|
||||
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
|
||||
"$DEST/upx-${UPX_VERSION}.tar.xz"
|
||||
;;
|
||||
macOS)
|
||||
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/macosx/vlc-${VLC_VERSION}-universal.dmg" \
|
||||
"$DEST/vlc-${VLC_VERSION}.dmg"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
|
||||
# a display server on Linux. xvfb provides a virtual framebuffer.
|
||||
- name: Install xvfb (Linux)
|
||||
@@ -227,16 +151,22 @@ jobs:
|
||||
:quartz:compileTestKotlinIosArm64
|
||||
|
||||
# :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the
|
||||
# iOS plan. Compile-only for now — actual UI / lifecycle wiring will
|
||||
# land with the iosApp module in Phase 3. The container that runs Claude
|
||||
# Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit),
|
||||
# so this is the first place commonMain Compose code is actually
|
||||
# type-checked against an Apple Native frontend.
|
||||
- name: Compile Commons for iOS
|
||||
# iOS plan. Actual UI / lifecycle wiring will land with the iosApp module
|
||||
# in Phase 3, but the shared commonMain + commonTest sources are already
|
||||
# built here against an Apple Native frontend. Same two-task shape as
|
||||
# quartz above:
|
||||
# - iosSimulatorArm64Test compiles AND runs the shared commonTest suite
|
||||
# on the simulator. commonTest is built for every target, so a test
|
||||
# reaching for a JVM-only API (JUnit, javaClass, @JvmStatic, or a
|
||||
# jvmAndroid-only symbol) breaks the Apple build even though
|
||||
# :commons:jvmTest stays green — this is the job that catches it.
|
||||
# - compileTestKotlinIosArm64 catches device-only compile drift
|
||||
# (iosArm64 = aarch64-apple-ios) without needing a physical device.
|
||||
- name: Test Commons on iOS
|
||||
run: |
|
||||
./gradlew \
|
||||
:commons:compileKotlinIosSimulatorArm64 \
|
||||
:commons:compileKotlinIosArm64
|
||||
:commons:iosSimulatorArm64Test \
|
||||
:commons:compileTestKotlinIosArm64
|
||||
|
||||
- name: Upload iOS Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
test_tag:
|
||||
description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push'
|
||||
description: 'Synthetic tag name for dry-run (e.g. vX.YY.Z-dryrun); ignored on tag push'
|
||||
type: string
|
||||
default: 'v0.0.0-dryrun'
|
||||
|
||||
@@ -23,9 +23,9 @@ env:
|
||||
# Single source of truth in scripts/asset-name.sh.
|
||||
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
|
||||
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH
|
||||
# mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the
|
||||
# AppDir as-is, which is what we actually want.
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
|
||||
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
|
||||
# embeds the AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
|
||||
@@ -101,8 +101,39 @@ jobs:
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
# macOS only: import the Developer ID Application cert into a throwaway
|
||||
# keychain so jpackage's codesign pass can find it. Soft — if the
|
||||
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
|
||||
# provisioned) the DMG is built UNSIGNED, exactly as before. notarytool
|
||||
# runs as part of the gradle task when the identity env is exported below.
|
||||
- name: Import Apple Developer ID certificate (macOS leg, if configured)
|
||||
if: matrix.family == 'macos'
|
||||
id: mac_keychain
|
||||
uses: ./.github/actions/import-macos-cert
|
||||
with:
|
||||
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Build desktop artifacts
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
env:
|
||||
# Empty on non-macOS legs and on the macOS leg when no cert is
|
||||
# configured — the gradle macOS{} block skips signing when the
|
||||
# identity is blank. Prefer the full common name resolved from the
|
||||
# keychain over the raw secret: Compose's signer maps the identity via
|
||||
# `security find-certificate` and only matches the exact "Developer ID
|
||||
# Application: …" CN, whereas the secret may be a hash or partial name
|
||||
# (which bare codesign accepts but Compose does not). Fall back to the
|
||||
# secret if resolution failed.
|
||||
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && (steps.mac_keychain.outputs.identity || secrets.MAC_SIGN_IDENTITY) || '' }}
|
||||
# Explicit keychain for Compose's MacSigner. Its `security
|
||||
# find-certificate` lookup doesn't resolve the imported cert via the
|
||||
# search list on these runners ("Could not find certificate ... in
|
||||
# keychain []"), so point it at the throwaway keychain directly.
|
||||
AMETHYST_MAC_SIGN_KEYCHAIN: ${{ steps.mac_keychain.outputs.signing == 'true' && steps.mac_keychain.outputs.keychain || '' }}
|
||||
AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
|
||||
AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
|
||||
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
|
||||
with:
|
||||
max_attempts: 2
|
||||
timeout_minutes: 15
|
||||
@@ -212,7 +243,7 @@ jobs:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -261,6 +292,96 @@ jobs:
|
||||
timeout_minutes: 15
|
||||
command: ./gradlew --no-daemon :cli:${{ matrix.tasks }}
|
||||
|
||||
# amy is headless: the Compose UI render stack (skiko + its native dylibs,
|
||||
# foundation/material/material3/ui/animation) must never reach the CLI
|
||||
# image. cli/build.gradle.kts excludes it from runtimeClasspath; this
|
||||
# guards against a transitive dep silently dragging it back (size + macOS
|
||||
# notarization-surface regression). compose.runtime is CLI-safe and stays.
|
||||
- name: Assert no Compose UI in the amy image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LIB="cli/build/install/amy/lib"
|
||||
leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)"
|
||||
if [ -n "$leak" ]; then
|
||||
echo "::error::Compose UI render stack leaked into the amy CLI image:"
|
||||
echo "$leak" | sed 's/^/ /'
|
||||
echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))."
|
||||
|
||||
# macOS only: import the Developer ID cert (no-op without the secret) so
|
||||
# the next step can codesign the jlink image. The jvm bundle for
|
||||
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
|
||||
- name: Import Apple Developer ID certificate (macOS leg, if configured)
|
||||
if: matrix.family == 'macos'
|
||||
id: mac_keychain
|
||||
uses: ./.github/actions/import-macos-cert
|
||||
with:
|
||||
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
# Codesign + notarize the macOS jlink image (amy-<ver>-macos-arm64.tar.gz)
|
||||
# for users who download it directly. A loose tarball can't be stapled
|
||||
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
|
||||
# online on first run. Runs before "Collect" so the tarred image is signed.
|
||||
- name: Sign + notarize amy image (macOS leg, if configured)
|
||||
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
|
||||
env:
|
||||
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
|
||||
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
|
||||
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
|
||||
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="cli/build/amy-image/amy"
|
||||
ENTITLEMENTS="cli/packaging/macos/amy.entitlements"
|
||||
# First sign the macOS Mach-O natives buried INSIDE the bundled jars
|
||||
# (secp256k1/jna/sqlite/skiko/jkeychain/mediaplayer). The loose-file
|
||||
# loop below can't see them, but Apple's notary recurses into jars and
|
||||
# rejects any unsigned Mach-O — so this must run before notarize.
|
||||
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
|
||||
# Sign every loose Mach-O binary in the bundled JRE. Each is signed
|
||||
# independently (no enclosing .app seals them), so order is irrelevant.
|
||||
# Executables get the hardened-runtime entitlements; dylibs don't.
|
||||
while IFS= read -r f; do
|
||||
case "$(file -b "$f")" in
|
||||
*Mach-O*executable*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
|
||||
*Mach-O*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--sign "$SIGN_IDENTITY" "$f" ;;
|
||||
esac
|
||||
done < <(find "$IMG" -type f)
|
||||
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
|
||||
# Notarize: zip the signed image, submit, wait for Apple's verdict.
|
||||
# The notary service recursively inspects the lib/*.jar files; their
|
||||
# embedded Mach-O natives are signed by sign-macos-jar-natives.sh
|
||||
# above. Surface the per-file log on any non-Accepted verdict so a
|
||||
# regression is diagnostic rather than a bare failure.
|
||||
ZIP="$RUNNER_TEMP/amy-notarize.zip"
|
||||
OUT="$RUNNER_TEMP/notary-submit.json"
|
||||
ditto -c -k --keepParent "$IMG" "$ZIP"
|
||||
if ! xcrun notarytool submit "$ZIP" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
|
||||
echo "::warning::notarytool submit exited non-zero"
|
||||
fi
|
||||
cat "$OUT"
|
||||
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
|
||||
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Notarization status: $STATUS"
|
||||
if [ -n "$SUBMISSION_ID" ]; then
|
||||
echo "----- notary log -----"
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jpackage pins libicu to the build host's version (libicu74 on
|
||||
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
|
||||
- name: Relax libicu dependency in .deb
|
||||
@@ -277,6 +398,23 @@ jobs:
|
||||
source scripts/asset-name.sh
|
||||
collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
|
||||
|
||||
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
|
||||
# cannot use the jlink tarball above (bundled runtime) nor build from
|
||||
# source (its sandbox blocks Gradle's Maven downloads). installDist
|
||||
# (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure
|
||||
# JVM bytecode, so one platform-independent asset serves every OS; we cut
|
||||
# it on the linux leg only. amyImage depends on installDist, so the
|
||||
# cli/build/install/amy tree already exists here.
|
||||
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
|
||||
if: matrix.family == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
SRC="cli/build/install/amy"
|
||||
test -x "$SRC/bin/amy"
|
||||
( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib )
|
||||
echo "Collected: dist/amy-${VER}-jvm.tar.gz"
|
||||
|
||||
- name: Enforce CLI size budget (200 MB per asset)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -434,6 +572,48 @@ jobs:
|
||||
"dist/amethyst-fdroid-${TAG}.aab"
|
||||
ls -la dist
|
||||
|
||||
# Accrescent does not accept AABs or monolithic APKs — it requires a signed
|
||||
# APK set (.apks) of split APKs generated by bundletool from the AAB. We build
|
||||
# it from the F-Droid flavor (no proprietary Google deps) and sign the splits
|
||||
# with the same release keystore used above. Upload is still manual: drop this
|
||||
# .apks into https://console.accrescent.app (no publish API/CLI exists yet).
|
||||
- name: Build Accrescent APK set (F-Droid)
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
BUNDLETOOL_VERSION="1.18.3" # must be >= 1.11.4 per Accrescent requirements
|
||||
curl -fsSL -o bundletool.jar \
|
||||
"https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar"
|
||||
|
||||
# Same base64 keystore secret consumed by the r0adkll signing steps above.
|
||||
echo "$SIGNING_KEY" | base64 -d > release.keystore
|
||||
|
||||
# --mode=default emits the split-APK set Accrescent wants (NOT --mode=universal,
|
||||
# which produces a monolithic APK that Accrescent rejects).
|
||||
java -jar bundletool.jar build-apks \
|
||||
--bundle="dist/amethyst-fdroid-${TAG}.aab" \
|
||||
--output="dist/amethyst-fdroid-${TAG}.apks" \
|
||||
--ks=release.keystore \
|
||||
--ks-key-alias="$KEY_ALIAS" \
|
||||
--ks-pass="pass:$KEY_STORE_PASSWORD" \
|
||||
--key-pass="pass:$KEY_PASSWORD" \
|
||||
--mode=default
|
||||
|
||||
rm -f release.keystore bundletool.jar
|
||||
|
||||
# Accrescent's automated check rejects an APK set larger than 128 MiB.
|
||||
SIZE_BYTES=$(stat -c%s "dist/amethyst-fdroid-${TAG}.apks")
|
||||
echo "Accrescent APK set size: $((SIZE_BYTES / 1024 / 1024)) MiB"
|
||||
if [ "$SIZE_BYTES" -gt $((128 * 1024 * 1024)) ]; then
|
||||
echo "::warning::amethyst-fdroid-${TAG}.apks exceeds Accrescent's 128 MiB limit; the console will reject this upload."
|
||||
fi
|
||||
ls -la dist
|
||||
|
||||
- name: Classify release
|
||||
id: classify
|
||||
run: |
|
||||
|
||||
@@ -10,12 +10,21 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# Single job so the Crowdin translation sync and the translator-placeholder seed
|
||||
# land in ONE pull request instead of two. The Crowdin action only downloads into
|
||||
# the working tree (push_translations/create_pull_request disabled); the seed
|
||||
# script then edits translators.json; finally one create-pull-request step opens a
|
||||
# single PR with both sets of changes (and no-ops when there is no diff).
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Need tags so scripts/translators.sh can resolve the last v* release tag
|
||||
# for the "since last tag" window.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
@@ -23,12 +32,42 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
# Let the downloaded translations stay in the working tree; the single
|
||||
# create-pull-request step below opens the combined PR.
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# Keep docs/changelog/translators.json seeded with everyone who has translated
|
||||
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
|
||||
# can resolve them to npubs. Only adds rows when a genuinely new contributor
|
||||
# appears.
|
||||
- name: Seed translator placeholders from Crowdin
|
||||
run: bash scripts/translators.sh --seed
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Open or update the combined Crowdin PR
|
||||
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
|
||||
# linked into any shipped artifact). It no-ops when there is no diff.
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: l10n_crowdin_translations
|
||||
add-paths: |
|
||||
amethyst/src/main/res/**/strings.xml
|
||||
docs/changelog/translators.json
|
||||
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
|
||||
title: 'New Crowdin Translations'
|
||||
body: |
|
||||
New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action).
|
||||
|
||||
Any new Crowdin contributors were added to `docs/changelog/translators.json`
|
||||
with blank npubs. Fill in the npubs you have so the next release's
|
||||
`## Translations` credits generate automatically via
|
||||
`scripts/translators.sh --from <prev-tag> --to <this-tag>`.
|
||||
|
||||
@@ -68,36 +68,6 @@ jobs:
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Cache vlc-setup downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/vlcSetup
|
||||
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
vlcsetup-Linux-
|
||||
|
||||
- name: Pre-fetch VLC + UPX archives
|
||||
env:
|
||||
VLC_VERSION: "3.0.20"
|
||||
UPX_VERSION: "4.2.4"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$HOME/.gradle/vlcSetup"
|
||||
mkdir -p "$DEST"
|
||||
fetch() {
|
||||
local url="$1" out="$2"
|
||||
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
|
||||
echo "fetching: $url"
|
||||
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
|
||||
--retry-max-time 900 --connect-timeout 30 \
|
||||
-o "$out.part" "$url"
|
||||
mv "$out.part" "$out"
|
||||
}
|
||||
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
|
||||
"$DEST/vlc-${VLC_VERSION}.jar"
|
||||
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
|
||||
"$DEST/upx-${UPX_VERSION}.tar.xz"
|
||||
|
||||
- name: Install xvfb + packaging deps
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
|
||||
|
||||
|
||||
+14
-4
@@ -161,10 +161,20 @@ TASKS.md
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Downloaded VLC binaries (vlc-setup plugin)
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
# Per-OS appResources slots — historically the ir.mahozad.vlc-setup plugin
|
||||
# populated these with VLC binaries (no longer used; superseded by
|
||||
# kdroidFilter ComposeMediaPlayer). We still ignore the directory contents
|
||||
# by default to keep stale workspaces from accidentally bundling old VLC
|
||||
# trees into local packages, but explicitly track the ffmpeg/README.md
|
||||
# drop-in slot for the LGPL FFmpeg binaries used by VideoThumbnailCache.
|
||||
desktopApp/src/jvmMain/appResources/linux/*
|
||||
desktopApp/src/jvmMain/appResources/macos/*
|
||||
desktopApp/src/jvmMain/appResources/windows/*
|
||||
!desktopApp/src/jvmMain/appResources/linux/ffmpeg/
|
||||
!desktopApp/src/jvmMain/appResources/macos/ffmpeg/
|
||||
!desktopApp/src/jvmMain/appResources/windows/ffmpeg/
|
||||
desktopApp/src/jvmMain/appResources/*/ffmpeg/*
|
||||
!desktopApp/src/jvmMain/appResources/*/ffmpeg/README.md
|
||||
|
||||
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
|
||||
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
Generated
+1
-1
@@ -8,6 +8,6 @@
|
||||
</component>
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.3.21" />
|
||||
<option name="version" value="2.4.0" />
|
||||
</component>
|
||||
</project>
|
||||
+299
-35
@@ -1,13 +1,22 @@
|
||||
# Building Amethyst Desktop
|
||||
|
||||
This guide covers building Amethyst Desktop from source, the release pipeline,
|
||||
and one-time bootstrap steps for distribution channels.
|
||||
This guide has everything **any fork** needs to build Amethyst from source and
|
||||
cut its own release: prerequisites, build commands, the CI release pipeline, the
|
||||
secrets it needs, the distribution channels, and one-time bootstrap steps.
|
||||
|
||||
> **Amethyst maintainers:** the account-specific checklist for shipping the
|
||||
> official build (Play Console upload, Zapstore `zsp publish` with our nsec,
|
||||
> secret ownership) lives in [`RELEASE_OPS.md`](RELEASE_OPS.md). This
|
||||
> file stays fork-generic.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Clone + first build](#clone--first-build)
|
||||
- [Generated & vendored artifacts](#generated--vendored-artifacts)
|
||||
- [Per-format build commands](#per-format-build-commands)
|
||||
- [Asset naming contract](#asset-naming-contract)
|
||||
- [Release runbook](#release-runbook)
|
||||
- [Secrets the CI needs](#secrets-the-ci-needs)
|
||||
- [Distribution channels](#distribution-channels)
|
||||
- [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time)
|
||||
- [Troubleshooting installs](#troubleshooting-installs)
|
||||
- [Uninstall + state paths](#uninstall--state-paths)
|
||||
@@ -68,6 +77,29 @@ cd amethyst
|
||||
|
||||
---
|
||||
|
||||
## Generated & vendored artifacts
|
||||
|
||||
Two build inputs are **generated by tools but committed to the repo**, so a
|
||||
normal build or release does **not** run either — Gradle just consumes the
|
||||
checked-in output. You only regenerate them under the specific conditions below,
|
||||
and each has its own guide:
|
||||
|
||||
| Artifact | Committed at | Regenerate when | Guide |
|
||||
|---|---|---|---|
|
||||
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
|
||||
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
|
||||
|
||||
> **Material Symbols is mandatory after icon changes.** The bundled font is a
|
||||
> ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at
|
||||
> runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt`
|
||||
> change. Reusing an existing codepoint needs no regeneration.
|
||||
|
||||
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
|
||||
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
|
||||
are **not** required to build Amethyst from the committed sources.
|
||||
|
||||
---
|
||||
|
||||
## Per-format build commands
|
||||
|
||||
| Artifact | Command | Output |
|
||||
@@ -111,11 +143,11 @@ amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
|
||||
Where:
|
||||
|
||||
| Field | Values |
|
||||
|---|---------------------------------------------------------|
|
||||
| `<version>` | Tag stripped of leading `v` (e.g. `1.11.0`) |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| Field | Values |
|
||||
|---|-------------------------------------------------------|
|
||||
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
|
||||
|
||||
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
|
||||
@@ -124,10 +156,60 @@ any change is a breaking contract.
|
||||
|
||||
Examples:
|
||||
|
||||
- `amethyst-desktop-1.11.0-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.11.0-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.11.0-windows-x64.msi`
|
||||
- `amethyst-desktop-1.11.0-linux-x64.AppImage`
|
||||
- `amethyst-desktop-1.12.1-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.12.1-windows-x64.msi`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
|
||||
|
||||
---
|
||||
|
||||
## Reproducible Android builds
|
||||
|
||||
The release APKs are **bit-for-bit reproducible**: anyone can rebuild the exact
|
||||
bytes we ship (minus the signature) from the tagged source and confirm the
|
||||
artifact on F-Droid / Zapstore / GitHub was built from this code and nothing
|
||||
else. What makes that hold:
|
||||
|
||||
- **Pinned toolchain.** AGP, Kotlin, R8, and the Compose compiler are pinned in
|
||||
`gradle/libs.versions.toml`; the build targets **JDK 21**. R8 is deterministic
|
||||
for a fixed version + inputs, so the minified output is stable. Build with the
|
||||
same JDK 21 you see in `BUILDING.md` / CI.
|
||||
- **No build-time clock.** Nothing injects `System.currentTimeMillis()` /
|
||||
build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and
|
||||
`commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour
|
||||
apart are identical.
|
||||
- **Deterministic version name.** `generateVersionName` only appends a branch
|
||||
suffix off feature branches; a release tag builds in detached-`HEAD` (or from a
|
||||
source tarball with no `.git`) resolve to the bare `app` version.
|
||||
- **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false;
|
||||
includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from
|
||||
embedding the Google-encrypted dependency protobuf in the signing block — that
|
||||
ciphertext is non-deterministic.
|
||||
- **Reproducible native library.** The bundled Tor (Arti) `.so` is the one
|
||||
binary we compile ourselves; it is built reproducibly from source (pinned Rust
|
||||
toolchain, locked deps, canonical build path). See
|
||||
[`tools/arti-build/README.md`](tools/arti-build/README.md) → "Reproducible
|
||||
builds". All other native libs (`secp256k1`, `webrtc`) are version-pinned Maven
|
||||
prebuilts and so are byte-identical by download.
|
||||
|
||||
### Verify a release APK reproduces
|
||||
|
||||
```bash
|
||||
# 1. Check out the exact released tag and build the same variant unsigned.
|
||||
git checkout v1.12.1
|
||||
./gradlew clean :amethyst:assembleFdroidRelease
|
||||
|
||||
# 2. Diff your unsigned build against the published APK, ignoring only the
|
||||
# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
|
||||
# diffoscope gives a human-readable breakdown of any remaining delta.
|
||||
diffoscope \
|
||||
amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
|
||||
amethyst-fdroid-arm64-v8a-1.12.1.apk
|
||||
```
|
||||
|
||||
A clean run shows differences confined to `META-INF/` (the signing files). Any
|
||||
diff in `classes*.dex`, `resources.arsc`, or native libs means something in the
|
||||
toolchain drifted — file it before publishing.
|
||||
|
||||
---
|
||||
|
||||
@@ -136,38 +218,37 @@ Examples:
|
||||
The release flow is driven by a tag push. Every cut ships Android + Desktop +
|
||||
Quartz library in one pipeline.
|
||||
|
||||
1. **Bump the app version** in `gradle/libs.versions.toml`:
|
||||
1. **Bump the app version and Android `versionCode`** in
|
||||
`gradle/libs.versions.toml` (`appCode` is a monotonic integer — it must
|
||||
increment even when `app` is unchanged):
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
app = "1.08.1" # new semver
|
||||
appCode = "449" # Android versionCode
|
||||
```
|
||||
|
||||
2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer,
|
||||
must increment even for same `versionName`):
|
||||
`amethyst/build.gradle.kts` reads both from the catalog
|
||||
(`versionCode = libs.versions.appCode.get().toInt()`), so there is nothing
|
||||
else to edit.
|
||||
|
||||
```groovy
|
||||
versionCode = 447
|
||||
versionName = generateVersionName(libs.versions.app.get())
|
||||
```
|
||||
|
||||
3. **Commit + tag + push**:
|
||||
2. **Commit + tag + push**:
|
||||
|
||||
```bash
|
||||
git commit -am "chore(release): 1.08.1"
|
||||
git tag -s v1.08.1 -m "Release 1.08.1"
|
||||
git commit -am "chore(release): 1.12.1"
|
||||
git tag -s v1.12.1 -m "Release 1.12.1"
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
4. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
5. **Verify**:
|
||||
4. **Verify**:
|
||||
- GH Release contains 8 desktop assets + 12 Android assets
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Intel + ARM DMGs both present
|
||||
- Android flow unchanged
|
||||
|
||||
6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
|
||||
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
|
||||
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
|
||||
Homebrew + Winget bump workflows.
|
||||
|
||||
@@ -203,19 +284,149 @@ uninstall before a new release. Leave it alone forever.
|
||||
|
||||
---
|
||||
|
||||
## Secrets the CI needs
|
||||
|
||||
The `Create Release Assets` workflow reads these from GitHub repo secrets. A
|
||||
fork must provide its **own** values — none are inherited. (`GITHUB_TOKEN` is
|
||||
provided automatically; everything else you set yourself.)
|
||||
|
||||
| Secret | What it is | Used for |
|
||||
|---|---|---|
|
||||
| `SIGNING_KEY` | Base64 of your **Android keystore** (`.jks`/`.keystore`) | Signs the Play + F-Droid **AAB and APK** |
|
||||
| `KEY_ALIAS` | Keystore key alias | Same Android signing step |
|
||||
| `KEY_STORE_PASSWORD` | Keystore password | Same |
|
||||
| `KEY_PASSWORD` | Key password | Same |
|
||||
| `SONATYPE_USERNAME` | Maven Central (Sonatype) user token name | Publishing the `quartz` library |
|
||||
| `SONATYPE_PASSWORD` | Maven Central user token password | Same |
|
||||
| `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) |
|
||||
| `SIGNING_PASSWORD` | Passphrase for that GPG key | Same |
|
||||
| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball |
|
||||
| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain |
|
||||
| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with |
|
||||
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
|
||||
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
|
||||
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
|
||||
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
|
||||
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
|
||||
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
|
||||
|
||||
Note the **three distinct signing identities** people often conflate:
|
||||
`SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` +
|
||||
`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` +
|
||||
`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS
|
||||
desktop DMG. They are unrelated — each comes from a different authority.
|
||||
|
||||
The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset
|
||||
the release workflow still builds the DMG **and** the macOS `amy` tarball, just
|
||||
**unsigned** (the previous behavior). Provision all six to switch signing +
|
||||
notarization on for both. Obtaining them requires Apple Developer Program
|
||||
membership ($99/yr). The same one certificate signs both artifacts.
|
||||
|
||||
The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means
|
||||
codesigning every Mach-O binary in that runtime with hardened-runtime
|
||||
entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can
|
||||
load the secp256k1 native library it extracts at runtime). A loose `.tar.gz`
|
||||
cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so
|
||||
Gatekeeper verifies notarization **online** on first run — fine for a CLI.
|
||||
Note the Homebrew-core jvm bundle (`amy-<version>-jvm.tar.gz`) is **not** signed:
|
||||
Homebrew removes the quarantine attribute on its own downloads.
|
||||
|
||||
> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled
|
||||
> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via
|
||||
> secp256k1 with no library-validation crash. Dropping `disable-library-validation`
|
||||
> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted
|
||||
> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative.
|
||||
>
|
||||
> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar`
|
||||
> recursively and checks every Mach-O for a signature + hardened runtime. Our
|
||||
> sign loop only touches loose files, so 9 unsigned natives ride along inside
|
||||
> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2),
|
||||
> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders).
|
||||
> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified**
|
||||
> (the local validation had no notary creds). **Decide it with one run:** set the
|
||||
> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch`
|
||||
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
|
||||
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
|
||||
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
|
||||
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the
|
||||
> `:commons` core/ui split the size budget already flags). The **desktop** app
|
||||
> bundles the same jars through Compose/jpackage notarization, so run a desktop
|
||||
> dry-run too; its in-jar handling differs and is likewise unverified.
|
||||
|
||||
Generating the values:
|
||||
|
||||
```bash
|
||||
# Android keystore → base64 for SIGNING_KEY (one line, no wrapping)
|
||||
keytool -genkey -v -keystore upload.jks -keyalg RSA -keysize 2048 \
|
||||
-validity 10000 -alias upload # creates the keystore (once)
|
||||
base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY
|
||||
|
||||
# GPG key → armored private key for SIGNING_PRIVATE_KEY
|
||||
gpg --full-generate-key # create the key (once)
|
||||
gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY
|
||||
|
||||
# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
|
||||
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
|
||||
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
|
||||
base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12
|
||||
security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string
|
||||
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
|
||||
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.
|
||||
```
|
||||
|
||||
`SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from
|
||||
<https://central.sonatype.com> (Account → Generate User Token), not your login.
|
||||
A fork that doesn't publish a library can drop the `Publish Quartz Lib` step and
|
||||
the four Sonatype/GPG secrets.
|
||||
|
||||
---
|
||||
|
||||
## Distribution channels
|
||||
|
||||
One `v*` tag fans out to several channels. Which apply depends on where a fork
|
||||
distributes; the official Amethyst rollout for each is in
|
||||
[`RELEASE_OPS.md`](RELEASE_OPS.md).
|
||||
|
||||
| Channel | How it ships | Push or pull |
|
||||
|---|---|---|
|
||||
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
|
||||
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
|
||||
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
|
||||
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
|
||||
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
|
||||
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
|
||||
|
||||
Two channels need the build to stay split into product flavors (see
|
||||
`amethyst/build.gradle.kts` → `productFlavors`):
|
||||
|
||||
- **`play`** carries Firebase / Google Play Services (push notifications, ML
|
||||
Kit, etc.) → the Google Play AAB.
|
||||
- **`fdroid`** swaps those for UnifiedPush and no-op/open-source
|
||||
implementations (`amethyst/src/fdroid/…`) so the build is free of proprietary
|
||||
dependencies → what F-Droid builds and what Zapstore distributes.
|
||||
|
||||
**F-Droid is pull, not push.** We never upload to F-Droid; its server builds our
|
||||
tagged source. Keeping the `fdroid` flavor proprietary-free and the
|
||||
`fastlane/metadata/android/` descriptions current is all that's required. F-Droid
|
||||
reads an optional per-release changelog from
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap runbook (one-time)
|
||||
|
||||
### Secrets to provision in GitHub repo settings
|
||||
|
||||
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
|
||||
The two that need the most setup care are the package-manager PATs, because of
|
||||
their token type and scope:
|
||||
|
||||
| Secret | Purpose | Scope |
|
||||
|---|---|---|
|
||||
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
|
||||
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
|
||||
|
||||
All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain
|
||||
unchanged.
|
||||
|
||||
Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md`
|
||||
Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md`
|
||||
or equivalent issue tracker. On rotation, paste new token and run
|
||||
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
|
||||
|
||||
@@ -223,19 +434,64 @@ or equivalent issue tracker. On rotation, paste new token and run
|
||||
|
||||
```bash
|
||||
brew bump-cask-pr amethyst-nostr \
|
||||
--version 1.11.0 \
|
||||
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-macos-arm64.dmg"
|
||||
--version 1.12.1 \
|
||||
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-macos-arm64.dmg"
|
||||
```
|
||||
|
||||
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
|
||||
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
|
||||
auto-submits new version bumps on each stable release.
|
||||
|
||||
> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the
|
||||
> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in
|
||||
> homebrew-**core**; both are "mainline." A private tap is only the *fallback*
|
||||
> if Homebrew ever rejects the (now signed + notarized) cask.
|
||||
|
||||
### Homebrew-core formula for the `amy` CLI (one-time initial PR)
|
||||
|
||||
The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask —
|
||||
casks are for GUI apps. homebrew-core builds in a **network-sandboxed**
|
||||
environment, so a from-source Gradle build can't resolve its Maven
|
||||
dependencies there. Instead the formula downloads the pre-built **no-JRE jar
|
||||
bundle** `amy-<version>-jvm.tar.gz` (published by `create-release.yml`) and
|
||||
`depends_on "openjdk"`. The reference formula lives at
|
||||
[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb).
|
||||
|
||||
To submit:
|
||||
|
||||
```bash
|
||||
# 1. Grab the published asset's sha256
|
||||
curl -fsSL -o amy-jvm.tar.gz \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
|
||||
shasum -a 256 amy-jvm.tar.gz
|
||||
|
||||
# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR
|
||||
brew create --set-name amy --tap homebrew/core \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
|
||||
# (paste the reference formula body, run `brew audit --new amy`,
|
||||
# `brew install --build-from-source amy`, `brew test amy`, then PR it.)
|
||||
```
|
||||
|
||||
Caveats that the maintainer must weigh before submitting:
|
||||
|
||||
- **Name collision.** `amy` may already exist in homebrew-core — check with
|
||||
`brew search amy` first. If taken, fall back to `amethyst-cli`.
|
||||
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
|
||||
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
|
||||
justify it (sandboxed Gradle can't fetch Maven deps).
|
||||
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
|
||||
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
|
||||
core/ui split) would shrink it and smooth review — tracked as a follow-up.
|
||||
|
||||
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
|
||||
auto-open version-bump PRs on each stable release — no token or workflow on our
|
||||
side (unlike the cask/winget bumps).
|
||||
|
||||
### Winget (one-time initial submission)
|
||||
|
||||
```bash
|
||||
wingetcreate new \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-windows-x64.msi
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-windows-x64.msi
|
||||
```
|
||||
|
||||
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is
|
||||
@@ -372,9 +628,17 @@ for the deprecation date. When it hits:
|
||||
Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask`
|
||||
on 2026-09-01. Before that date:
|
||||
|
||||
**Option A**: Commit budget to Apple Developer Program ($99/yr), add
|
||||
`signing { sign.set(true) }` + `notarization {}` blocks to
|
||||
`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI.
|
||||
**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }`
|
||||
+ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on
|
||||
the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of
|
||||
`create-release.yml` imports a Developer ID cert into a throwaway keychain and
|
||||
exports the signing/notary env. It all stays a **no-op until the six
|
||||
`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI
|
||||
needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it
|
||||
on: join the Apple Developer Program ($99/yr), create a *Developer ID
|
||||
Application* certificate, generate an app-specific password, and set the six
|
||||
secrets. The first signed+notarized DMG is best validated with a
|
||||
`workflow_dispatch` dry-run before a real tag.
|
||||
|
||||
**Option B**: Pivot to a private Homebrew tap:
|
||||
|
||||
|
||||
+8
-7917
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
# Amethyst plans index
|
||||
|
||||
_Cross-module roll-up of every `plans/` folder. Surveyed 2026-06-30._
|
||||
|
||||
Plans in this repo are **decentralized**: each module keeps its own design docs
|
||||
in `<module>/plans/YYYY-MM-DD-<slug>.md` (see `.claude/CLAUDE.md` →
|
||||
"Plans per module"). There is no single plans directory — **this file is the
|
||||
master index** that stitches the per-folder indexes together.
|
||||
|
||||
Each plan carries a `Status:` header (shipped | in-progress | queued |
|
||||
abandoned) backed by codebase evidence. **Shipped** plans are moved into each
|
||||
folder's `archive/`; live work stays at the top level. For the full per-folder
|
||||
listing (including archived plans), open that folder's `README.md`.
|
||||
|
||||
> `docs/plans/` is the **frozen** legacy global folder — it is indexed here for
|
||||
> completeness, but new plans must go in the owning module's `plans/` folder.
|
||||
|
||||
## Totals
|
||||
|
||||
**142 plans** across 10 folders:
|
||||
|
||||
| Status | Count |
|
||||
| ------ | ----: |
|
||||
| shipped (archived) | 122 |
|
||||
| in-progress | 9 |
|
||||
| queued | 8 |
|
||||
| abandoned | 3 |
|
||||
|
||||
## By module
|
||||
|
||||
| Module | Plans | Shipped | In-prog | Queued | Aband. | Index |
|
||||
| ------ | ----: | ------: | ------: | -----: | -----: | ----- |
|
||||
| amethyst | 21 | 19 | 1 | 1 | 0 | [amethyst/plans](amethyst/plans/README.md) |
|
||||
| nestsClient | 26 | 23 | 1 | 2 | 0 | [nestsClient/plans](nestsClient/plans/README.md) |
|
||||
| desktopApp | 13 | 10 | 2 | 1 | 0 | [desktopApp/plans](desktopApp/plans/README.md) |
|
||||
| quartz | 9 | 7 | 0 | 2 | 0 | [quartz/plans](quartz/plans/README.md) |
|
||||
| commons | 6 | 2 | 2 | 2 | 0 | [commons/plans](commons/plans/README.md) |
|
||||
| cli | 6 | 5 | 1 | 0 | 0 | [cli/plans](cli/plans/README.md) |
|
||||
| quic | 4 | 3 | 0 | 0 | 1 | [quic/plans](quic/plans/README.md) |
|
||||
| quic/interop | 1 | 1 | 0 | 0 | 0 | [quic/interop/plans](quic/interop/plans/README.md) |
|
||||
| geode | 4 | 4 | 0 | 0 | 0 | [geode/plans](geode/plans/README.md) |
|
||||
| docs (frozen) | 52 | 48 | 2 | 0 | 2 | [docs/plans](docs/plans/README.md) |
|
||||
|
||||
## Live work (not shipped)
|
||||
|
||||
Everything still open, across all modules. Shipped plans are omitted here — find
|
||||
them under each folder's `archive/` via the per-module index above.
|
||||
|
||||
### In progress (9)
|
||||
|
||||
| Module | Plan | Summary |
|
||||
| ------ | ---- | ------- |
|
||||
| amethyst | [ios-support](amethyst/plans/2026-05-24-ios-support.md) | KMP-to-iOS port; quartz/commons iOS targets configured (Phase 1) but no `iosApp` module yet. |
|
||||
| commons | [custom-feeds-plan](commons/plans/2026-05-04-custom-feeds-plan.md) | Custom feeds; model + builder + kind 31890 + desktop UI shipped, but relay-filter layer, DVM marketplace, kind 10090 sync, list resolution pending. |
|
||||
| commons | [nest-subscription-manager-extraction](commons/plans/2026-05-06-nest-subscription-manager-extraction.md) | Split per-speaker subscription state machine out of `NestViewModel`; only the `ActiveSubscription` stepping-stone extracted. |
|
||||
| desktopApp | [wallet-zapping-test-coverage](desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md) | NWC handler + RPC round-trip tests shipped; wallet-column-state and zap-dialog-logic tests still missing. |
|
||||
| desktopApp | [napplet-desktop-host](desktopApp/plans/2026-06-21-napplet-desktop-host.md) | Desktop NIP-5A/5D host; shared core extractions done, desktop engine/scheme-handler/transport/UI edge not built. |
|
||||
| cli | [cashu-cli](cli/plans/2026-05-28-cashu-cli.md) | NIP-60/61/87 Cashu wallet verbs in amy; full command surface ships, production-mint interop harness pending. |
|
||||
| nestsClient | [t16-closure-roadmap](nestsClient/plans/2026-05-07-t16-closure-roadmap.md) | Priorities 1 & 2 closed and suite passes, but CI gating deferred and framesPerGroup rerun + two upstream items open. |
|
||||
| docs | [viewport-aware-metadata-loading](docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md) | Base preloader/rate-limiter infra exists but the LazyListState/snapshotFlow viewport selection isn't clearly wired. |
|
||||
| docs | [macos-bunker-relogin](docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md) | PR 1 defense-in-depth shipped, but the cold-boot root cause is still open/unidentified. |
|
||||
|
||||
### Queued (8)
|
||||
|
||||
| Module | Plan | Summary |
|
||||
| ------ | ---- | ------- |
|
||||
| amethyst | [napplet-inter-applet](amethyst/plans/2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not built. |
|
||||
| quartz | [local-headers-explorer](quartz/plans/2026-05-08-local-headers-explorer.md) | Headers-only Bitcoin P2P client to verify NIP-03 OTS attestations without a trusted block explorer. |
|
||||
| quartz | [giftwrap-deletion-requests](quartz/plans/2026-06-12-giftwrap-deletion-requests.md) | Let a recipient-authored kind-5 delete/block a gift wrap (kind 1059) addressed to them. |
|
||||
| commons | [event-renderer](commons/plans/2026-04-21-event-renderer.md) | Cross-platform UI-agnostic `RenderedEvent` subsystem shared by Amy, Desktop, Android; not started. |
|
||||
| commons | [amethyst-to-commons-migration](commons/plans/2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. |
|
||||
| desktopApp | [embedded-wallet-phase2-research](desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md) | Research for an embedded self-custodial Lightning wallet (Breez/ldk-node/lightning-kmp); parked, no code. |
|
||||
| nestsClient | [cross-stack-interop-ci-gating](nestsClient/plans/2026-05-07-cross-stack-interop-ci-gating.md) | CI gating for the cross-stack interop suite; infra built then removed over wallclock cost, kept as a ready revisit target. |
|
||||
| nestsClient | [framespergroup-production-rerun](nestsClient/plans/2026-05-07-framespergroup-production-rerun.md) | Re-run the two-phone field tests to settle the framesPerGroup test-pin (5) vs default (50); needs prod-rig access. |
|
||||
|
||||
### Abandoned (3)
|
||||
|
||||
| Module | Plan | Summary |
|
||||
| ------ | ---- | ------- |
|
||||
| quic | [congestion-control](quic/plans/2026-05-05-congestion-control.md) | NewReno congestion control parked indefinitely; the real concern was solved by the smaller `SendBuffer.bestEffort` fix instead. |
|
||||
| docs | [desktop-relay-config-single-source](docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md) | Single `DesktopRelayConfig` class never built; relay state landed as `DesktopRelayCategories`/`LocalRelayCategories` instead. |
|
||||
| docs | [macos-vlc-bundled-discovery](docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) | macOS bundled-VLC `setenv` discovery fix; moot after VLC/VLCJ was removed entirely in the kdroidFilter migration. |
|
||||
@@ -14,7 +14,8 @@ Join the social network you control.
|
||||
[](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
[](https://github.com/vitorpamplona/amethyst)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
[](https://deepwiki.com/vitorpamplona/amethyst)
|
||||
@@ -269,18 +270,16 @@ For the Play build:
|
||||
|
||||
## Deploying
|
||||
|
||||
Full release + bootstrap runbooks (Android AAB upload, desktop packaging,
|
||||
Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live
|
||||
in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and
|
||||
[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time).
|
||||
A release is one tag push. Bump `app` and `appCode` in
|
||||
`gradle/libs.versions.toml`, then `git tag -s vX.Y.Z && git push --tags` — the
|
||||
`Create Release Assets` workflow builds and signs every Android, desktop, CLI,
|
||||
and Maven artifact, and Homebrew + Winget auto-bump on stable tags.
|
||||
|
||||
TL;DR for cutting a release:
|
||||
|
||||
1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`)
|
||||
2. Bump `versionCode` in `amethyst/build.gradle`
|
||||
3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags`
|
||||
4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags
|
||||
5. Upload AAB to Play Store manually (existing step)
|
||||
- **[BUILDING.md](BUILDING.md)** — everything any fork needs: build commands,
|
||||
the CI pipeline, the secrets it requires, and the distribution channels.
|
||||
- **[RELEASE_OPS.md](RELEASE_OPS.md)** — the Amethyst maintainers'
|
||||
ship checklist: the manual Play Store upload, Zapstore `zsp publish`, F-Droid
|
||||
pull, and release-notes publishing.
|
||||
|
||||
## Using the Quartz library
|
||||
|
||||
@@ -298,20 +297,43 @@ repositories {
|
||||
Add the following line to your `commonMain` dependencies:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz:1.12.6')
|
||||
```
|
||||
|
||||
Variations to each platform are also available:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1.12.6')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.6')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.6')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.6')
|
||||
```
|
||||
|
||||
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
|
||||
|
||||
#### Snapshots (JitPack)
|
||||
|
||||
Tagged releases go to Maven Central. For **pre-release / snapshot** builds —
|
||||
e.g. to test an unreleased fix straight from `main` or a feature branch — use
|
||||
[JitPack](https://jitpack.io/#vitorpamplona/amethyst), which builds the module
|
||||
on demand from any git ref:
|
||||
|
||||
```gradle
|
||||
repositories {
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// version can be a tag, a commit hash, or "<branch>-SNAPSHOT"
|
||||
implementation("com.github.vitorpamplona.amethyst:quartz:main-SNAPSHOT")
|
||||
}
|
||||
```
|
||||
|
||||
The resolvable refs and the exact module coordinates are listed on the
|
||||
[JitPack page](https://jitpack.io/#vitorpamplona/amethyst). Prefer a Maven
|
||||
Central release for anything shipping to production — JitPack snapshots are not
|
||||
guaranteed stable.
|
||||
|
||||
### How to use
|
||||
|
||||
Manage logged in users with the `KeyPair` class
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# Release Ops (Amethyst maintainers)
|
||||
|
||||
This is the **operational checklist the Amethyst team follows to ship a
|
||||
release** — the account-specific, push-the-buttons side of cutting a version.
|
||||
The *generic* build/release mechanics (how the CI pipeline works, the asset
|
||||
naming contract, the secret names a fork must set, desktop packaging) live in
|
||||
[`BUILDING.md`](BUILDING.md). Read that first; this doc only covers what is
|
||||
specific to shipping the official Amethyst artifacts.
|
||||
|
||||
> Forks: you do **not** need this file. `BUILDING.md` has everything you need to
|
||||
> build and release your own fork. This describes our accounts and channels.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
A release is one tag push that fans out to five distribution channels:
|
||||
|
||||
| Channel | Mechanism | Who pushes |
|
||||
|---|---|---|
|
||||
| **GitHub Releases** | Automatic — the `Create Release Assets` workflow builds + signs everything on the `v*` tag | CI |
|
||||
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
|
||||
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
|
||||
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
|
||||
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
|
||||
|
||||
Maven Central (the `quartz` library) also publishes automatically from the same
|
||||
workflow.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-tag checklist
|
||||
|
||||
1. **Bump the version** in `gradle/libs.versions.toml` — both keys:
|
||||
```toml
|
||||
app = "1.12.1" # semver, drives every module + the tag
|
||||
appCode = "449" # Android versionCode, monotonic — must increment
|
||||
```
|
||||
That single edit propagates to Android (`versionName`/`versionCode`),
|
||||
Desktop & CLI (`packageVersion`), `quartz` (Maven version) and `geode`
|
||||
(`RelayInfo.VERSION`). Nothing else hardcodes the version.
|
||||
|
||||
2. **Write the changelog** as `docs/changelog/vMAJOR.MINOR.PP.md` (zero-padded,
|
||||
e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the
|
||||
house style: plain text, short verb-first sentences.
|
||||
|
||||
For the `## Translations` section, generate the credits instead of writing
|
||||
them by hand — no token needed:
|
||||
```bash
|
||||
scripts/translators.sh
|
||||
```
|
||||
This runs offline. It reads `docs/changelog/translators.json` (kept next to
|
||||
the changelogs), which CI keeps fresh: the Crowdin sync workflow's
|
||||
`seed-translators` job records everyone who has translated since the last `v*`
|
||||
tag, with their languages, in the file's `sinceLastTag` list, and accumulates
|
||||
their npubs in the forever-growing `mappings` registry. The script resolves
|
||||
that list to npubs and prints the `## Translations` block grouped by language.
|
||||
Contributors with no npub yet are listed under `UNMAPPED` — credit them by
|
||||
hand, then add their npub under `mappings` so future releases pick them up
|
||||
automatically. (To re-query Crowdin live as a sanity check, run
|
||||
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
|
||||
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
|
||||
|
||||
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
|
||||
its event id into `amethyst/build.gradle.kts`:
|
||||
```kotlin
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")
|
||||
```
|
||||
This id is what the in-app drawer's "Release Notes" link and the donation
|
||||
card open (`DrawerContent.kt`, `ShowDonationCard.kt`). It must point at the
|
||||
note for *this* version, so publish the note **before** tagging and commit
|
||||
the new id together with the version bump.
|
||||
|
||||
<!-- TODO(maintainer): document the exact command/account used to publish the
|
||||
release-notes note (which signer, which relays). -->
|
||||
|
||||
4. **Sanity-build locally** (optional but cheap): `./gradlew assembleRelease`
|
||||
and a desktop `packageDistributionForCurrentOS`, or run the workflow's
|
||||
dry-run (see BUILDING.md § Dry-run).
|
||||
|
||||
---
|
||||
|
||||
## 2. Cut the release
|
||||
|
||||
Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook)
|
||||
for the exact commands. The tag must equal `app` from the catalog (the workflow
|
||||
asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is
|
||||
classified **stable** and triggers the Homebrew/Winget bumps; anything with a
|
||||
`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
|
||||
|
||||
When the `Create Release Assets` workflow finishes (~25–30 min) the GH Release
|
||||
holds, per the asset-name contract:
|
||||
|
||||
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
|
||||
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
|
||||
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
|
||||
- **CLI:** the `amy` artifacts
|
||||
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-channel shipping
|
||||
|
||||
### GitHub Releases — automatic
|
||||
Nothing to do beyond pushing the tag. Verify the asset count and that Intel +
|
||||
ARM DMGs are both present (BUILDING.md § Verify).
|
||||
|
||||
### Google Play — manual upload
|
||||
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
|
||||
2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the
|
||||
staged-rollout track we're using) → create release → upload the AAB.
|
||||
3. The release notes field can reuse the `docs/changelog` text.
|
||||
4. Roll out.
|
||||
|
||||
### F-Droid — pull / build-from-source
|
||||
F-Droid does **not** accept an upload from us. Its build server polls the repo,
|
||||
and when it sees the new `v*` tag it builds the **`fdroid` product flavor** from
|
||||
source (reproducibly) per the recipe in the separate
|
||||
[`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo
|
||||
(`metadata/com.vitorpamplona.amethyst.yml`), then signs and publishes to the
|
||||
F-Droid repo on its own cadence.
|
||||
|
||||
What we own to keep that working:
|
||||
- The **`fdroid` flavor** (`amethyst/src/fdroid/…`) must stay free of
|
||||
proprietary deps — it swaps Firebase/Google services for UnifiedPush and
|
||||
no-op/open implementations (ML Kit, writing assistant, push). Google-only
|
||||
libraries live behind the `play` flavor.
|
||||
- The fastlane metadata under `fastlane/metadata/android/` (descriptions,
|
||||
images). F-Droid reads per-version changelogs from
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` if present —
|
||||
add one (e.g. `449.txt`) when we want a changelog shown on F-Droid; otherwise
|
||||
none is displayed.
|
||||
- The `AutoUpdateMode`/`UpdateCheckMode` in the fdroiddata recipe tracks tags,
|
||||
so a correct `vX.Y.Z` tag + bumped `versionCode` is usually all F-Droid needs.
|
||||
|
||||
After a release, just confirm F-Droid picked up the new version (it can lag a
|
||||
few days): <https://f-droid.org/packages/com.vitorpamplona.amethyst/>.
|
||||
|
||||
### Zapstore — `zsp publish` with Amethyst's nsec
|
||||
[Zapstore](https://zapstore.dev/) is a Nostr-native app store. The `zsp` CLI
|
||||
reads [`zapstore.yaml`](zapstore.yaml) at the repo root (name, summary,
|
||||
description, tags, license, `icon`, screenshots, `supported_nips`, and the
|
||||
`variants` regexes that match our `*-fdroid-*.apk` / `*-googleplay-*.apk`
|
||||
GH-release assets), then publishes a signed software-release event to Nostr
|
||||
relays.
|
||||
|
||||
```bash
|
||||
# from the repo root, after the GH Release assets exist
|
||||
zsp publish
|
||||
```
|
||||
|
||||
It signs with **Amethyst's nsec** — provide the key the way `zsp` expects
|
||||
(`SIGN_WITH` env var / prompt / its own config), never commit it.
|
||||
|
||||
**Relays.** `zsp` does *not* take relays from `zapstore.yaml`; it reads the
|
||||
`RELAY_URLS` env var (comma-separated) and defaults to `wss://relay.zapstore.dev`
|
||||
when unset. To fan the release event out to more relays for discoverability,
|
||||
set `RELAY_URLS` for the run:
|
||||
|
||||
```bash
|
||||
RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vitor.nostr1.com" \
|
||||
SIGN_WITH=<amethyst-nsec> zsp publish
|
||||
```
|
||||
|
||||
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
|
||||
itself reads from.
|
||||
|
||||
### Homebrew + Winget — automatic
|
||||
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
|
||||
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
|
||||
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
|
||||
then see BUILDING.md § Bootstrap and § Incident response.
|
||||
|
||||
---
|
||||
|
||||
## 4. Operated infrastructure
|
||||
|
||||
### Push notification server
|
||||
|
||||
The Google Play (FCM) flavor delivers push through a server we operate at
|
||||
`push.amethyst.social`, built from
|
||||
[`vitorpamplona/amethyst-push-notif-server`](https://github.com/vitorpamplona/amethyst-push-notif-server).
|
||||
It registers devices, watches their NIP-65 inbox / NIP-17 DM relays, and sends
|
||||
wake-up pushes.
|
||||
|
||||
- **`play` flavor** → push via this server (Firebase/FCM).
|
||||
- **`fdroid` flavor** → UnifiedPush through a distributor app the user installs
|
||||
(e.g. ntfy); it does **not** use our server.
|
||||
- Both are complemented by the on-device always-on `NotificationRelayService`
|
||||
(see [`PULL_NOTIFICATION.md`](PULL_NOTIFICATION.md)), which keeps the user's
|
||||
relay connections alive without any push server at all.
|
||||
|
||||
The push server has its **own repo, deploy, and release cadence** — a normal app
|
||||
release does **not** redeploy it. Coordinate a server deploy only when the app
|
||||
changes the registration/push contract (token format, payload, or endpoint), so
|
||||
the running server stays compatible with the shipped app.
|
||||
|
||||
<!-- TODO(maintainer): document the push-server deploy steps + hosting, and
|
||||
which app-side changes require a coordinated push-server deploy. -->
|
||||
|
||||
---
|
||||
|
||||
## 5. Secrets ownership & rotation
|
||||
|
||||
The workflow's required secrets and what they sign are inventoried generically
|
||||
in [`BUILDING.md` § Secrets](BUILDING.md#secrets-the-ci-needs). Amethyst-specific
|
||||
ownership:
|
||||
|
||||
| Secret(s) | Protects | Rotation |
|
||||
|---|---|---|
|
||||
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
|
||||
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
|
||||
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
|
||||
| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) |
|
||||
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
|
||||
|
||||
Owner assignments and rotation reminders live with the team (issue tracker).
|
||||
|
||||
<!-- TODO(maintainer): name the owner per secret and where backups live. -->
|
||||
|
||||
---
|
||||
|
||||
## 6. Post-release verification
|
||||
|
||||
- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane.
|
||||
- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time).
|
||||
- [ ] Play Console: rollout started, no policy rejection.
|
||||
- [ ] Zapstore: release event visible.
|
||||
- [ ] F-Droid: new version detected (may lag days).
|
||||
- [ ] Homebrew + Winget bump PRs opened (stable only).
|
||||
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`.
|
||||
- [ ] Push still works on a `play` build (only if the push contract changed —
|
||||
see § 4); UnifiedPush still works on an `fdroid` build.
|
||||
|
||||
If anything ships broken, see [`BUILDING.md` § Incident response](BUILDING.md#incident-response).
|
||||
@@ -76,9 +76,12 @@ android {
|
||||
libs.versions.android.targetSdk
|
||||
.get()
|
||||
.toInt()
|
||||
versionCode = 447
|
||||
versionCode =
|
||||
libs.versions.appCode
|
||||
.get()
|
||||
.toInt()
|
||||
versionName = generateVersionName(libs.versions.app.get(), rootDir)
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"8ec0d94550b5538115226c6858159b1115713c9c6ed942173bd4fd5d292d8ba6\"")
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"40e817712e397c07ba31784a92fa474aa095896a828c0e2dea0d09c60d49ee1e\"")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -261,6 +264,23 @@ android {
|
||||
resValues = true
|
||||
}
|
||||
|
||||
// Reproducible builds: keep AGP from embedding the dependency-metadata blob
|
||||
// in the APK/AAB. That blob is a protobuf of the resolved dependency tree
|
||||
// encrypted with a Google public key; the ciphertext is non-deterministic,
|
||||
// so its presence makes every release artifact impossible to reproduce
|
||||
// bit-for-bit. Dropping it (F-Droid's documented recommendation) lets
|
||||
// F-Droid / Zapstore independently rebuild and verify our developer-signed
|
||||
// APKs.
|
||||
//
|
||||
// Play-channel trade-off: with includeInBundle = false the uploaded .aab no
|
||||
// longer carries this metadata, so Play Console's app-dependency insights /
|
||||
// known-vulnerability SDK alerts go unpopulated. Uploads still succeed; only
|
||||
// that advisory feature is lost.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
|
||||
@@ -282,7 +302,7 @@ android {
|
||||
unitTests.all { test ->
|
||||
test.systemProperty(
|
||||
"java.library.path",
|
||||
"${projectDir}/src/test/native-libs/x86_64-linux",
|
||||
"$projectDir/src/test/native-libs/x86_64-linux",
|
||||
)
|
||||
project
|
||||
.findProperty("amethyst.arti.integration")
|
||||
@@ -328,12 +348,31 @@ composeCompiler {
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
// Compose composition tracing — DEBUG ONLY, profiling aid (not shipped). Makes each
|
||||
// recomposition show up as a NAMED slice in Perfetto system traces so we can see which
|
||||
// composable recomposes (e.g. during the cold-start feed first-paint). All Apache-2.0.
|
||||
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
|
||||
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
|
||||
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
|
||||
debugImplementation("androidx.compose.runtime:runtime-tracing")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
implementation(project(":nestsClient"))
|
||||
implementation(project(":nappletHost"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
// Client side of the cross-process UI embedding: renders the sandboxed browser surface (hosted in
|
||||
// the keyless `:napplet` process) inside a Compose component in the main app.
|
||||
implementation(libs.androidx.privacysandbox.ui.core)
|
||||
implementation(libs.androidx.privacysandbox.ui.client)
|
||||
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
@@ -365,6 +404,9 @@ dependencies {
|
||||
// Background Work
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
// Reads workouts from Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin, …)
|
||||
implementation(libs.androidx.health.connect.client)
|
||||
|
||||
// Websockets API
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttpCoroutines)
|
||||
@@ -402,6 +444,9 @@ dependencies {
|
||||
implementation(libs.zxing)
|
||||
implementation(libs.zxing.embedded)
|
||||
|
||||
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
|
||||
implementation(libs.osmdroid.android)
|
||||
|
||||
// Markdown
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
@@ -412,6 +457,14 @@ dependencies {
|
||||
implementation(libs.markdown.ui.material3)
|
||||
implementation(libs.markdown.commonmark)
|
||||
|
||||
// Syntax highlighting for the git repository code browser (Apache-2.0)
|
||||
implementation(libs.highlights)
|
||||
|
||||
// LaTeX math rendering ($...$ and $$...$$ inline equations)
|
||||
implementation(libs.jlatexmath.android)
|
||||
implementation(libs.jlatexmath.font.greek)
|
||||
implementation(libs.jlatexmath.font.cyrillic)
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
@@ -469,9 +522,6 @@ dependencies {
|
||||
// EXIF metadata stripping
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
// Voice anonymization DSP
|
||||
implementation(libs.tarsosdsp)
|
||||
|
||||
// WebRTC for voice/video calls
|
||||
implementation(libs.stream.webrtc.android)
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# iOS Support for Amethyst
|
||||
|
||||
> **Status:** in-progress — `iosArm64`/`iosSimulatorArm64` targets are configured in `quartz` and `commons` (Phase 1), but no `iosApp` module exists yet — later phases not built.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Status:** Phase 1 complete; Phase 2 in flight
|
||||
**Owner:** TBD
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Napplet inter-applet communication (NAP-INC / NAP-INTENT) — design notes
|
||||
|
||||
> **Status:** queued — Explicitly deferred; no `MESSAGING` capability exists in `NappletCapability` and the prerequisites (multi-applet hosting, archetype registry) are unbuilt.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Status:** Deferred — design only. Prereqs not yet built (see below).
|
||||
**Parent:** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
|
||||
|
||||
## Why this is deferred (not just "next")
|
||||
|
||||
Inter-applet messaging is the one napplet capability that needs **new
|
||||
architecture**, not just a new broker op + gateway. Two hard prerequisites are
|
||||
missing today:
|
||||
|
||||
1. **Multiple applets running at once.** `NappletHostActivity` is declared
|
||||
`launchMode="singleTask"` and hosts exactly one applet. True live A↔B
|
||||
messaging (the full NAP-INC request/result transport) requires either
|
||||
multi-applet hosting (several iframes in one host, or several host processes)
|
||||
plus a routing layer — none of which exists.
|
||||
2. **An archetype / handler registry.** NAP-INTENT dispatches by *archetype*
|
||||
(`note`, `feed`, `profile`, …) to a default-handler napplet. Our
|
||||
`NappletManifest` has no `handles`/archetype declaration and there is no
|
||||
"which napplet is the default handler for X" registry.
|
||||
|
||||
Both are sizeable subsystems. Shipping a half-version would also risk **forking
|
||||
the wire format** from upstream while it is still being defined.
|
||||
|
||||
## Upstream model (napplet/naps survey, 2026-06-20)
|
||||
|
||||
Inter-applet is split into two shell-mediated specs (applets never reach each
|
||||
other directly — every message crosses the shell/broker):
|
||||
|
||||
- **NAP-INC** (`inc`) — the transport. Messages are `{ type: "domain.action",
|
||||
id, … }`, request/result correlated by `id`. Addressing is direct
|
||||
(napplet→napplet) *or* archetype-mediated by the runtime.
|
||||
- **NAP-INTENT** (`intent`) — invoke a napplet by **archetype** via
|
||||
default-handler dispatch (`shell.supports("intent")`). The shell launches the
|
||||
handler; napplets cannot invoke directly.
|
||||
- **NAP-1…5** — concrete protocols on top of NAP-INC: `profile:*` (NAP-1),
|
||||
`stream:*` (NAP-2), `chat:*` (NAP-3), `note:open` (NAP-4), `feed:*` (NAP-5).
|
||||
Producer/consumer model.
|
||||
|
||||
Discovery is capability-probe based: `shell.supports("inc")`,
|
||||
`shell.supports("inc", "NAP-N")`.
|
||||
|
||||
## How it would map onto our boundary
|
||||
|
||||
The broker model fits "shell-mediated" naturally — every message would cross
|
||||
`NappletBrokerService` exactly like every other capability, gated by the ledger.
|
||||
The pieces:
|
||||
|
||||
1. **Capability.** Add `NappletCapability.MESSAGING` mapped from NAP domains
|
||||
`inc` / `intent` (default-deny like every other domain). Consent is a *link*
|
||||
grant ("Applet A may message / open Applet B"), distinct from per-op consent.
|
||||
2. **Protocol.** New `NappletRequest`/`NappletResponse` variants under
|
||||
`MESSAGING`, shaped to mirror NAP-INC (`type = "domain.action"`, `id`
|
||||
correlation) so we don't fork the wire format.
|
||||
3. **Addressing.** Direct by napplet coordinate first; archetype dispatch only
|
||||
after the registry (below) exists.
|
||||
|
||||
### Two viable implementation shapes (pick at build time)
|
||||
|
||||
- **NAP-INTENT, direct coordinate** — `napplet.intent({ target, payload })` →
|
||||
consent → broker resolves `target` to a manifest in `LocalCache` → launches it
|
||||
via a `NappletIntentLauncher` gateway, passing an initial payload the target
|
||||
reads on startup (`napplet.intent` / `onIntent`). Fits the single-applet model
|
||||
(you switch to the target). No simultaneous hosting needed. **Lowest lift; most
|
||||
aligned with NAP-INTENT.** Result-return across the switch is awkward (fire-and-
|
||||
forget, or a callback event).
|
||||
- **NAP-INC brokered mailbox** — `napplet.sendTo(coordinate, msg)` /
|
||||
`pollMessages()` with broker-persisted per-napplet inboxes, consent per link.
|
||||
Works with no simultaneous hosting and is fully unit-testable, but it is async
|
||||
fire-and-collect, not the request/result transport upstream describes.
|
||||
|
||||
Full live NAP-INC (simultaneous A↔B, request/result) needs the multi-applet
|
||||
hosting prereq regardless.
|
||||
|
||||
## Prerequisites to build first
|
||||
|
||||
1. **Multi-applet hosting** — either N iframes in one `NappletHostActivity` with
|
||||
per-iframe origin isolation + routing, or a host-per-applet process model and
|
||||
a cross-process router in the broker. Decide the model before coding NAP-INC.
|
||||
2. **Archetype registry** — a manifest `handles`/archetype tag (align with
|
||||
upstream naps), an index over installed napplets, and a user-set default
|
||||
handler per archetype (mirror NIP-89 handler selection, which Amethyst already
|
||||
models for app recommendations).
|
||||
3. **Link-consent UX** — distinct from capability consent: "Allow *Chess* to open
|
||||
*Wallet*?", revocable per pair in a permissions screen.
|
||||
|
||||
## Recommendation
|
||||
|
||||
When picked up: start with **NAP-INTENT direct-coordinate** (smallest, aligned,
|
||||
no new hosting), build the archetype registry next (unlocks default-handler
|
||||
dispatch + reuses NIP-89 patterns), and only then tackle live NAP-INC once
|
||||
multi-applet hosting lands. Keep the wire `type`/`id` shape identical to upstream
|
||||
NAP-INC throughout to avoid a fork.
|
||||
@@ -0,0 +1,36 @@
|
||||
# amethyst plans
|
||||
|
||||
_Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 abandoned._
|
||||
|
||||
## In progress
|
||||
| Plan | Summary |
|
||||
| ---- | ------- |
|
||||
| [2026-05-24-ios-support.md](2026-05-24-ios-support.md) | Incremental KMP-to-iOS port; quartz/commons iOS targets are configured (Phase 1) but no `iosApp` module exists yet. |
|
||||
|
||||
## Queued
|
||||
| Plan | Summary |
|
||||
| ---- | ------- |
|
||||
| [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. |
|
||||
|
||||
## Archived (shipped)
|
||||
| Plan | Summary |
|
||||
| ---- | ------- |
|
||||
| [archive/2026-05-14-onchain-zaps.md](archive/2026-05-14-onchain-zaps.md) | NIP-BC (kind 8333) onchain Bitcoin zaps — hand-rolled `quartz/nipBCOnchainZaps/` consensus layer plus Android send/receive/display. |
|
||||
| [archive/2026-05-25-appfunctions-signer-prompts.md](archive/2026-05-25-appfunctions-signer-prompts.md) | How AppFunctions write verbs acquire signatures across the three signer types; write verbs now ship. |
|
||||
| [archive/2026-05-26-appfunctions-gemini-discovery.md](archive/2026-05-26-appfunctions-gemini-discovery.md) | Verifying Gemini-side discovery of Amethyst's AppFunctions; description-based (`isDescribedByKDoc`) discovery shipped. |
|
||||
| [archive/2026-05-26-appfunctions-screens-as-verbs.md](archive/2026-05-26-appfunctions-screens-as-verbs.md) | Map every Amethyst screen's feed filter to an AppFunction/MCP verb; 46 verbs now ship. |
|
||||
| [archive/2026-05-26-avif-implementation-plan.md](archive/2026-05-26-avif-implementation-plan.md) | Task-by-task plan for AVIF support via a `MediaMimeTypes` helper across the upload pipeline. |
|
||||
| [archive/2026-05-26-avif-support.md](archive/2026-05-26-avif-support.md) | Comprehensive design making AVIF a first-class image format on every Amethyst surface. |
|
||||
| [archive/2026-05-27-avif-instrumented-tests-design.md](archive/2026-05-27-avif-instrumented-tests-design.md) | Design for on-device AVIF upload-pipeline regression tests with committed fixtures. |
|
||||
| [archive/2026-05-27-avif-instrumented-tests-plan.md](archive/2026-05-27-avif-instrumented-tests-plan.md) | Implementation plan for the AVIF instrumented + JVM unit tests and their fixtures. |
|
||||
| [archive/2026-06-01-dm-live-tail-and-history-slices.md](archive/2026-06-01-dm-live-tail-and-history-slices.md) | DM loading split into a fixed live tail plus per-relay backward history paging (`WindowLoadTracker` / `RelayLoadingCursors`). |
|
||||
| [archive/2026-06-19-napplet-sandbox-host.md](archive/2026-06-19-napplet-sandbox-host.md) | Keyless `:napplet`-process WebView host with brokered, consent-gated capabilities for NIP-5A/5D content. |
|
||||
| [archive/2026-06-20-napplet-ecosystem-audit.md](archive/2026-06-20-napplet-ecosystem-audit.md) | Audit of our napplet shell against the upstream `@napplet` SDK; wire-compat gaps subsequently closed. |
|
||||
| [archive/2026-06-21-napplet-code-audit.md](archive/2026-06-21-napplet-code-audit.md) | Code audit of the napplet subsystem recording correctness/perf/refactor fixes and deferred items. |
|
||||
| [archive/2026-06-21-napplet-sdk-conformance-audit.md](archive/2026-06-21-napplet-sdk-conformance-audit.md) | Feature-by-feature SDK conformance audit; the four conformance breakers were fixed and pinned by tests. |
|
||||
| [archive/2026-06-22-napplet-nsite-security.md](archive/2026-06-22-napplet-nsite-security.md) | Security review of the nsite/napplet attack surface; launch-token identity and per-applet origins landed. |
|
||||
| [archive/2026-06-23-napplet-nap-theme-notify-inc.md](archive/2026-06-23-napplet-nap-theme-notify-inc.md) | Add the `theme`, `notify`, and `inc` NAP domains so demo napplets boot; capabilities + `NappletIncBus` shipped. |
|
||||
| [archive/2026-06-24-napplet-embedded-tabs.md](archive/2026-06-24-napplet-embedded-tabs.md) | Embedded warm bottom-bar napplet/nsite/browser tabs via `SurfaceControlViewHost`, in the new `:nappletHost` module. |
|
||||
| [archive/2026-06-25-embed-text-selection-native-parity.md](archive/2026-06-25-embed-text-selection-native-parity.md) | Host-drawn text selection (handles, magnifier, toolbar, IME proxy) for embedded sandboxed surfaces. |
|
||||
| [archive/2026-06-25-web-app-naming.md](archive/2026-06-25-web-app-naming.md) | Naming overhaul for web-app / nApplet / nSite / favorite surfaces (route, screen, controller renames). |
|
||||
| [archive/2026-06-26-nsite-napplet-favorite-icons.md](archive/2026-06-26-nsite-napplet-favorite-icons.md) | Derive favorited nSite/nApplet bottom-nav icons from verified manifest blobs (`NappletIconPath`). |
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# Onchain Zaps in Amethyst
|
||||
|
||||
> **Status:** shipped — Full `quartz/nipBCOnchainZaps/` consensus + builder + verify layer ships with Android model/UI wiring (`OnchainZapResolver`, `OnchainZapEvent` view, `ReusableZapButton`).
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Status:** Active
|
||||
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# AppFunctions signer prompts — design
|
||||
|
||||
> **Status:** shipped — `AmethystAppFunctions.kt` ships 46 `@AppFunction` verbs including write verbs that acquire signatures across signer types.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Status:** Draft — no code yet
|
||||
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# Verifying Gemini-side AppFunctions discovery
|
||||
|
||||
> **Status:** shipped — Verification doc; verbs ship with `@AppFunction(isDescribedByKDoc = true)` description-based discovery as the doc concludes.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Active — answers the open question from
|
||||
`2026-05-25-appfunctions-signer-prompts.md`
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# All Amethyst screens as AppFunctions / MCP endpoints
|
||||
|
||||
> **Status:** shipped — 46 `@AppFunction` verbs ship in `AmethystAppFunctions.kt`, exceeding the screen-to-verb surface this plan proposed.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Active — informs the v1 read-verb surface and guides
|
||||
future MCP work
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# AVIF Support Implementation Plan
|
||||
|
||||
> **Status:** shipped — `service/uploads/MediaMimeTypes.kt` and the rest of the AVIF upload pipeline are present in `amethyst/`.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make AVIF (still and animated) work as a first-class image format in Amethyst across every user-visible surface — uploads (Blossom + NIP-96), feeds, profiles, DMs, emoji packs, reactions, gallery, and caching — with graceful no-crash fallback on Android API < 31.
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# AVIF support — comprehensive design
|
||||
|
||||
> **Status:** shipped — AVIF upload/display support landed — `MediaMimeTypes.kt` plus the compressor/stripper/preview changes are in tree.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Issue:** [vitorpamplona/amethyst#837](https://github.com/vitorpamplona/amethyst/issues/837)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main)
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# Design — AVIF instrumented tests
|
||||
|
||||
> **Status:** shipped — The designed test files and AVIF fixtures exist under `amethyst/src/androidTest/` and `src/test/`.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Design (pre-implementation)
|
||||
**Owning module:** `amethyst/`
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
# AVIF Instrumented Tests — Implementation Plan
|
||||
|
||||
> **Status:** shipped — `AvifUploadPipelineInstrumentedTest.kt`, `MediaMimeTypesTest.kt`, and the three `.avif` fixtures are committed.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an Android on-device test layer that catches regressions in the AVIF upload pipeline and the Phase E bugs fixed manually (`C2`–`C8` plus follow-ups in commits `df84475d4`, `5a760c046`, `b9112550b`, `1db110cdf`).
|
||||
@@ -0,0 +1,335 @@
|
||||
# DM loading: live tail + per-relay history paging
|
||||
|
||||
> **Status:** shipped — Live-tail + per-relay history layers exist (`WindowLoadTracker`, `RelayLoadingCursors`, `AccountGiftWrapsHistoryEoseManager`); doc marks itself authoritative-as-of-code.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
> **Status:** authoritative as of 2026-06-05. The "Current architecture"
|
||||
> section below describes the code as it actually stands. The original
|
||||
> time-slice design and the round-model history are kept at the bottom under
|
||||
> **Design evolution (historical)** — they are superseded and no longer match
|
||||
> the code; don't trust them for how it works today.
|
||||
|
||||
## Problem
|
||||
|
||||
The DM loaders used a single subscription whose `since` floor grew as the user
|
||||
scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`,
|
||||
no `until`**, so every widen re-requested the whole window and the relay
|
||||
re-streamed the entire history from the new floor. Traces showed this directly:
|
||||
|
||||
```
|
||||
[giftwrap] load summary: 589 event(s) (14d)
|
||||
[giftwrap] load summary: 1486 event(s) (28d)
|
||||
[giftwrap] load summary: 2609 event(s) (56d)
|
||||
```
|
||||
|
||||
Each step re-downloaded everything it already had plus the new slice — the
|
||||
"getting all events over and over again" the owner reported. It also cascaded:
|
||||
a few pixels of scroll walked the window to the 10-year backstop, because
|
||||
widening pulls older *messages* but the rooms list is keyed by *conversation*,
|
||||
so a handful of busy correspondents flood thousands of events without adding a
|
||||
single new row, and the "scrolled near the oldest room" trigger never clears.
|
||||
|
||||
---
|
||||
|
||||
## Current architecture
|
||||
|
||||
### Two layers per protocol
|
||||
|
||||
Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs
|
||||
(kind 4) — is split into two independent responsibilities:
|
||||
|
||||
1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the
|
||||
future. Never widens. New messages always arrive here. Backed by the
|
||||
**round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done"
|
||||
when all settle, drives the boot spinner.
|
||||
2. **History** — everything *older* than the week floor, paged **backward by
|
||||
`until`+`limit`, per relay, on demand**. Backed by the **per-relay model**
|
||||
(`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers.
|
||||
|
||||
The two are disjoint in time, so re-issuing a history page never re-streams the
|
||||
live tail, and consecutive history pages never re-stream each other.
|
||||
|
||||
| Surface | Live-tail manager (round) | History manager (per-relay) |
|
||||
|---|---|---|
|
||||
| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` |
|
||||
| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` |
|
||||
| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` |
|
||||
|
||||
Accessed from the UI via `accountViewModel.dataSources()` as
|
||||
`.account.giftWrapsHistory`, `.chatroom.nip04History`,
|
||||
`.chatroomList.nip04History`.
|
||||
|
||||
### The history paging primitive: `RelayLoadingCursors`
|
||||
|
||||
The time-window model can't tell "this relay is empty" from "this is a gap" — a
|
||||
`since`/`until` slice that returns nothing might just be a quiet stretch above
|
||||
older messages. Paging by `until`+`limit` removes that ambiguity: a relay
|
||||
returns up to `limit` (**10000**) of its newest events older than the cursor,
|
||||
**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**.
|
||||
|
||||
Per relay, two cursors are kept deliberately decoupled:
|
||||
|
||||
- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`.
|
||||
Leaving it untouched on EOSE is what makes paging demand-driven: a relay that
|
||||
finished a page just **parks** at the same filter (no re-REQ) until advanced.
|
||||
- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE.
|
||||
The in-stream markers sit here; the next page starts at `reachedUntil − 1`.
|
||||
|
||||
Stop signals: an empty page marks the relay **`done`**. A relay returning fewer
|
||||
than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay
|
||||
that returns events but none older than already reached (echoing its newest
|
||||
events) is also treated as the bottom, so its marker can't re-request the same
|
||||
window forever. Tested in `RelayLoadingCursorsTest.kt`.
|
||||
|
||||
### The two completion models (and where each lives)
|
||||
|
||||
**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all
|
||||
relays; the window is "done" only when **every** expected relay reaches a
|
||||
terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle /
|
||||
silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a
|
||||
*barrier*: nobody moves on until the cohort answers. It is the right shape for
|
||||
the one-shot fixed-window backfill the live tail does.
|
||||
|
||||
> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`,
|
||||
> and **none of the three live-tail managers pass `tracksReqSends = true`**, so
|
||||
> in current use only the settle / idle / cap paths ever fire. The REQ-aware
|
||||
> machinery is dormant in production — see "Things to scrutinize".
|
||||
|
||||
**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).**
|
||||
Each relay advances to its next page the instant *it* EOSEs, independent of the
|
||||
others; the subscription layer diffs per relay, so re-issuing only re-REQs the
|
||||
relay whose cursor moved. `loading` starts **`false`** (a `true` start would
|
||||
wedge the scroll loader's `!loading` gate on first open). Fast relays race to
|
||||
the bottom in back-to-back pages; slow / auth-walled relays catch up at their
|
||||
own pace and **none are abandoned** — a stalled relay keeps its subscription
|
||||
open and resumes when re-advanced. This removes the round model's
|
||||
slowest-relay coupling, which matters most on the conversation screen where the
|
||||
fan-out includes correspondents' (often auth-walled, slow) relays.
|
||||
|
||||
`exhausted` (per history manager) flips true when **every relay is `done` OR
|
||||
`stalled`** — "nothing more reachable right now". A merely *parked* relay (more
|
||||
to load, just not advancing) keeps it false.
|
||||
|
||||
> All three history managers (`AccountGiftWrapsHistoryEoseManager`,
|
||||
> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`)
|
||||
> were structurally the same per-relay loader, so that bookkeeping is now a
|
||||
> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It
|
||||
> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the
|
||||
> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per
|
||||
> account), so they share the cached messages' lifetime and survive an account
|
||||
> switch. The orchestrator owns only the transient bits — in-flight + silence
|
||||
> tracking, the stalled set, and the display flows — and
|
||||
> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager
|
||||
> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription
|
||||
> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`,
|
||||
> re-issues filters after `advance`/`advanceAll`). The earlier round-model history
|
||||
> (and the rooms-list "stall-gate") was fully removed — see Design evolution.
|
||||
|
||||
### What drives `advance()`: on-screen markers, off viewport visibility
|
||||
|
||||
History paging is demand-driven by **per-relay window-limit markers** placed in
|
||||
the message stream, not by a scroll-position trigger:
|
||||
|
||||
- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth,
|
||||
its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the
|
||||
`advance()` that pulls *that relay's* next page. Built in the feed views from
|
||||
each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a
|
||||
protocol drops out of the list once `exhausted`).
|
||||
- **`RelayReachSentinels`** — the load *driver*, **hoisted above the
|
||||
`LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit
|
||||
gets one stable effect (keyed by `protocol:url`) that watches `listState` and
|
||||
fires `advance()` when its gap is among the **currently visible rows** AND
|
||||
either it just scrolled into view OR its `reachedUntil` moved (a page landed —
|
||||
keep paging while visible). Driving off **viewport visibility** instead of row
|
||||
composition is deliberate: an earlier version placed the sentinel *inside* the
|
||||
hosting row, so any feed reorder (a live DM, a slow relay dribbling a page)
|
||||
tore the effect down and re-fired `advance()` on a static screen — re-arming
|
||||
stalled relays into a silence-watchdog storm. (commit `0394ec2a`)
|
||||
- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the
|
||||
`markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's
|
||||
reached depth. Can be re-placed on every reorder without triggering paging.
|
||||
- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live
|
||||
tail came back empty for a thread/list whose newest message is older than a
|
||||
week) there are no rows to host markers, so this steps every relay one page at
|
||||
a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until
|
||||
messages appear and the markers take over, or the protocol exhausts.
|
||||
|
||||
### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`)
|
||||
|
||||
A conversation's NIP-04 filters previously named the whole participant set on
|
||||
every relay, so a relay belonging to one correspondent was asked about all of
|
||||
them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox
|
||||
relays — which auth-walled relays reject outright ("all authors must be
|
||||
authenticated"), stalling the load.
|
||||
|
||||
`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps**
|
||||
(`relay → which keys to name there`), built from the outbox model:
|
||||
|
||||
- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's
|
||||
outbox carries only that correspondent.
|
||||
- **from me** (`authors:[me]`) — my outbox carries the whole group; each
|
||||
correspondent's inbox carries only that correspondent.
|
||||
|
||||
So a relay only ever sees the keys that actually own it. The **conversation**
|
||||
history manager scopes its REQ to the armed relays' key sets this way; the
|
||||
**rooms-list** and **gift-wrap** history managers query only the account's *own*
|
||||
relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` /
|
||||
`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out
|
||||
stays fast and reachable.
|
||||
|
||||
### Status card terminal states (`DmHistoryLoadingCard`)
|
||||
|
||||
One card per protocol at its oldest-loaded boundary. While paging it shows the
|
||||
protocol tag, "N relays" being asked, and the reach-back date; it is tappable
|
||||
into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with
|
||||
`✓` done / `…` stalled / `↓` reaching and how far back each paged.
|
||||
|
||||
Because `exhausted` conflates `done` and `stalled`, the terminal state is split
|
||||
on `stalledCount` so it can't overclaim (commit `813110cc`):
|
||||
|
||||
- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up",
|
||||
lingers ~2.2s then collapses.
|
||||
- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable",
|
||||
error-coloured `…`, **stays put** (no collapse), tappable to see which.
|
||||
|
||||
### Reply placeholder (`LoadingReplyNote`)
|
||||
|
||||
A reply whose target message hasn't been paged in yet isn't *missing*, it's
|
||||
older than the loaded window (and for gift wraps the rumor id isn't even
|
||||
queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote`
|
||||
("post not found"), `LoadingReplyNote` actively walks the relevant protocol's
|
||||
history backward (kicking `advanceAll` each time a page settles) until the
|
||||
target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in
|
||||
and disposes this) or the protocol exhausts. Its terminal state mirrors the
|
||||
card: "Couldn't find this message" + an honest subtitle ("N relays unreachable ·
|
||||
tap to see which" when stalled, "Searched every relay · tap to see" when
|
||||
genuinely done), tappable into the same per-relay popup. Wired via
|
||||
`ChatMessageCompose.RenderReply` → `WatchNoteEvent(onBlank = …)`, with the pager
|
||||
chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`).
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Everything logs under one tag, **`DMPagination`** (debug builds):
|
||||
`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent,
|
||||
connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the
|
||||
"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local)
|
||||
per subscription so an unexpected relay can be traced to the list it leaks in
|
||||
from; and each assembler logs its milestones (paging start, a relay reaching
|
||||
the bottom / stalling with the reason, the "window settled" summary of
|
||||
done-vs-still-trying, each marker fire).
|
||||
|
||||
### Related fix: Tor guard-sample self-heal
|
||||
|
||||
`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's
|
||||
persisted `guards.json` and wipes the on-disk state if a non-empty guard set has
|
||||
**zero** usable guards (all `disabled` / `unlisted`). This recovers the
|
||||
long-standing "can't connect to Tor → relays permanently unreachable" wedge
|
||||
(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables
|
||||
them, and can't replenish once the 60-slot sample is full). Orthogonal to
|
||||
pagination, but it lived here because unreachable relays were part of the same
|
||||
"DM history stuck / relays never answer" symptom this branch chased.
|
||||
|
||||
---
|
||||
|
||||
## Component map (vs `origin/main`)
|
||||
|
||||
**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)** —
|
||||
the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target.
|
||||
- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor;
|
||||
held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the
|
||||
`until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against
|
||||
the quartz `jvmAndroidTest` geode relay)*
|
||||
- `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay.
|
||||
|
||||
**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the
|
||||
StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and**
|
||||
out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client +
|
||||
`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse
|
||||
them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android +
|
||||
Desktop, not iOS.
|
||||
- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three
|
||||
history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons
|
||||
`jvmTest`)*
|
||||
- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog.
|
||||
- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)*
|
||||
|
||||
**Diagnostics (amethyst)** — `service/relayClient/eoseManagers/DmRelayLog.kt`,
|
||||
`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs.
|
||||
|
||||
**Managers / assemblers**
|
||||
- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history).
|
||||
- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history).
|
||||
- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history).
|
||||
- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added.
|
||||
- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers.
|
||||
|
||||
**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android +
|
||||
Desktop (and any per-relay feed) render the same widgets; CMP `composeResources`
|
||||
strings, no app-theme / `java.time` deps.
|
||||
- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven
|
||||
paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`.
|
||||
- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog +
|
||||
`historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String`
|
||||
so each platform supplies its locale date formatter.
|
||||
|
||||
**Android UI (`amethyst/ui/screen/loggedIn/chats/`)**
|
||||
- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog).
|
||||
- `feed/HistoryDateFormat.kt` — `formatHistoryReachDate`, the Android locale formatter passed into the shared card.
|
||||
- `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots.
|
||||
- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring.
|
||||
- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`.
|
||||
- `res/values/strings.xml` — `chats_reply_*` (the card's `chats_history_*` now live in commons).
|
||||
|
||||
---
|
||||
|
||||
## Things to scrutinize (review notes)
|
||||
|
||||
1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards
|
||||
now distinguish them via `stalledCount`, but other consumers (the scroll
|
||||
`!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as
|
||||
terminal. Intentional (don't hammer dead relays), but confirm it's desired.
|
||||
2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the
|
||||
chatty relays finish, a legitimately-slow relay gets the full 15s silence
|
||||
window and can be marked stalled mid-delivery of a 10000-event page.
|
||||
3. **"All caught up" can still be technically-true-but-misleading** when
|
||||
`stalledCount == 0` yet a chat's messages live on a relay *not in the
|
||||
account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect.
|
||||
4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production
|
||||
(no live-tail manager sets `tracksReqSends`). Either the live tail should
|
||||
adopt them or the round model could be slimmer for its current role.
|
||||
5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still
|
||||
be a large payload on a dense relay.
|
||||
|
||||
---
|
||||
|
||||
## Design evolution (historical — superseded, do not trust for current behavior)
|
||||
|
||||
These sections describe earlier iterations, kept for context. The code has
|
||||
moved past all of them.
|
||||
|
||||
### v1 — time-slice history (superseded by `RelayLoadingCursors`)
|
||||
|
||||
History was first loaded in bounded `since`+`until` **time slices**
|
||||
(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band
|
||||
`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on
|
||||
the slice `since` for gift wraps. This bounded re-downloads but still couldn't
|
||||
tell an empty relay from a gap (an empty slice might sit above older messages),
|
||||
so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a
|
||||
20k-event firehose. Replaced by per-relay `until`+`limit` paging.
|
||||
|
||||
### v2 — round-model history + rooms-list "stall-gate" (both removed)
|
||||
|
||||
History paging once used the **round model** (`WindowLoadTracker`): each
|
||||
`loadMore` issued one page to all active relays and waited for the slowest to
|
||||
settle before the next — pacing every relay at the slowest one. The rooms list
|
||||
additionally had a **stall-gate**: an auto-fill loop that widened only while it
|
||||
brought in new private rooms, stopping once a widen added none (to avoid the
|
||||
conversation-keyed cascade).
|
||||
|
||||
Both are gone. All history paging is now per-relay independent
|
||||
(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker
|
||||
visibility like the conversation (commit `98fb8720` dropped the stall-gate;
|
||||
`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay
|
||||
model). `WindowLoadTracker` survives **only** as the live-tail completion
|
||||
barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list
|
||||
and gift-wrap history used the round model — that is no longer true.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Napplet / nsite sandbox host — design
|
||||
|
||||
> **Status:** shipped — Keyless sandbox host shipped: `:nappletHost` module, `NappletBrokerService`, `NappletLaunchRegistry` all present (rendering half superseded by embedded-tabs).
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification
|
||||
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
|
||||
|
||||
> **Update (2026-06-24):** the *rendering* model below (full-screen-only
|
||||
> `NappletHostActivity`, host living in `amethyst/androidMain/.../napplet/`) has
|
||||
> moved on. The sandbox runtime now lives in its own **`:nappletHost`** module and
|
||||
> renders three ways — full-screen, embedded warm bottom-bar tabs, and an arbitrary-URL
|
||||
> browser — with a cross-process `SurfaceControlViewHost`, a soft-keyboard IME proxy,
|
||||
> and per-site Tor routing. See **`amethyst/plans/2026-06-24-napplet-embedded-tabs.md`**
|
||||
> for the current architecture. The **trust model** in this doc (keyless `:napplet`
|
||||
> process, brokered consent-gated capabilities, verified-blob serving) is unchanged.
|
||||
|
||||
## Goal
|
||||
|
||||
Render NIP-5A static sites (nsites) and NIP-5D napplets *inside* Amethyst, with
|
||||
the applet's HTML/CSS/JS running behind a **hard trust boundary**: it must never
|
||||
be able to read the user's `nsec` or any other secret, read app storage,
|
||||
`LocalCache`, or other accounts' data, or sign / encrypt / publish / zap without
|
||||
explicit per-applet user consent. A napplet is untrusted third-party code served
|
||||
from an untrusted Blossom CDN; we treat it accordingly.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Adversary:** the applet bundle (HTML/CSS/JS), authored by an untrusted party,
|
||||
delivered from an untrusted CDN.
|
||||
|
||||
It must NOT be able to:
|
||||
|
||||
1. Read the `nsec` / any `NostrSigner`-held secret, or decrypted key material.
|
||||
2. Read app private storage, `LocalCache`, DataStore, other accounts, or another
|
||||
applet's sandboxed storage.
|
||||
3. Sign, encrypt/decrypt, publish, subscribe, or zap without explicit consent.
|
||||
4. Escalate to native code, other installed apps, or the file system.
|
||||
5. Reach the network directly to exfiltrate or fingerprint (network is a *brokered
|
||||
capability*, default-deny).
|
||||
6. Forge content past the signed manifest (already handled by `StaticSiteResolver`:
|
||||
manifest is authority, CDN is untrusted, every blob is sha256-verified).
|
||||
|
||||
**Trusted:** the main Amethyst process, the broker, the consent UI, quartz
|
||||
verification. **Assumption:** the Android System WebView (Chromium) renderer
|
||||
sandbox is sound — and we add an OS-process boundary on top so that even a full
|
||||
WebView/renderer escape lands in a process that holds no secrets.
|
||||
|
||||
## Why a separate OS process is the load-bearing decision
|
||||
|
||||
Android processes have isolated address spaces. The decrypted `privKey`, the
|
||||
`KeyPair`, and the `NostrSigner` instance live **only in the main process heap**.
|
||||
The applet host runs in a separate process (`android:process=":napplet"`) that:
|
||||
|
||||
- never constructs a `NostrSigner`, never touches `SecureKeyStorage`/Keystore,
|
||||
never holds an `Account` or `LocalCache`;
|
||||
- only holds an IPC handle to *request operations*, whose **results carry no key
|
||||
material** (a signed event, a ciphertext, a pubkey — never the private key).
|
||||
|
||||
So even arbitrary code execution inside the WebView renderer (already its own
|
||||
sandboxed process) or inside the `:napplet` app process cannot read the main
|
||||
process's memory where the secret lives. This is the guarantee the same-process
|
||||
approach cannot make.
|
||||
|
||||
## Process & component model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Main process (com.vitorpamplona.amethyst)│ │ Applet process (…:napplet) │
|
||||
│ │ │ │
|
||||
│ Account · NostrSigner · SecureKeyStorage │ │ NappletHostActivity │
|
||||
│ LocalCache · NostrClient · Blossom · NWC │ │ └─ WebView │
|
||||
│ │ │ ├─ shell page (trusted, │
|
||||
│ NappletBrokerService (bound, not exported)│◀──AIDL─▶│ │ app-asset origin) │
|
||||
│ └─ commons NappletBroker │ Binder │ │ exposes bridge → broker │
|
||||
│ └─ NappletPermissionLedger │ (UID │ └─ <iframe sandbox= │
|
||||
│ │ checked)│ "allow-scripts"> │
|
||||
│ NappletConsentActivity (consent UI) │ │ = applet, opaque origin│
|
||||
└─────────────────────────────────────────┘ └───────────────────────────────────┘
|
||||
holds secrets holds NO secrets
|
||||
```
|
||||
|
||||
### Three transport hops (each a trust step-down)
|
||||
|
||||
1. **applet iframe ↔ shell page** — `postMessage` with strict origin checks. The
|
||||
shell injects a tiny `window.napplet.*` client shim into the applet that wraps
|
||||
postMessage calls into promises (request-id correlation). This is the NIP-5D
|
||||
capability surface the applet codes against.
|
||||
2. **shell page ↔ `:napplet` native** — exactly one audited bridge,
|
||||
`WebView.addWebMessageListener` restricted to the **shell origin only** (the
|
||||
applet's opaque-origin iframe cannot reach it). The shell forwards validated
|
||||
requests.
|
||||
3. **`:napplet` process ↔ main-process broker** — AIDL/`Messenger`. The broker
|
||||
checks `Binder.getCallingUid() == Process.myUid()` (reject anything not from
|
||||
our own app), consults the permission ledger, runs the operation with the real
|
||||
signer/client, returns only the result.
|
||||
|
||||
### WebView hardening (`:napplet`)
|
||||
|
||||
- Shell document served from app assets via `WebViewAssetLoader` at a fixed
|
||||
internal origin (`https://napplet.localhost/`). The applet lives in a child
|
||||
`<iframe sandbox="allow-scripts">` **without `allow-same-origin`** → unique
|
||||
opaque origin: no access to the shell DOM, cookies, `localStorage`,
|
||||
`IndexedDB`, or the bridge.
|
||||
- Applet resources are served by `shouldInterceptRequest` from an **in-memory map
|
||||
of already-verified blob bytes** (resolved + sha256-checked by
|
||||
`StaticSiteResolver` *before* the WebView loads). Only manifest-declared paths
|
||||
resolve; everything else → 404. No `file://`, no `content://`.
|
||||
- `WebSettings`: `allowFileAccess=false`, `allowContentAccess=false`,
|
||||
`allowFileAccessFromFileURLs=false`, `allowUniversalAccessFromFileURLs=false`,
|
||||
`setGeolocationEnabled(false)`, `mediaPlaybackRequiresUserGesture=true`,
|
||||
`safeBrowsingEnabled=true`, no insecure mixed content. `domStorageEnabled`
|
||||
only for a partitioned per-applet store (or off in v1).
|
||||
- **CSP**: the shell injects `connect-src 'none'` for the applet by default — the
|
||||
applet has *no direct network*. Relay/Blossom/identity all go through the
|
||||
broker. Direct network is itself a capability (`net`) that widens `connect-src`
|
||||
to user-approved origins only.
|
||||
- External navigation blocked in `shouldOverrideUrlLoading`; links open in the
|
||||
system browser only after consent.
|
||||
|
||||
## Capability / NAP-domain model
|
||||
|
||||
`NappletManifest.requires()` already yields the bare NAP domains
|
||||
(`identity`, `relay`, `storage`, …; see `quartz/.../tags/RequiresTag.kt`). Map
|
||||
each to a `NappletCapability` with the concrete broker operations it unlocks:
|
||||
|
||||
| NAP domain | Capability | Broker operations |
|
||||
|---|---|---|
|
||||
| `identity` | `IDENTITY` | `getPublicKey`, `signEvent`, `nip04/44 encrypt/decrypt` |
|
||||
| `relay` | `RELAY` | `publish`, scoped `subscribe` (read) |
|
||||
| `value` / `wallet` | `WALLET` | NIP-57 zap request / invoice; NWC pay (stricter, separate grant) |
|
||||
| `storage` | `STORAGE` | per-applet sandboxed KV store, namespaced by applet identity — **never** app storage |
|
||||
| `net` | `NET` | widen CSP `connect-src` to approved origins |
|
||||
| *(unknown)* | — | **denied by default**, surfaced to the user |
|
||||
|
||||
**Applet identity for the ledger** = author pubkey + `d` identifier (the
|
||||
addressable coordinate), *not* the blob hash, so grants survive updates. We still
|
||||
record the manifest aggregate hash (`computeAggregateHash()`) so a user who picks
|
||||
"ask again on code change" is re-prompted when the bundle changes.
|
||||
|
||||
## Permission ledger
|
||||
|
||||
`NappletPermissionLedger` — per-applet-identity grants, each
|
||||
`NappletCapability → GrantState` (`ASK` / `ALLOW_ONCE` / `ALLOW_SESSION` /
|
||||
`ALLOW_ALWAYS` / `DENY`), persisted via a `NappletPermissionStore` interface
|
||||
(DataStore actual on Android). The broker consults it on every request:
|
||||
|
||||
- `DENY` → immediate `Denied` response.
|
||||
- `ALLOW_*` → execute.
|
||||
- `ASK` → suspend, launch `NappletConsentActivity` (main process), await the
|
||||
user's decision, optionally persist, then execute or deny.
|
||||
|
||||
Consent UI reuses the signer-prompt design
|
||||
(`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`) and
|
||||
`commons/.../ui/signing`.
|
||||
|
||||
### Capability-aware consent policy
|
||||
|
||||
The uniform "once / session / always / deny" is refined per capability:
|
||||
|
||||
- **Payments (`WALLET`)** — `requiresPerUseConsent = true`: every `payInvoice`
|
||||
re-prompts with the decoded sats amount; the dialog never offers "Always allow"
|
||||
and a stray always/session grant is downgraded to one-shot so nothing persists.
|
||||
- **Identity (`IDENTITY`)** — gated by us **only when Amethyst holds the key**
|
||||
(`NostrSignerInternal`). For remote (NIP-46) / external (NIP-55) signers the
|
||||
broker defers to the signer's own per-request consent (no double-prompt), while
|
||||
still honoring a standing per-napplet `DENY` and the `requires` declaration. The
|
||||
sign prompt shows a kind + content preview.
|
||||
- **Foreground-only execution** — the napplet WebView's JS/timers are paused when
|
||||
the host isn't resumed (`onPause`/`onResume`), so a backgrounded applet cannot
|
||||
fire a sign/decrypt/pay request whose prompt would surface over — and be
|
||||
confused with — Amethyst's own UI. This is the precondition that makes deferring
|
||||
identity to an external signer safe.
|
||||
|
||||
## Module placement
|
||||
|
||||
- **`quartz`** — protocol done. (Optional later: a canonical `NapDomain` constant
|
||||
set once the upstream NAP list stabilizes — an open question in the resolver
|
||||
plan.)
|
||||
- **`commons/commonMain`** — new CLI-safe `napplet/` feature package:
|
||||
- `napplet/NappletCapability.kt` — NAP-domain ↔ capability mapping.
|
||||
- `napplet/protocol/` — `NappletRequest` / `NappletResponse` sealed families + JSON codec.
|
||||
- `napplet/permissions/` — `NappletPermissionLedger`, `GrantState`, `NappletPermissionStore`.
|
||||
- `napplet/NappletBroker.kt` — platform-agnostic broker: `(NostrSigner +
|
||||
relay/blossom/zap handles + ledger) → (NappletRequest → NappletResponse)`.
|
||||
The heart of the boundary; **fully unit-testable on the JVM**.
|
||||
- `napplet/ui/` — shared consent composables.
|
||||
- **`amethyst/androidMain`** —
|
||||
- `NappletHostActivity` (`:napplet`) + WebView + `WebViewAssetLoader` + the
|
||||
shell HTML/JS shim asset.
|
||||
- `NappletBrokerService` (main process, bound, `exported=false`) wrapping the
|
||||
commons broker; `Binder` UID check.
|
||||
- `NappletConsentActivity` (main process) using the commons consent UI.
|
||||
- AIDL/`Messenger` plumbing; OkHttp `BlobFetcher` wiring (the resolver plan's
|
||||
named follow-up).
|
||||
- AndroidManifest: `:napplet` process declaration, the bound service, the
|
||||
consent activity.
|
||||
- **`desktopApp`** — out of scope for v1 (the `:napplet` process model is
|
||||
Android-specific; desktop needs a separate child-process/WebView strategy).
|
||||
|
||||
## Inter-applet communication (v2)
|
||||
|
||||
Napplets' differentiator is talking to each other. Model: applets address each
|
||||
other by napplet coordinate; `napplet.send(target, msg)` is **routed through the
|
||||
broker** (shell→broker→shell) so two opaque-origin iframes never share an origin
|
||||
or memory, and the user (or a manifest-declared allowlist) consents to the link.
|
||||
Deferred to v2; v1 nails the single-applet boundary first.
|
||||
|
||||
## Testing & verification
|
||||
|
||||
- **commons (now, JVM):** broker decision logic per capability
|
||||
(granted/denied/ask), ledger state transitions + persistence semantics,
|
||||
protocol JSON round-trips, and security cases — unknown NAP domain denied,
|
||||
request for an undeclared path 404s without fetching, signer responses never
|
||||
contain key bytes.
|
||||
- **Android (needs emulator):** instrumented tests for the WebView host, the
|
||||
opaque-origin iframe isolation, and the process boundary — flagged as on-device
|
||||
verification.
|
||||
|
||||
## Phasing (within the "full napplet" milestone)
|
||||
|
||||
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done.
|
||||
2. **Android host** — `:napplet` process + WebView + verified-blob serving via
|
||||
`shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`).
|
||||
3. **Broker IPC + `identity` + `relay`** — Messenger broker
|
||||
(`NappletBrokerService`), `window.napplet.*` shim, consent UI
|
||||
(`NappletConsentActivity`), DataStore ledger. ✅ implemented.
|
||||
4. **Capabilities beyond identity/publish:**
|
||||
- **`relay` read** — `QueryEvents`: bounded live relay fetch (`fetchAll`,
|
||||
EOSE/timeout) merged with `LocalCache`, newest-first. ✅
|
||||
- **`storage`** — per-applet sandboxed KV store (`DataStoreNappletStorage`). ✅
|
||||
- **`value`/`wallet`** — `PayInvoice` wired to the user's NWC wallet
|
||||
(`sendZapPaymentRequestFor`); consent shows the decoded sats amount; throws
|
||||
(→ `Failed`) on no-wallet/error/timeout. ✅ (needs on-device verification)
|
||||
- **`net`** — CSP widening to approved origins. ⏳
|
||||
5. **Capability enforcement** — the broker refuses any request whose capability is
|
||||
not in the manifest's `requires` (passed host→broker as `declared`), before any
|
||||
consent prompt. ✅
|
||||
6. **Inter-applet (NAP-INC / NAP-INTENT)** — deferred; design + prerequisites in
|
||||
`2026-06-20-napplet-inter-applet.md`. **`net`** capability and an install-style
|
||||
up-front capability grant UI also remain. ⏳
|
||||
|
||||
### Implemented Android components (amethyst `…/napplet/`)
|
||||
|
||||
| File | Process | Role |
|
||||
|---|---|---|
|
||||
| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client |
|
||||
| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check |
|
||||
| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker |
|
||||
| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) |
|
||||
| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract |
|
||||
| `NappletLauncher` | caller | Packs a verified manifest into the host Intent |
|
||||
| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages |
|
||||
|
||||
### Remaining before user-facing ship
|
||||
|
||||
- **On-device verification (needs emulator/device):** opaque-origin iframe really
|
||||
excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real
|
||||
napplet renders and round-trips a `getPublicKey` / `signEvent` through consent.
|
||||
- ✅ **UI entry point:** a "Napplets" drawer item → `NappletsScreen` that lists
|
||||
cached napplet manifests (kinds 15129/35129) and launches the host.
|
||||
- ✅ **Process isolation:** `Amethyst.onCreate` now skips `AppModules` entirely in
|
||||
the `:napplet` process, so the account/signer are never loaded there. (Previously
|
||||
`initiate()` loaded the account in every process — a real hole, now closed.)
|
||||
- ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy
|
||||
when active; the port is passed in by the launcher (main process) so the sandbox
|
||||
process never touches the account-bound HTTP stack.
|
||||
- ✅ **Relay discovery:** `NappletsFilterAssembler` (registered in
|
||||
`RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds
|
||||
15129/35129 from the user's read relays while the screen is open, so manifests
|
||||
flow into `LocalCache` for the list to render.
|
||||
- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
|
||||
and a per-capability rationale; batch-grant on first run.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Napplet implementation audit vs the upstream SDK / demo runtimes
|
||||
|
||||
> **Status:** shipped — Audit doc; the wire-compatibility gaps it identified were subsequently closed (shell handshake, keys, upload now in the broker/codec).
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Sources:** `github.com/napplet/naps` (NAP specs), `github.com/napplet/web`
|
||||
(`@napplet/shim` SDK), `github.com/kehto/web` + `kehto.github.io/web/playground`
|
||||
(reference runtime). Audited against our branch `claude/awesome-pasteur-xwiwad`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Our shell is a **correct and security-hardened NIP-5A/5D renderer + capability
|
||||
broker** — process isolation, verified-blob serving, default-deny CSP,
|
||||
capability-aware + signer-aware consent, foreground-only, permissions UI. Those
|
||||
are *shell-quality* properties the demo runtimes don't even specify, and we're
|
||||
ahead there.
|
||||
|
||||
**But it is not wire-compatible with the napplet ecosystem.** Real napplets are
|
||||
built against `@napplet/shim`, which exposes a **namespaced** `window.napplet.*`
|
||||
and a **`{type:"domain.action", id}`** postMessage envelope. We inject a **flat**
|
||||
`window.napplet.*` and use a **`{id, payload:{op}}`** envelope. A napplet from the
|
||||
kehto playground calling `window.napplet.relay.publish(...)` or
|
||||
`window.napplet.shell.supports("relay")` hits `undefined` on our shell. So today:
|
||||
**0 real ecosystem napplets run as-is.**
|
||||
|
||||
## Reference: the upstream surface
|
||||
|
||||
`window.napplet` namespaces (from `@napplet/web`):
|
||||
|
||||
| Namespace | Methods (upstream) |
|
||||
|---|---|
|
||||
| `shell` | `supports(domain)` — **required of every runtime** |
|
||||
| `identity` | `getPublicKey()`, `onChanged(handler)` |
|
||||
| `keys` | signing / NIP-04 / NIP-44 (separate domain from `identity`) |
|
||||
| `relay` | `subscribe(filters)` (live), `publish(event)`, `query(filters)`, `publishEncrypted` |
|
||||
| `storage` | `get(key)`, `set(key, value)`, `remove(key)` |
|
||||
| `inc` | `emit(type, payload)`, `on(type, handler)` (inter-napplet) |
|
||||
| `resource` | `bytes(url)`, `bytesAsObjectURL(url)` — fetch https/blossom/nostr/data |
|
||||
| `value` | shell-mediated value transfer + zaps (depends on `relay`) |
|
||||
| `upload` | shell-mediated file/blob upload (Blossom; depends on `relay`) |
|
||||
| `intent` | invoke a napplet by archetype |
|
||||
| `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm` | further domains |
|
||||
|
||||
**Envelope:** `{ type: "<domain>.<action>", id, ...payload }` →
|
||||
result `{ type: "<domain>.<action>.result", id, ok, ... }`.
|
||||
**Sandbox:** `iframe sandbox="allow-scripts"` only (no `allow-same-origin`) — **we
|
||||
match this exactly.**
|
||||
|
||||
## Coverage scorecard (our broker vs upstream domains)
|
||||
|
||||
| Domain | Status | Notes |
|
||||
|---|---|---|
|
||||
| `shell` | ✗ **missing** | `supports()` is foundational; without it well-behaved napplets bail early |
|
||||
| `identity` | ◐ | `getPublicKey` ✓; `onChanged` ✗ (no push) |
|
||||
| `keys` (sign/nip04/nip44) | ◐ | implemented, but lumped under our `IDENTITY` capability, not a `keys` domain |
|
||||
| `relay` | ◐ | `publish` ✓, `query` ✓ (now live); `subscribe` (live) ✗, `publishEncrypted` ✗ |
|
||||
| `storage` | ✓ | get/set/remove — shape matches (wire differs) |
|
||||
| `value` | ◐ | we do `payInvoice` via NWC; upstream `value` is zaps/value-transfer (depends on relay) — different method shape |
|
||||
| `resource` | ✗ | no `bytes(url)`; we only serve manifest subresources |
|
||||
| `upload` | ✗ | no Blossom upload |
|
||||
| `inc` | ✗ | deferred (see inter-applet plan) |
|
||||
| `intent` | ✗ | deferred |
|
||||
| `theme` `notify` `media` `config` `outbox` `ifc` `cvm` | ✗ | not modeled |
|
||||
|
||||
Our extra `NET` domain has **no upstream equivalent** — upstream fetching is
|
||||
`resource`. Our `fromNapDomain` recognizes `identity/relay/value/storage/net`;
|
||||
everything else (`shell`, `resource`, `upload`, `inc`, `intent`, `keys`, …) maps
|
||||
to *unknown → denied*. So a manifest `requires: ["relay","shell","resource"]`
|
||||
would have two of three flagged unknown — and `shell` is mandatory.
|
||||
|
||||
## The two interop blockers
|
||||
|
||||
1. **Envelope.** Real napplets send `{type:"relay.publish", id, event}` and await
|
||||
`{type:"relay.publish.result", id, ok}`. We expect `{id, payload:{op:"publish"}}`
|
||||
and reply `{id, response:{type:"published"}}`. Incompatible.
|
||||
2. **API surface.** Upstream is namespaced (`relay.publish`, `identity.getPublicKey`,
|
||||
`keys.signEvent`, `shell.supports`, `resource.bytes`). Ours is flat
|
||||
(`getPublicKey`, `signEvent`, `publish`, `queryEvents`, `payInvoice`) + a
|
||||
namespaced `storage`. Only `storage` lines up.
|
||||
|
||||
Both live in our **edge layer** (the injected JS shim + `NappletProtocolJson` +
|
||||
`NappletIpc`) and the capability enum — *not* in the security core (broker,
|
||||
ledger, process model), which is dialect-agnostic. So aligning is an edge rewrite,
|
||||
not an architecture change.
|
||||
|
||||
## What we have that the demos don't
|
||||
|
||||
- Separate-OS-process sandbox (`:napplet`), not just an iframe.
|
||||
- Verified-blob serving (manifest-authoritative, per-blob sha256) with default-deny
|
||||
CSP (`connect-src 'none'`) and Tor-routed fetch.
|
||||
- Capability **declaration enforcement** (manifest `requires` gate), **per-use**
|
||||
payment consent, **signer-aware** identity deferral, **foreground-only** execution.
|
||||
- A persisted permission ledger + a permissions-management UI.
|
||||
|
||||
These are real-shell concerns the reference runtimes leave to the implementer; we
|
||||
should keep them.
|
||||
|
||||
## Recommended path to ecosystem compatibility (priority order)
|
||||
|
||||
1. **Adopt the upstream envelope** `{type:"domain.action", id}` / `….result` in the
|
||||
shim + `NappletProtocolJson` + `NappletIpc`. (Unblocks everything.)
|
||||
2. **Re-shape the injected shim** to the namespaced `window.napplet.*` and add
|
||||
**`shell.supports(domain)`** (cheap; derive from the granted capability set).
|
||||
3. **Split capabilities** to match domains: `keys` (sign/nip04/nip44) distinct from
|
||||
`identity` (getPublicKey/onChanged); rename `NET`→`resource`; add `SHELL`,
|
||||
`UPLOAD`, `VALUE` semantics (zap-by-target, not raw invoice).
|
||||
4. **Add the push channel**: `relay.subscribe` (live events), `identity.onChanged`,
|
||||
later `inc.on` — the host already holds a `JavaScriptReplyProxy` we can push
|
||||
unsolicited `…event` messages through.
|
||||
5. **`resource.bytes`** (consented fetch of https/blossom/nostr/data) and
|
||||
**`upload`** (Blossom), both brokered + consent-gated.
|
||||
6. Lower priority / app-specific: `theme`, `notify`, `media`, `config`, `outbox`,
|
||||
`intent`, `inc`, `ifc`, `cvm`.
|
||||
|
||||
## Update (2026-06-20): ecosystem alignment landed
|
||||
|
||||
Acted on #1–#5. The dialect mismatch is resolved:
|
||||
|
||||
- **Envelope** is now `{type:"<domain>.<action>", id}` → `{type:"….result", id, ok, …}`,
|
||||
matching upstream (codec + host shuttle + shim rewritten; round-trip unit-tested).
|
||||
- **Namespaced `window.napplet.*`** shim: `shell.supports`, `identity.getPublicKey`
|
||||
(+`onChanged` stub), `keys.{signEvent,nip04*,nip44*}`, `relay.{publish,query,subscribe}`,
|
||||
`storage.{get,set,remove}`, `value.payInvoice`, `resource.{bytes,bytesAsObjectURL}`,
|
||||
`upload.blob`. The applet's own SDK-targeted code now runs unchanged.
|
||||
- **`shell.supports(domain)`** implemented (no consent; reflects declared+brokered domains).
|
||||
- **Capabilities split** to the domain model: `SHELL`, `IDENTITY`, `KEYS`, `RELAY`,
|
||||
`STORAGE`, `VALUE`, `RESOURCE`, `UPLOAD` (was `IDENTITY/RELAY/WALLET/STORAGE/NET`).
|
||||
- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed,
|
||||
consent-gated); `blossom:`/`nostr:` are a follow-up.
|
||||
|
||||
## Update (2026-06-21): return shapes verified against `@napplet/nap@0.15.0`
|
||||
|
||||
Pulled the canonical message types (`@napplet/nap` `*/types.d.ts` + `value-types`) and corrected
|
||||
the result field names — several were wrong guesses. The authoritative wire:
|
||||
|
||||
| Method | Request `type` | Result field(s) |
|
||||
|---|---|---|
|
||||
| `identity.getPublicKey` | `identity.getPublicKey` | `pubkey: string` |
|
||||
| `identity.getProfile` | `identity.getProfile` | `profile: ProfileData \| null` |
|
||||
| `identity.getRelays` | `identity.getRelays` | `relays: Record<url, {read,write}>` |
|
||||
| `identity.getFollows`/`getMutes`/`getBlocked` | same | `pubkeys: string[]` |
|
||||
| `identity.getList` | `identity.getList` | `entries: string[]` |
|
||||
| `identity.getZaps` / `getBadges` | same | `zaps[]` / `badges[]` |
|
||||
| `storage.getItem/setItem/removeItem/keys` | **`storage.get`/`set`/`remove`/`keys`** | `value` / — / — / `keys: string[]` |
|
||||
| `relay.publish` / `publishEncrypted` | same | template in the **`event`** field; result `{ok, event, eventId}` |
|
||||
| `relay.query` | `relay.query` | `events: NostrEvent[]` |
|
||||
| `resource.bytes` | `resource.bytes` | `blob: Blob`, `mime: string` |
|
||||
|
||||
`ProfileData` is `{ name?, displayName?, about?, picture?, banner?, nip05?, lud16?, website? }` —
|
||||
note **`displayName`** (camelCase), so the shell maps kind-0 `display_name` → `displayName` rather
|
||||
than dumping raw content. Corrected in code: identity reads now emit method-specific fields;
|
||||
`storage.*` wire types fixed (the *function* is `getItem`, the *envelope* is `storage.get`);
|
||||
`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`;
|
||||
`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`.
|
||||
|
||||
**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts
|
||||
**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates
|
||||
cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's
|
||||
object messages were dropped before (our shell only forwarded strings). Fixed:
|
||||
|
||||
- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to
|
||||
the string the native bridge carries (requests carry no Blobs); native→applet parses the reply
|
||||
to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not
|
||||
a string. The injected shim accepts either form.
|
||||
- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime`
|
||||
before delivering, so both the SDK and our shim resolve to a `Blob`.
|
||||
- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match)
|
||||
then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame
|
||||
lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a
|
||||
fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**.
|
||||
|
||||
Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus
|
||||
`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom
|
||||
`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised
|
||||
by the JVM unit tests.
|
||||
|
||||
## Update (2026-06-21, even later): feed surfacing + nsite runtime hardening
|
||||
|
||||
- **Feed surfacing (sandbox-preserving).** Napplets gained an inline feed card (nsites already had
|
||||
one), both render via `NoteCompose`, are indexed in `LocalCache`, and a profile **"Apps & Sites"**
|
||||
tab lists a user's manifests. The cards are inert (`Text`+`Button`, no WebView); execution begins
|
||||
only on explicit tap, in the `:napplet` process.
|
||||
- **SPA route fallback** — a document navigation (Accept: text/html) to a route not in the manifest
|
||||
serves the verified `index.html`; missing sub-resources still 404.
|
||||
- **External-link handoff** — a user-tapped off-origin http(s) link opens in the system browser
|
||||
(gesture-gated so a hostile site can't auto-redirect); the sandbox WebView never navigates away.
|
||||
- **`resource.bytes` `blossom:` scheme** — `blossom:<sha256>` fetches from the user's kind:10063
|
||||
Blossom servers and verifies the hash before returning. `nostr:` stays deferred (unspecified).
|
||||
- **Content-type byte-sniffing** — when a manifest path has no/unknown extension, the resolver
|
||||
sniffs magic bytes; text/markup is never sniffed, so HTML detection stays extension-driven.
|
||||
Unit-tested in quartz.
|
||||
- **kind:10063 fallback** — the launcher augments the manifest's `servers` with the author's
|
||||
published Blossom list (best-effort); every blob is still sha256-verified.
|
||||
- **Blob caching** — the host OkHttp client caches blobs on disk (forced-immutable, since they're
|
||||
content-addressed); the resolver re-verifies every served blob, so a stale entry can't be served.
|
||||
|
||||
Remaining: live subscription tail + `identity.onChanged`/`inc.on`, the Blossom `upload` gateway,
|
||||
`getList`/`getZaps`/`getBadges`, the `nostr:` resource scheme, multi-`filters` queries — and
|
||||
**on-device verification** of all the WebView-host behavior.
|
||||
|
||||
## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected
|
||||
|
||||
Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the
|
||||
implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the
|
||||
verified surface is:
|
||||
|
||||
| Namespace | Verified methods (v0.16.0) |
|
||||
|---|---|
|
||||
| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` |
|
||||
| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) |
|
||||
| `keys` | **keyboard/command actions** — `registerAction/unregisterAction/onAction` (NOT signing) |
|
||||
| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` |
|
||||
| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) |
|
||||
| `resource` | `bytes(url)` → `Blob`, `bytesAsObjectURL(url)` |
|
||||
| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` |
|
||||
|
||||
Crucial design fact, quoted: **"signing and encryption are mediated by the shell via
|
||||
`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the
|
||||
shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no
|
||||
raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0.
|
||||
|
||||
Corrections landed (this commit):
|
||||
|
||||
- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` /
|
||||
`keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`)
|
||||
and the broker signs it as the user and returns the signed event — exactly the upstream contract.
|
||||
Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs +
|
||||
publishes). The broker still defers the per-signature prompt to remote/external signers
|
||||
(`signsAsUser` + non-internal signer) and honors standing DENY.
|
||||
- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`),
|
||||
implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using
|
||||
napplets don't crash. They never cross the broker boundary.
|
||||
- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end
|
||||
(protocol + broker + DataStore + shim), matching upstream.
|
||||
- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field
|
||||
renamed `contentType`→`mime`.
|
||||
- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`,
|
||||
`onReady`, `services` stubs.
|
||||
- **`relay.subscribe`** wired (initial matches; live tail still a follow-up).
|
||||
- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions**
|
||||
(no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they
|
||||
can't conflict.
|
||||
|
||||
Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec
|
||||
round-trip test (`NappletProtocolJsonTest`) both green.
|
||||
|
||||
Still open (documented, not blocking basic napplets):
|
||||
- **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom
|
||||
gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed
|
||||
auth event + server selection, which needs on-device verification.
|
||||
- **Live push** — `relay.subscribe` returns initial matches via `query`; a live tail and
|
||||
`identity.onChanged`/`inc.on` need a push channel over the existing reply proxy.
|
||||
- **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`,
|
||||
`outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to).
|
||||
- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed
|
||||
against `@napplet/shim@0.16.0` (see the later update above), not guessed.
|
||||
- **Identity read API** — ✅ partly landed. `identity.getProfile` (kind-0 content), `getRelays`
|
||||
(NIP-65 read/write map), `getFollows` (kind-3 authors), `getMutes` and `getBlocked` (NIP-51
|
||||
decrypted user tags) now read from the active `Account` and return JSON, gated by the IDENTITY
|
||||
consent (and deferred to remote/external signers). `getList`/`getZaps`/`getBadges` route through
|
||||
but degrade to `Unsupported` for now; `identity.onChanged` is still a client-side no-op (needs
|
||||
the live push channel). Return shapes still want on-device verification against a real napplet.
|
||||
- **On-device verification** of the whole round-trip with a real playground napplet.
|
||||
|
||||
Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using
|
||||
identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource +
|
||||
`shell.supports` now run against the *verified* contract; remaining gaps are the identity read
|
||||
API, keyboard-action wiring, live subscription tails, the niche domains, and device verification.
|
||||
|
||||
## Verdict (original assessment, pre-update)
|
||||
|
||||
- **As a secure NIP-5A/5D renderer + broker:** ~85% — the hard, security-critical
|
||||
parts are done and tested; gaps are on-device verification and breadth of ops.
|
||||
- **As an ecosystem-compatible napplet host (runs real napplets):** ~25% — blocked
|
||||
by the envelope + namespaced-API mismatch and missing `shell`/`resource`. Until
|
||||
#1–#2 land, existing napplets won't run regardless of how solid the core is.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Napplet subsystem code audit — bugs, performance, refactor, placement
|
||||
|
||||
> **Status:** shipped — Audit recording completed fixes (LiveSub teardown, broker caching, shim extraction); referenced files exist.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-21. Scope: the napplet/nsite subsystem (`amethyst/.../napplet/`,
|
||||
`commons/.../napplet/`, `quartz/.../nip5aStaticWebsites` + `nip5dNapplets`).
|
||||
|
||||
## Fixed in this pass
|
||||
|
||||
| # | Category | Issue | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | 🐛 correctness | **Live subscription unsubscribed from the wrong client after an account switch** — `closeLiveSubscription`/`onDestroy` used the *current* account's client, leaking the sub on the original. | `LiveSub` holder stores the exact `INostrClient` that opened the sub; teardown uses it. |
|
||||
| 2 | 🐛 correctness | **Multi-relay subscriptions emitted N `relay.eose`** (one per relay) — the SDK expects one. | An `eoseSent` latch (`compareAndSet`) emits a single `relay.eose`. |
|
||||
| 3 | ⚡ perf | **Broker rebuilt on every request** (new gateways/prompt each call). | `broker()` caches per account (reference identity), rebuilt only on switch. |
|
||||
| 4 | ⚡ perf | **A fresh `OkHttpClient` per blob fetch** (no connection pooling). | `blobHttpClient()` caches the client keyed by Tor port (`@Synchronized`). |
|
||||
| 5 | ♻️ refactor | **105-line `SHIM_JS` string constant** in `NappletHostActivity.kt` (no highlighting, hard to edit). | Moved to `assets/napplet/shim.js`, loaded once like `shell.html`. |
|
||||
| 6 | 📝 docs | Stale shim comments (`onChanged` "follow-up", `subscribe` "snapshot…follow-up"). | Rewritten to match reality (no-op onChanged; live tail). |
|
||||
|
||||
All compile; `commons:jvmTest` + the amethyst napplet suite stay green.
|
||||
|
||||
## Deferred — with rationale (not silently dropped)
|
||||
|
||||
- **Duplicate events across relays** — the same event id can arrive from multiple relays, so
|
||||
`relay.event` is pushed more than once. Deduping needs a per-subscription seen-id set (unbounded
|
||||
memory for long subs); napplets already dedupe by id. Left as-is; documented.
|
||||
- **Background teardown of live subscriptions** — a backgrounded-but-alive napplet keeps its relay
|
||||
subscription open (the WebView is paused, but the service keeps streaming). It is *not* a
|
||||
permanent leak: the service is bind-only, so closing the napplet → `unbindService` → service
|
||||
`onDestroy` → all subs torn down. A proper pause/resume (unsubscribe on background, re-`REQ` on
|
||||
foreground) is a real optimization but needs an IPC pause/resume signal + device verification.
|
||||
- **Request ordering** — requests are handled concurrently (`scope.launch` per message), so
|
||||
`storage.set` then `storage.get` aren't guaranteed in-order. Matches the SDK's async model;
|
||||
serializing would hurt throughput. Documented, not changed.
|
||||
- **`runBlocking` in `shouldInterceptRequest`** — this runs on a WebView *background* worker thread
|
||||
(not the UI thread), so blocking there during a blob fetch is acceptable; WebView fans out
|
||||
resource loads across workers. Left as-is.
|
||||
- **Over-flags from the sweep that aren't real:** `pendingRequests` / `bridgeReplyProxy` "races" —
|
||||
both the `WebMessageListener` callback and the reply `Handler` run on the **main looper**, so
|
||||
there is no cross-thread access. A null `bridgeReplyProxy` only drops a reply to an
|
||||
already-gone WebView (the applet is gone too) — harmless.
|
||||
|
||||
## Recommended moves to commons / quartz
|
||||
|
||||
- **quartz (protocol-only): correct as-is.** NIP-5A/5D events, `NappletManifest`,
|
||||
`StaticSiteResolver` + `StaticSitePathLookup` (`sniffContentType`), `SiteAggregateHash` are all in
|
||||
`commonMain`. No app policy leaked in.
|
||||
- **commons (shared logic): mostly correct.** Broker, capability, identity, request/response,
|
||||
permissions ledger/store, and gateway interfaces are in `commonMain` — right home.
|
||||
- **DONE: `NappletProtocolJson` → `commons/jvmAndroid`.** Moved to
|
||||
`commons/.../napplet/protocol/` (next to the types it marshals) so the future **desktop** host
|
||||
reuses the exact wire codec. Its tests stay in `amethyst` (JUnit4) for now and still exercise it
|
||||
via the commons dependency; converting them to `kotlin.test` and moving to commons `jvmTest` is a
|
||||
small follow-up. See `desktopApp/plans/2026-06-21-napplet-desktop-host.md`.
|
||||
- **amethyst (Android-only): correctly platform-bound.** `NappletHostActivity` (WebView/process),
|
||||
`NappletBrokerService` (Service/Messenger/account), the gateway *implementations* (account,
|
||||
`BlossomUploader`, DataStore, NWC), `NappletLauncher`, consent UI, `NappletIpc`, and the screens
|
||||
all belong here.
|
||||
|
||||
## Lower-priority refactors (not done)
|
||||
|
||||
- Extract a shared Tor-aware OkHttp builder (host `buildHttpClient` vs service `blobHttpClient`
|
||||
duplicate the proxy logic) into a small util.
|
||||
- `summaryFor`'s long `when` could become a per-request-type method, and `encodeResponse`'s big
|
||||
`when` is repetitive — both are readability, not correctness.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Napplet SDK conformance audit — feature by feature
|
||||
|
||||
> **Status:** shipped — Conformance audit; the four breakers are marked fixed and pinned by `NappletSdkConformanceTest`.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Authoritative sources (verified, not from memory):**
|
||||
`@napplet/nap@0.15.0` (`dist/<domain>/types.d.ts` — the canonical wire message types),
|
||||
`@napplet/shim@0.16.0` (the SDK napplets bundle), `@napplet/core@0.15.0` (base envelope +
|
||||
shell handshake). Audited against branch `claude/awesome-pasteur-xwiwad`.
|
||||
|
||||
Our edge layer: `NappletProtocolJson` (codec), `NappletRequest`/`NappletResponse` (commons),
|
||||
`NappletHostActivity` (`SHIM_JS` + `shell.html` relay), `NappletBroker`, `NappletBrokerService`.
|
||||
|
||||
## Update — the four 🔴 breakers are now implemented
|
||||
|
||||
All four conformance breakers below are fixed (codec pinned by `NappletSdkConformanceTest`):
|
||||
|
||||
1. **Shell handshake** — the host answers `shell.ready` with `shell.init { capabilities:{domains,
|
||||
protocols}, services }` built from the declared domains (`NappletProtocolJson.encodeShellInit`),
|
||||
so a stock napplet's cached environment is populated and `supports()` works.
|
||||
2. **Id-less messages** — `onShellMessage` no longer drops messages without an `id`: `shell.ready`
|
||||
is answered locally, and other fire-and-forget messages get a synthetic id so they reach the broker.
|
||||
3. **keys** — `keys.registerAction`/`keys.unregisterAction` decode and the broker acknowledges them
|
||||
(declared-gated, no consent) so `registerAction()` resolves; the shim dispatches the `keys.action`
|
||||
push. (The actual global-key binding is still a follow-up — `keys.action` isn't emitted yet.)
|
||||
4. **upload** — realigned to `upload.upload { request:{ data, mimeType, filename } }` → rich
|
||||
`UploadResult { ok, uploadId, status, url, sha256, size, mimeType }`; `shell.html` inlines the
|
||||
request `Blob` as base64 so it survives the bridge; the gateway uploads via the app's
|
||||
`BlossomUploader` to the user's kind:10063 server with a signed auth event.
|
||||
|
||||
Also now implemented (the ◐ follow-ups):
|
||||
- **Live subscription tail** — `relay.subscribe` opens a real `client.subscribe` whose listener
|
||||
streams `relay.event` (stored + live), `relay.eose`, and `relay.closed` pushes by `subId`;
|
||||
`relay.close` unsubscribes (tracked in `liveSubs`, torn down in `onDestroy`).
|
||||
- **Multi-`filters`** — `relay.query`/`subscribe` honor every filter in the `filters[]` array,
|
||||
not just the first (`decodeFilterList`, gateway `query(List<Filter>)`).
|
||||
- **`resource.cancel`** — accepted at the host edge as a no-op `Done`.
|
||||
|
||||
Still open: identity `getList`/`getZaps`/`getBadges` + `onChanged` (object shapes / list-type
|
||||
semantics underspecified), the `keys.action` push (needs a host command-palette UI to *trigger*
|
||||
actions — registration already conforms), the `resource` `nostr:` scheme (unspecified bytes),
|
||||
`inc`/`intent`/the niche domains, and **on-device verification** of the host/shell behavior.
|
||||
|
||||
## Base envelope & error convention (verified)
|
||||
|
||||
- `NappletMessage` carries only **`type`** (`"domain.action"`). **There is no universal `id`** —
|
||||
request/response pairs add `id`; fire-and-forget and handshake/push messages have **no `id`**.
|
||||
- **No universal `ok`.** Each domain picks its own: `relay.publish`/`publishEncrypted`,
|
||||
`outbox.publish`, `upload`, `intent` use `ok: boolean`; identity/storage/query results omit `ok`
|
||||
and signal success by the data field's presence + an optional `error?: string`. The SDK shim
|
||||
rejects when `error` is present and otherwise reads the domain's data field.
|
||||
- **Ours:** we set `ok` on *every* result. Harmless (the SDK reads the data field and ignores the
|
||||
extra `ok`), and our own injected shim relies on `ok`. ✅ compatible, ⚠️ non-canonical.
|
||||
|
||||
## Transport (verified) — two architectural gaps
|
||||
|
||||
1. **Structured-clone objects, not strings.** `@napplet/core` posts cloneable **objects**. Our
|
||||
`shell.html` now bridges object↔string both ways (done earlier). ✅
|
||||
2. **🔴 The host drops id-less messages.** `NappletHostActivity.onShellMessage` does
|
||||
`id = optString("id").ifEmpty { return }` — so every message **without an `id`** is dropped:
|
||||
`shell.ready`, `inc.emit`, `keys.unregisterAction`. This silently breaks the shell handshake and
|
||||
all fire-and-forget messages. **Must fix** to forward/handle id-less messages.
|
||||
3. **🔴 Blob-carrying requests can't cross.** `upload.upload`'s request payload contains a `Blob`
|
||||
(`data: Blob | ArrayBuffer`). Our applet→native bridge does `JSON.stringify`, which turns a Blob
|
||||
into `{}`. Real-napplet uploads lose their bytes. Needs a Blob-aware request path.
|
||||
|
||||
## Shell handshake (verified) — 🔴 not implemented
|
||||
|
||||
The SDK does **not** send a `shell.supports` message. Instead:
|
||||
- napplet posts **`shell.ready`** (no payload), and
|
||||
- the shell replies **once** with **`shell.init`** = `{ capabilities: { domains: string[],
|
||||
protocols: Record<string,string[]> }, services: string[] }`.
|
||||
- `shell.supports(capability, protocol?)` is then answered **synchronously and locally** from that
|
||||
cached environment.
|
||||
|
||||
**Ours:** we implement a `shell.supports` *request* (`ShellSupports` → `Supported`) and our injected
|
||||
shim calls it async. A real napplet never sends `shell.supports`; it sends `shell.ready` — which our
|
||||
host **drops** (no id) — so its cached environment stays empty and `supports()` returns `false` for
|
||||
everything, likely making well-behaved napplets bail early. **Highest-impact gap.**
|
||||
Fix: host answers `shell.ready` with a `shell.init` carrying the declared domains.
|
||||
|
||||
## Per-domain conformance matrix
|
||||
|
||||
Legend: ✅ conformant · ◐ partial · 🔴 mismatch/missing · ➖ not modeled.
|
||||
|
||||
| Domain | SDK surface (wire) | Ours | Verdict |
|
||||
|---|---|---|---|
|
||||
| **shell** | `shell.ready`→`shell.init{capabilities,services}`; `supports()` local | `shell.supports` request→`{supported}` | 🔴 wrong model (no handshake) |
|
||||
| **identity** | `getPublicKey`→`{pubkey}`; `getProfile`→`{profile}`; `getRelays`→`{relays}`; `getFollows`/`getMutes`/`getBlocked`→`{pubkeys}`; `getList`→`{entries}`; `getZaps`→`{zaps}`; `getBadges`→`{badges}`; `onChanged` push | getPublicKey ✅; profile/relays/follows/mutes/blocked ✅ (exact fields); getList/getZaps/getBadges → Unsupported; onChanged no-op | ◐ reads ✅, push/3 methods missing |
|
||||
| **relay** | `publish{event}`→`{ok,event,eventId}`; `publishEncrypted{event,recipient,encryption}`; `query{filters[]}`→`{events}`; `subscribe{subId,filters,relay?}`; `close{subId}`; pushes `relay.event{subId,event,resources?}`, `relay.eose{subId}`, `relay.closed{subId,reason?}` | publish/publishEncrypted ✅ (read `event`); query ◐ (first filter only); subscribe ✅ + event/eose push ✅ (snapshot); close ✅ (no-op); `relay.closed` not emitted | ◐ strong; multi-filter + live tail + `relay.closed` open |
|
||||
| **storage** | `get`→`{value}`; `set`; `remove`; `keys`→`{keys}` (512 KB quota) | ✅ exact (`get/set/remove/keys`, `value`/`keys` fields) | ✅ (quota not enforced) |
|
||||
| **resource** | `bytes{url}`→`{blob,mime}`; `cancel`; https/blossom/nostr/data | bytes ✅ (shell builds Blob); https/data/blossom ✅; nostr ➖; cancel ➖ | ◐ nostr + cancel missing |
|
||||
| **keys** | `registerAction{action}`→`{actionId,binding?}`; `unregisterAction{actionId}`; pushes `keys.action{actionId}`, `keys.bindings{bindings[]}` | client-side no-op stubs only; **host rejects** `keys.*` (decodes to null) | 🔴 real napplets' `registerAction` rejects |
|
||||
| **upload** | `upload.upload{request:{data:Blob,mimeType?,...}}`→`{ok,uploadId,status,url?,sha256?,...}`; `upload.status` | type `upload` + `{bytes(base64),contentType}`→`{url}`; gateway null→Unsupported | 🔴 wrong type + shape + Blob transport |
|
||||
| **inc** | `inc.emit`; `inc.subscribe`/`.result`; `inc.unsubscribe`; `inc.event` push; channel mode (`inc.channel.*`) | ➖ (deferred; maps to null→denied) | ➖ not modeled |
|
||||
| **intent** | invoke a napplet by archetype | ➖ | ➖ |
|
||||
| **theme/notify/media/config/outbox/ifc/cvm** | further domains | ➖ | ➖ |
|
||||
|
||||
## Inconsistencies, ranked
|
||||
|
||||
1. **🔴 Shell handshake missing** (`shell.ready`→`shell.init`). Real napplets get an empty
|
||||
capability environment → `supports()` false → likely bail. *Fix: host emits `shell.init`.*
|
||||
2. **🔴 Id-less messages dropped** by the host. Breaks the handshake + every fire-and-forget
|
||||
(`inc.emit`, `keys.unregisterAction`). *Fix: forward/handle id-less messages.*
|
||||
3. **🔴 `keys.*` rejects** for real napplets (we only stub client-side). *Fix: decode
|
||||
`keys.registerAction`/`unregisterAction`, answer a stub `{actionId}`; later wire `keys.action`.*
|
||||
4. **🔴 `upload` non-conformant** (`upload` vs `upload.upload`, flat base64 vs `request:{data:Blob}}`,
|
||||
`{url}` vs rich `UploadResult`) AND the Blob can't cross our string bridge. *Fix: realign the
|
||||
wire + a Blob-aware request path when the gateway lands.*
|
||||
5. **◐ `relay.query`/`subscribe` use only the first of `filters[]`.** Multi-filter napplets get
|
||||
partial results. *Fix: honor all filters.*
|
||||
6. **◐ Identity `getList`/`getZaps`/`getBadges` + `onChanged`** unimplemented.
|
||||
7. **◐ `resource` `nostr:` scheme + `resource.cancel`** unimplemented.
|
||||
8. **◐ `relay.closed` push** not emitted; **live subscription tail** absent (snapshot only).
|
||||
9. **⚠️ Non-canonical `ok` on every result** (harmless, but not how the SDK signals success for
|
||||
identity/storage/query).
|
||||
|
||||
## What the conformance tests assert
|
||||
|
||||
`NappletSdkConformanceTest` (amethyst, JVM) pins the codec to the **exact SDK wire** for the
|
||||
methods we support, so a regression that drifts from `@napplet/nap` fails CI:
|
||||
|
||||
- **Requests:** the SDK's exact envelope (`relay.publish{event}`, `storage.get`, `identity.*`,
|
||||
`resource.bytes`, multi-`filters`) decodes to the right `NappletRequest`.
|
||||
- **Results:** `encodeResponse` emits the SDK's exact field names (`pubkey`, `profile`, `relays`,
|
||||
`pubkeys`, `entries`, `value`, `keys`, `events`, `event`/`eventId`, `bytes`/`mime`).
|
||||
- **Pushes:** `relay.event{subId,event}` / `relay.eose{subId}` match the SDK push shapes.
|
||||
- **Gap guards:** tests that *document current behavior* for the known gaps (`shell.ready`,
|
||||
`keys.registerAction`, `upload.upload`, `inc.emit` currently decode to `null`), each annotated
|
||||
with the audit item so the day we fix them the guard flips intentionally.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Napplet / nsite security review (2026-06-22)
|
||||
|
||||
> **Status:** shipped — Security review recording completed hardening; `NappletLaunchRegistry` token model and per-applet origins are in tree.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets,
|
||||
the protections in place, and the residual risks — with what was fixed in this pass
|
||||
and what remains as future work.
|
||||
|
||||
## Trust model (what holds)
|
||||
|
||||
- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer.
|
||||
Signing happens only in the main process (`signer` fixes `pubkey`, host clock
|
||||
prevents backdating). *"Even a full WebView/renderer escape into this process yields
|
||||
no secret."*
|
||||
- **Content integrity.** Every blob is sha256-verified against the **signed** manifest
|
||||
before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a
|
||||
poisoned/stale cache can't be served. nsites launch with **zero** capabilities.
|
||||
- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered,
|
||||
consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame.
|
||||
- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always
|
||||
prompt per-use with the amount.
|
||||
|
||||
## Fixed in this pass
|
||||
|
||||
1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker
|
||||
used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the
|
||||
`:napplet` process. A WebView→native escape could forge another napplet's coordinate
|
||||
and read/act as it. Now the **main process** mints a random launch token
|
||||
(`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker
|
||||
resolves it back to the trusted identity + declared set. A compromised sandbox can act
|
||||
as nothing but the napplet it was launched as (it holds only its own token).
|
||||
|
||||
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()`).
|
||||
|
||||
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
|
||||
access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an
|
||||
allow-always grant can't act completely silently. Also fixes the host drawing under
|
||||
the status/navigation bars (edge-to-edge insets).
|
||||
|
||||
4. **Real per-applet storage origin (was: opaque sandbox → broken apps).** The applet
|
||||
ran in an `allow-scripts`-only iframe served under `napplet.local/app/`, i.e. an
|
||||
**opaque ("null") origin**. That has no `localStorage`/`IndexedDB`/service worker
|
||||
(reads throw `SecurityError`), and module scripts / asset fetches are CORS-blocked, so
|
||||
essentially every bundled SPA rendered **blank** or **crash-looped** ("cache version
|
||||
0 → reset → reload" forever, because IndexedDB never persisted the version). Each
|
||||
applet now loads on its **own** internal origin `https://<id>.napplet.local` —
|
||||
`id = sha256(author + ":" + identifier)`, truncated + letter-prefixed to a DNS label —
|
||||
with `allow-scripts allow-same-origin`, served at the **origin root** (bundlers emit
|
||||
absolute `/assets/...` URLs). A real origin restores DOM storage / IndexedDB / SW and
|
||||
makes the applet's own assets same-origin (no CORS shim needed). **Isolation is
|
||||
preserved precisely because the applet origin is distinct from the shell's:** the
|
||||
bridge stays origin-restricted to the shell (`napplet.local`), so the cross-origin
|
||||
applet still cannot reach it or read the shell DOM — it talks only via `postMessage`,
|
||||
which the shell relays. Per-applet subdomains keep applets' storage isolated from one
|
||||
another; CSP `connect-src 'none'` is unchanged; the app CSP was **tightened** to
|
||||
`'self'` (it no longer grants the shell origin).
|
||||
|
||||
## Residual risks / future work
|
||||
|
||||
- **`allow-same-origin` is safe only while the applet origin ≠ the shell/bridge origin.**
|
||||
A frame carrying *both* `allow-scripts` and `allow-same-origin` can strip its own
|
||||
sandbox **iff it is same-origin with its embedder**; here it never is (applet on a
|
||||
per-applet subdomain, shell on `napplet.local`), so it cannot. **Load-bearing
|
||||
invariant — do not break:** never serve the applet on the shell origin, and never add
|
||||
an applet origin (`*.napplet.local`) to the bridge's `addWebMessageListener` allowlist
|
||||
(kept to `setOf(ORIGIN)`). Collapsing the two origins would hand the applet the bridge.
|
||||
- **Persistent client storage is now a persistence/exfil surface.** The applet keeps
|
||||
`localStorage`/`IndexedDB` across launches (origin-scoped, per applet, key-free and
|
||||
isolated from other applets). A consented applet can build durable local state and,
|
||||
combined with allow-always RESOURCE, a durable profile. Tie to the session-scoped-grant
|
||||
proposal above; consider a "clear this applet's data" affordance.
|
||||
- **Per-applet origin id.** `sha256(author:identifier)` is stable per applet and
|
||||
collision-resistant; root/replaceable applets (kinds 15128/15129, no `d` tag) collapse
|
||||
to one origin per author — fine, since there's one root per author. Changing the id
|
||||
scheme later resets an applet's storage (new origin); acceptable.
|
||||
- **Service workers are reachable but unwired.** With a real origin the applet *can* now
|
||||
register a service worker; the host does not yet route SW fetches
|
||||
(`ServiceWorkerControllerCompat`), so registration currently fails gracefully (a
|
||||
warning, not a crash). If SW support is wired later, SW-originated fetches MUST go
|
||||
through the same verified content server, never the network.
|
||||
|
||||
- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A
|
||||
consented napplet can still, thereafter, publish as you (RELAY), read your social graph
|
||||
(IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new
|
||||
chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:**
|
||||
a session-scoped grant ("Allow while open", cleared on close) in `GrantState` +
|
||||
consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin
|
||||
`resource.bytes` https fetches.
|
||||
- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can
|
||||
encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the
|
||||
payload. Tie to the per-origin/session proposal above.
|
||||
- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is
|
||||
the author's own (content sha256-pinned), so it's not an escalation. `connect-src
|
||||
'none'` remains the real boundary. Residual, accepted.
|
||||
- **Trust pivots on the author key.** A compromised author key lets an attacker push a
|
||||
new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash
|
||||
is enforced when present (only *recommended* by the spec); per-path hashes always
|
||||
protect.
|
||||
- **Sandbox isolation is now structural.** The `:napplet` runtime
|
||||
(`NappletHostActivity`, content server, IPC, key actions) lives in its own
|
||||
`:nappletHost` module that depends only on `:commons` + `:quartz` — so it is
|
||||
*compile-time incapable* of importing `Amethyst`/`LocalCache`/`Account`. The
|
||||
broker-side (signer, gateways, `NappletLaunchRegistry`) stays in `:amethyst`;
|
||||
the activity binds the broker service by class-name string and the two halves
|
||||
communicate only over Messenger IPC.
|
||||
- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly
|
||||
unregistered on sandbox close (the sandbox is a separate process and can't reach the
|
||||
main-process registry). A long-backgrounded napplet whose token was evicted would need
|
||||
relaunch. Acceptable; revisit if it bites.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Napplet NAP domains: theme, notify, inc
|
||||
|
||||
> **Status:** shipped — `NappletCapability` has `THEME`/`NOTIFY`/`INC` and `NappletIncBus` is wired into `NappletBrokerService`.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
Date: 2026-06-23
|
||||
Status: in progress
|
||||
|
||||
## Problem
|
||||
|
||||
Real-world demo napplets (e.g. kehto/web's `apps/playground/napplets/*`) hard-gate
|
||||
their own boot: each reads `window.napplet.shell.supports(<domain>)` for every
|
||||
domain in its manifest `requires` and aborts ("unavailable") if any is missing.
|
||||
**Every** kehto demo `requires: theme`; most also need `inc`; toaster needs
|
||||
`notify`. Amethyst's `fromNapDomain` returns `null` for `theme/notify/inc/cvm`,
|
||||
so `shell.supports()` is false for them and all demos fail at boot.
|
||||
|
||||
These are real NAP service domains (kehto ships reference handlers). We add the
|
||||
three the demos need (theme, notify, inc); `cvm` is deferred (its own design).
|
||||
|
||||
## Wire contracts (verified against kehto reference services + demos)
|
||||
|
||||
- **theme** — `theme.get` → `theme.get.result { theme: { colors: { background, text, primary } } }`.
|
||||
Optional host push `theme.changed { theme }` (we skip the push for v1; the
|
||||
app theme rarely changes while a napplet is foreground). Read-only, **no consent**.
|
||||
- **notify** — `notify.create { title, body }` → `notify.created { id }` (past-tense,
|
||||
**not** the generic `.result`), `notify.list` → `notify.listed { notifications }`,
|
||||
`notify.dismiss { notificationId }` fire-and-forget. Consent-gated (ask once).
|
||||
Host shows a system notification + tracks a per-coordinate store for list/dismiss.
|
||||
- **inc** — a topic pub/sub bus. `inc.emit { topic, args, payload }` (fire-and-forget)
|
||||
delivers `inc.event { topic, payload }` to **other** subscribed napplet sessions
|
||||
(no echo to the sender). `inc.subscribe`/`inc.unsubscribe` register interest.
|
||||
Gated on the INC declaration at the router edge (like `identity.watch`), no
|
||||
per-call consent. NOTE: Amethyst runs napplets **foreground-only, one at a time**,
|
||||
so cross-napplet delivery is usually a no-op in practice — but the bus is correct
|
||||
if/when multiple sessions overlap, and it lets the demos boot + emit without error.
|
||||
|
||||
## Capability mapping & consent
|
||||
|
||||
`NappletCapability` gains `THEME`, `NOTIFY`, `INC`; `fromNapDomain` maps the bare
|
||||
domains. `requiresConsent` is false for `SHELL` and `THEME` (negotiation/cosmetic),
|
||||
true otherwise. INC is authorized at the router (declared-only) and never reaches
|
||||
the broker consent path.
|
||||
|
||||
## Touch points
|
||||
|
||||
- commons: `NappletCapability`, `NappletRequest`, `NappletResponse`,
|
||||
`NappletBrokerCollaborators` (new gateways), `NappletBroker`,
|
||||
`protocol/NappletProtocolJson` (decode + custom reply types + inc/theme pushes),
|
||||
`NappletRequestRouter` (inc edge ops).
|
||||
- amethyst: `gateways/AccountNappletGateways` (+ theme/notify gateways), an
|
||||
app-wide `NappletIncBus`, and `NappletBrokerService` wiring (notify store + inc
|
||||
push transport, like `NappletLiveSubscriptions`/`NappletIdentityWatch`).
|
||||
|
||||
Staged commits: (1) theme, (2) notify, (3) inc.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Embedded napplet / nsite / browser tabs — final architecture
|
||||
|
||||
> **Status:** shipped — `EmbeddedTabLayer` and the `:nappletHost` module exist; embedded warm-tab + browser render paths are implemented (PR #3348).
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Status:** Implemented on `claude/webview-menu-custom-url-ikrgbz` (PR #3348); needs on-device verification.
|
||||
**Supersedes the rendering half of** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
|
||||
(full-screen-only `NappletHostActivity` in `amethyst/androidMain`). The trust
|
||||
model is unchanged and still governed by
|
||||
`amethyst/plans/2026-06-22-napplet-nsite-security.md`.
|
||||
|
||||
## What changed since the sandbox-host plan
|
||||
|
||||
The 2026-06-19 design rendered a napplet/nsite **only** full-screen, in its own
|
||||
`:napplet` Activity/task. This branch adds:
|
||||
|
||||
1. A second, **embedded** render path: warm bottom-bar "favorite" tabs that live
|
||||
inside the main Amethyst window, swapped in place with no relaunch — built on
|
||||
a cross-process `SurfaceControlViewHost` surface.
|
||||
2. A **browser** host: the same keyless `:napplet` sandbox now also renders an
|
||||
arbitrary user-typed URL (not just a verified-blob nsite/napplet), as both a
|
||||
full-screen activity and an embedded tab.
|
||||
3. A working **soft keyboard** inside the embedded surface (the cross-process
|
||||
surface window cannot itself be an IME target).
|
||||
4. The sandbox runtime extracted into its **own `:nappletHost` module** (depends
|
||||
only on `:commons` + `:quartz`; cannot import `Amethyst`/`LocalCache`/`Account`).
|
||||
5. **Per-site Tor / open-web** routing memory, and a startup **preloader** that
|
||||
warms every bottom-bar favorite so the first tap is instant.
|
||||
|
||||
The keyless-sandbox guarantee is preserved throughout: keys live only in the main
|
||||
process, every NIP-07 / `window.napplet` call is brokered + consent-gated, and the
|
||||
embedded surface is z-ordered **below** the client window.
|
||||
|
||||
## Two render paths, one sandbox
|
||||
|
||||
| | Full-screen | Embedded tab |
|
||||
|---|---|---|
|
||||
| Container | `NappletHostActivity` / `NappletBrowserActivity` (`:napplet` task) | `SandboxedSdkView` in `EmbeddedTabLayer` (main window) |
|
||||
| Surface | the Activity's own window | `SurfaceControlViewHost` shipped to the host `SandboxedSdkView` |
|
||||
| Chrome (pull-down) | `NappletControlSheet` (native Android `View`s) | `TopControlSheet` (Compose) |
|
||||
| Soft keyboard | the Activity's own window (native) | `RemoteImeView` proxy in the **main** window |
|
||||
| Lifecycle | one session per task | many warm sessions, parked off-screen |
|
||||
|
||||
Both paths run the **same** `NappletHostService` / `NappletBrowserService` and the
|
||||
same `shell.html` + `shim.js`; only the host/embedding differs.
|
||||
|
||||
## Persistent warm-surface layer (embedded)
|
||||
|
||||
`EmbeddedTabLayer` is mounted once in the app shell, below the drawer/dialogs. It
|
||||
renders **every** warm session's `SandboxedSdkView` and keeps it attached:
|
||||
|
||||
- `EmbeddedTabHost` (object) owns the set of warm `sessions`, the active id, and
|
||||
the reserved `contentBounds`. `setActive(id)` returns a token; `clearActiveIfOwner(token)`
|
||||
only clears if the caller still owns it (so a fast tab A→B→A swap can't have B's
|
||||
teardown clear A's just-set active id). `retainOnly(ids)` warm-keeps the
|
||||
bottom-bar favorites (+ the momentarily-active tab) and evicts the rest.
|
||||
- The active session is offset over the current tab's bounds; **inactive warm tabs
|
||||
keep the same size and are merely shifted ~10 000 dp off-screen** (never resized
|
||||
to 1 dp). Parking by translation rather than resize is what avoids the ~1 s black
|
||||
flash on every tab switch (a resize forces a surface re-render).
|
||||
- The surface is z-below, so Compose draws over it — that's how `TopControlSheet`
|
||||
sits on top. Each surface is wrapped in `EmbeddedSurfaceTouchHolder` so a scroll
|
||||
gesture isn't stolen by a host-side ancestor (the cross-process WebView can't
|
||||
`requestDisallowInterceptTouchEvent` for itself).
|
||||
|
||||
`EmbeddedTabFactory` builds/acquires the per-app controller (`EmbeddedBrowserController`
|
||||
/ `EmbeddedNappletController`, both `EmbeddedSurfaceController` + `EmbeddedImeBridge`)
|
||||
and gates preloading on Tor (won't warm a Tor-routed site over clearnet while Tor is
|
||||
still connecting).
|
||||
|
||||
## Per-session services (the multi-tab refactor)
|
||||
|
||||
`NappletHostService` / `NappletBrowserService` are **single shared instances** (same
|
||||
bind `Intent`); per-tab state lives in a `NappletTab` / `BrowserTab` map keyed by the
|
||||
client-stamped `KEY_SESSION_ID` (`NappletEmbedContract` / `NappletBrowserContract`).
|
||||
Each tab carries its **own** reply `Messenger`, so broker responses + pushes route to
|
||||
the right tab (the broker echoes `replyTo`). Critical correctness rules baked in:
|
||||
|
||||
- `contentServer` is `@Volatile` (read on the WebView worker thread in
|
||||
`shouldInterceptRequest`, written on main).
|
||||
- broker-reply delivery drops a reply whose tab was replaced (`tabs[id] !== tab`)
|
||||
and wraps `postMessage` in `runCatching` (a torn-down WebView can't crash the relay).
|
||||
- session close / `onDestroy` tears down **that tab's** content server + WebView only
|
||||
(an earlier bug destroyed sibling tabs' WebViews → "open A, switch to B, back to A =
|
||||
black").
|
||||
|
||||
## Embedded soft keyboard (IME proxy)
|
||||
|
||||
The embedded surface is a `SurfaceControlViewHost` window: in z-below mode it forwards
|
||||
touch but **cannot be an IME target**, so a focused field would get no keyboard. The fix
|
||||
mirrors Flutter's `TextInputPlugin`:
|
||||
|
||||
- `RemoteImeView` — an invisible, focusable `EditText` in the **main** app window takes
|
||||
the keyboard. A real local `Editable` is the source of truth (the platform handles
|
||||
composing regions, suggestions, autofill); edits are **coalesced across batch edits**
|
||||
and the whole **editing state** (text + selection + composing) is shipped — not
|
||||
individual ops. It flushes **synchronously** at the outermost `endBatchEdit` so a
|
||||
compose-then-commit in one frame preserves the composing region.
|
||||
- `shim.js` IME agent (gated on `window.__nappletImeProxy`) applies that state to the
|
||||
focused field and synthesizes the matching DOM `input`/composition events so web
|
||||
frameworks react as if typed natively. It is surrogate-pair-safe (emoji / CJK-supplement
|
||||
never split into a lone surrogate) and supports `contenteditable` via Range-mapped char
|
||||
offsets (in-place replacement, not a `textContent` overwrite), with selection-echo dedup.
|
||||
- `EmbeddedTabLayer` shrinks the active surface to clear the keyboard using the **snapped**
|
||||
`WindowInsets.imeAnimationTarget` (not the per-frame animated `ime`), so the expensive
|
||||
cross-process surface resize happens once, not every animation frame.
|
||||
|
||||
State + ops cross over `MSG_IME_EVENT` / `MSG_IME_OP` (`EmbeddedImeBridge`). Full-screen
|
||||
hosts set neither IME flag (they have a native keyboard).
|
||||
|
||||
## Per-site network routing
|
||||
|
||||
`WebUrlNetworkRegistry` (keyed by site host) and `NappletNetworkRegistry` (keyed by
|
||||
`author:identifier`) remember whether a site routes through **Tor** (default) or the
|
||||
**open web** — some servers reject Tor exits, so a user can opt one out and it must
|
||||
survive relaunch. Both hydrate from DataStore asynchronously and now expose
|
||||
`awaitReady()`; the preloader awaits hydration **before** its first routing decision so a
|
||||
cold start can't route an open-web-pinned site through Tor. Live in the **main** process
|
||||
only; the keyless sandbox never reads them (the launcher stamps the choice into the
|
||||
launch intent).
|
||||
|
||||
## The two control-sheet twins
|
||||
|
||||
`TopControlSheet` (Compose, `:amethyst`, drawn in the main process over the z-below
|
||||
surface) and `NappletControlSheet` (hand-built Android `View`s, `:nappletHost`, in the
|
||||
keyless sandbox process) are deliberate twins: the sandbox module is Compose-free and
|
||||
can't depend on `:amethyst`, so the composable can't be shared. They are kept visually
|
||||
identical by hand — same 10 dp row rhythm, same muted-icon + framework `Switch` Tor row.
|
||||
Both are a top-center pull-down (collapsed = a small grabber out of the corner where a
|
||||
site puts its own avatar/menu): route over Tor, reload, "what it can access" (sandboxed
|
||||
apps), open full screen.
|
||||
|
||||
## Bottom bar
|
||||
|
||||
Built-ins and favorites are one ordered list (`BottomBarEntry`, polymorphic with
|
||||
`@SerialName("builtIn")` / `@SerialName("favorite")`). `UISharedPreferences.decodeBottomBarItems`
|
||||
migrates the old persisted discriminator (the kotlinx default fully-qualified class name)
|
||||
to the short names, falling back to `DefaultBottomBarEntries` — fixing the one-time bottom
|
||||
bar reset the `@SerialName` change would otherwise have caused.
|
||||
|
||||
The **Browser** launcher (`BrowserScreen`) is an omnibox + the shared `FavoriteAppsGrid`;
|
||||
each opened URL lands in its own full-screen `NappletBrowserActivity`. When the Browser is
|
||||
reached from the drawer (pushed) rather than as a bottom-bar tab, its omnibox shows a back
|
||||
arrow — the standard `nav.canPop()` rule (mirrors `NappletsTopBar`).
|
||||
|
||||
## File map (final)
|
||||
|
||||
**`:nappletHost`** (`com.vitorpamplona.amethyst.napplethost`, `:napplet` process):
|
||||
`NappletHostService` / `NappletBrowserService` (per-session WebView hosts),
|
||||
`NappletHostActivity` / `NappletBrowserActivity` (full-screen),
|
||||
`NappletHostUiAdapter` / `NappletBrowserUiAdapter` (`SandboxedUiAdapter` for the embedded
|
||||
surface), `NappletControlSheet` (native pull-down), `NappletContentServer` +
|
||||
`NappletBlobHttp` + `NappletBlobCache` + `NappletBlobPrefetcher` (verified-blob serving,
|
||||
Tor-routed, content-addressed), `NappletEmbedContract` / `NappletBrowserContract` /
|
||||
`NappletHostContract` (Messenger wire keys), `NappletIpc`, `NappletKeyActions`,
|
||||
`NappletWebViewInsets`.
|
||||
|
||||
**`:amethyst` embed** (`ui/screen/loggedIn/embed`): `EmbeddedTabLayer`, `EmbeddedTabHost`,
|
||||
`EmbeddedTabFactory`, `EmbeddedTabChrome`, `EmbeddedSurfaceController`,
|
||||
`EmbeddedSurfaceTouchHolder`, `EmbeddedImeBridge`, `RemoteImeView`, `TopControlSheet`,
|
||||
`EmbeddedTabPreloader`, `TorToggleButton`.
|
||||
|
||||
**`:amethyst` main-process napplet** (`napplet/`): `NappletBrokerService`,
|
||||
`NappletLauncher`, `NappletLaunchRegistry`, `WebUrlNetworkRegistry`,
|
||||
`NappletNetworkRegistry`, `SandboxForegroundHold`, consent (`NappletConsent*`), gateways,
|
||||
DataStore stores.
|
||||
|
||||
**`:amethyst` launchers/screens**: `BrowserScreen`, `FavoriteWebAppScreen`,
|
||||
`FavoriteNappletScreen`, `FavoriteAppsScreen` + `FavoriteAppsGrid`.
|
||||
|
||||
**`:commons`**: `shell.html`, `shim.js` (`composeResources/files/napplet/`),
|
||||
`FavoriteApp`/`FavoriteAppIcon`, `BottomBarEntry`.
|
||||
|
||||
## Residual / on-device verification
|
||||
|
||||
- All embedded-surface behavior (IME, scroll-gesture claiming, warm-keep across swaps, the
|
||||
Tor shrink) needs emulator/device verification — `SurfaceControlViewHost` + the IME proxy
|
||||
can't be unit-tested.
|
||||
- The IME proxy runs a host-window `EditText`; it ships only editing **state** to the page,
|
||||
never to any key material — the keyless-sandbox boundary is unaffected.
|
||||
- Launch-token lifecycle, coarse persistent grants, and the `resource.bytes` exfil channel
|
||||
remain as tracked in the 2026-06-22 security review.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Embedded text selection — native-Android parity
|
||||
|
||||
> **Status:** shipped — Doc states core feature-complete; host-drawn selection (handles, magnifier, IME proxy) landed in `EmbeddedTabLayer`/`RemoteImeView`.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Status:** core feature-complete. The working set landed in `fix(embed): IME typing +
|
||||
host-drawn text selection for embedded surfaces` (commit `e0a2a9ab81`); subsequent
|
||||
sessions added the magnifier, the `SelectionUiState` refactor, hybrid word+char
|
||||
handle-extend, and a run of polish/bug fixes (below). **The one open platform bug —
|
||||
full-screen round-trip kills selection paint — is now FIXED** (root cause was
|
||||
process-global `pauseTimers()` + an attached `WebView.destroy()`; see that section).
|
||||
|
||||
**2026-06-26 fixes (branch `fix/embed-ime-selection`):**
|
||||
- **No-blink word-select** — the overlay handles + toolbar blinked 2–3× on long-press
|
||||
word-select; cause was the shim's selection-*reveal* scrolls (a `<textarea>` auto-
|
||||
scrolling to show a forming/re-asserted range) tripping the hide-on-scroll path. The
|
||||
shim now timestamps selection activity (`lastSelActivityAt`) and treats a scroll within
|
||||
350 ms as a reveal-scroll (reposition, don't hide, don't re-arm the timer). Plus a
|
||||
`RemoteImeView` range-lost debounce as a safety net. (#2, #3)
|
||||
- **Page selection clears on field focus** — focusing a field left the page-text handles
|
||||
+ Copy bar up (the shim's page `selectionchange` is muted once a field is focused), and
|
||||
being z-above they STOLE the field handle's drag. Fixed: `focusin` emits `pagesel:false`
|
||||
(+ resets the scroll state); host `ImeEvent.Focus` also drops the page overlay. (#2)
|
||||
- **Caret handle drag** moved the loupe but not the caret — the unified `awaitEachGesture`
|
||||
did `change.consume()` BEFORE `change.positionChange()`, and `positionChange()` returns
|
||||
`Offset.Zero` once consumed, so `fp` never accumulated. Fixed with
|
||||
`positionChangeIgnoreConsumed()` (also immune to the sandbox surface consuming the move). (#10)
|
||||
- **Hybrid word+char handle-extend** completed (#5, below).
|
||||
- **Full-screen round-trip corruption FIXED** (was the open bug; see section).
|
||||
- **Embed WebViews follow the APP theme** — separate from selection, but same surfaces:
|
||||
see `embed-webview-prefers-color-scheme-limitation` memo / `EmbedWebViewTheme.kt`.
|
||||
|
||||
## Why we draw selection ourselves
|
||||
|
||||
Editable fields inside an embedded napplet/nsite/browser tab live in the keyless
|
||||
`:napplet` process and render through `SurfaceControlViewHost` /
|
||||
`SandboxedSdkView` (privacy-sandbox UI). A WebView rendered into an off-window
|
||||
surface like this **cannot host the soft keyboard and cannot present Chrome's
|
||||
own text-selection UI** (handles, the floating action-mode toolbar, the
|
||||
magnifier). Chrome detects it has nowhere to put that UI and collapses the
|
||||
selection to the focus endpoint.
|
||||
|
||||
So, exactly like Flutter's `TextInputPlugin` did for its virtual-display era, we
|
||||
relay editing to the main process: an invisible `EditText` (`RemoteImeView`)
|
||||
hosts the keyboard, `shim.js` mirrors DOM selection/caret geometry out over the
|
||||
Messenger channel, and `EmbeddedTabLayer` draws the selection UI in Compose on
|
||||
top of the surface. Everything we want for parity, we draw — the platform gives
|
||||
us nothing here.
|
||||
|
||||
## What native Android gives a text field (the parity target)
|
||||
|
||||
This is the full feature inventory we are cloning, with activation/deactivation
|
||||
rules, so we can check off coverage. Native impl lives in `android.widget.Editor`
|
||||
(+ `SelectionActionModeHelper`, `android.widget.Magnifier`,
|
||||
`PopupTouchHandleDrawable` on the Chrome side).
|
||||
|
||||
| # | Native feature | Activates | Deactivates | Our status |
|
||||
|---|----------------|-----------|-------------|------------|
|
||||
| 1 | **Insertion handle** (the teardrop "blob" under the caret) | tap in editable text; tap again to re-show | typing, scroll start, focus loss, ~4s inactivity timeout | ✅ `InsertionHandle`. **Native availability rule now matched (2026-06-25):** only shown when the field is NON-EMPTY (`Editor` gates the handle behind `text.length() > 0`, via `SelectionUiState.fieldHasText`) — fixes it popping up on focus of an empty box; hides on typing (`onEdited`), scroll (`scrolling`), focus loss, and ~4s inactivity (`hideCaret` timeout), re-showing on the next tap — via an explicit `ime.carettap` shim signal (DOM `click`), so a tap that doesn't move the caret still re-shows it. Device-verified. |
|
||||
| 2 | **Selection handles** (asymmetric left/right teardrops) | long-press word, double-tap word, drag-extend | tap-collapse, typing, new selection | ✅ `SelectionHandle(isStart)` + drag-to-extend, for BOTH plain page text (`pageExtend`) AND in-field `<input>`/`<textarea>` selections (`fieldExtend`, 2026-06-25). The shim reports the selection's caret feet (`sx/sb`,`ex/eb`, flagged `rng`) via the same mirror-div as the caret; the host holds the range geometry separately so Chrome's transient collapse-to-caret (the re-assert fight) doesn't yank the handles to the field edges. **Tap-to-collapse (2026-06-25):** a single tap inside a selection dismisses it to a caret at the tapped offset + insertion handle — the shim's `click` handler collapses explicitly via `offsetFromPoint` (off-window Chrome doesn't do it itself). Device-verified. |
|
||||
| 3 | **Floating toolbar** (Cut/Copy/Paste/Select-All/Share/…) | selection made, or tap insertion handle (Paste/Select-All) | scroll/fling (hides, returns on settle), handle drag (hides), tap-collapse | ⚠️ `EmbeddedSelectionToolbar` (Cut/Copy/Paste/Select-All). **Hide-during-handle-drag ✅ device-verified.** Hide-during-scroll via `SelectionUiState.scrolling` (shim `ime.scroll` + re-report on settle). **2026-06-26: the scroll path was hardened** — selection-*reveal* scrolls (forming/re-asserting a range auto-scrolls a `<textarea>`) are no longer treated as user scrolls (they blinked the overlays); the shim guards them via `lastSelActivityAt` and the hide self-heals instead of re-arming. (User content-scroll-hide still wants a clean on-device pass.) Still missing: overflow, Share/Web-Search/process-text. |
|
||||
| 4 | **Magnifier / loupe** (the zoom bubble above the finger while dragging a handle or the caret) | finger down + moving on a handle or the caret | finger up | ✅ **Built (2026-06-25).** `Magnifier` bubble in [EmbeddedMagnifier.kt] follows the dragged caret/selection handle, showing live magnified page pixels captured in the `:napplet` provider and shipped over IPC ([EmbeddedMagnifierProbe], option B). Both embed paths wired (browser + napplet); verified on device for the browser path. Capture Y is locked to the caret/selection line (X follows the finger). Possible further polish: RGB_565 to cut encode, themed crosshair, clamp capture X to the line so a fast drag past EOL doesn't show blank. |
|
||||
| 5 | **Word-granularity long-press** then char-extend | long-press | — | ✅ HYBRID word+char in-field handle-extend (2026-06-25, completed): `fieldExtend` keeps per-drag state (`fieldDragWordEnd`/`fieldDragWordStart`, reset on a >250ms gap or edge switch). The drag baselines at the current selection edge; sweeping PAST that word's far boundary snaps to the next whole word (`wordEndAt`/`wordStartAt`), while moving within/back from the furthest-reached word gives CHARACTER precision — so you can fine-tune to a single character (the previously-missing "then char" mode). Page-text extend stays char (no offset model). |
|
||||
| 6 | **Double-tap = word, long-press = word, (triple-tap/drag = paragraph)** | tap count | — | ✅ double-tap + long-press both select a word (2026-06-25). Chrome word-selects on the 2nd tap, then abandons it by collapsing to the end (off-window quirk); the host re-assert restores it. The shim `click` handler DEFERS its tap-to-collapse ~300ms and the real `dblclick` cancels that timer, so the word selection survives (a timing-only guard was flaky ~40%). Triple-tap/paragraph not done. |
|
||||
| 7 | **Smart selection / entity expansion** (`TextClassifier`: phone, URL, address, date → entity actions in toolbar) | selection lands on an entity | — | ❌ not built (low priority) |
|
||||
| 8 | **Drag selected text** (long-press a selection → drag-and-drop to move) | long-press on existing selection | drop | ❌ not built (low priority) |
|
||||
| 9 | **Auto-scroll while dragging to a viewport edge** | handle dragged near top/bottom edge | finger leaves edge / up | ✅ works (2026-06-25, user-confirmed). The drag driver (`onMagnify`) detects the finger in the surface's top/bottom edge zone and sends `ime.autoscroll`; the shim scrolls the textarea (else the window) and re-reports geometry, flagged so the hide-on-scroll path doesn't fire. Scrolls per drag-move in the edge zone (not on a perfectly-held finger). **Fixed alongside:** the nav drawer's left-edge swipe was hijacking the edge drag — `EmbeddedSelectionDrag.dragging` (set by `onMagnify`) now suspends the drawer's `gesturesEnabled` while a handle is dragged. |
|
||||
| 10 | **Caret snapping to character boundaries** | always during caret/handle drag | — | ✅ via `offsetFromPoint` binary search + Y-clamp. **2026-06-26 fix:** the insertion-handle drag stopped moving the caret (loupe showed, caret frozen) — the unified `awaitEachGesture` consumed the pointer change BEFORE reading `positionChange()`, which returns `Offset.Zero` once consumed, so the accumulated finger position never advanced. Now reads `positionChangeIgnoreConsumed()` first. |
|
||||
| 11 | **Themed handle/caret drawables + blink** | always | — | ✅/⚠️ The host-drawn handles use `colorScheme.primary` — which IS the native `textSelectHandle`/accent color — so the handle drawables are themed (the actionable part). The caret bar + selection-highlight are drawn by Chrome inside the off-window surface: the caret already blinks natively, and theming its color/the highlight would mean injecting CSS into arbitrary third-party pages (intrusive; `::selection` was already ruled out as non-painting), so those are intentionally left to Chrome. |
|
||||
| 12 | **Insertion-handle Paste/Select-All mini-popup** | tap the insertion handle | tap elsewhere | ✅ built (2026-06-25). Tapping the bare insertion handle toggles a Paste/Select-All bar above the caret (`SelectionUiState.insertionPopup`, toggled from the handle's unified tap/drag gesture); tap-elsewhere/typing/blur/selection/scroll dismiss it. Fixed a latent bug: toolbar items now consume the *down* (not just the up) so the tap doesn't bleed through to the surface and blur the field. Device-verified (Select-all selects all text, field stays focused). |
|
||||
|
||||
Legend: ✅ done · ⚠️ partial · ❌ missing.
|
||||
|
||||
### Activation/deactivation is the hard part
|
||||
|
||||
Most of the bugs we already fixed were activation-timing bugs (cursor-jumps-to-end,
|
||||
collapse-on-tap, focus-transfer races). The remaining features each carry their
|
||||
own state machine.
|
||||
|
||||
**Done (2026-06-25): `SelectionUiState`** (`SelectionUiState.kt`) now centralizes
|
||||
what used to be scattered flags in `EmbeddedTabLayer` (`showInsertionHandle`,
|
||||
`showSelectionToolbar`, `fieldGeometry`, `rangeFieldGeometry`, `pageSelection`).
|
||||
It holds the three mutually-exclusive contexts (insertion caret / in-field range /
|
||||
page-text range) plus the transient modifiers `dragging` and `scrolling`, and
|
||||
exposes derived visibility (`insertionHandle`, `fieldHandles`, `fieldToolbar`,
|
||||
`pageHandles`, `pageToolbar`) so the rules are expressed once:
|
||||
- **toolbar hides while a handle is dragged** (`dragging`, set from the same
|
||||
`OnMagnify` lifecycle that drives the loupe) — the dragged handle also hides its
|
||||
own teardrop (the loupe stands in), like Android; the other handle stays.
|
||||
- **all overlays hide while scrolling** (`scrolling`, from the shim's `ime.scroll`);
|
||||
the shim re-reports geometry just before `active=false` so they reappear
|
||||
repositioned.
|
||||
Still emergent / TODO: insertion-handle auto-timeout, tap-to-re-show.
|
||||
|
||||
**Selection-blink fix (2026-06-25).** A field selection — especially in a `<textarea>` —
|
||||
flickered: off-window Chrome abandons the selection by collapsing the caret to an
|
||||
endpoint every ~25 ms, and the FIELD re-assert round-tripped through the host EditText
|
||||
(`RemoteImeView.onPageState` → `ime.set` → page), leaving a visible collapsed frame each
|
||||
cycle. Fix: re-assert field selections **synchronously in the shim's `selectionchange`
|
||||
handler** (mirror of the page-text path that never blinked) — `lastFieldRange`/`lastFieldAt`
|
||||
tracked via `noteSel()`, and a collapse-to-endpoint within 1500 ms is reverted with `setSel`
|
||||
(guarded, not re-reported) so it reverts before paint. The host re-assert stays as a
|
||||
fallback. Device-verified: textarea selection is stable; input select still works.
|
||||
|
||||
## Priority order for parity work
|
||||
|
||||
1. **Magnifier (#4).** Biggest perceived gap. We already report caret/handle
|
||||
geometry; the magnifier needs a *magnified pixel view of the surface* at the
|
||||
drag point. Options:
|
||||
- **A. Compose-side zoom of a surface snapshot. ❌ RULED OUT (spiked 2026-06-25).**
|
||||
The surface is a `SurfaceControlViewHost` — we can't trivially `Bitmap`-grab a
|
||||
remote surface from the main process. We spiked `PixelCopy.request(SurfaceView, …)`
|
||||
against the live embedded surface (`SurfaceMagnifierProbe`, wired into
|
||||
`EmbeddedTabLayer` behind `BuildConfig.DEBUG`, fired on field focus). The capture
|
||||
target is the privacysandbox `ContentView extends SurfaceView` — the only real
|
||||
child of `SandboxedSdkView` once the session opens. **Result on a clearly-painted
|
||||
surface (1080×2088): every capture returns `ERROR_SOURCE_NO_DATA`** (center
|
||||
region, repeated 3×). Cause: the WebView pixels live in a *child* `SurfaceControl`
|
||||
reparented under the SurfaceView via `ContentView.setChildSurfacePackage(...)`; the
|
||||
host SurfaceView's *own* buffer is never drawn into, so `PixelCopy` on the parent
|
||||
reads an empty buffer. Host-side pixel capture of the sandboxed content is not
|
||||
available. (A `PixelCopy.request(Window, …)` against the host window would also
|
||||
miss it — the sandbox layer is a *separate* SurfaceControl z-ordered below the
|
||||
window.)
|
||||
- **B. Capture in `:napplet` and ship the loupe content. ✅ SPIKED & VIABLE
|
||||
(2026-06-25).** Inside the keyless provider the WebView IS a real in-window view, so
|
||||
`WebView.draw(Canvas)` into a software bitmap renders real DOM pixels. Spike added
|
||||
`MSG_MAGNIFIER_REQUEST`/`MSG_MAGNIFIER_FRAME` to `NappletBrowserContract`:
|
||||
`NappletBrowserService.onMagnifierRequest` draws a zoomed slice
|
||||
(`canvas.scale(zoom); translate(-(cx-box/2), -(cy-box/2)); webView.draw(canvas)`),
|
||||
PNG-encodes it, and ships the bytes back; `EmbeddedBrowserController` (now also an
|
||||
`EmbeddedMagnifierProbe`) requests on focus and `EmbeddedTabLayer` logs the result.
|
||||
**10-frame burst, 160px source × 1.5× zoom → 240×240 PNG, center `#FF111111`
|
||||
(real opaque content):** provider draw 0.7–1.2 ms steady (≈5 ms cold), provider
|
||||
total draw+PNG 3–4 ms steady (≈12 ms cold), client round-trip 4–8 ms steady
|
||||
(occasional ~18 ms), payload 8–15 KB (far under the 1 MB Binder limit). Comfortably
|
||||
within a frame budget if throttled to ~30 fps.
|
||||
|
||||
**✅ Real loupe shipped (2026-06-25).** `MagnifierUiState` + `Magnifier`
|
||||
(`EmbeddedMagnifier.kt`); the caret/selection handles call an `OnMagnify` callback
|
||||
on drag start/move/end; `EmbeddedTabLayer` tracks the drag point, throttles capture
|
||||
requests to one in flight (100 ms timeout), decodes each `MagnifierFrame` to an
|
||||
`ImageBitmap`, and floats the bubble above the finger (clamped, flips below near the
|
||||
top). Capture mirrored onto BOTH embed paths (browser:
|
||||
`NappletBrowserContract`/`NappletBrowserService`/`EmbeddedBrowserController`;
|
||||
napplet: `NappletEmbedContract`/`NappletHostService`/`EmbeddedNappletController`).
|
||||
Verified on device (browser): drag → bubble shows live magnified "ello world" with
|
||||
the caret, centered on the line → follows finger → hides on release. The dead
|
||||
Option-A probe (`SurfaceMagnifierProbe`) was removed. **Polish done (2026-06-25):**
|
||||
capture Y is locked to the authoritative caret/selection line (the handles pass a
|
||||
`lineHalfPx` so the box centers on the line, not the finger or the caret foot); X
|
||||
still follows the finger. Remaining nice-to-haves: RGB_565/raw to cut PNG encode,
|
||||
themed crosshair, clamp capture X to the line so a fast drag past EOL isn't blank,
|
||||
reuse one off-screen bitmap.
|
||||
- **Gesture-routing caveat (found during the spike).** The host-drawn handles'
|
||||
drag is fragile: the sandbox `ContentView.onTouchEvent` always returns `true`, so
|
||||
via Compose's `AndroidView` interop it consumes the drag-move pointer and cancels
|
||||
the overlay handle's `detectDragGestures` (synthetic `adb` drags on the handle
|
||||
never produced an `onDrag`). The magnifier trigger should ride the existing
|
||||
caret/selection-move path (which already round-trips through the shim), not a fresh
|
||||
Compose drag layered over the surface.
|
||||
2. **Toolbar state rules (#3 hide-during-drag/scroll) + insertion-handle popup
|
||||
(#12).** Pure Compose/state work, no platform unknowns. Do alongside the
|
||||
`SelectionUiState` refactor.
|
||||
3. **Handle inactivity timeout + tap-to-re-show (#1).** Small.
|
||||
4. **Double-tap-to-select (#6)** and **word-granularity drag (#5).**
|
||||
5. **Auto-scroll (#9), themed drawables/blink (#11).**
|
||||
6. Defer: smart selection (#7), drag-to-move (#8).
|
||||
|
||||
## ✅ FIXED — full-screen round-trip corrupted the embedded WebViews (2026-06-26)
|
||||
|
||||
**Symptom (was).** Open an embedded field's page in its own full-screen activity,
|
||||
then `back` to the embedded version. From then on **every** embedded surface in the
|
||||
`:napplet` process was broken — and it was far more than the selection highlight: DOM
|
||||
reads returned empty (a field that visibly showed text reported `value == ""`, so typing
|
||||
prepended at offset 0 and backspace did nothing), DNS died (`ERR_NAME_NOT_RESOLVED`), the
|
||||
selection highlight stopped painting, and IME broke. The page showed a stale last frame
|
||||
over a functionally-dead renderer.
|
||||
|
||||
**Root cause — two process-global defects in the full-screen hosts** corrupting the
|
||||
shared multiprocess WebView state the embedded surfaces rely on. (Diagnosed with temporary
|
||||
logging: page console → logcat, all `onReceivedError`/`onReceivedHttpError`, and the shim's
|
||||
focused-field type/caret. The user's own insight — "the activity is gone but the service
|
||||
doesn't come back; am I using something from the activity?" — pointed straight at it.)
|
||||
|
||||
1. **`pauseTimers()`/`resumeTimers()` are PROCESS-GLOBAL** (they pause JS, layout and
|
||||
parsing timers for *every* WebView in the process). `NappletBrowserActivity` /
|
||||
`NappletHostActivity` `onPause`/`onResume` and `NappletHostService`'s embed
|
||||
pause/resume all called them on their own lifecycle — so returning from full-screen
|
||||
*froze* the embedded surfaces, which had no resume of their own. **Fix:** removed ALL
|
||||
process-global timer calls; rely only on per-WebView `onPause()`/`onResume()` (which
|
||||
pause just that surface's JS/DOM — still meets the napplet background-security goal).
|
||||
2. **`WebView.destroy()` while still attached to the window** corrupts the shared
|
||||
multiprocess renderer (`cr_AwContents: "WebView.destroy() called while WebView is still
|
||||
attached to window"`). **Fix:** `stopLoading()` + `(parent as ViewGroup).removeView(...)`
|
||||
before `destroy()` in both full-screen activities' `onDestroy`.
|
||||
|
||||
Device-verified: the round-trip no longer corrupts the embeds. This supersedes the old
|
||||
"recreate the session on return" hypothesis and the ruled-out attempts (`::selection` CSS,
|
||||
surface resize, focus-cycle, `MSG_WAKE`) — none were the real cause.
|
||||
|
||||
## ✅ Embed WebViews follow the app theme (2026-06-26, separate concern)
|
||||
|
||||
Not text-selection, but the same off-window surfaces: embedded (and full-screen) WebViews
|
||||
rendered web content in the *device* theme, ignoring the app's DARK/LIGHT preference.
|
||||
**Root cause:** WebView's dark decision (`prefers-color-scheme` via algorithmic darkening)
|
||||
reads the context's **theme** (`?android:attr/isLightTheme`), NOT just `Configuration.uiMode`
|
||||
— and the off-window `SurfaceControlViewHost` surface context carries neither. The old
|
||||
`applyNightMode` used `UiModeManager.setNightMode` (permission-gated no-op). **Fix:** build
|
||||
every WebView from `nightThemedContext()` — `ContextThemeWrapper(createConfigurationContext(
|
||||
<night|day>), Theme.DeviceDefault.DayNight)` for the resolved theme — shared in
|
||||
`nappletHost/.../EmbedWebViewTheme.kt`, used by both embed services + both full-screen
|
||||
activities. The full debugging arc (config-only context fails; `setForceDark` gone at
|
||||
targetSdk 37; `setApplicationNightMode` does nothing; it's the unthemed context, not the
|
||||
process boundary) is in the `embed-webview-prefers-color-scheme-limitation` memo. Upstream
|
||||
WebView is still buggy here (a cross-process SCVH WebView ignores `uiMode`); the
|
||||
theme-wrapper is the app-side workaround.
|
||||
|
||||
## How to test
|
||||
|
||||
The on-device harness lives at **`tools/ime-test/`** (`index.html` + `README.md`).
|
||||
It's a single page with an `<input>`, a `<textarea>`, and an on-page log that
|
||||
timestamps focus/selection/input/composition events, **paint latency**,
|
||||
long-tasks, and main-thread blocks — the instrumentation that pinned the erase,
|
||||
caret-jump, and first-letter-freeze bugs, and exactly what we'll want when
|
||||
profiling the magnifier.
|
||||
|
||||
Run it (full details in `tools/ime-test/README.md`):
|
||||
|
||||
1. `cd tools/ime-test && python3 -m http.server 8765`
|
||||
2. Reach it: emulator → `http://10.0.2.2:8765`; USB device →
|
||||
`adb reverse tcp:8765 tcp:8765` then `http://localhost:8765`.
|
||||
3. Open that URL in the **in-app browser** to load it as an *embedded* tab. (Opening
|
||||
the same URL full-screen and pressing `back` used to reproduce the
|
||||
highlight/corruption bug — now fixed; it's still the regression test for it.)
|
||||
|
||||
Console log lines are tagged `[ImeDiag]` and surface in `adb logcat` (the
|
||||
`:napplet` process owns the WebView console). This is a dev tool — nothing under
|
||||
`tools/` ships, which is why those diagnostic strings are kept out of `src/`.
|
||||
|
||||
## Key files
|
||||
|
||||
- `commons/src/commonMain/composeResources/files/napplet/shim.js` — DOM bridge.
|
||||
Geometry sources: `caretCoords` (287), `offsetFromPoint` (318), `fieldGeom`
|
||||
(336), `reportState` (360), `pageGeom` (462), `sendPageSel` (470), `pageExtend`
|
||||
(494). A magnifier built via option B would add a loupe-render here.
|
||||
- `amethyst/.../embed/EmbeddedTabLayer.kt` — the Compose overlay. `InsertionHandle`
|
||||
(557), `SelectionHandle` (496), `EmbeddedSelectionToolbar` (616),
|
||||
`PageSelectionOverlay` (460), the `showInsertionHandle`/`showSelectionToolbar`
|
||||
state to be folded into a `SelectionUiState`. Magnifier popup (option A) lands
|
||||
here.
|
||||
- `amethyst/.../embed/RemoteImeView.kt` — invisible host `EditText`; selection
|
||||
re-assert + copy/cut/paste/select-all + edit callbacks.
|
||||
- `amethyst/.../embed/EmbeddedImeBridge.kt` — `SelectionGeometry` (caret + handle
|
||||
feet + viewport), `ImeEvent.{Focus,State,PageSelection}`, `parseSelectionGeometry`.
|
||||
- `amethyst/.../{browser/EmbeddedBrowserController,favorites/EmbeddedNappletController}.kt`
|
||||
— parse `ime.pagesel` + geometry off the Messenger channel.
|
||||
- Context: `amethyst/plans/2026-06-19-napplet-sandbox-host.md`,
|
||||
`2026-06-24-napplet-embedded-tabs.md`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Web-app naming overhaul (favorites / browser / app surfaces)
|
||||
|
||||
> **Status:** shipped — Renames applied — `WebAppScreen`, `NostrAppScreen`, `EmbeddedWebAppController`, `FavoriteAppsScreen` all present.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
## Problem
|
||||
|
||||
The in-app "app" surfaces had colliding, sometimes inaccurate names:
|
||||
|
||||
- `software_apps` (NIP-89 native Android apps, an install-from-a-store flow) showed as
|
||||
**"Apps"** — colliding with the in-app favorites, which showed as **"Favorite apps"**.
|
||||
- The **host screens** that render a single web client / nSite / nApplet were named
|
||||
`FavoriteWebAppScreen` / `FavoriteNappletScreen`. But they open *any* url/coordinate,
|
||||
favorited or not — "Favorite" described how they happened to be reached (a pinned
|
||||
bottom-bar tab), not what they are. And `FavoriteNappletScreen` also renders **nSites**
|
||||
(website-mode), so "Napplet" was narrower than reality.
|
||||
- Three vocabularies for two model cases: model `WebUrl`/`NostrApp`, route
|
||||
`FavoriteWebApp`/`FavoriteNostrApp`, screen `FavoriteWebApp`/`FavoriteNapplet`.
|
||||
|
||||
## Taxonomy (decided with maintainer)
|
||||
|
||||
User-facing terms, now distinct:
|
||||
|
||||
| Concept | User-facing | What it is |
|
||||
|---|---|---|
|
||||
| Native app store | **App Store** | NIP-89 native Android apps you install off-device |
|
||||
| Nostr web client | **Web app** | an `https://` client that runs in-app (WebView) |
|
||||
| nApplet | **nApplet** | NIP-5D sandboxed JS app |
|
||||
| nSite | **nSite** | NIP-5A static website |
|
||||
| Pinned set | **Favorite** | a cross-cutting attribute (the star), *not* a screen |
|
||||
|
||||
Code axis (favorites / route / screen / embedded-controller layer): **`WebApp`** (url-based,
|
||||
no nostr identity) and **`NostrApp`** (coordinate-based nSite *or* nApplet). The cross-process
|
||||
sandbox infra (`napplet/`, `nappletHost/`, `NappletHostService`, `NappletEmbedContract`)
|
||||
keeps **"Napplet"** — that process genuinely is the napplet host (it serves nSites in
|
||||
website-mode too, but the host *is* the napplet runtime).
|
||||
|
||||
"Favorite" is reserved for the **grid of pinned apps** (`FavoriteAppsScreen` /
|
||||
`Route.FavoriteApps`) and the star toggle — the only things that are actually about favorites.
|
||||
|
||||
## Renames
|
||||
|
||||
Routes: `FavoriteWebApp(url)` → `WebApp(url)`; `FavoriteNostrApp(coordinate)` → `NostrApp(coordinate)`.
|
||||
Screens: `FavoriteWebAppScreen` → `WebAppScreen`; `FavoriteNappletScreen` → `NostrAppScreen`.
|
||||
Controllers: `EmbeddedBrowserController` → `EmbeddedWebAppController`;
|
||||
`EmbeddedNappletController` → `EmbeddedNostrAppController`.
|
||||
Factory: `acquireBrowser`/`browserId` → `acquireWebApp`/`webAppId`;
|
||||
`acquireNapplet`/`nappletId` → `acquireNostrApp`/`nostrAppId`.
|
||||
Model: `FavoriteApp.WebUrl` → `FavoriteApp.WebApp`. Registry: `WebUrlNetworkRegistry` →
|
||||
`WebAppNetworkRegistry`.
|
||||
|
||||
Strings: `software_apps` "Apps" → "App Store"; `favorite_apps_empty` reworded to name
|
||||
nApplet/nSite.
|
||||
|
||||
## Stable (do NOT change — persistence / wire compat)
|
||||
|
||||
- Favorite `id` prefixes `"url:"` / `"nostr:"` (persisted dedup + bottom-bar keys).
|
||||
- DataStore names `"favorite_apps"`, `"weburl_network"`; serialized type tags `"url"` / `"nostr"`.
|
||||
- `napplet/` + `nappletHost/` sandbox infra names and IPC contracts.
|
||||
|
||||
## Follow-ups (not in this pass)
|
||||
|
||||
- Move `NostrAppScreen` + `EmbeddedNostrAppController` out of the `ui/...favorites/`
|
||||
package (they are no longer favorites-specific) into a host package alongside the web side.
|
||||
- Recent nApplets / nSites (parallel to the browser's recent web apps), surfaced on the
|
||||
discovery screens.
|
||||
@@ -0,0 +1,80 @@
|
||||
# nSite / nApplet favorite icons
|
||||
|
||||
> **Status:** shipped — `quartz/.../NappletIconPath.kt` and `amethyst/.../favorites/NappletFavoriteIcon.kt` are in tree.
|
||||
> _Audited 2026-06-30._
|
||||
|
||||
**Date:** 2026-06-26
|
||||
**Status:** implemented (pending on-device verification of the blob image-load path)
|
||||
|
||||
## Problem
|
||||
|
||||
When a user favorites a plain web app and pins it to the bottom nav, the generic globe
|
||||
icon is replaced by the **site's favicon**. Favorited nSites (NIP-5A) and nApplets
|
||||
(NIP-5D) did not get the same treatment — they fell back to the generic grid glyph.
|
||||
|
||||
## Why the webapp trick doesn't carry over
|
||||
|
||||
The webapp favicon is **captured live** from the WebView that loads the page
|
||||
(`NappletBrowserActivity.onReceivedIcon` → IPC `MSG_RECORD_ICON` →
|
||||
`BrowserIconRegistry`, keyed by host). That works because, for a plain webapp, the site
|
||||
**is** the WebView's main frame.
|
||||
|
||||
nSites/nApplets render differently: they always load inside a **cross-origin sandboxed
|
||||
iframe** under a trusted shell document (`commons/.../composeResources/files/napplet/shell.html`,
|
||||
`iframe.src = '__APP_ORIGIN__/'`). `WebChromeClient.onReceivedIcon` only reports the
|
||||
**main frame's** favicon — i.e. the shell (`<title>Napplet</title>`, no icon), never the
|
||||
applet's iframe. So the live-capture approach is structurally blind to the app's own
|
||||
favicon here, and mirroring it would silently show nothing.
|
||||
|
||||
## Approach: derive the icon from the manifest's own bundled blobs
|
||||
|
||||
An nSite/nApplet ships its files as `path → sha256` (`path` tags). Its icon is almost
|
||||
always one of those blobs (a conventional `/favicon.png`, `/icon.png`,
|
||||
`/apple-touch-icon.png`, …). We already download + sha256-verify every manifest blob into
|
||||
a shared, content-addressed cache (`NappletBlobCache` / `NappletBlobPrefetcher`,
|
||||
Tor-routed). So the icon can be resolved from the manifest itself — no WebView, no iframe
|
||||
problem, content-addressed and verifiable, on the same private network path as everything
|
||||
else.
|
||||
|
||||
### Resolution priority (per favorite)
|
||||
|
||||
1. **Captured/bundled blob** (`iconModel`) — the conventional icon path picked from the
|
||||
manifest's blobs, loaded from the verified cache as a `file://` model.
|
||||
2. **Manifest `icon` tag** (`FavoriteApp.iconUrl`) — the publisher-declared icon URL
|
||||
(already wired before this change).
|
||||
3. **Type glyph** — grid (nostr app) / globe (web), already the fallback in
|
||||
`FavoriteAppIcon`.
|
||||
|
||||
Blob beats the `icon` URL deliberately: the blob is verified and rides the site's
|
||||
Tor-routed path, whereas a remote `icon` URL would be a clearnet fetch by Coil. Both still
|
||||
beat the glyph.
|
||||
|
||||
## Changes
|
||||
|
||||
- **quartz** `nip5aStaticWebsites/NappletIconPath.kt` (new) — pure, unit-tested heuristic
|
||||
that picks the best icon `PathTag` from a manifest's `path` tags (priority list of
|
||||
conventional names + a loose raster fallback; prefers shallower paths; raster formats
|
||||
over `.ico`/`.svg`). Tests in `NappletIconPathTest.kt`.
|
||||
- **quartz** — `NappletManifest.iconBlob()` (covers nApplet kinds) and
|
||||
`RootSiteEvent.iconBlob()` / `NamedSiteEvent.iconBlob()` (nSite kinds) delegate to it.
|
||||
- **amethyst** `favorites/NappletFavoriteIcon.kt` (new) — `rememberNappletIconModel(coordinate)`:
|
||||
re-resolves the live event from `LocalCache`, picks its icon blob, ensures it's in the
|
||||
shared cache (prefetching on demand, off the composition thread), and returns a `file://`
|
||||
Coil model. Returns null until the blob is on disk (icon appears on next recomposition).
|
||||
- **amethyst** — `AppBottomBar` and `FavoriteAppsScreen` now resolve that model for
|
||||
`FavoriteApp.NostrApp` and pass it as `iconModel`, exactly as they already did with the
|
||||
captured favicon for `FavoriteApp.WebApp`.
|
||||
|
||||
`FavoriteApp` is unchanged (no persistence migration): the icon is resolved from the live
|
||||
manifest at render time, consistent with the existing rule that a `NostrApp` favorite is
|
||||
only usable while its event is resolvable in `LocalCache`.
|
||||
|
||||
## Follow-ups / not done
|
||||
|
||||
- **On-device verification** of the blob → Coil image load (the heuristic + wiring are
|
||||
verified by unit tests + compilation; the actual image render needs a device).
|
||||
- **HTML `<link rel="icon">` parsing.** The heuristic matches by conventional file name.
|
||||
A future pass could fetch + parse the index blob to honor a non-conventional icon path.
|
||||
- **`.svg` / `.ico` decoding.** Listed as low-priority candidates; if Coil can't decode
|
||||
them the `FavoriteAppIcon` error fallback shows the glyph, so it's harmless but not
|
||||
guaranteed to render.
|
||||
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.service.cashu.CashuParser
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -48,12 +48,12 @@ class CashuBTest {
|
||||
assertEquals(2, parsed.proofs[0].amount)
|
||||
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
|
||||
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
|
||||
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
|
||||
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].c)
|
||||
|
||||
assertEquals(8, parsed.proofs[1].amount)
|
||||
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
|
||||
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
|
||||
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
|
||||
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,18 +68,18 @@ class CashuBTest {
|
||||
assertEquals(1, parsed[0].proofs[0].amount)
|
||||
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
|
||||
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
|
||||
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
|
||||
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].c)
|
||||
|
||||
assertEquals(3, parsed[1].totalAmount)
|
||||
assertEquals(2, parsed[1].proofs[0].amount)
|
||||
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
|
||||
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
|
||||
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
|
||||
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].c)
|
||||
|
||||
assertEquals(1, parsed[1].proofs[1].amount)
|
||||
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
|
||||
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
|
||||
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
|
||||
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].c)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@@ -93,11 +93,11 @@ class CashuBTest {
|
||||
assertEquals(64, parsed[0].proofs[0].amount)
|
||||
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
|
||||
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
|
||||
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
|
||||
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].c)
|
||||
|
||||
assertEquals(32, parsed[0].proofs[1].amount)
|
||||
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
|
||||
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
|
||||
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
|
||||
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].c)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-12
@@ -54,9 +54,10 @@ import org.junit.runner.RunWith
|
||||
* Asserts the three contracts the feature relies on:
|
||||
* 1. `feedKey` is mode-discriminated so each pinned tab caches independently.
|
||||
* 2. `followList()` honors `modeOverride` when set; falls back to the spinner setting otherwise.
|
||||
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for
|
||||
* `TopFilter.Global` (so `isGlobal()` is true, allowing non-follower notifications through),
|
||||
* and a non-Global filter for `TopFilter.AllFollows` (forcing the follow-membership gate).
|
||||
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for both
|
||||
* `TopFilter.Global` and `TopFilter.Selected` (so `isGlobal()` is true, allowing
|
||||
* non-follower notifications through), and a non-Global filter for `TopFilter.AllFollows`
|
||||
* (forcing the follow-membership gate).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationFeedFilterModeOverrideTest {
|
||||
@@ -66,13 +67,16 @@ class NotificationFeedFilterModeOverrideTest {
|
||||
|
||||
private val client =
|
||||
NostrClient(
|
||||
OkHttpWebSocket.Builder {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
},
|
||||
OkHttpWebSocket.Builder(
|
||||
httpClient = {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
},
|
||||
canDial = { true },
|
||||
),
|
||||
scope,
|
||||
)
|
||||
|
||||
@@ -130,8 +134,8 @@ class NotificationFeedFilterModeOverrideTest {
|
||||
fun followListFallsBackToSpinnerWhenOverrideNull() {
|
||||
val spinner = NotificationFeedFilter(account)
|
||||
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.Global
|
||||
assertEquals(TopFilter.Global, spinner.followList())
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.Selected
|
||||
assertEquals(TopFilter.Selected, spinner.followList())
|
||||
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.AllFollows
|
||||
assertEquals(TopFilter.AllFollows, spinner.followList())
|
||||
@@ -151,6 +155,22 @@ class NotificationFeedFilterModeOverrideTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFilterParamsForSelectedOverrideReportsGlobal() {
|
||||
val selected = NotificationFeedFilter(account, TopFilter.Selected)
|
||||
|
||||
val params = selected.buildFilterParams(account)
|
||||
|
||||
// Selected rides the same GlobalFeedFlow relay set as Global, so it must
|
||||
// also report isGlobal and let non-followers through; the difference is
|
||||
// that acceptableEvent applies the per-kind relevance heuristics, which
|
||||
// Global skips.
|
||||
assertTrue(
|
||||
"Selected mode's FilterByListParams must report isGlobal so non-followers pass the gate",
|
||||
params.isGlobal(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFilterParamsForAllFollowsOverrideIsNotGlobal() {
|
||||
val following = NotificationFeedFilter(account, TopFilter.AllFollows)
|
||||
|
||||
+10
-7
@@ -58,13 +58,16 @@ class ThreadDualAxisChartAssemblerTest {
|
||||
|
||||
val client =
|
||||
NostrClient(
|
||||
OkHttpWebSocket.Builder {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
},
|
||||
OkHttpWebSocket.Builder(
|
||||
httpClient = {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
},
|
||||
canDial = { true },
|
||||
),
|
||||
scope,
|
||||
)
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.images
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThumbnailDiskCacheInstrumentedTest {
|
||||
private val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var cache: ThumbnailDiskCache
|
||||
private lateinit var sourceFile: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
|
||||
cache = ThumbnailDiskCache(cacheDir)
|
||||
sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg")
|
||||
val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
|
||||
sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
bitmap.recycle()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheDir.deleteRecursively()
|
||||
sourceFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generatesThumbnailFromJpeg() {
|
||||
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg"
|
||||
|
||||
assertTrue("generateFromFile must return true for a valid JPEG", cache.generateFromFile(url, sourceFile))
|
||||
assertNotNull("load() must return a non-null Bitmap for a cached JPEG", cache.load(url))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreatesCacheDirClearedAtRuntime() {
|
||||
// The system (cache trim) or the user (Settings > Clear cache) can delete
|
||||
// the cache dir while the app runs; the next write must recreate it
|
||||
// instead of failing with ENOENT.
|
||||
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg"
|
||||
assertTrue(cacheDir.deleteRecursively())
|
||||
|
||||
assertTrue(
|
||||
"generateFromFile must recreate the cache dir after it is cleared at runtime",
|
||||
cache.generateFromFile(url, sourceFile),
|
||||
)
|
||||
assertNotNull("load() must return the thumbnail written after dir recreation", cache.load(url))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<!-- Health Connect data store (Android 8–13 ships it as a separate system app) -->
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -73,6 +78,16 @@
|
||||
<!-- Adds Geohash to posts if active -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Reads finished workouts from Health Connect to suggest a kind 1301 post.
|
||||
Read-only; requested on demand, never on cold start. -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
|
||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_ELEVATION_GAINED" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -90,7 +105,6 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
@@ -109,6 +123,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -246,6 +265,22 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
|
||||
the permission request fails silently (no dialog appears). The system launches it,
|
||||
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
|
||||
MainActivity. Android 13 and lower use the ACTION_SHOW_PERMISSIONS_RATIONALE
|
||||
intent-filter declared on MainActivity above. -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".ui.MainActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
@@ -366,6 +401,71 @@
|
||||
android:name=".service.call.CallNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
|
||||
<activity
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
|
||||
android:process=":napplet"
|
||||
android:exported="false"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Amethyst" />
|
||||
|
||||
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
|
||||
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
|
||||
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
|
||||
<activity
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
|
||||
android:process=":napplet"
|
||||
android:exported="false"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:documentLaunchMode="intoExisting"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Amethyst" />
|
||||
|
||||
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
|
||||
<activity
|
||||
android:name=".napplet.NappletConsentActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletConnectActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- Per-operation signer consent dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletSignerConsentActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
|
||||
<service
|
||||
android:name=".napplet.NappletBrokerService"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Embedded-browser provider: hosts the in-app browser WebView in the isolated, keyless
|
||||
process and ships its rendered surface to the main app (SurfaceControlViewHost). -->
|
||||
<service
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserService"
|
||||
android:process=":napplet"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Embedded nsite/napplet provider: hosts the verified-blob WebView in the isolated, keyless
|
||||
process and ships its surface to the main app, so a favorited nsite/napplet can render as
|
||||
an in-app tab instead of taking over the screen. Same trust model as NappletHostActivity. -->
|
||||
<service
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostService"
|
||||
android:process=":napplet"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
@@ -43,6 +44,9 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
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.napplet.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
@@ -60,6 +64,7 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
|
||||
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -68,7 +73,9 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
@@ -109,6 +116,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
||||
@@ -119,8 +127,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -147,6 +158,9 @@ class AppModules(
|
||||
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
|
||||
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
|
||||
|
||||
// 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).
|
||||
@@ -231,6 +245,11 @@ class AppModules(
|
||||
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
|
||||
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
|
||||
|
||||
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
|
||||
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
|
||||
// on Tor-enabled clients to transparently redirect to .onion addresses.
|
||||
val onionLocationCache = OnionLocationCache()
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients: DualHttpClientManager =
|
||||
DualHttpClientManager(
|
||||
@@ -249,6 +268,7 @@ class AppModules(
|
||||
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
@@ -339,6 +359,7 @@ class AppModules(
|
||||
isPrimaryCoreRpc = true,
|
||||
)
|
||||
}
|
||||
|
||||
NamecoinBackend.ELECTRUMX -> {
|
||||
// Custom servers first (if any). If the user only has the public
|
||||
// defaults configured, primary == defaultElectrumx and the
|
||||
@@ -422,14 +443,24 @@ class AppModules(
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
scope = applicationIOScope,
|
||||
dns = surgeDns,
|
||||
onionCache = onionLocationCache,
|
||||
)
|
||||
|
||||
// Connects the INostrClient class with okHttp
|
||||
val websocketBuilder =
|
||||
OkHttpWebSocket.Builder { url ->
|
||||
val useTor = torEvaluatorFlow.flow.value.useTor(url)
|
||||
okHttpClientForRelays.getHttpClient(useTor)
|
||||
}
|
||||
OkHttpWebSocket.Builder(
|
||||
httpClient = { url ->
|
||||
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
|
||||
okHttpClientForRelays.getHttpClient(useTor)
|
||||
},
|
||||
// Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the
|
||||
// whole Tor-routed relay set is hammered with doomed dials against the dead
|
||||
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
|
||||
// ignoreRetryDelays=true) the instant Tor flips to Active.
|
||||
canDial = { url ->
|
||||
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
|
||||
},
|
||||
)
|
||||
|
||||
// Caches all events in Memory
|
||||
val cache: LocalCache = LocalCache
|
||||
@@ -471,6 +502,18 @@ class AppModules(
|
||||
// Provides a relay pool
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||
|
||||
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
|
||||
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
|
||||
// fail with zero successes in the window, pokes TorManager to drop + re-init Arti.
|
||||
val torCircuitHealthTracker =
|
||||
TorCircuitHealthTracker(
|
||||
client = client,
|
||||
isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) },
|
||||
isTorActive = { torManager.isSocksReady() },
|
||||
isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active },
|
||||
onCircuitsDead = { torManager.onTorCircuitsDead() },
|
||||
).also { it.register() }
|
||||
|
||||
// Watches for changes on Tor and Relay List Settings
|
||||
val relayProxyClientConnector =
|
||||
RelayProxyClientConnector(
|
||||
@@ -489,6 +532,15 @@ class AppModules(
|
||||
// Show messages from the Relay and controls their dismissal
|
||||
val notifyCoordinator = NotifyCoordinator(client)
|
||||
|
||||
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
|
||||
val relayAuthPermissionStore by lazy {
|
||||
DataStoreRelayAuthPermissionStore(appContext)
|
||||
}
|
||||
|
||||
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
|
||||
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
|
||||
@@ -511,6 +563,9 @@ class AppModules(
|
||||
val relayReqStats = if (isDebug) RelayReqStats(client) else null
|
||||
val logger = if (isDebug) RelaySpeedLogger(client) else null
|
||||
|
||||
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
|
||||
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
|
||||
|
||||
// Coordinates all subscriptions for the Nostr Client
|
||||
val sources: RelaySubscriptionsCoordinator =
|
||||
RelaySubscriptionsCoordinator(
|
||||
@@ -718,6 +773,18 @@ class AppModules(
|
||||
sessionManager.loginWithDefaultAccountIfLoggedOff()
|
||||
}
|
||||
|
||||
// One-time hygiene: remove per-account directories (MLS/Marmot stores) left behind
|
||||
// by accounts that are no longer saved — account deletion historically didn't clean
|
||||
// them up, so they leaked disk across every add/remove.
|
||||
applicationIOScope.launch {
|
||||
val keep =
|
||||
LocalPreferences
|
||||
.allSavedAccounts()
|
||||
.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }
|
||||
.toSet()
|
||||
accountsCache.pruneOrphanAccountDirs(keep)
|
||||
}
|
||||
|
||||
// forces initialization of uiPrefs in the main thread to avoid blinking themes
|
||||
uiPrefs
|
||||
|
||||
@@ -739,6 +806,13 @@ class AppModules(
|
||||
resourceCacheInit()
|
||||
}
|
||||
|
||||
// Initialize napplet permission stores on an IO thread to avoid StrictMode violations
|
||||
// when ConnectedAppsScreen first accesses them on the main thread.
|
||||
applicationIOScope.launch {
|
||||
nappletPermissionStore
|
||||
signerPermissionStore
|
||||
}
|
||||
|
||||
// registers to receive events
|
||||
pokeyReceiver.register(appContext)
|
||||
|
||||
@@ -826,12 +900,71 @@ class AppModules(
|
||||
accountsCache.clear()
|
||||
}
|
||||
|
||||
fun trim() {
|
||||
fun trim(level: Int) {
|
||||
_trimLevelEvents.tryEmit(level)
|
||||
applicationIOScope.launch {
|
||||
// Backgrounding is a natural moment to flush the DNS cache.
|
||||
dnsStore.save()
|
||||
val loggedIn = accountsCache.accounts.value.values
|
||||
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
|
||||
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
|
||||
// Trim in-process caches proportional to OS memory pressure.
|
||||
//
|
||||
// Background levels (app not visible, ordered highest-first so the when
|
||||
// chain short-circuits at the right tier):
|
||||
// COMPLETE (80) — at the bottom of the LRU list, kill imminent
|
||||
// MODERATE (60) — system is hurting, neighbouring apps being killed
|
||||
// BACKGROUND(40) — backgrounded, mild system pressure
|
||||
// UI_HIDDEN (20) — just backgrounded, no pressure yet
|
||||
//
|
||||
// Foreground levels (app is active but system is low):
|
||||
// RUNNING_CRITICAL (15), RUNNING_LOW (10)
|
||||
//
|
||||
// UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches
|
||||
// (Robohash SVG assembly, rich-text parsing) there — clearing them
|
||||
// forces a full rebuild on every resume and causes visible jank.
|
||||
when {
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
|
||||
// Kill imminent: free everything.
|
||||
memoryCache.trimToSize(0)
|
||||
CachedRichTextParser.trimToSize(0)
|
||||
CachedRobohash.trimToSize(0)
|
||||
nip11Cache.trimToSize(0)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> {
|
||||
// System under real pressure: clear images and most parsed state.
|
||||
memoryCache.trimToSize(0)
|
||||
CachedRichTextParser.trimToSize(50)
|
||||
CachedRobohash.trimToSize(10)
|
||||
nip11Cache.trimToSize(100)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
|
||||
// Backgrounded with mild pressure: trim significantly.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 4)
|
||||
CachedRichTextParser.trimToSize(100)
|
||||
CachedRobohash.trimToSize(20)
|
||||
nip11Cache.trimToSize(200)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
|
||||
// Just backgrounded, no pressure yet: trim images (bitmaps are the
|
||||
// largest allocations) but keep parsed-text and avatar caches warm
|
||||
// so resuming is instant.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 2)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
|
||||
// Foreground, critically low memory.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 4)
|
||||
CachedRichTextParser.trimToSize(100)
|
||||
CachedRobohash.trimToSize(20)
|
||||
nip11Cache.trimToSize(200)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
|
||||
// Foreground, low memory.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 2)
|
||||
CachedRichTextParser.trimToSize(250)
|
||||
CachedRobohash.trimToSize(50)
|
||||
nip11Cache.trimToSize(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,58 @@ import kotlin.time.measureTimedValue
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
|
||||
|
||||
data class MemorySnapshot(
|
||||
val heapUsedMb: Long,
|
||||
val heapMaxMb: Long,
|
||||
val nativeHeapUsedMb: Long,
|
||||
val imageCacheUsedMb: Long,
|
||||
val imageCacheMaxMb: Long,
|
||||
val imageDiskUsedMb: Long,
|
||||
val imageDiskMaxMb: Long,
|
||||
val noteCount: Int,
|
||||
val userCount: Int,
|
||||
val addressableCount: Int,
|
||||
val chatroomCount: Int,
|
||||
val memoryClassMb: Int,
|
||||
) {
|
||||
val heapFraction: Float get() = heapUsedMb.toFloat() / heapMaxMb.coerceAtLeast(1L).toFloat()
|
||||
}
|
||||
|
||||
fun collectMemorySnapshot(context: Context): MemorySnapshot {
|
||||
val rt = Runtime.getRuntime()
|
||||
val heapUsedMb = (rt.totalMemory() - rt.freeMemory()) / 1_048_576L
|
||||
val heapMaxMb = rt.maxMemory() / 1_048_576L
|
||||
val nativeHeapUsedMb = Debug.getNativeHeapAllocatedSize() / 1_048_576L
|
||||
|
||||
val app = Amethyst.instance
|
||||
val imageCacheUsedMb = app.memoryCache.size / 1_048_576L
|
||||
val imageCacheMaxMb = app.memoryCache.maxSize / 1_048_576L
|
||||
val imageDiskUsedMb = app.diskCache.size / 1_048_576L
|
||||
val imageDiskMaxMb = app.diskCache.maxSize / 1_048_576L
|
||||
|
||||
val activityManager: ActivityManager? = context.getSystemService()
|
||||
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
|
||||
val memoryClassMb =
|
||||
activityManager?.let {
|
||||
if (isLargeHeap) it.largeMemoryClass else it.memoryClass
|
||||
} ?: 0
|
||||
|
||||
return MemorySnapshot(
|
||||
heapUsedMb = heapUsedMb,
|
||||
heapMaxMb = heapMaxMb,
|
||||
nativeHeapUsedMb = nativeHeapUsedMb,
|
||||
imageCacheUsedMb = imageCacheUsedMb,
|
||||
imageCacheMaxMb = imageCacheMaxMb,
|
||||
imageDiskUsedMb = imageDiskUsedMb,
|
||||
imageDiskMaxMb = imageDiskMaxMb,
|
||||
noteCount = LocalCache.notes.size(),
|
||||
userCount = LocalCache.users.size(),
|
||||
addressableCount = LocalCache.addressables.size(),
|
||||
chatroomCount = LocalCache.chatroomList.size(),
|
||||
memoryClassMb = memoryClassMb,
|
||||
)
|
||||
}
|
||||
|
||||
private const val STATE_DUMP_TAG = "STATE DUMP"
|
||||
|
||||
fun debugState(context: Context) {
|
||||
|
||||
@@ -25,8 +25,10 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.content.edit
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
@@ -104,6 +106,10 @@ private object PrefKeys {
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
|
||||
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
|
||||
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
|
||||
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
|
||||
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
|
||||
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
|
||||
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
|
||||
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
|
||||
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
|
||||
@@ -121,9 +127,12 @@ private object PrefKeys {
|
||||
const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList"
|
||||
const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList"
|
||||
const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList"
|
||||
const val DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST = "defaultAppRecommendationsFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
|
||||
const val NWC_WALLETS = "nwcWallets"
|
||||
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId"
|
||||
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
|
||||
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
|
||||
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
@@ -147,7 +156,13 @@ private object PrefKeys {
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
|
||||
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
|
||||
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
|
||||
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
|
||||
|
||||
// One-shot stamp: set once an account has gone through the notifications
|
||||
// Global -> Selected (Curated) migration (or was created after it shipped).
|
||||
const val NOTIF_GLOBAL_TO_CURATED_MIGRATED = "notif_global_to_curated_migrated"
|
||||
const val TOR_SETTINGS = "tor_settings"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
@@ -171,6 +186,12 @@ object LocalPreferences {
|
||||
|
||||
private var currentAccount: String? = null
|
||||
private val savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
|
||||
|
||||
// Guards the one-time lazy population of [savedAccounts]. Without it, concurrent callers
|
||||
// of savedAccounts() (e.g. the account-load path, the always-on notification service, and
|
||||
// the orphan-dir sweep, all launched at startup) would each see a null value, run the IO
|
||||
// read in parallel, and the migration branch could double-write ALL_ACCOUNT_INFO.
|
||||
private val savedAccountsMutex = Mutex()
|
||||
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
@@ -200,41 +221,46 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
private suspend fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts.value == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
JsonMapper.fromJson<List<AccountInfo>>(it)
|
||||
}
|
||||
// Fast path: already populated, no lock needed.
|
||||
savedAccounts.value?.let { return it }
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
savedAccounts.emit(newSystemOfAccounts)
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
return savedAccountsMutex.withLock {
|
||||
// Re-check under the lock: another coroutine may have populated it while we waited.
|
||||
savedAccounts.value ?: loadSavedAccountsFromStorage().also { savedAccounts.emit(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val migrated =
|
||||
oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
savedAccounts.emit(migrated)
|
||||
|
||||
edit {
|
||||
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
|
||||
}
|
||||
private suspend fun loadSavedAccountsFromStorage(): List<AccountInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
JsonMapper.fromJson<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
|
||||
val migrated =
|
||||
oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
edit {
|
||||
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
|
||||
}
|
||||
|
||||
migrated
|
||||
}
|
||||
}
|
||||
}
|
||||
// it's always not null when it gets here.
|
||||
return savedAccounts.value!!
|
||||
}
|
||||
|
||||
fun accountsFlow() = savedAccounts
|
||||
|
||||
@@ -317,6 +343,9 @@ object LocalPreferences {
|
||||
suspend fun deleteAccount(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Drop the in-memory copy as well; otherwise re-adding the same account later
|
||||
// would resurrect the deleted settings from this cache.
|
||||
mutex.withLock { cachedAccounts.remove(accountInfo.npub) }
|
||||
encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
@@ -329,7 +358,27 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
|
||||
/**
|
||||
* Make [accountSettings] the current account, persisting + caching it. Returns the settings that
|
||||
* actually became current — normally [accountSettings] itself, but see the downgrade guard below.
|
||||
*/
|
||||
suspend fun setDefaultAccount(accountSettings: AccountSettings): AccountSettings {
|
||||
val npub = accountSettings.keyPair.pubKey.toNpub()
|
||||
|
||||
// Downgrade guard: adding a read-only npub for a pubkey we already hold a SIGNING account for
|
||||
// must not clobber that account. Accounts dedup by npub, so saving fresh read-only settings
|
||||
// here would overwrite the signing account's per-npub file — wiping its cached follow/relay/
|
||||
// mute lists and flipping hasPrivKey off, which silently disables its push notifications. A
|
||||
// signing account already does everything the read-only one would, so keep it and just make
|
||||
// it current instead of degrading it.
|
||||
if (!accountSettings.isWriteable()) {
|
||||
val existing = loadAccountConfigFromEncryptedStorage(npub)
|
||||
if (existing != null && existing.isWriteable()) {
|
||||
setCurrentAccount(existing)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
// Save the per-npub file before emitting onto the savedAccounts flow.
|
||||
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
|
||||
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
|
||||
@@ -337,9 +386,9 @@ object LocalPreferences {
|
||||
// rest of the session — making every later switch to this account land on
|
||||
// LoggedOff instead of LoggedIn.
|
||||
saveToEncryptedStorage(accountSettings)
|
||||
val npub = accountSettings.keyPair.pubKey.toNpub()
|
||||
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
|
||||
setCurrentAccount(accountSettings)
|
||||
return accountSettings
|
||||
}
|
||||
|
||||
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
@@ -377,6 +426,10 @@ object LocalPreferences {
|
||||
|
||||
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
|
||||
@@ -394,6 +447,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value))
|
||||
|
||||
val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() }
|
||||
if (walletEntries.isNotEmpty()) {
|
||||
@@ -401,9 +455,19 @@ object LocalPreferences {
|
||||
} else {
|
||||
remove(PrefKeys.NWC_WALLETS)
|
||||
}
|
||||
settings.defaultNwcWalletId.value?.let {
|
||||
putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it)
|
||||
} ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
|
||||
|
||||
val debitEntries = settings.clinkDebitWallets.value.map { it.denormalize() }
|
||||
if (debitEntries.isNotEmpty()) {
|
||||
putString(PrefKeys.CLINK_DEBIT_WALLETS, JsonMapper.toJson(debitEntries))
|
||||
} else {
|
||||
remove(PrefKeys.CLINK_DEBIT_WALLETS)
|
||||
}
|
||||
|
||||
settings.defaultPaymentSourceId.value?.let {
|
||||
putString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, it)
|
||||
} ?: remove(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID)
|
||||
// Legacy NWC-only default key is superseded by DEFAULT_PAYMENT_SOURCE_ID.
|
||||
remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
|
||||
|
||||
// Remove legacy key after migration
|
||||
remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
@@ -444,7 +508,14 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
|
||||
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
|
||||
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
|
||||
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
|
||||
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
|
||||
// Any account that reaches a save has its notification filter in its
|
||||
// post-split meaning, so stamp it as migrated. This keeps the one-shot
|
||||
// Global -> Selected rewrite from ever touching it again and preserves a
|
||||
// deliberate raw-Global choice (including on brand-new accounts).
|
||||
putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true)
|
||||
|
||||
// migrating from previous design
|
||||
remove(PrefKeys.USE_PROXY)
|
||||
@@ -554,7 +625,12 @@ object LocalPreferences {
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
|
||||
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.IF_IN_MY_LIST
|
||||
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
|
||||
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
|
||||
@@ -565,6 +641,8 @@ object LocalPreferences {
|
||||
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
|
||||
val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null)
|
||||
val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null)
|
||||
val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null)
|
||||
val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null)
|
||||
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
|
||||
|
||||
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
|
||||
@@ -619,6 +697,10 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
}
|
||||
val clinkDebitsLoaded =
|
||||
async {
|
||||
parseOrNull<List<ClinkDebitWalletEntry>>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList()
|
||||
}
|
||||
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
|
||||
|
||||
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() }
|
||||
@@ -660,12 +742,50 @@ object LocalPreferences {
|
||||
|
||||
Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" }
|
||||
|
||||
// Resolve every parallel parse into a local before constructing AccountSettings.
|
||||
// Awaiting inside the 70-argument constructor expression below would place ~27
|
||||
// suspension points in the middle of a single huge operand stack, forcing the
|
||||
// coroutine state machine to spill/restore every partially-evaluated argument at
|
||||
// each point. That bloats the generated method past the compiler's per-method
|
||||
// instruction limit ("Method exceeds compiler instruction limit"). Awaiting into
|
||||
// vals first keeps each suspension point at a statement boundary (near-empty
|
||||
// operand stack) and leaves the constructor as straight-line, suspension-free code.
|
||||
val nwcWalletsResolved = nwcWalletsLoaded.await()
|
||||
val clinkDebitsResolved = clinkDebitsLoaded.await()
|
||||
val defaultFileServerResolved = defaultFileServer.await()
|
||||
val viewedPollResultNoteIdsResolved = viewedPollResultNoteIds.await()
|
||||
val pendingAttestationsResolved = pendingAttestations.await()
|
||||
val lastReadPerRouteResolved = lastReadPerRoute.await()
|
||||
val latestUserMetadataResolved = latestUserMetadata.await()
|
||||
val latestContactListResolved = latestContactList.await()
|
||||
val latestDmRelayListResolved = latestDmRelayList.await()
|
||||
val latestNip65RelayListResolved = latestNip65RelayList.await()
|
||||
val latestSearchRelayListResolved = latestSearchRelayList.await()
|
||||
val latestIndexRelayListResolved = latestIndexRelayList.await()
|
||||
val latestRelayFeedsListResolved = latestRelayFeedsList.await()
|
||||
val latestBlockedRelayListResolved = latestBlockedRelayList.await()
|
||||
val latestTrustedRelayListResolved = latestTrustedRelayList.await()
|
||||
val latestMuteListResolved = latestMuteList.await()
|
||||
val latestPrivateHomeRelayListResolved = latestPrivateHomeRelayList.await()
|
||||
val latestAppSpecificDataResolved = latestAppSpecificData.await()
|
||||
val latestChannelListResolved = latestChannelList.await()
|
||||
val latestCommunityListResolved = latestCommunityList.await()
|
||||
val latestHashtagListResolved = latestHashtagList.await()
|
||||
val latestGeohashListResolved = latestGeohashList.await()
|
||||
val latestEphemeralListResolved = latestEphemeralList.await()
|
||||
val latestTrustProviderListResolved = latestTrustProviderList.await()
|
||||
val latestPaymentTargetsResolved = latestPaymentTargets.await()
|
||||
val latestCashuWalletResolved = latestCashuWallet.await()
|
||||
val latestNutzapInfoResolved = latestNutzapInfo.await()
|
||||
|
||||
Log.d("LocalPreferences") { "Load account from file $npub - asyncs resolved" }
|
||||
|
||||
return@with AccountSettings(
|
||||
keyPair = keyPair,
|
||||
transientAccount = false,
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelayServers = MutableStateFlow(localRelayServers),
|
||||
defaultFileServer = defaultFileServer.await(),
|
||||
defaultFileServer = defaultFileServerResolved,
|
||||
stripLocationOnUpload = stripLocationOnUpload,
|
||||
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
|
||||
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
|
||||
@@ -676,6 +796,10 @@ object LocalPreferences {
|
||||
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
|
||||
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
|
||||
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
|
||||
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
|
||||
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
|
||||
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
|
||||
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
|
||||
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
|
||||
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
|
||||
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
|
||||
@@ -693,39 +817,50 @@ object LocalPreferences {
|
||||
defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets),
|
||||
defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities),
|
||||
defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks),
|
||||
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
|
||||
defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second),
|
||||
defaultAppRecommendationsFollowList = MutableStateFlow(followListPrefs.appRecommendations),
|
||||
nwcWallets = MutableStateFlow(nwcWalletsResolved.first),
|
||||
clinkDebitWallets = MutableStateFlow(clinkDebitsResolved),
|
||||
// Prefer the new unified default; migrate from the legacy NWC default;
|
||||
// else fall back to the first configured source (NWC before debits).
|
||||
defaultPaymentSourceId =
|
||||
MutableStateFlow(
|
||||
defaultPaymentSourceIdStr
|
||||
?: nwcWalletsResolved.second
|
||||
?: clinkDebitsResolved.firstOrNull()?.id,
|
||||
),
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
backupUserMetadata = latestUserMetadata.await(),
|
||||
backupContactList = latestContactList.await(),
|
||||
backupNIP65RelayList = latestNip65RelayList.await(),
|
||||
backupDMRelayList = latestDmRelayList.await(),
|
||||
backupSearchRelayList = latestSearchRelayList.await(),
|
||||
backupIndexRelayList = latestIndexRelayList.await(),
|
||||
backupRelayFeedsList = latestRelayFeedsList.await(),
|
||||
backupBlockedRelayList = latestBlockedRelayList.await(),
|
||||
backupTrustedRelayList = latestTrustedRelayList.await(),
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(),
|
||||
backupMuteList = latestMuteList.await(),
|
||||
backupAppSpecificData = latestAppSpecificData.await(),
|
||||
backupChannelList = latestChannelList.await(),
|
||||
backupCommunityList = latestCommunityList.await(),
|
||||
backupHashtagList = latestHashtagList.await(),
|
||||
backupGeohashList = latestGeohashList.await(),
|
||||
backupEphemeralChatList = latestEphemeralList.await(),
|
||||
backupTrustProviderList = latestTrustProviderList.await(),
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
backupContactList = latestContactListResolved,
|
||||
backupNIP65RelayList = latestNip65RelayListResolved,
|
||||
backupDMRelayList = latestDmRelayListResolved,
|
||||
backupSearchRelayList = latestSearchRelayListResolved,
|
||||
backupIndexRelayList = latestIndexRelayListResolved,
|
||||
backupRelayFeedsList = latestRelayFeedsListResolved,
|
||||
backupBlockedRelayList = latestBlockedRelayListResolved,
|
||||
backupTrustedRelayList = latestTrustedRelayListResolved,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayListResolved,
|
||||
backupMuteList = latestMuteListResolved,
|
||||
backupAppSpecificData = latestAppSpecificDataResolved,
|
||||
backupChannelList = latestChannelListResolved,
|
||||
backupCommunityList = latestCommunityListResolved,
|
||||
backupHashtagList = latestHashtagListResolved,
|
||||
backupGeohashList = latestGeohashListResolved,
|
||||
backupEphemeralChatList = latestEphemeralListResolved,
|
||||
backupTrustProviderList = latestTrustProviderListResolved,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
|
||||
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations.await()),
|
||||
backupNipA3PaymentTargets = latestPaymentTargets.await(),
|
||||
backupCashuWallet = latestCashuWallet.await(),
|
||||
backupNutzapInfo = latestNutzapInfo.await(),
|
||||
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
|
||||
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
|
||||
backupCashuWallet = latestCashuWalletResolved,
|
||||
backupNutzapInfo = latestNutzapInfoResolved,
|
||||
callsEnabled = MutableStateFlow(callsEnabled),
|
||||
)
|
||||
}
|
||||
@@ -755,6 +890,10 @@ object LocalPreferences {
|
||||
val discovery: TopFilter,
|
||||
val polls: TopFilter,
|
||||
val pictures: TopFilter,
|
||||
val napplets: TopFilter,
|
||||
val nsites: TopFilter,
|
||||
val workouts: TopFilter,
|
||||
val gitRepositories: TopFilter,
|
||||
val calendars: TopFilter,
|
||||
val products: TopFilter,
|
||||
val shorts: TopFilter,
|
||||
@@ -772,16 +911,45 @@ object LocalPreferences {
|
||||
val browseEmojiSets: TopFilter,
|
||||
val communities: TopFilter,
|
||||
val followPacks: TopFilter,
|
||||
val appRecommendations: TopFilter,
|
||||
)
|
||||
|
||||
/**
|
||||
* One-shot migration of the notifications filter.
|
||||
*
|
||||
* The notifications "Global" mode was split into a raw [TopFilter.Global]
|
||||
* (every event that p-tags the user) and a curated [TopFilter.Selected].
|
||||
* Existing users who had selected the old, curated "Global" keep a value
|
||||
* that now deserializes to the much-more-permissive raw Global. Move them to
|
||||
* [TopFilter.Selected] exactly once, then stamp the account so a later,
|
||||
* deliberate raw-Global choice is never reverted. Accounts created after the
|
||||
* split are stamped at save time, so they are never touched here.
|
||||
*/
|
||||
private fun SharedPreferences.migrateNotificationFilter(current: TopFilter): TopFilter {
|
||||
if (getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return current
|
||||
|
||||
val migrated = if (current is TopFilter.Global) TopFilter.Selected else current
|
||||
edit {
|
||||
if (migrated !== current) {
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(migrated))
|
||||
}
|
||||
putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs =
|
||||
FollowListPrefs(
|
||||
home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows),
|
||||
stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Global),
|
||||
notification = migrateNotificationFilter(parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected)),
|
||||
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
|
||||
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
|
||||
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
@@ -799,6 +967,7 @@ object LocalPreferences {
|
||||
browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows),
|
||||
followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
)
|
||||
|
||||
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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 android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
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.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
|
||||
|
||||
/**
|
||||
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
|
||||
* in the omnibox suggestions.
|
||||
*/
|
||||
@Serializable
|
||||
data class BrowserHistoryEntry(
|
||||
val url: String,
|
||||
val title: String,
|
||||
val host: String,
|
||||
val lastVisitedAt: Long,
|
||||
val visitCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
|
||||
*
|
||||
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
|
||||
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
|
||||
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
|
||||
* to [MAX_ENTRIES] most-recent entries.
|
||||
*
|
||||
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
|
||||
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
|
||||
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
|
||||
*/
|
||||
object BrowserHistoryRegistry {
|
||||
private val KEY = stringPreferencesKey("history")
|
||||
private const val MAX_ENTRIES = 500
|
||||
|
||||
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
|
||||
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
@Volatile private var hydrated = false
|
||||
|
||||
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
scope.launch {
|
||||
val json = ctx.browserHistoryDataStore.data.first()[KEY]
|
||||
val loaded = if (json != null) decode(json) else emptyList()
|
||||
// Merge disk under anything already recorded this session (session wins, newest-first).
|
||||
update { current -> dedupeNewestFirst(current + loaded) }
|
||||
hydrated = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful visit to [url], moving it to the front. An existing entry for the same URL is
|
||||
* bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended.
|
||||
*/
|
||||
fun record(
|
||||
url: String,
|
||||
title: String,
|
||||
) {
|
||||
val host = OmniboxInput.hostOf(url) ?: url
|
||||
val now = System.currentTimeMillis()
|
||||
update { current ->
|
||||
val existing = current.firstOrNull { it.url == url }
|
||||
val entry =
|
||||
if (existing != null) {
|
||||
existing.copy(
|
||||
title = title.ifBlank { existing.title },
|
||||
host = host,
|
||||
lastVisitedAt = now,
|
||||
visitCount = existing.visitCount + 1,
|
||||
)
|
||||
} else {
|
||||
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
|
||||
}
|
||||
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
|
||||
|
||||
fun clear() = update { emptyList() }
|
||||
|
||||
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
|
||||
list
|
||||
.sortedByDescending { it.lastVisitedAt }
|
||||
.distinctBy { it.url }
|
||||
.take(MAX_ENTRIES)
|
||||
|
||||
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
|
||||
val next = transform(_history.value)
|
||||
if (next == _history.value) return
|
||||
_history.value = next
|
||||
persist(encode(next))
|
||||
}
|
||||
|
||||
private fun persist(json: String) {
|
||||
val ctx = appContext ?: return
|
||||
scope.launch {
|
||||
ctx.browserHistoryDataStore.edit { it[KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
private fun encode(list: List<BrowserHistoryEntry>): String = JsonMapper.toJson(list)
|
||||
|
||||
private fun decode(json: String): List<BrowserHistoryEntry> =
|
||||
try {
|
||||
JsonMapper.fromJson<List<BrowserHistoryEntry>>(json)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
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
|
||||
|
||||
/** Binds the app context and indexes already-stored icons. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
|
||||
iconDir = dir
|
||||
_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)
|
||||
try {
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLauncher
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
|
||||
/**
|
||||
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
|
||||
* codebase, nothing more:
|
||||
*
|
||||
* - [FavoriteApp.WebApp] → a full-screen direct-WebView
|
||||
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
|
||||
* task/recents entry), so the web client owns the whole screen and scrolls/zooms natively.
|
||||
* - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its
|
||||
* `requires`/website-mode off the event, then hand to [NappletLauncher] (the sandboxed `:napplet`
|
||||
* host). nsite vs napplet is decided *here*, from the event, never from stored state.
|
||||
*
|
||||
* A [FavoriteApp.NostrApp] whose event hasn't loaded yet can't launch; we surface that instead of
|
||||
* failing silently.
|
||||
*/
|
||||
object FavoriteAppLauncher {
|
||||
fun launch(
|
||||
context: Context,
|
||||
app: FavoriteApp,
|
||||
) {
|
||||
when (app) {
|
||||
is FavoriteApp.WebApp -> launchUrl(context, app.url)
|
||||
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
|
||||
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
|
||||
* resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor]
|
||||
* forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only
|
||||
* resolves over Tor.
|
||||
*/
|
||||
fun launchUrl(
|
||||
context: Context,
|
||||
url: String,
|
||||
preferTor: Boolean = false,
|
||||
) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
|
||||
val themeType = Amethyst.instance.uiPrefs.value.theme.value
|
||||
val theme =
|
||||
when (themeType) {
|
||||
ThemeType.DARK -> "DARK"
|
||||
ThemeType.LIGHT -> "LIGHT"
|
||||
ThemeType.SYSTEM -> {
|
||||
val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
||||
}
|
||||
}
|
||||
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
|
||||
val intent =
|
||||
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
|
||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun launchNostrApp(
|
||||
context: Context,
|
||||
coordinate: String,
|
||||
) {
|
||||
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
|
||||
when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.launch(context, event, event.pubKey, "")
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.launch(context, event, event.pubKey, event.identifier())
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.launch(
|
||||
context = context,
|
||||
paths = event.paths(),
|
||||
servers = event.servers(),
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = "",
|
||||
aggregateHash = null,
|
||||
title = event.title() ?: "nsite",
|
||||
requires = emptyList(),
|
||||
profile = HostProfile.WEBSITE,
|
||||
)
|
||||
is NamedSiteEvent ->
|
||||
NappletLauncher.launch(
|
||||
context = context,
|
||||
paths = event.paths(),
|
||||
servers = event.servers(),
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = event.identifier(),
|
||||
aggregateHash = null,
|
||||
title = event.title() ?: event.identifier(),
|
||||
requires = emptyList(),
|
||||
profile = HostProfile.WEBSITE,
|
||||
)
|
||||
else -> {
|
||||
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
|
||||
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the main-process-minted launch parameters for embedding the nsite/napplet at [coordinate]
|
||||
* as an in-app tab (see `NappletHostService`). Returns null when the event isn't resolvable in
|
||||
* [LocalCache] yet — the caller shows a loading state. nsite vs napplet (and website mode) is decided
|
||||
* here from the live event, exactly as in [launchNostrApp].
|
||||
*/
|
||||
fun embedParams(
|
||||
context: Context,
|
||||
coordinate: String,
|
||||
): Bundle? {
|
||||
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
|
||||
return when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: "Napplet",
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
)
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: event.identifier(),
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
)
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
null,
|
||||
event.title() ?: "nsite",
|
||||
emptyList(),
|
||||
HostProfile.WEBSITE,
|
||||
)
|
||||
is NamedSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
null,
|
||||
event.title() ?: event.identifier(),
|
||||
emptyList(),
|
||||
HostProfile.WEBSITE,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
|
||||
* instead of the content hash so the favorite survives routine code/manifest updates.
|
||||
*/
|
||||
fun coordinateOf(event: Event): String {
|
||||
val dTag =
|
||||
when (event) {
|
||||
is NamedNappletEvent -> event.identifier()
|
||||
is NamedSiteEvent -> event.identifier()
|
||||
else -> ""
|
||||
}
|
||||
return "${event.kind}:${event.pubKey}:$dTag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
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.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps")
|
||||
|
||||
/**
|
||||
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
|
||||
* bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated
|
||||
* by [FavoriteApp.id].
|
||||
*
|
||||
* Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never
|
||||
* touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it
|
||||
* synchronously, with write-through persistence to a DataStore on a background scope. The list is
|
||||
* stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key
|
||||
* per entry).
|
||||
*/
|
||||
object FavoriteAppsRegistry {
|
||||
private val KEY = stringPreferencesKey("favorites")
|
||||
|
||||
// Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable
|
||||
// coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and
|
||||
// offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never
|
||||
// has to. The relay subscription that warms these favorites keeps the cache fresh.
|
||||
private val MANIFESTS_KEY = stringPreferencesKey("manifests")
|
||||
|
||||
private val _favorites = MutableStateFlow<List<FavoriteApp>>(emptyList())
|
||||
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
|
||||
|
||||
private val manifestCache = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
// Hydration runs async on a background scope, so the user can add/remove before the disk list
|
||||
// merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
|
||||
// resurrect a just-deleted favorite from disk.
|
||||
@Volatile private var hydrated = false
|
||||
private val removedBeforeHydration = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
scope.launch {
|
||||
val prefs = ctx.favoriteAppsDataStore.data.first()
|
||||
val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList()
|
||||
// Don't clobber adds made in this session before hydration finished, and don't resurrect
|
||||
// anything the user removed in that same window.
|
||||
update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } }
|
||||
|
||||
// Same race rules for the manifest cache: a cacheManifest() in this session wins over the
|
||||
// disk copy, and a manifest whose favorite was removed pre-hydration must not come back.
|
||||
val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap()
|
||||
updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current }
|
||||
|
||||
hydrated = true
|
||||
removedBeforeHydration.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id }
|
||||
|
||||
/** Adds [app] to the end if not already present (by [FavoriteApp.id]). */
|
||||
fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app }
|
||||
|
||||
fun remove(id: String) {
|
||||
if (!hydrated) removedBeforeHydration.add(id)
|
||||
update { current -> current.filterNot { it.id == id } }
|
||||
// Drop the cached manifest too — favorite ids for nsites/napplets are "nostr:<coordinate>".
|
||||
if (id.startsWith("nostr:")) updateManifests { it - id.removePrefix("nostr:") }
|
||||
}
|
||||
|
||||
/** The cached manifest event JSON for a favorited nsite/napplet [coordinate], or null if none. */
|
||||
fun cachedManifest(coordinate: String): String? = manifestCache.value[coordinate]
|
||||
|
||||
/**
|
||||
* Caches the raw manifest event [eventJson] for a favorited nsite/napplet [coordinate] so the next
|
||||
* launch can resolve it instantly / offline. Write-through; no-ops when the JSON is unchanged.
|
||||
*/
|
||||
fun cacheManifest(
|
||||
coordinate: String,
|
||||
eventJson: String,
|
||||
) = updateManifests { if (it[coordinate] == eventJson) it else it + (coordinate to eventJson) }
|
||||
|
||||
/** Replaces the whole list, e.g. after a drag-reorder. */
|
||||
fun setOrder(newOrder: List<FavoriteApp>) = update { newOrder }
|
||||
|
||||
private inline fun update(transform: (List<FavoriteApp>) -> List<FavoriteApp>) {
|
||||
val next = transform(_favorites.value)
|
||||
if (next == _favorites.value) return
|
||||
_favorites.value = next
|
||||
persist(encode(next))
|
||||
}
|
||||
|
||||
private inline fun updateManifests(transform: (Map<String, String>) -> Map<String, String>) {
|
||||
val next = transform(manifestCache.value)
|
||||
if (next == manifestCache.value) return
|
||||
manifestCache.value = next
|
||||
persistManifests(encodeManifests(next))
|
||||
}
|
||||
|
||||
private fun persist(json: String) {
|
||||
val ctx = appContext ?: return
|
||||
scope.launch {
|
||||
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistManifests(json: String) {
|
||||
val ctx = appContext ?: return
|
||||
scope.launch {
|
||||
ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Persistence DTO ------------------------------------------------------------------------
|
||||
// A flat, type-tagged record so we serialize one concrete shape instead of relying on
|
||||
// polymorphic (sealed) (de)serialization. Mapping to/from the sealed model lives here.
|
||||
|
||||
@Serializable
|
||||
private data class Entry(
|
||||
val type: String,
|
||||
val ref: String,
|
||||
val label: String,
|
||||
val addedAt: Long,
|
||||
val iconUrl: String? = null,
|
||||
)
|
||||
|
||||
private fun encode(list: List<FavoriteApp>): String =
|
||||
JsonMapper.toJson(
|
||||
list.map {
|
||||
when (it) {
|
||||
is FavoriteApp.NostrApp -> Entry(TYPE_NOSTR, it.coordinate, it.label, it.addedAt, it.iconUrl)
|
||||
is FavoriteApp.WebApp -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun decode(json: String): List<FavoriteApp> =
|
||||
try {
|
||||
JsonMapper.fromJson<List<Entry>>(json).mapNotNull { entry ->
|
||||
when (entry.type) {
|
||||
TYPE_NOSTR -> FavoriteApp.NostrApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
|
||||
TYPE_URL -> FavoriteApp.WebApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("FavoriteAppsRegistry", "Failed to decode favorites", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private const val TYPE_NOSTR = "nostr"
|
||||
private const val TYPE_URL = "url"
|
||||
|
||||
// --- Manifest cache persistence -------------------------------------------------------------
|
||||
// Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated
|
||||
// shape as the favorites list, so we never serialize a raw polymorphic map.
|
||||
|
||||
@Serializable
|
||||
private data class ManifestEntry(
|
||||
val coordinate: String,
|
||||
val json: String,
|
||||
)
|
||||
|
||||
private fun encodeManifests(manifests: Map<String, String>): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) })
|
||||
|
||||
private fun decodeManifests(json: String): Map<String, String> =
|
||||
try {
|
||||
JsonMapper.fromJson<List<ManifestEntry>>(json).associate { it.coordinate to it.json }
|
||||
} catch (e: Exception) {
|
||||
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Pre-fetches the addressable events behind the user's favorited [FavoriteApp.NostrApp]s (nSites /
|
||||
* nApplets) while a screen that can launch them is on screen.
|
||||
*
|
||||
* A favorite stores only the addressable coordinate `kind:pubkey:dtag`, never the event. The launch
|
||||
* path ([FavoriteAppLauncher.launchNostrApp] / [FavoriteAppLauncher.embedParams]) re-resolves the live
|
||||
* event from [LocalCache] at tap time, so a favorite whose event hasn't streamed in yet can't launch —
|
||||
* the user gets the "isn't loaded yet" toast / unavailable tab. Before this preloader, the only thing
|
||||
* that pulled those events into the cache was visiting the nsite/napplet feed (it subscribes by author),
|
||||
* which is why opening that feed and coming back made a favorite suddenly launchable.
|
||||
*
|
||||
* This subscribes each favorited coordinate to the shared
|
||||
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
|
||||
* — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
|
||||
* uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
|
||||
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
|
||||
* once the event arrives, so this is a one-shot fetch, not a standing feed.
|
||||
*/
|
||||
@Composable
|
||||
fun PreloadFavoriteNostrApps(
|
||||
apps: List<FavoriteApp>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
// Reuse the same query-state instances across recompositions (the manager ref-counts by identity),
|
||||
// recomputing only when the favorite list or account changes. Events that already loaded are still
|
||||
// included; the loader simply skips them because their note already has an event.
|
||||
val states =
|
||||
remember(apps, account) {
|
||||
apps
|
||||
.filterIsInstance<FavoriteApp.NostrApp>()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it.coordinate) }
|
||||
.map { EventFinderQueryState(it, account) }
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().eventFinder)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/** The chosen icon blob and the servers that hold it — resolved from the live manifest. */
|
||||
private class IconBlob(
|
||||
val path: PathTag,
|
||||
val servers: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Coil model (`file://…`) for the app icon an nsite/napplet bundles in its own content — the verified,
|
||||
* content-addressed blob the [NappletIconPath][com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath]
|
||||
* heuristic picks from the manifest's `path` tags — or null when the manifest declares no such icon (or it
|
||||
* hasn't downloaded yet). Used to decorate an nsite/napplet favorite the same way a captured favicon
|
||||
* decorates a web favorite, except this rides the same Tor-routed, sha256-verified blob path as the rest of
|
||||
* the site instead of a clearnet favicon fetch.
|
||||
*
|
||||
* Unlike a webapp's favicon, this can't be captured live: the applet runs in a cross-origin sandboxed
|
||||
* iframe under the trusted shell, so `WebChromeClient.onReceivedIcon` only ever reports the shell's icon,
|
||||
* never the applet's. Deriving it from the bundled blobs is the only path that sees the real icon.
|
||||
*
|
||||
* Observes the addressable note, so the icon resolves whenever the manifest arrives or updates in
|
||||
* LocalCache — not only if it already happened to be cached at first composition (on a cold start the
|
||||
* event streams in from relays a moment later). Returns null until the blob is on disk; the icon then
|
||||
* appears on the next recomposition. All disk + network work runs off the composition thread. The blob is
|
||||
* usually already cached (the browse/feed card prefetches every manifest blob, this one included); the
|
||||
* on-demand fetch here just covers favorites whose card isn't currently on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberNappletIconModel(coordinate: String): String? {
|
||||
val context = LocalContext.current
|
||||
|
||||
// checkGetOrCreate returns null only for a malformed coordinate, so this early return is stable for a
|
||||
// given coordinate (it never flips across recompositions, which would break composition structure).
|
||||
val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return null
|
||||
val noteState by note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Key on the event itself, not the NoteState wrapper: re-resolve only when the manifest actually
|
||||
// changes, not on every unrelated metadata bump (a reaction/zap tracked on the note).
|
||||
val event = noteState.note.event
|
||||
val icon = remember(event) { resolveIconBlob(event) } ?: return null
|
||||
|
||||
var model by remember(icon.path.hash) { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(icon.path.hash) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = File(NappletBlobCache.dirFor(context.cacheDir), icon.path.hash.lowercase())
|
||||
if (!file.isFile) {
|
||||
val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
runCatching { NappletBlobPrefetcher.prefetch(listOf(icon.path), icon.servers, context.cacheDir, torPort) }
|
||||
}
|
||||
if (file.isFile) model = "file://" + file.absolutePath
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/** Asks each nsite/napplet event type for its bundled icon blob + the servers that hold it. */
|
||||
private fun resolveIconBlob(event: Event?): IconBlob? =
|
||||
when (event) {
|
||||
is RootNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
|
||||
is NamedNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
|
||||
is RootSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
|
||||
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* for a given [url] (the host either always parses or never does), so composition structure is
|
||||
* preserved across recompositions.
|
||||
*/
|
||||
@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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A Coil model for the bundled icon of an napplet or nsite identified by [author] and
|
||||
* [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the
|
||||
* nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberManifestIconModel(
|
||||
author: String,
|
||||
identifier: String,
|
||||
): String? {
|
||||
val nappletCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
val nsiteCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
|
||||
@@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayStat
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.marmot.KeyPackageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
|
||||
@@ -70,6 +73,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
|
||||
@@ -101,6 +105,7 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
|
||||
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
|
||||
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
|
||||
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
@@ -177,6 +182,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
@@ -221,8 +228,8 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
@@ -285,6 +292,8 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
|
||||
|
||||
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
@@ -387,8 +396,10 @@ class Account(
|
||||
|
||||
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
|
||||
val interestSets = InterestSetsState(signer, cache, scope)
|
||||
val appRecommendations = AppRecommendationsState(signer, cache, scope)
|
||||
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
|
||||
val bookmarkState = BookmarkListState(signer, cache, scope)
|
||||
val gitRepositoryListState = GitRepositoryListState(signer, cache, scope)
|
||||
val pinState = PinListState(signer, cache, scope)
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
|
||||
@@ -406,6 +417,7 @@ class Account(
|
||||
// Relay settings
|
||||
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope)
|
||||
val mineRelays = AccountMineRelayState(nip65RelayList, privateStorageRelayList, localRelayList, proxyRelayList, scope)
|
||||
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
|
||||
|
||||
@@ -417,6 +429,8 @@ class Account(
|
||||
scope = scope,
|
||||
assembler = cashuWalletFilterAssembler(),
|
||||
outboxRelaysFlow = outboxRelays.flow,
|
||||
inboxRelaysFlow = notificationRelays.flow,
|
||||
dmRelaysFlow = dmRelays.flow,
|
||||
settings = settings,
|
||||
okHttpClient = okHttpClientForMoney,
|
||||
)
|
||||
@@ -496,6 +510,7 @@ class Account(
|
||||
followsRelays = defaultGlobalRelays.flow,
|
||||
blockedRelays = blockedRelayList.flow,
|
||||
proxyRelays = proxyRelayList.flow,
|
||||
mineRelays = mineRelays.flow,
|
||||
relayFeeds = relayFeedsList.flow,
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
@@ -524,6 +539,18 @@ class Account(
|
||||
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
|
||||
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
|
||||
|
||||
val liveNappletsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNappletsFollowList)
|
||||
val liveNappletsFollowListsPerRelay = OutboxLoaderState(liveNappletsFollowLists, cache, scope).flow
|
||||
|
||||
val liveNsitesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNsitesFollowList)
|
||||
val liveNsitesFollowListsPerRelay = OutboxLoaderState(liveNsitesFollowLists, cache, scope).flow
|
||||
|
||||
val liveWorkoutsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultWorkoutsFollowList)
|
||||
val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow
|
||||
|
||||
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
|
||||
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
|
||||
|
||||
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
|
||||
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
|
||||
|
||||
@@ -575,6 +602,11 @@ class Account(
|
||||
val liveFollowPacksFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultFollowPacksFollowList)
|
||||
val liveFollowPacksFollowListsPerRelay = OutboxLoaderState(liveFollowPacksFollowLists, cache, scope).flow
|
||||
|
||||
// App recommendations are read straight from LocalCache (no relay feed of its
|
||||
// own), so only the in-memory author/tag matcher is needed here, not a
|
||||
// per-relay outbox loader.
|
||||
val liveAppRecommendationsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultAppRecommendationsFollowList)
|
||||
|
||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||
|
||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||
@@ -651,6 +683,17 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeAudioVisualizer(style: VisualizerStyle) {
|
||||
if (settings.changeAudioVisualizer(style)) {
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toggleChatroomPin(room: ChatroomKey) {
|
||||
settings.toggleChatroomPin(room)
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
@@ -725,8 +768,10 @@ class Account(
|
||||
|
||||
val eventHint = note.toEventHint<Event>() ?: return null
|
||||
|
||||
// For NIP-17 private groups, we don't support tracked mode (too complex)
|
||||
if (eventHint.event is NIP17Group) return null
|
||||
// For NIP-17 private groups, we don't support tracked mode (too complex).
|
||||
// Unsealed rumors (empty sig) must never get a public reaction —
|
||||
// the e-tag would leak the private rumor id to public relays.
|
||||
if (eventHint.event is NIP17Group || eventHint.event.sig.isEmpty()) return null
|
||||
|
||||
val event = ReactionAction.reactTo(eventHint, reaction, signer)
|
||||
val relays = computeRelayListToBroadcast(event)
|
||||
@@ -873,6 +918,13 @@ class Account(
|
||||
return zapRequest
|
||||
}
|
||||
|
||||
private fun onchainBackendNotConfigured() =
|
||||
OnchainZapSendResult.Failure(
|
||||
OnchainZapSendStage.LOADING_UTXOS,
|
||||
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
|
||||
ONCHAIN_BACKEND_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
|
||||
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
|
||||
@@ -888,10 +940,7 @@ class Account(
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
cache.onchainBackend
|
||||
?: return OnchainZapSendResult.Failure(
|
||||
OnchainZapSendStage.LOADING_UTXOS,
|
||||
"Bitcoin chain backend is not configured",
|
||||
)
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.send(
|
||||
backend = backend,
|
||||
signer = signer,
|
||||
@@ -904,6 +953,29 @@ class Account(
|
||||
) { template -> signAndComputeBroadcast(template) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
|
||||
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
|
||||
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
|
||||
*/
|
||||
suspend fun sendOnchainToAddress(
|
||||
recipientAddress: String,
|
||||
amountSats: Long,
|
||||
feeRateSatPerVByte: Double,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendToAddress(
|
||||
backend = backend,
|
||||
signer = signer,
|
||||
senderPubKey = signer.pubKey,
|
||||
recipientAddress = recipientAddress,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
|
||||
* each recipient their precomputed share, plus one kind:8333 receipt per
|
||||
@@ -917,10 +989,7 @@ class Account(
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
cache.onchainBackend
|
||||
?: return OnchainZapSendResult.Failure(
|
||||
OnchainZapSendStage.LOADING_UTXOS,
|
||||
"Bitcoin chain backend is not configured",
|
||||
)
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendSplit(
|
||||
backend = backend,
|
||||
signer = signer,
|
||||
@@ -936,7 +1005,15 @@ class Account(
|
||||
note: Note,
|
||||
type: ReportType,
|
||||
content: String = "",
|
||||
) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
|
||||
) {
|
||||
if (note.isPrivateRumor()) {
|
||||
// A kind-1984 e-tagging the rumor would leak the private id onto
|
||||
// public relays. Report the author instead (p-tag only).
|
||||
note.author?.let { report(it, type, content) }
|
||||
} else {
|
||||
sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun report(
|
||||
user: User,
|
||||
@@ -966,6 +1043,28 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retracts rumor-only events (private reactions/replies) with a
|
||||
* gift-wrapped NIP-09 deletion delivered to the same participants as
|
||||
* the [target] rumor they referenced. A public deletion would e-tag
|
||||
* the private rumor ids onto public relays.
|
||||
*/
|
||||
suspend fun deletePrivately(
|
||||
notes: List<Note>,
|
||||
target: Note,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
val targetEvent = target.event ?: return
|
||||
|
||||
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
|
||||
if (myRumors.isEmpty()) return
|
||||
|
||||
val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey)
|
||||
broadcastPrivately(
|
||||
NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
event: Event,
|
||||
additionalRelays: Set<NormalizedRelayUrl>,
|
||||
@@ -1166,7 +1265,9 @@ class Account(
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
if (event is WrappedEvent) {
|
||||
// Seals, inner DM messages, and unsigned rumors never get broadcast
|
||||
// relays: they only travel inside gift wraps.
|
||||
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
@@ -1289,26 +1390,39 @@ class Account(
|
||||
|
||||
suspend fun broadcast(note: Note) {
|
||||
note.event?.let { noteEvent ->
|
||||
if (noteEvent is WrappedEvent && noteEvent.host != null) {
|
||||
// download the event and send it.
|
||||
noteEvent.host?.let { host ->
|
||||
client
|
||||
.fetchFirst(
|
||||
filters =
|
||||
note.relays.associateWith { _ ->
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
tags = mapOf("p" to listOf(pubKey)),
|
||||
ids = listOf(host.id),
|
||||
),
|
||||
)
|
||||
},
|
||||
)?.let { downloadedEvent ->
|
||||
val toRelays = computeRelayListToBroadcast(downloadedEvent)
|
||||
client.publish(downloadedEvent, toRelays)
|
||||
}
|
||||
}
|
||||
val host = note.rumorHost
|
||||
if (host != null) {
|
||||
// Rumors are rebroadcast as their delivering envelope: the
|
||||
// cached copy is content-stripped, so download it and send it.
|
||||
// A just-sent note has no relays until its self-wrap echoes
|
||||
// back — fall back to our own DM inbox relays. Bare seals
|
||||
// (kind 13) carry no p tag, so that filter is wrap-only.
|
||||
val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() }
|
||||
val filter =
|
||||
if (host.kind == SealedRumorEvent.KIND) {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
} else {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
tags = mapOf("p" to listOf(pubKey)),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
}
|
||||
client
|
||||
.fetchFirst(
|
||||
filters = relays.associateWith { _ -> listOf(filter) },
|
||||
)?.let { downloadedEvent ->
|
||||
val toRelays = computeRelayListToBroadcast(downloadedEvent)
|
||||
client.publish(downloadedEvent, toRelays)
|
||||
}
|
||||
} else if (noteEvent.sig.isEmpty()) {
|
||||
// Rumor with no known wrap: publishing it would disclose the
|
||||
// private content to relays even though they reject the
|
||||
// missing signature.
|
||||
return
|
||||
} else {
|
||||
client.publish(noteEvent, computeRelayListToBroadcast(note))
|
||||
}
|
||||
@@ -1939,6 +2053,14 @@ class Account(
|
||||
extraNotesToBroadcast.forEach { client.publish(it, relays) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The live [AddressableNote] backing a draft tag for this account. It is the same cached
|
||||
* note that draft events are consumed into, so its `event` tracks the draft over time. The
|
||||
* composer holds onto it (via DraftTagState) so [LocalCache]'s weak reference can't collect
|
||||
* it before a deletion needs it, which would otherwise orphan the draft on the relays.
|
||||
*/
|
||||
fun getOrCreateDraftNote(draftTag: String): AddressableNote = cache.getOrCreateAddressableNote(DraftWrapEvent.createAddress(signer.pubKey, draftTag))
|
||||
|
||||
suspend fun createAndSendDraftIgnoreErrors(
|
||||
draftTag: String,
|
||||
template: EventTemplate<out Event>,
|
||||
@@ -1975,18 +2097,25 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDraftIgnoreErrors(draftTag: String) {
|
||||
suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote?) {
|
||||
try {
|
||||
deleteDraftInner(draftTag)
|
||||
deleteDraftInner(draftNote)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDraftInner(draftTag: String) {
|
||||
suspend fun deleteDraftInner(draftNote: AddressableNote?) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
|
||||
// Only a real, still-present draft needs a deletion signed. The note's event is null when
|
||||
// no draft was ever saved (e.g. auto-drafts disabled) and already empty once it has been
|
||||
// deleted — in both cases there is nothing to delete, so we avoid prompting the signer.
|
||||
val draftEvent = draftNote?.event as? DraftWrapEvent
|
||||
if (draftEvent == null || draftEvent.isDeleted()) return
|
||||
|
||||
val draftTag = draftNote.dTag()
|
||||
val extraRelays = draftNote.relays
|
||||
|
||||
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
|
||||
val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft)))
|
||||
@@ -2238,6 +2367,18 @@ class Account(
|
||||
broadcastPrivately(events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a kind-1 note privately: signs the template, then gift-wraps
|
||||
* the rumor to every p-tagged user plus a self-copy and sends each wrap
|
||||
* to the recipient's DM relays. Used for private replies (the parent's
|
||||
* author and participants are already p-tagged) and for private posts
|
||||
* (the Notify list is the audience). Nothing reaches public relays.
|
||||
*/
|
||||
suspend fun sendPrivateNote(template: EventTemplate<TextNoteEvent>) {
|
||||
if (!isWriteable()) return
|
||||
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
wraps.forEach { wrap ->
|
||||
val relayList = computeRelayListToBroadcast(wrap)
|
||||
@@ -2874,6 +3015,16 @@ class Account(
|
||||
delete(note)
|
||||
}
|
||||
|
||||
suspend fun addGitRepositoryBookmark(note: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note))
|
||||
}
|
||||
|
||||
suspend fun removeGitRepositoryBookmark(note: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) }
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
@@ -3542,11 +3693,12 @@ class Account(
|
||||
if (isNew) {
|
||||
innerNote.event = innerEvent
|
||||
}
|
||||
marmotGroupList.restoreMessage(groupId, innerNote)
|
||||
marmotGroupList.addMessage(groupId, innerNote)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"Account",
|
||||
"Failed to restore persisted Marmot message for $groupId: ${e.message}",
|
||||
"Failed to restore persisted Marmot message for $groupId",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -36,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
@@ -91,6 +97,16 @@ sealed class TopFilter(
|
||||
@Serializable
|
||||
object Global : TopFilter(" Global ")
|
||||
|
||||
/**
|
||||
* Notifications-only curated mode: like [Global] it admits authors the
|
||||
* user doesn't follow, but it also applies per-kind relevance heuristics
|
||||
* to remove less interesting notes (reactions/reposts that don't target
|
||||
* the user's own notes, unrelated thread replies, etc.). In Notifications,
|
||||
* [Global] shows every event that p-tags the user instead.
|
||||
*/
|
||||
@Serializable
|
||||
object Selected : TopFilter(" Selected ")
|
||||
|
||||
@Serializable
|
||||
object AllFollows : TopFilter(" All Follows ")
|
||||
|
||||
@@ -170,10 +186,14 @@ class AccountSettings(
|
||||
val hideCommunityRulesViolations: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Selected),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNappletsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
|
||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
@@ -191,13 +211,18 @@ class AccountSettings(
|
||||
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
|
||||
// new key, migrated from the legacy NWC-only `defaultNwcWalletId`.
|
||||
val defaultPaymentSourceId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val splitNotificationsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val showMessagesInNotifications: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
var backupUserMetadata: MetadataEvent? = null,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||
@@ -243,6 +268,7 @@ class AccountSettings(
|
||||
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
|
||||
var callMaxBitrateBps: Int = 1_500_000,
|
||||
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
|
||||
) : EphemeralChatRepository,
|
||||
PublicChatListRepository {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
@@ -276,6 +302,13 @@ class AccountSettings(
|
||||
return newValue
|
||||
}
|
||||
|
||||
fun toggleShowMessagesInNotifications(): Boolean {
|
||||
val newValue = !showMessagesInNotifications.value
|
||||
showMessagesInNotifications.tryEmit(newValue)
|
||||
saveAccountSettings()
|
||||
return newValue
|
||||
}
|
||||
|
||||
// ---
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
@@ -325,14 +358,26 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun defaultNwcWallet(): NwcWalletEntryNorm? {
|
||||
val id = defaultNwcWalletId.value
|
||||
val wallets = nwcWallets.value
|
||||
return if (id != null) {
|
||||
wallets.firstOrNull { it.id == id }
|
||||
} else {
|
||||
wallets.firstOrNull()
|
||||
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
|
||||
if (syncedSettings.media.audioVisualizer.value != style) {
|
||||
syncedSettings.media.audioVisualizer.tryEmit(style)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
/**
|
||||
* The NWC wallet to use for NWC-only flows (balance display, mint top-up). Resolves
|
||||
* the unified default when it points at an NWC wallet, otherwise falls back to the
|
||||
* first NWC wallet so those flows keep working even when a debit is the zap default.
|
||||
*/
|
||||
fun defaultNwcWallet(): NwcWalletEntryNorm? {
|
||||
val wallets = nwcWallets.value
|
||||
return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull()
|
||||
}
|
||||
|
||||
fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri
|
||||
@@ -343,8 +388,10 @@ class AccountSettings(
|
||||
nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) })
|
||||
} else {
|
||||
nwcWallets.tryEmit(nwcWallets.value + wallet)
|
||||
if (nwcWallets.value.size == 1) {
|
||||
defaultNwcWalletId.tryEmit(wallet.id)
|
||||
// First configured source of any kind becomes the default; adding more never
|
||||
// silently changes an existing default.
|
||||
if (defaultPaymentSourceId.value == null) {
|
||||
defaultPaymentSourceId.tryEmit(wallet.id)
|
||||
}
|
||||
}
|
||||
saveAccountSettings()
|
||||
@@ -354,16 +401,68 @@ class AccountSettings(
|
||||
fun removeNwcWallet(walletId: String): Boolean {
|
||||
val wallets = nwcWallets.value.filter { it.id != walletId }
|
||||
nwcWallets.tryEmit(wallets)
|
||||
if (defaultNwcWalletId.value == walletId) {
|
||||
defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id)
|
||||
reassignDefaultIfRemoved(walletId)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
|
||||
fun addClinkDebitWallet(wallet: ClinkDebitWalletEntryNorm): Boolean {
|
||||
val existing = clinkDebitWallets.value.indexOfFirst { it.id == wallet.id }
|
||||
if (existing >= 0) {
|
||||
clinkDebitWallets.tryEmit(clinkDebitWallets.value.toMutableList().apply { set(existing, wallet) })
|
||||
} else {
|
||||
clinkDebitWallets.tryEmit(clinkDebitWallets.value + wallet)
|
||||
if (defaultPaymentSourceId.value == null) {
|
||||
defaultPaymentSourceId.tryEmit(wallet.id)
|
||||
}
|
||||
}
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
|
||||
fun setDefaultNwcWallet(walletId: String): Boolean {
|
||||
if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) {
|
||||
defaultNwcWalletId.tryEmit(walletId)
|
||||
fun removeClinkDebitWallet(walletId: String): Boolean {
|
||||
clinkDebitWallets.tryEmit(clinkDebitWallets.value.filter { it.id != walletId })
|
||||
reassignDefaultIfRemoved(walletId)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
|
||||
fun renameClinkDebitWallet(
|
||||
walletId: String,
|
||||
newName: String,
|
||||
): Boolean {
|
||||
val wallets = clinkDebitWallets.value.toMutableList()
|
||||
val index = wallets.indexOfFirst { it.id == walletId }
|
||||
if (index >= 0) {
|
||||
wallets[index] = wallets[index].copy(name = newName)
|
||||
clinkDebitWallets.tryEmit(wallets)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** When the removed source was the default, fall back to the first remaining source. */
|
||||
private fun reassignDefaultIfRemoved(walletId: String) {
|
||||
if (defaultPaymentSourceId.value == walletId) {
|
||||
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the default to the first remaining source if it no longer points at anything. */
|
||||
private fun reassignDefaultIfMissing() {
|
||||
val id = defaultPaymentSourceId.value ?: return
|
||||
val exists = nwcWallets.value.any { it.id == id } || clinkDebitWallets.value.any { it.id == id }
|
||||
if (!exists) {
|
||||
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects the unified default across both NWC wallets and CLINK debits. */
|
||||
fun setDefaultPaymentSource(sourceId: String): Boolean {
|
||||
val exists = nwcWallets.value.any { it.id == sourceId } || clinkDebitWallets.value.any { it.id == sourceId }
|
||||
if (defaultPaymentSourceId.value != sourceId && exists) {
|
||||
defaultPaymentSourceId.tryEmit(sourceId)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
@@ -389,7 +488,7 @@ class AccountSettings(
|
||||
if (newServer == null) {
|
||||
if (nwcWallets.value.isNotEmpty()) {
|
||||
nwcWallets.tryEmit(emptyList())
|
||||
defaultNwcWalletId.tryEmit(null)
|
||||
reassignDefaultIfMissing()
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
@@ -546,6 +645,50 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
|
||||
changeDefaultNappletsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultNappletsFollowList(name: TopFilter) {
|
||||
if (defaultNappletsFollowList.value != name) {
|
||||
defaultNappletsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNsitesFollowList(name: FeedDefinition) {
|
||||
changeDefaultNsitesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultNsitesFollowList(name: TopFilter) {
|
||||
if (defaultNsitesFollowList.value != name) {
|
||||
defaultNsitesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
|
||||
changeDefaultWorkoutsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultWorkoutsFollowList(name: TopFilter) {
|
||||
if (defaultWorkoutsFollowList.value != name) {
|
||||
defaultWorkoutsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) {
|
||||
changeDefaultGitRepositoriesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultGitRepositoriesFollowList(name: TopFilter) {
|
||||
if (defaultGitRepositoriesFollowList.value != name) {
|
||||
defaultGitRepositoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
|
||||
changeDefaultCalendarsFollowList(name.code)
|
||||
}
|
||||
@@ -722,6 +865,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
|
||||
changeDefaultAppRecommendationsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
|
||||
if (defaultAppRecommendationsFollowList.value != name) {
|
||||
defaultAppRecommendationsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
@@ -846,12 +1000,41 @@ class AccountSettings(
|
||||
|
||||
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
|
||||
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
|
||||
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
|
||||
// (an empty replacement carrying only an `alt` tag). Don't restore it
|
||||
// on next launch — backing it up would undo clearNutzapInfo() once the
|
||||
// empty event round-trips back through LocalCache.
|
||||
if (newNutzapInfo.mints().isEmpty()) {
|
||||
clearNutzapInfo()
|
||||
return
|
||||
}
|
||||
if (backupNutzapInfo?.id != newNutzapInfo.id) {
|
||||
backupNutzapInfo = newNutzapInfo
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached kind:17375 so a relaunch doesn't restore a wallet the
|
||||
* user just deleted. Called when the wallet event is NIP-09 deleted —
|
||||
* without this the [backupCashuWallet] would be re-consumed into
|
||||
* LocalCache on next launch and resurrect the deleted wallet.
|
||||
*/
|
||||
fun clearCashuWallet() {
|
||||
if (backupCashuWallet != null) {
|
||||
backupCashuWallet = null
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
|
||||
fun clearNutzapInfo() {
|
||||
if (backupNutzapInfo != null) {
|
||||
backupNutzapInfo = null
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NUT-13 keyset counters live in [CashuPreferences], a dedicated
|
||||
* SharedPreferences file with synchronous (`commit = true`) writes.
|
||||
@@ -1128,6 +1311,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// pinned chatrooms
|
||||
// ---
|
||||
|
||||
fun toggleChatroomPin(room: ChatroomKey) {
|
||||
syncedSettings.chats.pinnedChatrooms.update {
|
||||
if (room in it) it - room else it + room
|
||||
}
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
// ---
|
||||
// viewed poll results
|
||||
// ---
|
||||
@@ -1284,6 +1478,13 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
|
||||
if (defaultRelayAuthPolicy.value != policy) {
|
||||
defaultRelayAuthPolicy.tryEmit(policy)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -62,6 +64,14 @@ class AccountSyncedSettings(
|
||||
AccountVideoPlayerPreferences(
|
||||
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
|
||||
)
|
||||
val media =
|
||||
AccountMediaPreferences(
|
||||
MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)),
|
||||
)
|
||||
val chats =
|
||||
AccountChatPreferences(
|
||||
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
@@ -92,6 +102,8 @@ class AccountSyncedSettings(
|
||||
security.addClientTag.value,
|
||||
),
|
||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
|
||||
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -160,6 +172,16 @@ class AccountSyncedSettings(
|
||||
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
||||
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
||||
}
|
||||
|
||||
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
|
||||
if (media.audioVisualizer.value != newAudioVisualizer) {
|
||||
media.audioVisualizer.tryEmit(newAudioVisualizer)
|
||||
}
|
||||
|
||||
val newPinnedChatrooms = syncedSettingsInternal.chats.toChatroomKeys()
|
||||
if (chats.pinnedChatrooms.value != newPinnedChatrooms) {
|
||||
chats.pinnedChatrooms.tryEmit(newPinnedChatrooms)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -253,6 +275,18 @@ class AccountLanguagePreferences(
|
||||
): String? = languagePreferences.value["$source,$target"]
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountMediaPreferences(
|
||||
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountChatPreferences(
|
||||
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
|
||||
)
|
||||
|
||||
internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set<ChatroomKey> = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) }
|
||||
|
||||
@Stable
|
||||
class AccountSecurityPreferences(
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
|
||||
+16
@@ -155,6 +155,8 @@ class AccountSyncedSettingsInternal(
|
||||
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
|
||||
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
|
||||
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
|
||||
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
|
||||
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -197,3 +199,17 @@ class AccountSecurityPreferencesInternal(
|
||||
var sendKind0EventsToLocalRelay: Boolean = false,
|
||||
var addClientTag: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class AccountMediaPreferencesInternal(
|
||||
// Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation).
|
||||
var audioVisualizer: String = "CLASSIC",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class AccountChatPreferencesInternal(
|
||||
// Rooms pinned to the top of the chat list. Each room is its member
|
||||
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
|
||||
// regardless of set iteration order.
|
||||
var pinnedRooms: List<List<String>> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Cross-screen index of the most recent NIP-34 pull-request update event
|
||||
* (kind 1619) per parent pull-request id, kept up to date from
|
||||
* [LocalCache.observeEvents]. A PR update *revises* its parent PR with a
|
||||
* newer commit / merge base, so the UI folds the latest one into the PR rather
|
||||
* than listing updates separately. Like [GitStatusIndex], updates aren't tracked
|
||||
* in `Note.replies`, so a per-row cache scan would otherwise be required.
|
||||
*
|
||||
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
|
||||
* 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest]
|
||||
* is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly]
|
||||
* — never `WhileSubscribed` — because callers read `.value` synchronously and must
|
||||
* not see a stale map when no one is actively collecting. `null` means "not loaded yet".
|
||||
*/
|
||||
object GitPullRequestUpdateIndex {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val latestByPullRequest: StateFlow<Map<HexKey, GitPullRequestUpdateEvent>?> =
|
||||
LocalCache
|
||||
.observeEvents<GitPullRequestUpdateEvent>(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND)))
|
||||
.map { latestByParent(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private fun latestByParent(events: List<GitPullRequestUpdateEvent>): Map<HexKey, GitPullRequestUpdateEvent> {
|
||||
val latest = HashMap<HexKey, GitPullRequestUpdateEvent>()
|
||||
for (event in events) {
|
||||
val target = event.parentPullRequestId() ?: continue
|
||||
val current = latest[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
latest[target] = event
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
}
|
||||
@@ -20,66 +20,78 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Cross-screen index of the most recent NIP-34 status event (kinds
|
||||
* 1630-1633) per target id, kept up to date from
|
||||
* [LocalCache.live.newEventBundles]. Status events are not tracked in
|
||||
* [LocalCache.observeEvents]. Status events are not tracked in
|
||||
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
|
||||
* find them otherwise would be a full cache scan per row.
|
||||
*
|
||||
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
|
||||
* status event (and seeds it from the cache index via `init()`), so [latestByTarget]
|
||||
* is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly]
|
||||
* — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed
|
||||
* filters) read `.value` synchronously and must not see a stale map when no one is
|
||||
* actively collecting. `null` means "not loaded yet".
|
||||
*/
|
||||
object GitStatusIndex {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
|
||||
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
|
||||
private val statusKinds =
|
||||
listOf(
|
||||
GitStatusEvent.KIND_OPEN,
|
||||
GitStatusEvent.KIND_APPLIED,
|
||||
GitStatusEvent.KIND_CLOSED,
|
||||
GitStatusEvent.KIND_DRAFT,
|
||||
)
|
||||
|
||||
fun startIfNeeded() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
|
||||
// events that arrive between scan start and collector attach are picked up.
|
||||
LocalCache.live.newEventBundles
|
||||
.onStart {
|
||||
val initial = HashMap<HexKey, GitStatusEvent>()
|
||||
LocalCache.notes.forEach { _, note ->
|
||||
val event = note.event as? GitStatusEvent ?: return@forEach
|
||||
val target = event.rootEventId() ?: return@forEach
|
||||
val current = initial[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
initial[target] = event
|
||||
}
|
||||
}
|
||||
mutableLatestByTarget.value = initial
|
||||
}.collect { bundle -> processBundle(bundle) }
|
||||
}
|
||||
}
|
||||
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> =
|
||||
LocalCache
|
||||
.observeEvents<GitStatusEvent>(Filter(kinds = statusKinds))
|
||||
.map { reduceLatestByTarget(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private fun processBundle(bundle: Set<Note>) {
|
||||
val snapshot = mutableLatestByTarget.value ?: emptyMap()
|
||||
var modified: HashMap<HexKey, GitStatusEvent>? = null
|
||||
for (note in bundle) {
|
||||
val event = note.event as? GitStatusEvent ?: continue
|
||||
private fun reduceLatestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
|
||||
val latest = HashMap<HexKey, GitStatusEvent>()
|
||||
for (event in events) {
|
||||
val target = event.rootEventId() ?: continue
|
||||
val map = modified ?: snapshot
|
||||
val current = map[target]
|
||||
val current = latest[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
if (modified == null) modified = HashMap(snapshot)
|
||||
modified[target] = event
|
||||
latest[target] = event
|
||||
}
|
||||
}
|
||||
modified?.let { mutableLatestByTarget.value = it }
|
||||
return latest
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the latest status for [targetId] marks it as closed (kind 1632)
|
||||
* or applied/resolved/merged (kind 1631). Items with no status event, or
|
||||
* whose latest status is open (1630) or draft (1633), are considered open.
|
||||
*
|
||||
* Reads from the synchronous snapshot in [latestByTarget]; pass an explicit
|
||||
* [map] to avoid re-reading the value across a batch.
|
||||
*/
|
||||
fun isClosedOrResolved(
|
||||
targetId: HexKey,
|
||||
map: Map<HexKey, GitStatusEvent>? = latestByTarget.value,
|
||||
): Boolean {
|
||||
val event = map?.get(targetId) ?: return false
|
||||
return event is GitStatusClosedEvent || event is GitStatusAppliedEvent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
|
||||
@@ -74,6 +76,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
@@ -105,6 +109,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
@@ -115,12 +120,14 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
@@ -145,6 +152,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
@@ -209,11 +217,12 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
@@ -250,6 +259,7 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
|
||||
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
@@ -784,6 +794,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
// NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the
|
||||
// quoted note. Count it as a boost so it shows in the quoted note's repost
|
||||
// counter alongside kind:6/kind:16 reposts. The quoted note is deliberately
|
||||
// kept out of `replyTo` so the quote still renders as a root post in the home
|
||||
// feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove.
|
||||
addQuoteBoosts(event, note, replyTo)
|
||||
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
true
|
||||
@@ -792,6 +809,25 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [note] as a boost of every event/address referenced by a NIP-18 `q` tag
|
||||
* (a quote-repost). Targets already in [replyTo] are skipped so a note that both
|
||||
* replies to and quotes the same note isn't counted twice, and self-quotes are
|
||||
* ignored.
|
||||
*/
|
||||
private fun addQuoteBoosts(
|
||||
event: Event,
|
||||
note: Note,
|
||||
replyTo: List<Note>,
|
||||
) {
|
||||
event.taggedQuoteIds().forEach { quotedId ->
|
||||
val quoted = checkGetOrCreateNote(quotedId)
|
||||
if (quoted != null && quoted != note && quoted !in replyTo) {
|
||||
quoted.addBoost(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NipTextEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1352,30 +1388,39 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
* resurrected by `computeReplyTo` as a second Note for the same id.
|
||||
* - prune (see [unlinkAndRemove] callers): the whole child subtree is removed.
|
||||
*
|
||||
* Gift-wrapped events additionally drop their decrypted inner host.
|
||||
* Rumors additionally drop the envelope notes that delivered them.
|
||||
*/
|
||||
private fun deleteNote(deleteNote: Note) {
|
||||
(deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) }
|
||||
deleteEnvelopes(deleteNote)
|
||||
|
||||
deleteNote.detachFromChildren()
|
||||
|
||||
unlinkAndRemove(deleteNote)
|
||||
}
|
||||
|
||||
fun deleteWraps(event: WrappedEvent) {
|
||||
event.host?.let { hostStub ->
|
||||
// seal
|
||||
getNoteIfExists(hostStub.id)?.let { hostNote ->
|
||||
val noteEvent = hostNote.event
|
||||
if (noteEvent is WrappedEvent) {
|
||||
deleteWraps(noteEvent)
|
||||
}
|
||||
hostNote.clearFlow()
|
||||
refreshDeletedNoteObservers(hostNote)
|
||||
}
|
||||
/**
|
||||
* Removes the envelope notes that delivered [rumorNote]'s rumor: its
|
||||
* host (normally the kind-1059 wrap; a bare kind-13 seal otherwise)
|
||||
* and, when the host is a wrap, the seal layer it carried. Public
|
||||
* events have no envelopes and are ignored.
|
||||
*/
|
||||
fun deleteEnvelopes(rumorNote: Note) {
|
||||
val host = rumorNote.rumorHost ?: return
|
||||
|
||||
notes.remove(hostStub.id)
|
||||
getNoteIfExists(host.id)?.let { hostNote ->
|
||||
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
|
||||
getNoteIfExists(sealId)?.let { sealNote ->
|
||||
sealNote.clearFlow()
|
||||
refreshDeletedNoteObservers(sealNote)
|
||||
}
|
||||
notes.remove(sealId)
|
||||
}
|
||||
hostNote.clearFlow()
|
||||
refreshDeletedNoteObservers(hostNote)
|
||||
}
|
||||
|
||||
notes.remove(host.id)
|
||||
rumorNote.rumorHost = null
|
||||
}
|
||||
|
||||
fun consume(
|
||||
@@ -2236,20 +2281,18 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
// the wallet service we sent the request to. The pending
|
||||
// entry is left in place so the real response can still
|
||||
// resolve it; we silently drop this one.
|
||||
Log.w(
|
||||
"LocalCache",
|
||||
Log.w("LocalCache") {
|
||||
"Rejecting NWC response ${event.id}: expected author ${match.expected} but event was signed by ${match.actual}. " +
|
||||
"This may be a spoofed reply — keeping the request pending for the legitimate wallet response.",
|
||||
)
|
||||
"This may be a spoofed reply — keeping the request pending for the legitimate wallet response."
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
NwcPaymentTracker.MatchResult.NoMatch -> {
|
||||
Log.w(
|
||||
"LocalCache",
|
||||
Log.w("LocalCache") {
|
||||
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
|
||||
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.",
|
||||
)
|
||||
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag."
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2352,6 +2395,21 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
note.event is AppSpecificDataEvent
|
||||
)
|
||||
|
||||
/**
|
||||
* Tag names whose values should not match text searches: the `client` tag
|
||||
* names the app that published the event (searching for "Amethyst" would
|
||||
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
|
||||
* values are ids or descriptions of other events, not content of this one.
|
||||
*/
|
||||
private val excludedTagNamesFromSearch =
|
||||
setOf(
|
||||
ClientTag.TAG_NAME,
|
||||
PTag.TAG_NAME,
|
||||
ETag.TAG_NAME,
|
||||
ATag.TAG_NAME,
|
||||
AltTag.TAG_NAME,
|
||||
)
|
||||
|
||||
fun findNotesStartingWith(
|
||||
text: String,
|
||||
hiddenUsers: HiddenUsersState,
|
||||
@@ -2391,7 +2449,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (note.event?.tags?.tagValueContains(text, true) == true ||
|
||||
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
note.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
|
||||
@@ -2412,7 +2470,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (addressable.event?.tags?.tagValueContains(text, true) == true ||
|
||||
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
addressable.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
|
||||
@@ -2645,20 +2703,67 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
|
||||
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
|
||||
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
|
||||
// filter when accumulating below.
|
||||
val giftWrapFloor = room.giftWrapHistory.floor
|
||||
val accountNip04Floor = room.nip04History.floor
|
||||
|
||||
room.rooms.map { key, chatroom ->
|
||||
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
childrenToBeRemoved.addAll(removeIfWrap(it))
|
||||
unlinkAndRemove(it)
|
||||
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
|
||||
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
|
||||
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
|
||||
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
|
||||
// kind:4 message, so rooms that never paged conversation history pay nothing.
|
||||
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
|
||||
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
toBeRemoved.forEach { note ->
|
||||
when (val ev = note.event) {
|
||||
is BaseDMGroupEvent ->
|
||||
if (giftWrapFloor != null) {
|
||||
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
|
||||
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
|
||||
}
|
||||
is PrivateDmEvent -> {
|
||||
val until = ev.createdAt
|
||||
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
|
||||
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
|
||||
}
|
||||
}
|
||||
|
||||
childrenToBeRemoved.addAll(removeIfWrap(note))
|
||||
unlinkAndRemove(note)
|
||||
|
||||
childrenToBeRemoved.addAll(note.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
|
||||
// re-requests it on the next demand-advance instead of skipping the hole.
|
||||
if (giftWrapPruned.isNotEmpty()) {
|
||||
room.giftWrapHistory.rewindTo(giftWrapPruned)
|
||||
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
|
||||
}
|
||||
if (accountNip04Pruned.isNotEmpty()) {
|
||||
room.nip04History.rewindTo(accountNip04Pruned)
|
||||
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
|
||||
}
|
||||
if (roomNip04Pruned.isNotEmpty()) {
|
||||
chatroom.nip04History.rewindTo(roomNip04Pruned)
|
||||
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
|
||||
@@ -2669,21 +2774,21 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
fun removeIfWrap(note: Note): List<Note> {
|
||||
val noteEvent = note.event
|
||||
val host = note.rumorHost ?: return emptyList()
|
||||
|
||||
val children =
|
||||
if (noteEvent is WrappedEvent) {
|
||||
noteEvent.host?.id?.let {
|
||||
getNoteIfExists(it)?.let { it2 ->
|
||||
unlinkAndRemove(it2)
|
||||
it2.clearChildLinks()
|
||||
}
|
||||
val children = mutableListOf<Note>()
|
||||
getNoteIfExists(host.id)?.let { hostNote ->
|
||||
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
|
||||
getNoteIfExists(sealId)?.let { sealNote ->
|
||||
unlinkAndRemove(sealNote)
|
||||
children.addAll(sealNote.clearChildLinks())
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return children ?: emptyList()
|
||||
unlinkAndRemove(hostNote)
|
||||
children.addAll(hostNote.clearChildLinks())
|
||||
}
|
||||
note.rumorHost = null
|
||||
return children
|
||||
}
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
@@ -2787,6 +2892,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
|
||||
// detach this note from every quoted note's boosts here.
|
||||
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
|
||||
getNoteIfExists(quotedId)?.removeBoost(note)
|
||||
}
|
||||
|
||||
if (noteEvent is ReportEvent) {
|
||||
noteEvent.reportedAuthor().forEach {
|
||||
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
|
||||
@@ -3516,6 +3627,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RootNappletEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is NamedNappletEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ChessGameEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
@@ -3764,6 +3883,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RoadEventReportEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RoadEventConfirmationEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RelayDiscoveryEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
@@ -3884,6 +4011,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is WorkoutRecordEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ExerciseTemplateEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PaymentTargetsEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Stable
|
||||
@@ -44,7 +44,7 @@ data class UiSettings(
|
||||
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
|
||||
val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS,
|
||||
val bottomBarItems: List<NavBarItem> = DefaultBottomBarItems,
|
||||
val bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
|
||||
val showHomeNewThreadsTab: Boolean = true,
|
||||
val showHomeConversationsTab: Boolean = true,
|
||||
val showHomeEverythingTab: Boolean = false,
|
||||
@@ -53,6 +53,10 @@ data class UiSettings(
|
||||
val showProfileZapReceivedFeed: Boolean = true,
|
||||
val showProfileFollowersFeed: Boolean = true,
|
||||
val dontShowOnchainPublicWarning: Boolean = false,
|
||||
val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS,
|
||||
val accentColor: AccentColorType = AccentColorType.PURPLE,
|
||||
val fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
|
||||
val fontSize: FontSizeType = FontSizeType.NORMAL,
|
||||
)
|
||||
|
||||
enum class ThemeType(
|
||||
@@ -72,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType =
|
||||
else -> ThemeType.SYSTEM
|
||||
}
|
||||
|
||||
enum class AccentColorType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
PURPLE(0, R.string.accent_color_purple),
|
||||
BLUE(1, R.string.accent_color_blue),
|
||||
GREEN(2, R.string.accent_color_green),
|
||||
ORANGE(3, R.string.accent_color_orange),
|
||||
RED(4, R.string.accent_color_red),
|
||||
PINK(5, R.string.accent_color_pink),
|
||||
}
|
||||
|
||||
fun parseAccentColorType(screenCode: Int): AccentColorType =
|
||||
when (screenCode) {
|
||||
AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE
|
||||
AccentColorType.BLUE.screenCode -> AccentColorType.BLUE
|
||||
AccentColorType.GREEN.screenCode -> AccentColorType.GREEN
|
||||
AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE
|
||||
AccentColorType.RED.screenCode -> AccentColorType.RED
|
||||
AccentColorType.PINK.screenCode -> AccentColorType.PINK
|
||||
else -> AccentColorType.PURPLE
|
||||
}
|
||||
|
||||
enum class FontFamilyType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
SYSTEM(0, R.string.font_family_system),
|
||||
SANS_SERIF(1, R.string.font_family_sans_serif),
|
||||
SERIF(2, R.string.font_family_serif),
|
||||
MONOSPACE(3, R.string.font_family_monospace),
|
||||
}
|
||||
|
||||
fun parseFontFamilyType(screenCode: Int): FontFamilyType =
|
||||
when (screenCode) {
|
||||
FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM
|
||||
FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF
|
||||
FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF
|
||||
FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE
|
||||
else -> FontFamilyType.SYSTEM
|
||||
}
|
||||
|
||||
enum class FontSizeType(
|
||||
val scale: Float,
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
SMALL(0.85f, 0, R.string.font_size_small),
|
||||
NORMAL(1.0f, 1, R.string.font_size_normal),
|
||||
LARGE(1.15f, 2, R.string.font_size_large),
|
||||
HUGE(1.3f, 3, R.string.font_size_huge),
|
||||
}
|
||||
|
||||
fun parseFontSizeType(screenCode: Int): FontSizeType =
|
||||
when (screenCode) {
|
||||
FontSizeType.SMALL.screenCode -> FontSizeType.SMALL
|
||||
FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL
|
||||
FontSizeType.LARGE.screenCode -> FontSizeType.LARGE
|
||||
FontSizeType.HUGE.screenCode -> FontSizeType.HUGE
|
||||
else -> FontSizeType.NORMAL
|
||||
}
|
||||
|
||||
enum class ConnectivityType(
|
||||
val prefCode: Boolean?,
|
||||
val screenCode: Int,
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -44,7 +44,7 @@ class UiSettingsFlow(
|
||||
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val bottomBarItems: MutableStateFlow<List<NavBarItem>> = MutableStateFlow(DefaultBottomBarItems),
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
|
||||
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
@@ -53,6 +53,10 @@ class UiSettingsFlow(
|
||||
val showProfileZapReceivedFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE),
|
||||
val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM),
|
||||
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
|
||||
) {
|
||||
val listOfFlows: List<Flow<Any?>> =
|
||||
listOf<Flow<Any?>>(
|
||||
@@ -80,6 +84,10 @@ class UiSettingsFlow(
|
||||
showProfileZapReceivedFeed,
|
||||
showProfileFollowersFeed,
|
||||
dontShowOnchainPublicWarning,
|
||||
suggestWorkoutsFromHealthConnect,
|
||||
accentColor,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
)
|
||||
|
||||
// emits at every change in any of the propertyes.
|
||||
@@ -102,7 +110,7 @@ class UiSettingsFlow(
|
||||
flows[12] as BooleanType,
|
||||
flows[13] as BooleanType,
|
||||
flows[14] as BooleanType,
|
||||
flows[15] as List<NavBarItem>,
|
||||
flows[15] as List<BottomBarEntry>,
|
||||
flows[16] as Boolean,
|
||||
flows[17] as Boolean,
|
||||
flows[18] as Boolean,
|
||||
@@ -111,6 +119,10 @@ class UiSettingsFlow(
|
||||
flows[21] as Boolean,
|
||||
flows[22] as Boolean,
|
||||
flows[23] as Boolean,
|
||||
flows[24] as BooleanType,
|
||||
flows[25] as AccentColorType,
|
||||
flows[26] as FontFamilyType,
|
||||
flows[27] as FontSizeType,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,6 +152,10 @@ class UiSettingsFlow(
|
||||
showProfileZapReceivedFeed.value,
|
||||
showProfileFollowersFeed.value,
|
||||
dontShowOnchainPublicWarning.value,
|
||||
suggestWorkoutsFromHealthConnect.value,
|
||||
accentColor.value,
|
||||
fontFamily.value,
|
||||
fontSize.value,
|
||||
)
|
||||
|
||||
fun update(torSettings: UiSettings): Boolean {
|
||||
@@ -241,6 +257,22 @@ class UiSettingsFlow(
|
||||
dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning)
|
||||
any = true
|
||||
}
|
||||
if (suggestWorkoutsFromHealthConnect.value != torSettings.suggestWorkoutsFromHealthConnect) {
|
||||
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
|
||||
any = true
|
||||
}
|
||||
if (accentColor.value != torSettings.accentColor) {
|
||||
accentColor.tryEmit(torSettings.accentColor)
|
||||
any = true
|
||||
}
|
||||
if (fontFamily.value != torSettings.fontFamily) {
|
||||
fontFamily.tryEmit(torSettings.fontFamily)
|
||||
any = true
|
||||
}
|
||||
if (fontSize.value != torSettings.fontSize) {
|
||||
fontSize.tryEmit(torSettings.fontSize)
|
||||
any = true
|
||||
}
|
||||
|
||||
return any
|
||||
}
|
||||
@@ -290,6 +322,10 @@ class UiSettingsFlow(
|
||||
MutableStateFlow(uiSettings.showProfileZapReceivedFeed),
|
||||
MutableStateFlow(uiSettings.showProfileFollowersFeed),
|
||||
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
|
||||
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
|
||||
MutableStateFlow(uiSettings.accentColor),
|
||||
MutableStateFlow(uiSettings.fontFamily),
|
||||
MutableStateFlow(uiSettings.fontSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.preview.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@Stable
|
||||
|
||||
+33
@@ -108,6 +108,39 @@ class AccountCacheState(
|
||||
}
|
||||
}
|
||||
|
||||
/** The on-disk root that [loadAccount] creates a per-account directory under. */
|
||||
private fun accountsRootDir() = File(rootFilesDir(), "accounts")
|
||||
|
||||
/**
|
||||
* Deletes the on-disk per-account directory (the MLS/Marmot stores created in
|
||||
* [loadAccount]). Call only on permanent account deletion — [removeAccount] just
|
||||
* drops the in-memory copy and leaves these files behind.
|
||||
*/
|
||||
fun deleteAccountFiles(pubkey: HexKey) {
|
||||
val dir = File(accountsRootDir(), pubkey)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes per-account directories left behind by accounts that are no longer saved
|
||||
* (e.g. deleted before [deleteAccountFiles] existed). Keeps only [keepPubkeys]. Safe to
|
||||
* run alongside [loadAccount]: it only loads saved accounts, whose pubkeys are kept.
|
||||
*/
|
||||
fun pruneOrphanAccountDirs(keepPubkeys: Set<HexKey>) {
|
||||
val children = accountsRootDir().listFiles() ?: return
|
||||
children.forEach { child ->
|
||||
if (child.isDirectory && child.name !in keepPubkeys) {
|
||||
if (child.deleteRecursively()) {
|
||||
Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}…" }
|
||||
} else {
|
||||
Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAccount(accountSettings: AccountSettings): Account =
|
||||
loadAccount(
|
||||
signer =
|
||||
|
||||
+4
@@ -78,6 +78,10 @@ class AndroidMarmotMessageStore(
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
val existing = readAll(nostrGroupId).toMutableList()
|
||||
if (innerEventJson in existing) {
|
||||
Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" }
|
||||
return@withLock
|
||||
}
|
||||
existing.add(innerEventJson)
|
||||
writeAll(nostrGroupId, existing)
|
||||
Log.d(TAG) {
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.nip01UserMetadata
|
||||
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The set of relays to read the user's *own* events from for the "Mine" top-nav selection:
|
||||
* the user's NIP-65 outbox, their private-storage relays, their local relays and their proxy
|
||||
* relays. Deliberately mirrors [AccountOutboxRelayState] **minus broadcast**: broadcast relays are
|
||||
* write-only blast targets, so reading the user's own content back from them is wrong — they don't
|
||||
* serve reads and would only waste a subscription.
|
||||
*/
|
||||
class AccountMineRelayState(
|
||||
nip65: Nip65RelayListState,
|
||||
privateStorage: PrivateStorageRelayListState,
|
||||
local: LocalRelayListState,
|
||||
proxy: ProxyRelayListState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow =
|
||||
combine(
|
||||
nip65.outboxFlow,
|
||||
privateStorage.flow,
|
||||
local.flow,
|
||||
proxy.flow,
|
||||
) { nip65Outbox, privateOutBox, localRelays, proxyRelays ->
|
||||
nip65Outbox + privateOutBox + localRelays + proxyRelays
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
nip65.outboxFlow.value +
|
||||
privateStorage.flow.value +
|
||||
local.flow.value +
|
||||
proxy.flow.value,
|
||||
)
|
||||
}
|
||||
+3
@@ -68,6 +68,7 @@ class UserMetadataState(
|
||||
nip05: String? = null,
|
||||
lnAddress: String? = null,
|
||||
lnURL: String? = null,
|
||||
clinkOffer: String? = null,
|
||||
): MetadataEvent {
|
||||
val latest = getUserMetadataEvent()
|
||||
|
||||
@@ -85,6 +86,7 @@ class UserMetadataState(
|
||||
nip05 = nip05,
|
||||
lnAddress = lnAddress,
|
||||
lnURL = lnURL,
|
||||
clinkOffer = clinkOffer,
|
||||
)
|
||||
} else {
|
||||
MetadataEvent.createNew(
|
||||
@@ -98,6 +100,7 @@ class UserMetadataState(
|
||||
nip05 = nip05,
|
||||
lnAddress = lnAddress,
|
||||
lnURL = lnURL,
|
||||
clinkOffer = clinkOffer,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -36,6 +36,12 @@ class Nip11CachedRetriever(
|
||||
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
|
||||
private val retriever = Nip11Retriever(okHttpClient)
|
||||
|
||||
fun trimToSize(maxItems: Int) {
|
||||
relayInformationDocumentCache.trimToSize(maxItems)
|
||||
// relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders;
|
||||
// trimming it saves negligible memory but forces redundant NIP-11 HTTP fetches on resume.
|
||||
}
|
||||
|
||||
fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation {
|
||||
relayInformationEmptyCache.get(relay)?.let { return it }
|
||||
|
||||
|
||||
+11
-4
@@ -47,14 +47,21 @@ class Nip11Retriever(
|
||||
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val url = relay.toHttp()
|
||||
try {
|
||||
val request: Request =
|
||||
val request =
|
||||
try {
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
|
||||
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
val client = okHttpClient(relay)
|
||||
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
@@ -81,8 +88,8 @@ class Nip11Retriever(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
|
||||
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
Log.e("RelayInfoFail", "Failed to fetch NIP-11 from ${relay.url}", e)
|
||||
onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message ?: e::class.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -65,12 +65,10 @@ class NwcSignerState(
|
||||
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
|
||||
*/
|
||||
val defaultWalletUri: StateFlow<Nip47WalletConnect.Nip47URINorm?> =
|
||||
combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId ->
|
||||
if (defaultId != null) {
|
||||
wallets.firstOrNull { it.id == defaultId }?.uri
|
||||
} else {
|
||||
wallets.firstOrNull()?.uri
|
||||
}
|
||||
combine(settings.nwcWallets, settings.defaultPaymentSourceId) { wallets, defaultId ->
|
||||
// Use the NWC wallet the unified default points at; otherwise fall back to the
|
||||
// first NWC wallet so NWC zap routing is unchanged for NWC-only users.
|
||||
(wallets.firstOrNull { it.id == defaultId } ?: wallets.firstOrNull())?.uri
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest())
|
||||
|
||||
|
||||
+2
@@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
|
||||
|
||||
typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
|
||||
|
||||
typealias GitRepositoryListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
|
||||
|
||||
+2
-1
@@ -68,7 +68,8 @@ class TrustedRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
// Synchronously seed public tags from the backup; private tags may be absent on first boot.
|
||||
settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(trustedRelays: List<NormalizedRelayUrl>): TrustedRelayListEvent {
|
||||
|
||||
+358
-75
@@ -20,6 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
@@ -35,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
@@ -45,7 +52,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -55,6 +61,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -96,6 +103,8 @@ class CashuWalletState(
|
||||
private val scope: CoroutineScope,
|
||||
private val assembler: CashuWalletFilterAssembler,
|
||||
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val settings: AccountSettings,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) {
|
||||
@@ -219,6 +228,39 @@ class CashuWalletState(
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/**
|
||||
* Mints to surface in the wallet screen's per-mint list: the union of
|
||||
* our configured mints (kind:17375 — listed even at zero balance so the
|
||||
* user can top them up) and every mint we actually hold tokens at
|
||||
* (token-derived, via [mintBalances]). The token-derived half is what
|
||||
* keeps the per-mint rows summing to [balanceSats]: a balance
|
||||
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
|
||||
* not in our kind:10019) contributes to the total, so without a row for
|
||||
* it the displayed mint balances would silently under-count the wallet.
|
||||
* Configured mints come first; extra token-only mints follow.
|
||||
*/
|
||||
val displayMints: StateFlow<List<String>> =
|
||||
combine(_mints, mintBalances) { configured, balances ->
|
||||
(configured + balances.keys).distinct()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/**
|
||||
* Mints we hold a spendable balance at but never configured in our
|
||||
* kind:17375 wallet — keyed by mint URL → balance in sats. Almost
|
||||
* always coins auto-redeemed from a NIP-61 nutzap that was sent on a
|
||||
* mint outside our kind:10019. Surfaced so the wallet can highlight
|
||||
* them and nudge the user to move the funds to a mint they trust (or
|
||||
* out to Lightning): holding ecash at an unvetted mint means trusting
|
||||
* an issuer the user never chose. Empty in the common case where every
|
||||
* mint we hold is also configured.
|
||||
*/
|
||||
val unconfiguredMintBalances: StateFlow<Map<String, Long>> =
|
||||
combine(_mints, mintBalances) { configured, balances ->
|
||||
balances.filterKeys { it !in configured }.filterValues { it > 0 }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
|
||||
|
||||
@@ -402,12 +444,31 @@ class CashuWalletState(
|
||||
triggerAutoRedeem()
|
||||
}
|
||||
|
||||
// Keep the relay subscription in sync with the outbox set.
|
||||
// Keep the wallet subscription in sync with the relay sets it reads
|
||||
// from. Following the NIP-65 outbox model, the two halves of the
|
||||
// subscription read from different places:
|
||||
// - our own NIP-60 events (wallet/token/history) are read back from
|
||||
// our OUTBOX relays, where we published them;
|
||||
// - inbound kind:9321 nutzaps are read from our INBOX set, since
|
||||
// that is where other people deliver them. Per NIP-61 the source
|
||||
// of truth for "where to send me nutzaps" is the `relay` tags in
|
||||
// our own kind:10019 — and another client may have published that
|
||||
// with relays unrelated to our NIP-65 lists — so we listen on the
|
||||
// union of those plus our NIP-65 inbox + DM relays.
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.IO) {
|
||||
outboxRelaysFlow.collect { relays ->
|
||||
syncSubscription(relays)
|
||||
}
|
||||
combine(
|
||||
outboxRelaysFlow,
|
||||
inboxRelaysFlow,
|
||||
dmRelaysFlow,
|
||||
_nutzapInfoEvent,
|
||||
) { outbox, inbox, dm, info ->
|
||||
CashuWalletQueryState(
|
||||
pubkey = pubKey,
|
||||
ownEventRelays = outbox,
|
||||
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
|
||||
)
|
||||
}.collect { syncSubscription(it) }
|
||||
}
|
||||
|
||||
// Reactive incremental update: any new event arrival that matches our
|
||||
@@ -470,17 +531,16 @@ class CashuWalletState(
|
||||
// ============================================================
|
||||
// Subscription management
|
||||
// ============================================================
|
||||
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
|
||||
private fun syncSubscription(next: CashuWalletQueryState) {
|
||||
val previous = currentSubscription
|
||||
if (relays.isEmpty()) {
|
||||
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = null
|
||||
return
|
||||
}
|
||||
if (previous != null && previous.relays == relays) return // unchanged
|
||||
if (previous == next) return // unchanged
|
||||
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
val next = CashuWalletQueryState(pubKey, relays)
|
||||
currentSubscription = next
|
||||
assembler.subscribe(next)
|
||||
}
|
||||
@@ -563,6 +623,11 @@ class CashuWalletState(
|
||||
// Any wallet event resolves the "discovering" state — whether it
|
||||
// came from cache backfill or a fresh relay delivery.
|
||||
_discovering.value = false
|
||||
// The NUT-13 seed is derived from the wallet's P2PK key. A new
|
||||
// kind:17375 may carry a rotated key (our own recreateNutzapKey, or
|
||||
// a rotation from another client), so drop the cached seed and let
|
||||
// ensureSeed re-derive from whatever key the live event now holds.
|
||||
cachedSeed = null
|
||||
walletEventInternal?.let { evt ->
|
||||
_mints.value =
|
||||
runCatching { evt.mints(signer) }
|
||||
@@ -633,8 +698,16 @@ class CashuWalletState(
|
||||
if (dirtyWallet) {
|
||||
_walletEvent.value = null
|
||||
_mints.value = emptyList()
|
||||
// The cached kind:17375 mirrors the live event; once it's deleted
|
||||
// (our own teardown or an external NIP-09) drop the backup too, or
|
||||
// the next launch would re-consume it from settings and resurrect
|
||||
// the wallet.
|
||||
settings.clearCashuWallet()
|
||||
}
|
||||
if (dirtyNutzapInfo) {
|
||||
_nutzapInfoEvent.value = null
|
||||
settings.clearNutzapInfo()
|
||||
}
|
||||
if (dirtyNutzapInfo) _nutzapInfoEvent.value = null
|
||||
if (dirtyTokens) recomputeUnspent()
|
||||
if (dirtyHistory) _history.value = historyEvents.values.sortedByDescending { it.createdAt }
|
||||
if (dirtyQuotes || dirtyHistory) recomputePending()
|
||||
@@ -665,44 +738,13 @@ class CashuWalletState(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply `del` rollover.
|
||||
val deletedIds = mutableSetOf<HexKey>()
|
||||
all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) }
|
||||
|
||||
val unspent =
|
||||
all
|
||||
.filter { it.id !in deletedIds && tokenContents.containsKey(it.id) }
|
||||
.mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } }
|
||||
.sortedByDescending { it.event.createdAt }
|
||||
|
||||
_tokenEntries.value = unspent
|
||||
// Shared del-rollover + sort with the headless reader.
|
||||
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
|
||||
}
|
||||
|
||||
private fun recomputePending() {
|
||||
val now = TimeUtils.now()
|
||||
// A quote is "pending" if (1) not expired, and (2) no kind:7376 history
|
||||
// event references its id with a "destroyed" marker — completion of the
|
||||
// mint flow deletes the kind:7374, and history records a `destroyed`
|
||||
// reference to the now-fulfilled quote.
|
||||
val destroyedQuoteIds =
|
||||
historyEvents.values
|
||||
.asSequence()
|
||||
.flatMap { it.tags.asSequence() }
|
||||
.filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
|
||||
_pendingQuotes.value =
|
||||
quoteEvents.values
|
||||
.filter { it.id !in destroyedQuoteIds }
|
||||
.filter { evt ->
|
||||
val exp =
|
||||
evt.tags
|
||||
.firstOrNull { it.size >= 2 && it[0] == "expiration" }
|
||||
?.get(1)
|
||||
?.toLongOrNull()
|
||||
exp == null || exp > now
|
||||
}.sortedByDescending { it.createdAt }
|
||||
// Shared destroyed/expired filter with the headless reader.
|
||||
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
|
||||
}
|
||||
|
||||
private fun scanCacheForOwnEvents(): List<Event> {
|
||||
@@ -765,6 +807,80 @@ class CashuWalletState(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stop receiving nutzaps / delete wallet
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Stop receiving NIP-61 nutzaps: replace kind:10019 with an empty version
|
||||
* and NIP-09 delete it (see [CashuWalletOps.stopNutzaps]). Leaves the
|
||||
* kind:17375 wallet and held proofs intact — the wallet keeps sending.
|
||||
*
|
||||
* Clears the local index + on-disk backup immediately so the change is
|
||||
* effective without waiting for the kind:5 round-trip; the deletion bundle
|
||||
* arriving later via [removeEvents] is then a no-op.
|
||||
*/
|
||||
suspend fun stopNutzaps() {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
ops.stopNutzaps()
|
||||
nutzapInfoEventInternal = null
|
||||
_nutzapInfoEvent.value = null
|
||||
settings.clearNutzapInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the whole Cashu wallet: withdraws the nutzap advertisement and
|
||||
* NIP-09 deletes the kind:17375 (see [CashuWalletOps.deleteWallet]). Held
|
||||
* kind:7375 proofs are NOT deleted — that ecash still exists at the mint —
|
||||
* but with the P2PK key gone any unredeemed inbound nutzaps and any
|
||||
* remaining balance may become unrecoverable. The UI must warn first.
|
||||
*
|
||||
* No-op when no wallet is loaded. Clears local indexes + backups inline so
|
||||
* the wallet screen drops straight to its empty/create state.
|
||||
*/
|
||||
suspend fun deleteWallet() {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val wallet = walletEventInternal ?: return
|
||||
ops.deleteWallet(wallet)
|
||||
|
||||
walletEventInternal = null
|
||||
_walletEvent.value = null
|
||||
_mints.value = emptyList()
|
||||
nutzapInfoEventInternal = null
|
||||
_nutzapInfoEvent.value = null
|
||||
settings.clearCashuWallet()
|
||||
settings.clearNutzapInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the wallet's NIP-61 P2PK key: re-publish kind:17375 + kind:10019
|
||||
* with a fresh (or supplied) key, keeping the current mint list. After
|
||||
* this, senders lock nutzaps to the NEW key — any inbound nutzap still
|
||||
* locked to the OLD key that hasn't been redeemed yet becomes
|
||||
* unrecoverable. Rarely needed (key exposure, or restoring a specific key
|
||||
* from a backup), which is why it lives behind the settings Danger Zone.
|
||||
*
|
||||
* [manualPrivkeyHex] null/blank → generate a fresh random key; otherwise
|
||||
* adopt the supplied hex key (e.g. importing a backup).
|
||||
*/
|
||||
suspend fun recreateNutzapKey(manualPrivkeyHex: String? = null) {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val currentMints = _mints.value
|
||||
require(currentMints.isNotEmpty()) { "Wallet has no mints to re-publish" }
|
||||
ops.publishWalletEvents(
|
||||
mints = currentMints,
|
||||
p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() },
|
||||
// Advertise our NIP-65 inbox relays as the nutzap relays (NIP-65
|
||||
// outbox model): senders publish kind:9321 where we read inbound
|
||||
// events. We listen on a wider set (inbox + DM + these tags), but
|
||||
// the kind:10019 default copies the inbox relay list, not outbox.
|
||||
nutzapRelays = inboxRelaysFlow.value.toList(),
|
||||
)
|
||||
// The NUT-13 seed (derived from the P2PK key) is invalidated by
|
||||
// applyEvents when the new kind:17375 round-trips in, so it re-derives
|
||||
// from the rotated key. No need to reset it here.
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Send nutzap
|
||||
// ============================================================
|
||||
@@ -923,6 +1039,161 @@ class CashuWalletState(
|
||||
return outcome
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Find-my-wallet wizard support (cross-relay discovery)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Decrypt a discovered kind:17375 (possibly authored by another client,
|
||||
* pulled in by [com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletDiscovery])
|
||||
* into its mint list + wallet P2PK private key. Returns null if the
|
||||
* content can't be decrypted (not really ours / signer rejected).
|
||||
*/
|
||||
suspend fun decryptDiscoveredWallet(event: CashuWalletEvent): DiscoveredWalletConfig? =
|
||||
runCatching {
|
||||
DiscoveredWalletConfig(
|
||||
mints = event.mints(signer),
|
||||
privkeyHex = event.privkey(signer),
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w("CashuWallet") { "Failed to decrypt discovered wallet ${event.id.take(8)}: ${it.message}" }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Read-only probe of how much ecash is recoverable from a wallet's
|
||||
* NUT-13 seed, per mint, WITHOUT publishing anything. Drives the
|
||||
* wizard's "structural + balance" verification of each discovered
|
||||
* wallet — including OLD/duplicate wallets whose [privkeyHex] differs
|
||||
* from the main wallet's. Mints that error (unreachable, no funds) are
|
||||
* simply absent from the result. Secrets already held by the main
|
||||
* wallet are excluded so the figure reflects *new* recoverable funds.
|
||||
*/
|
||||
suspend fun probeRecoverableFromSeed(
|
||||
privkeyHex: String,
|
||||
mints: List<String>,
|
||||
): Map<String, Long> {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray())
|
||||
val existingSecrets =
|
||||
_tokenEntries.value
|
||||
.flatMap { it.content.proofs }
|
||||
.mapTo(HashSet()) { it.secret }
|
||||
val result = LinkedHashMap<String, Long>()
|
||||
for (mint in mints) {
|
||||
val recoverable =
|
||||
runCatching { ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets) }
|
||||
.onFailure { Log.w("CashuWallet") { "Probe of $mint failed: ${describeMintError(it)}" } }
|
||||
.getOrNull() ?: continue
|
||||
if (!recoverable.isEmpty) result[mint] = recoverable.amountSats
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover every spendable proof derivable from [privkeyHex]'s NUT-13
|
||||
* seed at [mints] into the **current** wallet: each mint is re-scanned
|
||||
* (NUT-09 + NUT-07) and its UNSPENT proofs are published as fresh
|
||||
* kind:7375 owned by this account. Funds locked to an OLD wallet's key
|
||||
* thus land in the main wallet as bearer proofs (surfacing as a
|
||||
* configured- or unconfigured-mint balance the user can then move).
|
||||
*
|
||||
* [bumpCounter] advances this wallet's persistent NUT-13 counter past the
|
||||
* recovered slots — correct ONLY when [privkeyHex] is the **main** wallet's
|
||||
* own key (the wizard adopting a discovered wallet's own balance). For a
|
||||
* FOREIGN old/duplicate wallet it must stay false: the foreign seed's slots
|
||||
* are unrelated to the main seed's, so bumping would corrupt the main
|
||||
* wallet's counter (see [CashuWalletOps.publishRecoveredProofs]).
|
||||
*
|
||||
* Returns the total sats recovered across all mints.
|
||||
*/
|
||||
suspend fun recoverFromSeed(
|
||||
privkeyHex: String,
|
||||
mints: List<String>,
|
||||
bumpCounter: Boolean = false,
|
||||
): Long {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray())
|
||||
var total = 0L
|
||||
for (mint in mints) {
|
||||
// existingSecrets is re-read per mint so a proof published while
|
||||
// recovering an earlier mint isn't double-counted here.
|
||||
val existingSecrets =
|
||||
_tokenEntries.value
|
||||
.flatMap { it.content.proofs }
|
||||
.mapTo(HashSet()) { it.secret }
|
||||
val outcome =
|
||||
runCatching {
|
||||
val recoverable = ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets)
|
||||
val published = ops.publishRecoveredProofs(recoverable)
|
||||
if (bumpCounter) {
|
||||
// Advance past EVERY slot the mint signed, even when all
|
||||
// recovered proofs were already spent (recoverable.proofs
|
||||
// empty after the NUT-07 filter). nextCounterAfterScan is
|
||||
// set from the pre-checkstate scan, so the delta still
|
||||
// covers those slots — without this a fully-spent adopt on
|
||||
// a fresh device would leave the counter at 0 and the next
|
||||
// mint would reuse an already-signed slot (unspendable).
|
||||
val current = settings.peekCashuCounter(recoverable.keysetId)
|
||||
val delta = (recoverable.nextCounterAfterScan - current).coerceAtLeast(0L)
|
||||
if (delta > 0) settings.reserveCashuCounters(recoverable.keysetId, delta.toInt())
|
||||
}
|
||||
published
|
||||
}.onFailure { Log.w("CashuWallet") { "Recover from $mint failed: ${describeMintError(it)}" } }
|
||||
.getOrNull() ?: continue
|
||||
total += outcome.amountRecoveredSats
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a discovered wallet as the account's live + main wallet by
|
||||
* **re-signing a fresh** kind:17375 + kind:10019 with the discovered
|
||||
* wallet's own mints and P2PK key.
|
||||
*
|
||||
* We deliberately do NOT rebroadcast the discovered event verbatim. The
|
||||
* crawl may surface a wallet the user already DELETED: the user's own
|
||||
* NIP-09 kind:5 (from [CashuWalletOps.deleteWallet]) carries both an `e`
|
||||
* tag (the old event id) and an `a` tag (the replaceable `17375:pubkey:`
|
||||
* address). Re-publishing the same event loses on both — relays that
|
||||
* honored the deletion reject the duplicate id, and the `a`-tag rule
|
||||
* re-deletes any version with `created_at <= deletion.created_at` the
|
||||
* moment the kind:5 propagates back (on relays and in our own LocalCache).
|
||||
*
|
||||
* A freshly-signed event sidesteps both: a new id isn't covered by the
|
||||
* `e` tag, and `created_at = now` is newer than the past deletion so it
|
||||
* survives the `a`-tag rule. Same key + mints means the same nutzap
|
||||
* address and the same recoverable funds (the NUT-13 seed is derived from
|
||||
* the key, independent of the event id). [nutzapInfo] is unused in this
|
||||
* path — publishWalletEvents re-issues a fresh kind:10019 advertising our
|
||||
* current inbox relays — but is kept for the decrypt-failure fallback.
|
||||
*/
|
||||
suspend fun adoptDiscoveredWallet(
|
||||
wallet: CashuWalletEvent,
|
||||
nutzapInfo: NutzapInfoEvent? = null,
|
||||
) {
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val config = decryptDiscoveredWallet(wallet)
|
||||
if (config != null && config.mints.isNotEmpty()) {
|
||||
ops.publishWalletEvents(
|
||||
mints = config.mints,
|
||||
p2pkPrivkeyHex = config.privkeyHex,
|
||||
nutzapRelays = inboxRelaysFlow.value.toList(),
|
||||
)
|
||||
} else {
|
||||
// Couldn't decrypt (shouldn't happen for a wallet the wizard
|
||||
// surfaced as valid) — fall back to rebroadcasting the raw event so
|
||||
// it's at least findable. This keeps the original id/created_at and
|
||||
// so remains vulnerable to a prior deletion, but it's the best we
|
||||
// can do without the plaintext to re-sign from.
|
||||
LocalCache.justConsumeMyOwnEvent(wallet)
|
||||
publishEvent(wallet)
|
||||
if (nutzapInfo != null) {
|
||||
LocalCache.justConsumeMyOwnEvent(nutzapInfo)
|
||||
publishEvent(nutzapInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan held kind:7375 events for accidental duplicates and NIP-09
|
||||
* delete the redundant ones. An event B is redundant when there
|
||||
@@ -1025,41 +1296,43 @@ class CashuWalletState(
|
||||
.groupBy { it.content.mint }
|
||||
.filterKeys { mintUrlFilter == null || it == mintUrlFilter }
|
||||
for ((mintUrl, entries) in byMint) {
|
||||
val allProofs = entries.flatMap { it.content.proofs }
|
||||
if (allProofs.isEmpty()) continue
|
||||
val states =
|
||||
runCatching { ops.checkProofStates(mintUrl, allProofs) }
|
||||
// Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance
|
||||
// scrub`. Returns the stale token events it published a deletion for.
|
||||
val staleEvents =
|
||||
runCatching { ops.scrubStaleProofs(mintUrl, entries) }
|
||||
.onFailure {
|
||||
Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it)
|
||||
Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it)
|
||||
}.getOrNull()
|
||||
?: continue
|
||||
|
||||
// Any entry with at least one SPENT proof gets purged. Keeping
|
||||
// mixed-state entries around would let the next send pick them
|
||||
// and trip the same HTTP 400 we're trying to prevent.
|
||||
val staleEntries =
|
||||
entries.filter { entry ->
|
||||
entry.content.proofs.any { states[it.secret] == ProofState.SPENT }
|
||||
}
|
||||
if (staleEntries.isEmpty()) continue
|
||||
if (staleEvents.isEmpty()) continue
|
||||
Log.i("CashuWallet") {
|
||||
"Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl"
|
||||
"Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl"
|
||||
}
|
||||
val staleIds = staleEntries.map { it.event.id }.toSet()
|
||||
runCatching {
|
||||
val template = DeletionEvent.build(staleEntries.map { it.event })
|
||||
val signed = signer.sign(template)
|
||||
publishEvent(signed)
|
||||
}.onFailure {
|
||||
Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it)
|
||||
}
|
||||
// Drop from internal indexes regardless of publish success — even
|
||||
// if the kind:5 didn't go out, we know these proofs are unusable
|
||||
// and shouldn't be selected for the next swap.
|
||||
removeEvents(staleIds)
|
||||
// Drop from internal indexes — even if the kind:5 didn't reach a
|
||||
// relay, these proofs are unusable and must not be re-selected.
|
||||
removeEvents(staleEvents.map { it.id }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively reconcile *every* mint we currently hold tokens at against
|
||||
* its NUT-07 `/checkstate` — not just the one mint a spend happens to
|
||||
* target. [scrubLocallyStaleProofs] with a null filter already iterates
|
||||
* the token-derived mint set ([mintBalances]), so proofs auto-redeemed
|
||||
* from a mint we never configured (e.g. a nutzap on a mint not in our
|
||||
* kind:10019) get their spent state checked here too, instead of sitting
|
||||
* unverified until the user happens to spend from that mint.
|
||||
*
|
||||
* Non-destructive (it only prunes proofs the mint reports SPENT) and
|
||||
* idempotent — safe to call on every wallet-screen open. Deliberately
|
||||
* does NOT run [migrateStaleKeysets]; that swap-then-publish sequence
|
||||
* isn't atomic and stays user-driven.
|
||||
*/
|
||||
suspend fun syncAllMints() {
|
||||
if (!started) return
|
||||
scrubLocallyStaleProofs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate proofs held on inactive keysets onto each mint's current
|
||||
* active keyset. Cheap when nothing needs migrating (one /v1/keys
|
||||
@@ -1096,13 +1369,14 @@ class CashuWalletState(
|
||||
|
||||
/**
|
||||
* Send a NIP-61 nutzap of [amountSats] to [recipientPubKey] referencing
|
||||
* [zappedEvent]. Returns the resulting [NutzapSent] on success or throws
|
||||
* [zappedEvent] (null for a profile nutzap that targets the person, not an
|
||||
* event). Returns the resulting [NutzapSent] on success or throws
|
||||
* — callers should surface errors via [describeMintError].
|
||||
*/
|
||||
suspend fun sendNutzap(
|
||||
amountSats: Long,
|
||||
recipientPubKey: HexKey,
|
||||
zappedEvent: EventHintBundle<out Event>,
|
||||
zappedEvent: EventHintBundle<out Event>?,
|
||||
message: String = "",
|
||||
preferredMintUrl: String? = null,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
@@ -1316,6 +1590,15 @@ class CashuWalletState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted config of a discovered kind:17375 — its configured [mints] and
|
||||
* wallet P2PK private key. See [CashuWalletState.decryptDiscoveredWallet].
|
||||
*/
|
||||
data class DiscoveredWalletConfig(
|
||||
val mints: List<String>,
|
||||
val privkeyHex: String?,
|
||||
)
|
||||
|
||||
/** Mint + recipient pubkey resolved from a kind:10019. */
|
||||
data class NutzapTarget(
|
||||
val mintUrl: String,
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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.nip89AppHandlers
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* This user's public NIP-89 app recommendations: one kind 31989 addressable
|
||||
* event per handled kind, each listing the recommended apps for that kind.
|
||||
*/
|
||||
class AppRecommendationsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
/**
|
||||
* Synchronous cache scan. Seeds [flow] and feeds the read-modify-write
|
||||
* publishers below, which must read current truth from the cache while
|
||||
* holding [publishMutex].
|
||||
*/
|
||||
fun existingRecommendationEvents(): List<AppRecommendationEvent> =
|
||||
cache.addressables
|
||||
.filterIntoSet(AppRecommendationEvent.KIND, signer.pubKey)
|
||||
.mapNotNull { it.event as? AppRecommendationEvent }
|
||||
|
||||
/**
|
||||
* My kind 31989 recommendation events (one per handled kind), kept in
|
||||
* sync as the cache consumes new versions. UI should collect this
|
||||
* instead of rescanning the cache on every event bundle.
|
||||
*
|
||||
* Eagerly started on purpose, like the sibling account states: the
|
||||
* registered observer holds strong references to these notes, pinning
|
||||
* them in the soft-reference cache so the read-modify-write publishers
|
||||
* below never rebuild a 31989 from a partially garbage-collected
|
||||
* snapshot (which would silently drop previously recommended apps).
|
||||
*/
|
||||
val flow: StateFlow<List<AppRecommendationEvent>> =
|
||||
cache
|
||||
.observeEvents<AppRecommendationEvent>(
|
||||
Filter(kinds = listOf(AppRecommendationEvent.KIND), authors = listOf(signer.pubKey)),
|
||||
).flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, existingRecommendationEvents())
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write of the per-kind recommendation events so
|
||||
* two rapid toggles can't race each other into losing updates.
|
||||
*/
|
||||
private val publishMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Returns a createdAt strictly greater than whatever AppRecommendationEvent
|
||||
* currently sits in cache for this d-tag. Needed because
|
||||
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
|
||||
* strictly greater, and TimeUtils.now() has only second resolution.
|
||||
*/
|
||||
private fun nextCreatedAt(supportedKind: String): Long {
|
||||
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
|
||||
val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L
|
||||
return maxOf(TimeUtils.now(), latest + 1)
|
||||
}
|
||||
|
||||
private fun currentRecommendations(supportedKind: String): List<RecommendationTag> {
|
||||
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
|
||||
val event = cache.getAddressableNoteIfExists(address)?.event as? AppRecommendationEvent
|
||||
return event?.recommendations() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [app] to this user's public NIP-89 recommendations, one kind 31989
|
||||
* event per event kind the app declares to handle via `k` tags.
|
||||
*/
|
||||
suspend fun recommendApp(
|
||||
app: AppDefinitionEvent,
|
||||
relayHint: NormalizedRelayUrl?,
|
||||
account: Account,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val kinds = app.supportedKinds()
|
||||
if (kinds.isEmpty()) return
|
||||
|
||||
val newTag = RecommendationTag(app.address(), relayHint, PlatformType.ANDROID.code)
|
||||
|
||||
publishMutex.withLock {
|
||||
kinds.forEach { kind ->
|
||||
val supportedKind = kind.toString()
|
||||
val current = currentRecommendations(supportedKind)
|
||||
if (current.any { it.address == app.address() }) return@forEach
|
||||
|
||||
val template =
|
||||
AppRecommendationEvent.buildFromTags(
|
||||
supportedKind = supportedKind,
|
||||
recommendations = current + newTag,
|
||||
createdAt = nextCreatedAt(supportedKind),
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(account.signer.sign(template))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the app at [address] from every kind 31989 recommendation event of this user. */
|
||||
suspend fun unrecommendApp(
|
||||
address: Address,
|
||||
account: Account,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
publishMutex.withLock {
|
||||
existingRecommendationEvents().forEach { event ->
|
||||
val current = event.recommendations()
|
||||
val updated = current.filterNot { it.address == address }
|
||||
if (updated.size == current.size) return@forEach
|
||||
|
||||
val template =
|
||||
AppRecommendationEvent.buildFromTags(
|
||||
supportedKind = event.dTag(),
|
||||
recommendations = updated,
|
||||
createdAt = nextCreatedAt(event.dTag()),
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(account.signer.sign(template))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -89,8 +89,8 @@ class BlossomServerListState(
|
||||
}.onStart {
|
||||
emit(mergeServerList(flow.value))
|
||||
}.onEach { servers ->
|
||||
if (servers.none { it == settings.defaultFileServer }) {
|
||||
settings.changeDefaultFileServer(servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0])
|
||||
resetTargetOrNull(flow.value, servers, settings.defaultFileServer)?.let {
|
||||
settings.changeDefaultFileServer(it)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
@@ -127,3 +127,25 @@ class BlossomServerListState(
|
||||
alt: String,
|
||||
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the persisted default file server must be reset, and to what.
|
||||
*
|
||||
* Returns the new default server, or `null` when no change should happen.
|
||||
*
|
||||
* The guard on [rawList] being non-empty is what prevents the startup race: before the user's
|
||||
* [BlossomServersEvent] (kind 10063) loads from cache/relay, [rawList] is empty and [merged] is the
|
||||
* transient [DEFAULT_MEDIA_SERVERS] fallback. Resetting against that fallback would clobber the
|
||||
* locally-saved pick on every launch. Only reset once a real, loaded list is in hand and it no
|
||||
* longer contains the current pick (e.g. the user removed it from their list).
|
||||
*/
|
||||
fun resetTargetOrNull(
|
||||
rawList: List<String>,
|
||||
merged: List<ServerName>,
|
||||
current: ServerName,
|
||||
): ServerName? =
|
||||
if (rawList.isNotEmpty() && merged.none { it == current }) {
|
||||
merged.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user