Merge upstream/main into feat/desktop-hashtag-spam-filter

This commit is contained in:
nrobi144
2026-07-01 09:53:09 +03:00
2226 changed files with 295425 additions and 28799 deletions
+87 -44
View File
@@ -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.
+3 -3
View File
@@ -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/`
+2 -2
View File
@@ -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
View File
@@ -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>`.
+1 -1
View File
@@ -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"
+12
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply 2>/dev/null",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
"timeout": 120
}
]
+23 -25
View File
@@ -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.
+23 -5
View File
@@ -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
+22 -113
View File
@@ -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
+1 -1
View File
@@ -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.
+9 -4
View File
@@ -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
+36 -22
View File
@@ -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() }
}
}
```
+6 -6
View File
@@ -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
+2 -3
View File
@@ -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
+183
View File
@@ -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` |
+32 -44
View File
@@ -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
+30 -24
View File
@@ -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")
}
-23
View File
@@ -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.
+8 -2
View File
@@ -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
View File
@@ -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
+185 -5
View File
@@ -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: |
+45 -6
View File
@@ -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>`.
-30
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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. |
+39 -17
View File
@@ -14,7 +14,8 @@ Join the social network you control.
[![PlayStore downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.vitorpamplona.amethyst%26gl%3DUS%26hl%3Den%26l%3DPlayStore%26m%3D%24shortinstalls)](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
[![Last Version](https://img.shields.io/github/release/vitorpamplona/amethyst.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/vitorpamplona/amethyst)
[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst)
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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
View File
@@ -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).
+56 -6
View File
@@ -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)
+3
View File
@@ -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.
+36
View File
@@ -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`). |
@@ -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
@@ -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
@@ -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`
@@ -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
@@ -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.
@@ -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)
@@ -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/`
@@ -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)
}
}
@@ -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)
@@ -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,
)
@@ -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))
}
}
+101 -1
View File
@@ -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>
@@ -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? {
@@ -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()
}
}
@@ -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),
@@ -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
@@ -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 =
@@ -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) {
@@ -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,
)
}
@@ -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,
)
}
@@ -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 }
@@ -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)
}
}
}
@@ -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())
@@ -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
@@ -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 {
@@ -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,
@@ -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))
}
}
}
}
@@ -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