diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0a55a49f52..c1ba392b9d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 — use `/nip ` 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) @@ -123,16 +112,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 +121,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:** diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md index 6b43f0d2bf..0b1f5eacb1 100644 --- a/.claude/commands/desktop-run.md +++ b/.claude/commands/desktop-run.md @@ -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/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md index 99c01eea7c..c9648a01ab 100644 --- a/.claude/commands/extract.md +++ b/.claude/commands/extract.md @@ -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 diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 16056cd4c4..1af746f6dd 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -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//` 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`. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 5b1409249a..d2e869fb85 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -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" diff --git a/.claude/hooks/stop-spotless.sh b/.claude/hooks/stop-spotless.sh new file mode 100755 index 0000000000..61666ba6ab --- /dev/null +++ b/.claude/hooks/stop-spotless.sh @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json index f44ad3a03c..15b897a70f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply 2>/dev/null", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh", "timeout": 120 } ] diff --git a/.claude/skills/account-state/SKILL.md b/.claude/skills/account-state/SKILL.md index ec370630eb..528a2dd273 100644 --- a/.claude/skills/account-state/SKILL.md +++ b/.claude/skills/account-state/SKILL.md @@ -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`. - - Expose a `StateFlow` 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.`. +- **Are you rendering "my follows", "my mutes", "my relays"?** → `account..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). diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md index e9bf1256f3..d7e37c5524 100644 --- a/.claude/skills/account-state/references/account-state-flow.md +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -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..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 = _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. diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 1116915273..02b33e8158 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -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. @@ -739,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) { ```gradle android { namespace = 'com.vitorpamplona.amethyst' - compileSdk = 36 + compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts defaultConfig { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) - targetSdk = 36 // Android 15 + targetSdk = 37 // android-targetSdk in libs.versions.toml versionCode = 447 - versionName = "1.11.0" + versionName = generateVersionName(libs.versions.app.get(), rootDir) vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md index 26b72b1b79..8b11a52d1b 100644 --- a/.claude/skills/auth-signers/SKILL.md +++ b/.claude/skills/auth-signers/SKILL.md @@ -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. diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index cbd3f5d48d..477880202d 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -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 diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md index 38011f82cb..ad829582f0 100644 --- a/.claude/skills/desktop-expert/references/desktop-navigation.md +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -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 diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md index 7526d5d34e..aada17ce3c 100644 --- a/.claude/skills/feed-patterns/SKILL.md +++ b/.claude/skills/feed-patterns/SKILL.md @@ -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 (abstract base) │ +│ IAdditiveFeedFilter / AdditiveFeedFilter │ +│ ChangesFlowFilter │ +│ FeedContentState, FeedState — the flow the UI collects │ └─────────────────────────────────────────────────────────────┘ ▲ │ uses │ ┌─────────────────────────────────────────────────────────────┐ -│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │ -│ FeedFilter (abstract) │ +│ amethyst/.../ui/dal/ (Android-only additions) │ │ AdditiveComplexFeedFilter │ -│ 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. │ +│ model/LocalCache.kt + account..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 : IFeedFilter`. Has `feed(): List` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`. +- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter : FeedFilter(), IAdditiveFeedFilter`. 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`. Has `feed(): List` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache). -- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`. 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 : FeedFilter()`: like `AdditiveFeedFilter` but the incoming items (`Set`) 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>` (or plain `FeedFilter` if additivity doesn't matter). Implement: +1. **Define the filter.** Extend `AdditiveFeedFilter` (or plain `FeedFilter` if additivity doesn't matter; `AdditiveComplexFeedFilter` 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): Set` and `sort(collection: Set): List`. + - If additive: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. 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 diff --git a/.claude/skills/feed-patterns/references/feed-filter-composition.md b/.claude/skills/feed-patterns/references/feed-filter-composition.md index 3f345b1b16..4c00329919 100644 --- a/.claude/skills/feed-patterns/references/feed-filter-composition.md +++ b/.claude/skills/feed-patterns/references/feed-filter-composition.md @@ -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` | -| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter>` | +| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter` | +| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter` (Android-only) | | The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter` + `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>() { +) : AdditiveFeedFilter() { override fun feedKey(): String = "Hashtag-$hashtag" @@ -28,7 +29,7 @@ class HashtagFeedFilter( override fun feed(): List { 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() } } } ``` diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md index 208caa11cc..04f67cf403 100644 --- a/.claude/skills/gradle-expert/SKILL.md +++ b/.claude/skills/gradle-expert/SKILL.md @@ -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.0" # 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 diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 411f4d0233..8b07e1bced 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -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.0" +composeBom = "2026.05.01" // AndroidX Compose ``` **Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md index bc1d81bc06..619170dd3e 100644 --- a/.claude/skills/kotlin-expert/SKILL.md +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -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 diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 373b14eb2b..c8c87c0cd7 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -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 = emptyList()): String - fun nprofileEncode(pubkey: HexKey, relays: List = emptyList()): String - fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List = 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) : Nip19Result() - data class NProfile(val hex: HexKey, val relays: List) : Nip19Result() - data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List) : Nip19Result() -} +// Encode TLV entities with relay hints (relays: List) +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") } ``` diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 950f2cb1a1..a18731696f 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -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 diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ebc53886f7..3c1293679f 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -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) +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( 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(Filter(search = "bitcoin")) ``` --- diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md deleted file mode 100644 index 44c187e0be..0000000000 --- a/.claude/skills/quartz-kmp.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 8aaa258d05..0bdea82309 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -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 │ ├── 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` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7abc8593ca..ee372de840 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7c5cdf1b47..a6be60a4f8 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 540d8466b2..de8cc824b9 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -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 diff --git a/.gitignore b/.gitignore index 459dc6effa..45f5f1d139 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 2dfa6bd053..733ec3a8ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -428,7 +428,7 @@ class AppModules( // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) okHttpClientForRelays.getHttpClient(useTor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 3bf5f7eeca..720d8d6375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ 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.model.AccountSettings @@ -123,7 +124,9 @@ private object PrefKeys { const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList" 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" @@ -401,9 +404,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) @@ -565,6 +578,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 +634,10 @@ object LocalPreferences { } } } + val clinkDebitsLoaded = + async { + parseOrNull>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList() + } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } @@ -694,7 +713,15 @@ object LocalPreferences { defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities), defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks), nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), - defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second), + clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()), + // 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 + ?: nwcWalletsLoaded.await().second + ?: clinkDebitsLoaded.await().firstOrNull()?.id, + ), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a899b593e8..89e3159f35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -22,9 +22,12 @@ 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.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -193,7 +196,10 @@ class AccountSettings( val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), - val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + val clinkDebitWallets: MutableStateFlow> = 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 = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -335,14 +341,17 @@ class AccountSettings( 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 id = defaultNwcWalletId.value val wallets = nwcWallets.value - return if (id != null) { - wallets.firstOrNull { it.id == id } - } else { - wallets.firstOrNull() - } + return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull() } fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri @@ -353,8 +362,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() @@ -364,16 +375,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 } @@ -399,7 +462,7 @@ class AccountSettings( if (newServer == null) { if (nwcWallets.value.isNotEmpty()) { nwcWallets.tryEmit(emptyList()) - defaultNwcWalletId.tryEmit(null) + reassignDefaultIfMissing() saveAccountSettings() return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index d3179dd07f..3bacb99eb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d7abdecf83..8b06eee55b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -65,12 +65,10 @@ class NwcSignerState( * Flow of the default wallet's NWC URI, derived from multi-wallet settings. */ val defaultWalletUri: StateFlow = - 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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 68aada42cb..a437179a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -105,4 +105,42 @@ class AccountsTorStateConnector( SharingStarted.Eagerly, emptySet(), ) + + // Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK + // Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the + // money-operations Tor preference instead of being classified as generic "new" relays. + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val allMoneyOpRelaysFlow: Flow> = + accountsCache.accounts + .debounce(200) + .transformLatest { snapshot -> + val perAccountFlows = + snapshot.map { (_, account) -> + combine( + account.settings.nwcWallets, + account.settings.clinkDebitWallets, + ) { nwcWallets, clinkDebitWallets -> + val relays = mutableSetOf() + nwcWallets.forEach { relays.add(it.uri.relayUri) } + clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) } + relays.toSet() + } + } + + val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) } + + emitAll( + combine(ready) { perAccount -> + val moneyOpRelays = mutableSetOf() + perAccount.forEach { moneyOpRelays.addAll(it) } + moneyOpRelays.toSet() + }, + ) + }.onEach { + torEvaluatorFlow.moneyOpRelays.tryEmit(it) + }.stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b6822fc21d..bd0366a1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient @Stable @@ -45,6 +46,58 @@ class TorRelayState( val dmRelays = MutableStateFlow>(emptySet()) val trustedRelays = MutableStateFlow>(emptySet()) + /** + * Relays known to be used for money operations from persistent configuration: NIP-47 wallet + * relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all + * logged-in accounts. These follow the money-operations Tor preference (see [TorRelayEvaluation]). + */ + val moneyOpRelays = MutableStateFlow>(emptySet()) + + /** + * Money-operation relays registered for the lifetime of a single ad-hoc round-trip whose relay + * isn't a saved wallet — e.g. paying someone's CLINK offer (`noffer`) pointer. Reference-counted + * so overlapping payments that share a relay don't unregister it while another is still in flight. + */ + private val adHocMoneyOpCounts = MutableStateFlow>(emptyMap()) + + private fun currentMoneyOpRelays(): Set = moneyOpRelays.value + adHocMoneyOpCounts.value.keys + + /** + * Marks [relays] as money-operation relays until a matching [unregisterMoneyOpRelays] call. + * Used by the CLINK offer/debit payers so a one-off payment relay honors the money-operations + * Tor preference instead of being treated as a generic "new" relay. + */ + fun registerMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { this[it] = (this[it] ?: 0) + 1 } + } + } + } + + fun unregisterMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { + val next = (this[it] ?: 0) - 1 + if (next <= 0) remove(it) else this[it] = next + } + } + } + } + + private fun currentSettings() = + TorRelaySettings( + torType = torSettingsFlow.torType.value, + onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, + dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, + newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, + trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, + moneyOperationsViaTor = torSettingsFlow.moneyOperationsViaTor.value, + ) + val torSettings = combine( torSettingsFlow.torType, @@ -66,27 +119,15 @@ class TorRelayState( newRelaysViaTor = newRelaysViaTor, trustedRelaysViaTor = trustedRelaysViaTor, ) + }.combine(torSettingsFlow.moneyOperationsViaTor) { settings, moneyOperationsViaTor -> + settings.copy(moneyOperationsViaTor = moneyOperationsViaTor) }.onStart { - emit( - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), - ) + emit(currentSettings()) }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), + currentSettings(), ) val flow = @@ -94,12 +135,21 @@ class TorRelayState( torSettings, trustedRelays, dmRelays, - ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + moneyOpRelays, + adHocMoneyOpCounts, + ) { + torSettings: TorRelaySettings, + trustedRelayList: Set, + dmRelayList: Set, + moneyOpRelayList: Set, + adHocMoneyOps: Map, + -> emit( TorRelayEvaluation( torSettings = torSettings, trustedRelayList = trustedRelayList, dmRelayList = dmRelayList, + moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys, ), ) }.onStart { @@ -108,6 +158,7 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) }.flowOn(Dispatchers.IO) @@ -118,10 +169,22 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) - fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay) + /** + * Resolves the Tor preference for [relay] from live source values rather than the cached [flow] + * snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the + * very next connection attempt, with no dependency on the combine pipeline having propagated yet. + */ + fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = + TorRelayEvaluation( + torSettings = currentSettings(), + trustedRelayList = trustedRelays.value, + dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), + ).useTor(relay) fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt new file mode 100644 index 0000000000..664b55ed61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -0,0 +1,152 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Debits payer round-trips: publishes a kind-21002 request (pay an + * invoice, or authorize a spending budget) and waits for the encrypted reply. The wallet + * authorizes against the account's own identity (no shared secret). + * + * This is the CLINK-debit spend rail that the zap button / offer card route through + * when a debit pointer is the selected default payment source. It MUST only be invoked + * after an explicit user confirmation — a debit moves real sats. + * + * Consume-only: Amethyst sends debit requests, it never answers them. + */ +object ClinkDebitPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * Asks the wallet to pay [bolt11]. + * + * @return the decrypted response (`res:"ok"` with optional preimage, or a `GFY` + * failure), or null if no reply arrived in time or the pointer carried no relay. + */ + suspend fun payInvoice( + account: Account, + pointer: NDebit, + bolt11: String, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? = + // Off the Main thread: building the request signs + NIP-44 encrypts, and callers reach + // this from Compose (Main) scopes (offer card, lightning-address row). See ClinkOfferPayer. + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) + } + + /** + * Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time + * budget; otherwise it recurs every `frequency` (day/week/month). + */ + suspend fun requestBudget( + account: Account, + pointer: NDebit, + amountSats: Long, + frequency: DebitFrequency? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? = + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) + } + + // Debits sign with the persistent account identity (unlike offer requests, which use a + // throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a + // budget authorization can cover repeat debits instead of prompting on every payment. + private fun clientFor( + pointer: NDebit, + account: Account, + ): DebitClient? = if (pointer.relays.isEmpty()) null else DebitClient(pointer, account.signer) + + /** Publishes [request] to the pointer's relays and awaits the matching kind-21002 reply. */ + private suspend fun sendAndAwait( + account: Account, + client: DebitClient, + request: DebitEvent, + timeoutMs: Long, + ): DebitResponse? { + val relays = client.pointer.relays.toSet() + + val reply = CompletableDeferred() + // A random short id: relays cap subscription ids at 64 chars (NIP-01); the reply is matched + // by request id in the listener, not by subId. + val subId = newSubId() + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is DebitEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + // Saved debit wallets are already fed into the money-op relay set by AccountsTorStateConnector, + // but register here too so a freshly-added wallet paid before that flow propagates — and any + // non-saved debit pointer — still routes under the money-operations Tor preference rather than + // the generic `newRelaysViaTor` policy. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) + account.client.subscribe(subId, filters, listener) + return try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + // Treat an undecryptable/malformed reply as no usable response rather than + // throwing — callers only handle null, and an uncaught decode error would + // leave the calling UI hung (spinner stuck, no toast, sibling zaps cancelled). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt new file mode 100644 index 0000000000..28a0d64fb0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -0,0 +1,128 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Offers payer round-trip: publishes a kind-21001 request to the + * offer's relays and waits for the service's encrypted reply, returning the decrypted + * [OfferResponse] (an invoice or an error). UI then hands a successful `bolt11` to the + * existing pay path (e.g. `payViaIntent`). + * + * Consume-only: Amethyst never answers offer requests, it only asks. + */ +object ClinkOfferPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * @param amountSats overrides the pointer's embedded price (required for spontaneous offers). + * @return the decrypted response, or null if no reply arrived before [timeoutMs] (or the + * pointer carried no relay to reach). + */ + suspend fun requestInvoice( + account: Account, + offer: NOffer, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): OfferResponse? { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return null + + // Keep the round-trip off the Main thread: the ephemeral keygen, JSON serialization, + // NIP-44 encryption and signing are CPU/crypto-heavy, and callers reach this from a + // Compose (Main) scope. StrictMode flags any of it running on the UI thread. + return withContext(Dispatchers.IO) { + // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker + // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this + // key and decrypted with it), so there is no reason to expose the user's real identity + // to every offer service they pay. Payer identity, when needed, travels in the request + // body (payer_data / a signed zap request), not the transport key. + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val client = OfferClient(offer, ephemeralSigner) + val request = client.requestInvoice(amountSats = amountSats) + + val reply = CompletableDeferred() + // A random short id: relays cap subscription ids at 64 chars (NIP-01) and reject an + // over-long REQ outright. The reply is matched by request id in the listener, not by subId. + val subId = newSubId() + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + // The offer's relays are an ad-hoc payment endpoint, not a saved wallet, so register them + // as money-operation relays for the duration of the round-trip. Otherwise account.client + // would treat them as generic "new" relays and route them per `newRelaysViaTor`, silently + // pushing the payment through Tor (and failing on services that block Tor exits) even when + // the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and + // BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) + account.client.subscribe(subId, filters, listener) + try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return@withContext null + // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON + // from a buggy or hostile relay) is treated as no usable response rather than + // thrown — callers only handle null, and an uncaught decode error would hang + // the UI (the Pay button stuck on "Requesting…"). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 9eb62ea3b7..977c0d5959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -44,8 +46,10 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.util.concurrent.atomic.AtomicInteger import kotlin.math.round class ZapPaymentHandler( @@ -206,14 +210,27 @@ class ZapPaymentHandler( onProgress(0.75f) } - if (account.nip47SignerState.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - // onProgress(1f) - } else { - onPayViaIntent(payables.toImmutableList()) - onProgress(0f) + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) + } } } @@ -337,7 +354,7 @@ class ZapPaymentHandler( onProgress: (percent: Float) -> Unit, context: Context, ): List { - var progressAllPayments = 0.00f + val progress = PaymentProgress(payables.size, onProgress) return mapNotNullAsync( items = payables, @@ -346,9 +363,8 @@ class ZapPaymentHandler( bolt11 = payable.invoice, zappedNote = note, onResponse = { response -> + progress.step() if (response is PayInvoiceErrorResponse) { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) onError( stringRes(context, R.string.error_dialog_pay_invoice_error), stringRes( @@ -359,15 +375,68 @@ class ZapPaymentHandler( ), payable.info.user, ) - } else { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) } }, ) - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) + progress.step() + + Paid(payable, true) + }, + ) + } + + /** + * Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two + * half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction. + * The counter is atomic because `mapNotNullAsync` runs the payables concurrently and the + * response half-step fires from an async callback, so plain `+=` would lose updates. + */ + private class PaymentProgress( + payableCount: Int, + private val onProgress: (percent: Float) -> Unit, + ) { + private val totalSteps = (payableCount * 2).coerceAtLeast(1) + private val done = AtomicInteger(0) + + fun step() = onProgress(done.incrementAndGet().toFloat() / totalSteps) + } + + /** + * Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to + * settle the BOLT-11. The service authorizes against the account identity. + * + * Fire-and-forget, like the NWC rail ([payViaNWC]): the request is dispatched on the + * account scope and each payable is reported paid optimistically so the zap UI completes + * promptly. A `GFY`/failure (or no reply within the debit timeout) surfaces later through + * [onError] rather than blocking the zap on the service's response. + */ + suspend fun payViaClinkDebit( + payables: List, + pointer: NDebit, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ): List { + val progress = PaymentProgress(payables.size, onProgress) + + return mapNotNullAsync( + items = payables, + runRequestFor = { payable: Payable -> + account.scope.launch { + val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + progress.step() + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + payable.info.user, + ) + } + } + + progress.step() Paid(payable, true) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index c83719af68..4c1f3263d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -303,6 +303,22 @@ fun NewUserMetadataScreen( singleLine = true, ) + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.clink_offer_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.clinkOffer.value, + onValueChange = { postViewModel.clinkOffer.value = it }, + placeholder = { + Text( + text = "noffer1…", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + // -- Social Proofs -- ExpandableSection( title = stringRes(R.string.social_proof), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 826b849028..4d7a908ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -60,6 +60,7 @@ class NewUserMetadataViewModel : ViewModel() { val nip05 = mutableStateOf("") val lnAddress = mutableStateOf("") val lnURL = mutableStateOf("") + val clinkOffer = mutableStateOf("") val twitter = mutableStateOf("") val github = mutableStateOf("") @@ -85,6 +86,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info.nip05 ?: "" lnAddress.value = it.info.lud16 ?: "" lnURL.value = it.info.lud06 ?: "" + clinkOffer.value = it.info.clinkOffer ?: "" } twitter.value = "" @@ -124,6 +126,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + clinkOffer = clinkOffer.value, ) val identities = @@ -149,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = "" lnAddress.value = "" lnURL.value = "" + clinkOffer.value = "" twitter.value = "" github.value = "" mastodon.value = "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index e7145aa8cc..e39f2e4334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment +import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment @@ -108,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -503,6 +505,10 @@ private fun RenderWordWithoutPreview( // as a wall of base64. is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + // Decoding is local and the network round-trip only fires on the Pay tap, + // so the offer card is safe to render even in the no-preview path. + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> Text(word.segmentText) @@ -549,6 +555,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 95264c003d..b6b99019e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -197,6 +197,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen @@ -317,6 +318,7 @@ fun BuildNavigation( composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEndArgs { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c592b4e8a..748a84b465 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -210,6 +210,10 @@ sealed class Route { @Serializable object WalletAddCashu : Route() + @Serializable data class WalletAddClinkDebit( + val ndebit: String? = null, + ) : Route() + @Serializable object CashuWallet : Route() @Serializable object CashuWalletSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt new file mode 100644 index 0000000000..ed8bb6d80d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.invoice + +import android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.ClinkOfferPayer +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlinx.coroutines.launch + +/** + * Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay" + * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, + * then pays it through the user's default payment source (confirmed for in-app wallets, + * see [InvoicePaymentDispatcher]). + */ +@Composable +fun ClinkOfferPreview( + offer: NOffer, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var requesting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } + var amountInput by remember { mutableStateOf("") } + var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) } + var amountRange by remember { mutableStateOf(null) } + // The pointer actually paid: starts as the rendered offer, swapped if the service + // replies "Expired or Moved" (code 3) with a replacement noffer. + var activeOffer by remember(offer) { mutableStateOf(offer) } + + errorMessage?.let { + ErrorMessageDialog( + title = stringRes(context, R.string.error_dialog_pay_invoice_error), + textContent = it, + onDismiss = { errorMessage = null }, + ) + } + + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { errorMessage = it }, + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) { + Icon( + imageVector = CustomHashTagIcons.Lightning, + contentDescription = null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringRes(R.string.clink_lightning_offer), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + val copiedMessage = stringRes(R.string.copied_to_clipboard) + IconButton( + onClick = { + scope.launch { + clipboard.setText(activeOffer.encode()) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.copy_to_clipboard), + tint = MaterialTheme.colorScheme.primary, + modifier = Size20Modifier, + ) + } + } + + HorizontalDivider(thickness = DividerThickness) + + // FIXED offers display their preset price; SPONTANEOUS offers (and the default + // when the pointer omits a price type) require the payer to enter an amount. + // Reflect the pointer actually being charged (which may have changed if the + // service redirected us to a replacement noffer via "Expired or Moved"). + val effectiveType = activeOffer.priceType + + if (effectiveType == OfferPriceType.FIXED) { + activeOffer.price?.let { + Text( + text = "$it ${stringRes(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + } + + if (needsAmount) { + OutlinedTextField( + value = amountInput, + onValueChange = { new -> amountInput = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_offer_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = + amountRange?.let { range -> + val min = range.min + val max = range.max + if (min != null && max != null) { + { Text(stringRes(R.string.clink_offer_amount_range, min.toString(), max.toString())) } + } else { + null + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + + val amountRequired = needsAmount + + suspend fun runOfferRequest( + useOffer: NOffer, + followMoved: Boolean, + ) { + val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price + + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount) + + val bolt11 = response?.bolt11 + val movedTo = + if (response?.code == OfferErrorCode.EXPIRED_OR_MOVED && followMoved) { + response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + } else { + null + } + + when { + bolt11 != null -> { + requesting = false + payingInvoice = bolt11 + } + // Follow a relocated offer once, paying the replacement pointer. + movedTo != null -> { + activeOffer = movedTo + runOfferRequest(movedTo, followMoved = false) + } + response?.code == OfferErrorCode.INVALID_AMOUNT -> { + // Reveal the amount field (or refine it) with the service's range. + requesting = false + needsAmount = true + amountRange = response.range + errorMessage = + response.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_offer_invalid_amount) + } + else -> { + requesting = false + errorMessage = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.error_dialog_pay_invoice_error) + } + } + } + + Button( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L), + onClick = { + requesting = true + scope.launch { runOfferRequest(activeOffer, followMoved = true) } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = stringRes(if (requesting) R.string.clink_requesting_invoice else R.string.pay), + color = Color.White, + fontSize = 20.sp, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt new file mode 100644 index 0000000000..3487f7fd44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.invoice + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse + +/** + * Pays a single BOLT-11 from an in-post card (offer card, invoice card) through the + * user's selected default payment source. + * + * Unlike the zap button — a deliberate small-amount tap that fires immediately — a + * card "Pay" can be a larger or variable amount, so an **in-app** payment (NWC or CLINK + * debit) is gated behind a confirmation dialog. The external-wallet path needs no extra + * confirmation: the wallet app presents its own. + * + * Drive it from a nullable `bolt11` state: set it to trigger, [onClear] resets it. + */ +@Composable +fun InvoicePaymentDispatcher( + bolt11: String?, + accountViewModel: AccountViewModel, + onClear: () -> Unit, + onError: (String) -> Unit, + onSuccess: () -> Unit = {}, +) { + if (bolt11 == null) return + val context = LocalContext.current + + val source = remember(bolt11) { accountViewModel.account.settings.defaultPaymentSource() } + + if (source == null) { + // No in-app wallet configured -> hand off to an external wallet app (it confirms). + LaunchedEffect(bolt11) { + payViaIntent(bolt11, context, onSuccess, onError) + onClear() + } + return + } + + val amountSats = + remember(bolt11) { + try { + LnInvoiceUtil.getAmountInSats(bolt11).toLong().takeIf { it > 0 } + } catch (_: Exception) { + null + } + } + + ConfirmPaymentDialog( + amountSats = amountSats, + sourceName = source.name, + onConfirm = { + when (source) { + is PaymentSource.Nwc -> + accountViewModel.sendZapPaymentRequestFor(bolt11, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> onSuccess() + is PayInvoiceErrorResponse -> + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + else -> {} + } + } + + is PaymentSource.ClinkDebit -> + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, bolt11) { response -> + if (response?.isOk() == true) { + onSuccess() + } else { + onError( + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + } + } + onClear() + }, + onDismiss = onClear, + ) +} + +@Composable +private fun ConfirmPaymentDialog( + amountSats: Long?, + sourceName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val message = + if (amountSats != null) { + val amountText = "$amountSats ${stringRes(context, R.string.sats)}" + stringRes(context, R.string.clink_confirm_pay_amount_via_source, amountText, sourceName) + } else { + stringRes(context, R.string.clink_confirm_pay_via_source, sourceName) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_confirm_payment_title)) }, + text = { Text(message) }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringRes(R.string.pay)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index ae6c23419f..23b5be1417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -89,7 +88,7 @@ fun MayBeInvoicePreview( LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> CrossfadeIfEnabled(targetState = invoiceAmount, label = "MayBeInvoicePreview", accountViewModel = accountViewModel) { if (it != null) { - InvoicePreview(it.invoice, it.amount) + InvoicePreview(it.invoice, it.amount, accountViewModel) } else { Text( text = lnbcWord, @@ -104,10 +103,12 @@ fun MayBeInvoicePreview( fun InvoicePreview( lnInvoice: String, amount: String?, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current var showErrorMessageDialog by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { ErrorMessageDialog( @@ -117,6 +118,13 @@ fun InvoicePreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { showErrorMessageDialog = it }, + ) + Column( modifier = Modifier @@ -172,7 +180,7 @@ fun InvoicePreview( Modifier .fillMaxWidth() .padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } }, + onClick = { payingInvoice = lnInvoice }, shape = QuoteBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index cfa7304b62..dc8a448dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User @@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize @@ -116,13 +121,23 @@ fun WatchResponses( val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { + // Snapshot once per result list, not per row. + val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } + LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel, trailingContent) { onSelect(item) } + val trailing = + trailingContent + ?: if (item.pubkeyHex in priority) { + { InThisChatChip() } + } else { + null + } + UserLine(item, accountViewModel, trailing) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -133,6 +148,22 @@ fun WatchResponses( } } +@Composable +private fun InThisChatChip() { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(R.string.user_suggestion_in_this_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + @Composable fun UserLine( baseUser: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index f066303433..76858b0776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client @@ -58,10 +59,36 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Moves users whose pubkey is in [priority] to the top of [found], + * preserving the relative order everywhere else (stable sort). Reorders + * only — it never adds or removes entries, so priority keys whose users + * didn't match the search have no effect. + */ +fun rankPriorityFirst( + found: List, + priority: Set, +): List = + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } + +/** + * Drives the @-mention autocomplete dropdown: searches the local cache, + * relays, and NIP-05 identifiers for the word currently being typed. + * + * [priorityPubkeys] is a live supplier of pubkeys to rank first in the + * results — pass the current conversation's participants (NIP-17 room + * users, public-chat authors, MLS group members, …) so they beat + * network-wide matches. Ranking only; it never filters anyone out. + */ @Stable class UserSuggestionState( val account: Account, val nip05Client: INip05Client, + val priorityPubkeys: () -> Set = { emptySet() }, ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") @@ -158,7 +185,10 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) + rankPriorityFirst( + account.cache.findUsersStartingWith(prefix, account), + priorityPubkeys(), + ) } } else { emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e4906a91c6..5e32335ddf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor @@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -87,6 +89,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -1669,6 +1673,11 @@ class AccountViewModel( replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, ) { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and collects + // the referenced users as p-tags. Lives here (not in the composer) so + // every send path gets mention handling. + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer // event through LocalCache which already handles own-message display. @@ -1676,10 +1685,11 @@ class AccountViewModel( account.marmotManager ?.buildTextMessage( nostrGroupId = nostrGroupId, - text = text, + text = tagger.message, replyToEventId = replyToInnerEventId, replyToAuthorPubKey = replyToInnerAuthorPubKey, persistOwn = false, + mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return val relays = account.marmotGroupRelays(nostrGroupId) @@ -2069,6 +2079,22 @@ class AccountViewModel( onSent() } + /** + * Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail + * counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted + * response (`isOk()` with optional preimage, or a GFY failure), or null on timeout, + * delivered on the main dispatcher so UI callbacks (toasts, dialogs) are safe. + * Untested end-to-end. + */ + fun payInvoiceViaClinkDebit( + pointer: NDebit, + bolt11: String, + onResult: (DebitResponse?) -> Unit, + ) = launchSigner { + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + withContext(Dispatchers.Main) { onResult(response) } + } + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b5936e8f2c..24d1dc92d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,16 +46,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote @@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -96,19 +96,15 @@ fun MarmotGroupChatView( WatchLifecycleAndUpdateModel(feedViewModel) - val chatroom = - remember(nostrGroupId) { - accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) - } + val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(nostrGroupId) DisposableEffect(nostrGroupId) { - chatroom.markAsRead() + newMessageModel.chatroom?.markAsRead() onDispose { } } - val messageState = remember(nostrGroupId) { TextFieldState() } - val replyTo = remember(nostrGroupId) { mutableStateOf(null) } - // Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS // message in the Notifications screen) into the actual Note once it has // landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids. @@ -116,14 +112,14 @@ fun MarmotGroupChatView( LaunchedEffect(replyToInnerNote) { val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote) if (parent != null) { - replyTo.value = parent + newMessageModel.reply(parent) } } } if (draftMessage != null) { LaunchedEffect(draftMessage) { - messageState.setTextAndPlaceCursorAtEnd(draftMessage) + newMessageModel.editFromDraft(draftMessage) } } @@ -139,7 +135,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { note -> replyTo.value = note }, + onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, ) } @@ -148,8 +144,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, - messageState = messageState, - replyTo = replyTo, + newMessageModel = newMessageModel, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -162,49 +157,59 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, - messageState: TextFieldState, - replyTo: MutableState, + newMessageModel: MarmotNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current var isUploading by remember { mutableStateOf(false) } - val uploadState = - remember { - ChatFileUploadState( - defaultServer = accountViewModel.account.settings.defaultFileServer, - defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, - ) - } - // Upload dialog - uploadState.multiOrchestrator?.let { - MarmotGroupFileUploadDialog( - nostrGroupId = nostrGroupId, - state = uploadState, - accountViewModel = accountViewModel, - nav = nav, - onUpload = { onMessageSent() }, - onCancel = uploadState::reset, - ) + DisposableEffect(nostrGroupId) { + onDispose { newMessageModel.userSuggestions?.reset() } } - replyTo.value?.let { + // Upload dialog + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + } + + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null + newMessageModel.clearReply() } } Column(modifier = EditFieldModifier) { + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } + ThinPaddingTextField( - state = messageState, + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, onContentReceived = { uri, mimeType -> - uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), shape = EditFieldBorder, placeholder = { @@ -216,9 +221,7 @@ fun MarmotGroupMessageComposer( leadingIcon = { MarmotGalleryLeadingIcon( isUploading = isUploading, - onImageChosen = { selectedMedia -> - uploadState.load(selectedMedia) - }, + onImageChosen = newMessageModel::pickedMedia, ) }, trailingIcon = { @@ -226,33 +229,18 @@ fun MarmotGroupMessageComposer( isActive = canPost, modifier = EditFieldTrailingIconModifier, ) { - val text = messageState.text.toString().trim() - if (text.isNotEmpty()) { - // Capture id+pubKey snapshot under the value? guard so - // a slow send doesn't race a user-cleared reply state. - val parentEvent = replyTo.value?.event - val replyId = parentEvent?.id - val replyAuthor = parentEvent?.pubKey - scope.launch(Dispatchers.IO) { - try { - accountViewModel.sendMarmotGroupMessage( - nostrGroupId = nostrGroupId, - text = text, - replyToInnerEventId = replyId, - replyToInnerAuthorPubKey = replyAuthor, - ) - messageState.clearText() - replyTo.value = null - onMessageSent() - } catch (e: Exception) { - launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to send message: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to send message: ${e.message}", + Toast.LENGTH_SHORT, + ).show() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt new file mode 100644 index 0000000000..d5501a39cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -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.ui.screen.loggedIn.chats.marmotGroup.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Marmot/MLS group message field, mirroring the + * structure of the other chat composers (ChatNewMessageViewModel, + * ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention + * suggestions, reply state, and file-upload state. Sending goes through + * AccountViewModel.sendMarmotGroupMessage, which owns mention tagging. + */ +@Stable +open class MarmotNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var nostrGroupId: HexKey? = null + var chatroom: MarmotGroupChatroom? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() }, + ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(nostrGroupId: HexKey) { + if (this.nostrGroupId != nostrGroupId) { + this.nostrGroupId = nostrGroupId + this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + /** Sends the field's text. Mention rewriting and p-tagging happen in + * AccountViewModel.sendMarmotGroupMessage. Throws on send failure so + * the caller can surface the error. */ + suspend fun sendPost() { + val groupId = nostrGroupId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + // Capture id+pubKey snapshot before suspending so a slow send + // doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = groupId, + text = text, + replyToInnerEventId = parentEvent?.id, + replyToInnerAuthorPubKey = parentEvent?.pubKey, + ) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 45d481b0af..8bbc1f2fe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -261,7 +261,12 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { room.value?.users ?: emptySet() }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f826189c6d..6a89bb8fe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -188,7 +188,16 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + // Public channels have no membership; recent posters are the + // closest thing. The cutoff also bounds the note scan. + channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b112e5264a..02b4cdc9ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -379,39 +380,57 @@ fun DvmPaymentActions( if (invoice != null) { val context = LocalContext.current Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - onStatusUpdate(nwcPaymentRequest) - }, - onResponse = { response -> + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + onStatusUpdate(nwcPaymentRequest) + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> onStatusUpdate( - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { + if (response?.isOk() == true) { thankYou + } else { + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) }, ) - }, - ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - onStatusUpdate(thankYou) - }, - onError = { - onStatusUpdate(it) - }, - ) + } + } + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou + }, + ) + }, + ) + } + + null -> { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } } }) { val amountInInvoice = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 4357b1eda9..a9e7993a97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -194,7 +194,16 @@ open class NestNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + (room?.event as? MeetingSpaceEvent)?.let { space -> + space.participantKeys().toSet() + space.pubKey + } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index bcb315079c..579f6c07a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent -import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( @@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter( ( noteEvent is PictureEvent || noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is AddressableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 2b2ac59469..38337579a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText @@ -109,22 +110,38 @@ fun DisplayLNAddress( lud16, user, accountViewModel, - onSuccess = { + onSuccess = { invoice -> zapExpanded = false - // pay directly - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = stringRes(context, R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = - response.error?.message - ?: response.error?.code?.toString() - ?: stringRes(context, R.string.error_parsing_error_message) + // pay directly through the selected default payment source + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> + if (response?.isOk() == true) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else { + showErrorMessageDialog = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) + } } } - } else { - payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor(invoice, null) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message) + } + } + } + + null -> { + payViaIntent(invoice, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + } } }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index f10296ca4a..d066d9b143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +import android.util.LruCache +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -28,15 +32,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User @@ -63,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.lastSeenSentence import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,13 +81,18 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserApp import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity @@ -87,6 +102,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private const val IDENTITY_ICON_CACHE_KEY = 0 @@ -218,6 +234,8 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) + DisplayClinkOffer(user, accountViewModel) + DisplayPaymentTargets(baseUser, accountViewModel) val website = user.info.website @@ -377,3 +395,113 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is GitHubIdentity -> R.string.github else -> R.string.github } + +/** + * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the + * lowercased nip05 address (NIP-05 identifiers are case-insensitive). Without it, every + * profile visit (and every relay-pushed kind-0 refresh while a profile is open) would + * re-fetch the domain's nostr.json. Caches "no offer" results too so profiles without one + * aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable parsed pointer + * (LruCache can't store nulls); absence means "not fetched yet". + */ +private class ResolvedClinkOffer( + val noffer: NOffer?, +) + +private val clinkOfferNip05Cache = LruCache(256) + +/** + * Shows a profile's advertised CLINK Offer as a compact, tappable chip (preferring the kind-0 + * `clink_offer` field, falling back to the NIP-05 `.well-known` `clink_offer`, cached). Tapping + * the chip expands the payable [ClinkOfferPreview] card — collapsed by default so the full card + * isn't shown until the user opts in. + */ +@Composable +private fun DisplayClinkOffer( + userInfo: UserInfo, + accountViewModel: AccountViewModel, +) { + val kind0Offer = + remember(userInfo) { + userInfo.info.clinkOffer()?.let { ClinkPointerParser.parse(it) as? NOffer } + } + + var offer by remember(userInfo) { mutableStateOf(kind0Offer) } + + val nip05 = userInfo.info.nip05 + LaunchedEffect(kind0Offer, nip05) { + if (kind0Offer != null) { + offer = kind0Offer + return@LaunchedEffect + } + // Fall back to the NIP-05 .well-known clink_offer (cached per address). + val id = nip05?.let { Nip05Id.parse(it) } + offer = + if (id != null && nip05 != null) { + // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. + val cacheKey = nip05.lowercase() + val cached = clinkOfferNip05Cache.get(cacheKey) + if (cached != null) { + cached.noffer + } else { + val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } + val parsed = fetched?.let { ClinkPointerParser.parse(it) as? NOffer } + clinkOfferNip05Cache.put(cacheKey, ResolvedClinkOffer(parsed)) + parsed + } + } else { + null + } + } + + offer?.let { resolved -> + var expanded by remember(resolved) { mutableStateOf(false) } + Column { + ClinkOfferChip(expanded) { expanded = !expanded } + if (expanded) { + ClinkOfferPreview(resolved, accountViewModel) + } + } + } +} + +/** + * Compact, payment-target-style chip for a profile's CLINK Offer. Tapping it toggles the + * payable [ClinkOfferPreview] card open/closed; collapsed by default so the profile mirrors the + * other payment-target chips instead of showing the full card up front. + */ +@Composable +private fun ClinkOfferChip( + expanded: Boolean, + onClick: () -> Unit, +) { + val label = stringRes(R.string.clink_lightning_offer) + Surface( + shape = RoundedCornerShape(50), + color = BitcoinOrange.copy(alpha = 0.10f), + border = BorderStroke(1.dp, BitcoinOrange.copy(alpha = if (expanded) 0.6f else 0.35f)), + modifier = + Modifier + .padding(vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = label, + tint = BitcoinOrange, + modifier = Size16Modifier, + ) + Text( + text = label, + color = BitcoinOrange, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt new file mode 100644 index 0000000000..9de69a8906 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt @@ -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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.getText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import kotlinx.coroutines.launch + +/** + * Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC + * there is no secret to paste — authorization is the account's own identity, pre-approved + * on the wallet service — so this screen only collects a name and the pointer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddClinkDebitWalletScreen( + accountViewModel: AccountViewModel, + nav: INav, + ndebit: String? = null, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + + var walletName by remember { mutableStateOf("") } + var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) } + var error by remember { mutableStateOf(null) } + var qrScanning by remember { mutableStateOf(false) } + + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_add_clink_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = walletName, + onValueChange = { walletName = it }, + label = { Text(stringRes(R.string.wallet_name)) }, + placeholder = { Text(stringRes(R.string.wallet_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Paste from clipboard + IconButton( + onClick = { + scope.launch { + val clipText = clipboardManager.getText() + if (clipText != null) { + ndebitUri = clipText + error = null + } + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentPaste, + contentDescription = stringRes(id = R.string.paste_from_clipboard), + modifier = Size24Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + + // QR code scanner + IconButton(onClick = { qrScanning = true }) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 3), + contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + ndebitUri = it + error = null + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = ndebitUri, + onValueChange = { + ndebitUri = it + error = null + }, + label = { Text(stringRes(R.string.wallet_paste_ndebit)) }, + placeholder = { Text("ndebit1...") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + val invalidMessage = stringRes(R.string.wallet_add_clink_invalid) + Button( + onClick = { + if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) { + nav.popBack() + } else { + error = invalidMessage + } + }, + enabled = ndebitUri.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.wallet_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt index 0c4d4e90de..2c33e6523f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -105,6 +105,12 @@ fun AddWalletScreen( description = stringRes(R.string.wallet_add_cashu_description), onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) }, ) + WalletTypeCard( + icon = MaterialSymbols.Bolt, + title = stringRes(R.string.wallet_add_clink_title), + description = stringRes(R.string.wallet_add_clink_description), + onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt new file mode 100644 index 0000000000..735a2f70ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency + +/** + * Asks the user for a CLINK debit spending budget: an amount and a cadence (one-time, or + * recurring per day/week/month). [onConfirm] receives the amount in sats and the chosen + * [DebitFrequency] (null for one-time). + */ +@Composable +fun ClinkBudgetDialog( + onConfirm: (amountSats: Long, frequency: DebitFrequency?) -> Unit, + onDismiss: () -> Unit, +) { + var amount by remember { mutableStateOf("") } + var cadence by remember { mutableStateOf(BudgetCadence.ONE_TIME) } + + val parsedAmount = amount.toLongOrNull() ?: 0L + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_budget_title)) }, + text = { + Column { + OutlinedTextField( + value = amount, + onValueChange = { new -> amount = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_budget_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + BudgetCadence.entries.forEach { option -> + CadenceRow( + option = option, + selected = cadence == option, + onSelect = { cadence = option }, + ) + } + } + }, + confirmButton = { + Button( + enabled = parsedAmount > 0, + onClick = { onConfirm(parsedAmount, cadence.toFrequency()) }, + ) { + Text(stringRes(R.string.clink_budget_request)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun CadenceRow( + option: BudgetCadence, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .selectable(selected = selected, onClick = onSelect), + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(stringRes(option.labelRes)) + } +} + +private enum class BudgetCadence( + val labelRes: Int, +) { + ONE_TIME(R.string.clink_budget_one_time), + DAILY(R.string.clink_budget_daily), + WEEKLY(R.string.clink_budget_weekly), + MONTHLY(R.string.clink_budget_monthly), + ; + + fun toFrequency(): DebitFrequency? = + when (this) { + ONE_TIME -> null + DAILY -> DebitFrequency(1, DebitFrequency.UNIT_DAY) + WEEKLY -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) + MONTHLY -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index cf096aa501..2f99a9c5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -66,6 +67,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -82,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency import kotlinx.coroutines.launch import java.text.NumberFormat import androidx.compose.material3.Icon as Material3Icon @@ -214,6 +217,7 @@ private fun MultiWalletHomeContent( cashuMintCount: Int, ) { val walletInfoList by walletViewModel.walletInfoList.collectAsState() + val context = LocalContext.current LaunchedEffect(Unit) { walletViewModel.fetchAllBalances() @@ -234,8 +238,11 @@ private fun MultiWalletHomeContent( WalletCard( walletInfo = walletInfo, onSelect = { - walletViewModel.selectWallet(walletInfo.walletId) - nav.nav(Route.WalletDetail(walletInfo.walletId)) + // The detail screen is NWC-only (balance/transactions); debits have neither. + if (walletInfo.canShowBalance) { + walletViewModel.selectWallet(walletInfo.walletId) + nav.nav(Route.WalletDetail(walletInfo.walletId)) + } }, onSetDefault = { walletViewModel.setDefaultWallet(walletInfo.walletId) @@ -246,6 +253,24 @@ private fun MultiWalletHomeContent( onRemove = { walletViewModel.removeWallet(walletInfo.walletId) }, + // Spending-budget authorization is a CLINK-debit-only capability. + onSetBudget = + if (!walletInfo.canShowBalance) { + { amount, frequency -> + walletViewModel.requestDebitBudget(walletInfo.walletId, amount, frequency) { response -> + val error = response?.failureDetail() + val msg = + when { + response?.isOk() == true -> context.getString(R.string.clink_budget_approved) + !error.isNullOrBlank() -> error + else -> context.getString(R.string.clink_debit_no_response) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + } else { + null + }, ) } @@ -285,9 +310,21 @@ private fun WalletCard( onSetDefault: () -> Unit, onRename: (String) -> Unit, onRemove: () -> Unit, + onSetBudget: ((Long, DebitFrequency?) -> Unit)? = null, ) { var showRemoveDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } + var showBudgetDialog by remember { mutableStateOf(false) } + + if (showBudgetDialog && onSetBudget != null) { + ClinkBudgetDialog( + onConfirm = { amount, frequency -> + showBudgetDialog = false + onSetBudget(amount, frequency) + }, + onDismiss = { showBudgetDialog = false }, + ) + } if (showRemoveDialog) { AlertDialog( @@ -325,7 +362,7 @@ private fun WalletCard( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onSelect), + .clickable(enabled = walletInfo.canShowBalance, onClick = onSelect), shape = RoundedCornerShape(16.dp), border = if (walletInfo.isDefault) { @@ -377,8 +414,14 @@ private fun WalletCard( } } - // Balance - if (walletInfo.isLoading && walletInfo.balanceSats == null) { + // Balance — debits are spend-only, so show a capability badge instead. + if (!walletInfo.canShowBalance) { + Text( + text = stringRes(R.string.clink_debit_pay_only), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (walletInfo.isLoading && walletInfo.balanceSats == null) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { Column(horizontalAlignment = Alignment.End) { @@ -440,6 +483,16 @@ private fun WalletCard( Text(stringRes(R.string.wallet_rename), style = MaterialTheme.typography.bodySmall) } + if (onSetBudget != null) { + OutlinedButton( + onClick = { showBudgetDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text(stringRes(R.string.clink_budget_set), style = MaterialTheme.typography.bodySmall) + } + } + Spacer(modifier = Modifier.weight(1f)) IconButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e335b033a6..e988daeed4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -22,9 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod @@ -94,6 +100,8 @@ data class WalletInfo( val isDefault: Boolean = false, val isLoading: Boolean = false, val error: String? = null, + // CLINK debits are spend-only: no balance/transactions to fetch or show. + val canShowBalance: Boolean = true, ) private const val NWC_TIMEOUT_MS = 30_000L @@ -111,23 +119,43 @@ class WalletViewModel : ViewModel() { private val _wallets = MutableStateFlow>(emptyList()) val wallets = _wallets.asStateFlow() + private val _debitWallets = MutableStateFlow>(emptyList()) + val debitWallets = _debitWallets.asStateFlow() + private val _defaultWalletId = MutableStateFlow(null) val defaultWalletId = _defaultWalletId.asStateFlow() val walletInfoList = - combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap -> - wallets.map { wallet -> - val info = infoMap[wallet.id] - WalletInfo( - walletId = wallet.id, - name = wallet.name, - alias = info?.alias, - balanceSats = info?.balanceSats, - isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()), - isLoading = info?.isLoading == true, - error = info?.error, - ) - } + combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap -> + // The unified default falls back to the first source overall (NWC before debits). + val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id + + val nwcRows = + wallets.map { wallet -> + val info = infoMap[wallet.id] + WalletInfo( + walletId = wallet.id, + name = wallet.name, + alias = info?.alias, + balanceSats = info?.balanceSats, + isDefault = wallet.id == effectiveDefault, + isLoading = info?.isLoading == true, + error = info?.error, + canShowBalance = true, + ) + } + + val debitRows = + debits.map { debit -> + WalletInfo( + walletId = debit.id, + name = debit.name, + isDefault = debit.id == effectiveDefault, + canShowBalance = false, + ) + } + + nwcRows + debitRows }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // Selected wallet for detail view @@ -218,8 +246,9 @@ class WalletViewModel : ViewModel() { fun refreshWalletList() { val acc = account ?: return _wallets.value = acc.settings.nwcWallets.value - _defaultWalletId.value = acc.settings.defaultNwcWalletId.value - _hasWalletSetup.value = _wallets.value.isNotEmpty() + _debitWallets.value = acc.settings.clinkDebitWallets.value + _defaultWalletId.value = acc.settings.defaultPaymentSourceId.value + _hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty() } fun refreshWalletSetup() { @@ -258,16 +287,70 @@ class WalletViewModel : ViewModel() { fun setDefaultWallet(walletId: String) { val acc = account ?: return - acc.settings.setDefaultNwcWallet(walletId) - _defaultWalletId.value = walletId + // Only reflect the change locally if it actually persisted (the id must exist + // in one of the lists); otherwise the star and the stored default would diverge. + if (acc.settings.setDefaultPaymentSource(walletId)) { + _defaultWalletId.value = walletId + } } fun removeWallet(walletId: String) { val acc = account ?: return - acc.settings.removeNwcWallet(walletId) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.removeClinkDebitWallet(walletId) + } else { + acc.settings.removeNwcWallet(walletId) + } refreshWalletList() } + /** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */ + fun addClinkDebitWallet( + name: String, + ndebit: String, + ): Boolean { + val acc = account ?: return false + val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false + val entry = + ClinkDebitWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Debit" }, + pointer = pointer, + ) + acc.settings.addClinkDebitWallet(entry) + refreshWalletList() + return true + } + + /** + * Asks a CLINK debit wallet to authorize a spending budget (kind-21002). Omit + * [frequency] for a one-time budget; otherwise it recurs every day/week/month. + * [onResult] reports the wallet's decision (ok, GFY error text, or null on timeout). + */ + fun requestDebitBudget( + walletId: String, + amountSats: Long, + frequency: DebitFrequency?, + onResult: (DebitResponse?) -> Unit, + ) { + val acc = account ?: return + val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return + viewModelScope.launch { + // A malformed budget (e.g. an out-of-spec frequency unit) makes requestBudget throw; + // treat it as "no response" so the dialog dismisses instead of hanging on a spinner. + val response = + try { + ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency) + } catch (_: IllegalArgumentException) { + null + } + onResult(response) + } + } + fun addWallet( name: String, uri: Nip47WalletConnect.Nip47URINorm, @@ -292,7 +375,11 @@ class WalletViewModel : ViewModel() { newName: String, ) { val acc = account ?: return - acc.settings.renameNwcWallet(walletId, newName) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.renameClinkDebitWallet(walletId, newName) + } else { + acc.settings.renameNwcWallet(walletId, newName) + } refreshWalletList() } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8a89516664..d6137ede96 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -207,6 +207,7 @@ Upravuje výšku vašeho hlasu. Poznámka: základní změny výšky hlasu mohou být odhodlanými posluchači potenciálně zpětně rozpoznány. Uživatel nemá nastavenou LN adresu pro přijímání sats "Odpověď zde…" + V tomto chatu Zkopíruje ID poznámky do schránky pro sdílení Zkopírovat ID kanálu (poznámka) do schránky Upravit metadata kanálu @@ -267,8 +268,13 @@ Vygenerovat nový klíč Načítání zdroje Načítání účtu + šifrované + zastaralé + Hledání původní zprávy… + Tuto zprávu se nepodařilo najít + Prohledány všechny relaye · klepnutím zobrazíte "Chyba při načítání odpovědí: " Zkusit znovu Zatím žádná oznámení. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index f3a137fd77..530a7f41f8 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -205,6 +205,7 @@ erie gespeichert Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden. Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen "Hier antworten…" + In diesem Chat Kopiert die Notiz-ID zum Teilen in die Zwischenablage Kopiere Kanal-ID (Notiz) in die Zwischenablage Bearbeitet die Kanalmetadaten @@ -267,8 +268,13 @@ anz der Bedingungen ist erforderlich Neuen Schlüssel generieren Feed wird geladen Konto wird geladen + verschlüsselt + veraltet + Suche nach der ursprünglichen Nachricht… + Diese Nachricht konnte nicht gefunden werden + Alle Relays durchsucht · zum Anzeigen tippen "Fehler beim Laden der Antworten: " Erneut versuchen Noch keine Benachrichtigungen. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a4c303f918..ca3a27e48f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -267,8 +267,13 @@ Wygeneruj nowy klucz Wczytywanie zawartości Ładowanie konta + zaszyfrowane + starsza wersja + Szukam oryginalnej wiadomości… + Nie można znaleźć tej wiadomości + Przeszukano wszystkie transmitery · kliknij, by wyświetlić "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. @@ -2104,6 +2109,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odtwórz wideo w pływającym oknie (opcja ukryta, jeśli nie jest obsługiwana) Przenieś na urządzenie Prześlij film do urządzenia Chromecast podłączonego do sieci Wi-Fi (opcja ukryta w przypadku plików lokalnych) + Wizualizator dźwięku + Wybierz animację wyświetlaną podczas odtwarzania plików audio. + Wyłączone + Spektrogram + Barwa fali + Pierścień promieniowy + Aurora Glow + Fala klasyczna + Obraz statyczny Zdjęcie profilowe %1$s Transmiter %1$s Rozwiń listę transmiterów @@ -2857,6 +2871,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Sfinansowano %1$s z %2$s satoszów Kończy się %1$s Darowizna on-chain + + Birdex · %1$d gatunek + Birdex · %1$d gatunków + Birdex · %1$d gatunków + Birdex · %1$d gatunki + + + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + Kwota zbiorki (w satoszach) 100000 Opisz cel zbiórki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8a79361319..4c9fb017f7 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -203,6 +203,7 @@ Altera o tom da sua voz. Nota: alterações básicas de tom podem potencialmente ser revertidas por ouvintes determinados. Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " + Neste chat Copia o ID do canal (note) para compartilhar Copiar ID do canal (Note) Editar os dados do canal @@ -263,8 +264,13 @@ Gerar uma nova chave Carregando feed Carregando conta + criptografado + legado + Procurando a mensagem original… + Não foi possível encontrar esta mensagem + Pesquisado em todos os relays · toque para ver "Erro ao carregar respostas" Tente novamente Ainda não há notificações. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e6c38d7673..764adfd388 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -203,6 +203,7 @@ Ändrar tonhöjden på din röst. Obs: enkla förändringar av tonhöjd kan potentiellt återskapas av målmedvetna lyssnare. Användaren har inte en Lightningadressinställning för att ta emot sats "svara här.. " + I den här chatten Kopierar antecknings-ID till urklipp för delning Kopiera kanal-ID (anteckningen) till Urklipp Redigerar kanalmetadata @@ -263,8 +264,13 @@ Skapa en ny nyckel Ladda flöde Laddar kontot + krypterat + föråldrat + Letar efter ursprungsmeddelandet… + Kunde inte hitta detta meddelande + Sökte på alla relayer · tryck för att visa "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6309b2ccfd..177e96cbdc 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -201,6 +201,7 @@ 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " + 在此聊天中 复制笔记ID到剪贴板以便于在 Nostr 中分享 复制频道ID(笔记)到剪贴板 修改频道元数据 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 626953ddeb..46156114fb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,6 +112,30 @@ Logout Show More Lightning Invoice + CLINK Offer + Requesting invoice… + The debit service did not complete the payment. + Confirm payment + Pay %1$s via %2$s? + Pay this invoice via %1$s? + Amount (sats) + Enter a valid amount for this offer. + Allowed range: %1$s–%2$s sats + CLINK Offer (noffer) + Budget + Spending budget + Amount (sats) + Request + Budget approved + One-time + Daily + Weekly + Monthly + Pay only + CLINK Debit + Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. + Invalid CLINK debit pointer. Expected an ndebit1… string. + Paste ndebit pointer Pay Lightning Tips Note to Receiver @@ -214,6 +238,7 @@ Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats "reply here… " + In this chat Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index ed03deb8e7..a9b1ef80cd 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -31,9 +31,12 @@ import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -1408,7 +1411,7 @@ class AmethystAppFunctions { // "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice // for you to paste somewhere." Falls back to manual when NWC // isn't set up or the wallet declines. - val nwc = payViaNwcOrNull(account, invoice, null) + val nwc = payViaDefaultSourceOrNull(account, invoice, null) return ZapResult( chain = "lightning", @@ -1633,7 +1636,7 @@ class AmethystAppFunctions { // Try NWC for every invoice that came back. Failed splits // stay as a manual invoice with nwcError set — the others // still go through. - val nwc = invoice?.let { payViaNwcOrNull(account, it, note) } + val nwc = invoice?.let { payViaDefaultSourceOrNull(account, it, note) } ZapInvoice( recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, recipientPubkeyHex = req.recipient.pubkey, @@ -1694,42 +1697,65 @@ class AmethystAppFunctions { ?.lnAddress() } - /** Internal result of [payViaNwcOrNull]. */ - private data class NwcOutcome( + /** Internal result of [payViaDefaultSourceOrNull]. */ + private data class PayOutcome( val success: Boolean, val preimage: String?, val errorMessage: String?, ) /** - * Try to pay [bolt11] through the active account's Nostr Wallet - * Connect setup. Returns null when no NWC wallet is configured — - * caller should fall back to surfacing the invoice for manual - * payment. Returns an outcome with [NwcOutcome.success] = true on - * a wallet-confirmed payment, false (with [NwcOutcome.errorMessage] - * set) on rejection or timeout. - * - * The wallet's response can take a few seconds; bounded by - * [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the - * dispatch. + * Try to pay [bolt11] through the account's selected default payment source — an NWC + * wallet or a CLINK debit. Returns null when no in-app source is configured (caller + * should fall back to surfacing the invoice for manual payment), otherwise an outcome + * with [PayOutcome.success] = true on a wallet-confirmed payment, or false (with + * [PayOutcome.errorMessage]) on rejection or timeout. */ - private suspend fun payViaNwcOrNull( + private suspend fun payViaDefaultSourceOrNull( account: com.vitorpamplona.amethyst.model.Account, bolt11: String, zappedNote: com.vitorpamplona.amethyst.model.Note?, - ): NwcOutcome? { - if (!account.nip47SignerState.hasWalletConnectSetup()) return null + ): PayOutcome? = + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> payViaNwc(account, bolt11, zappedNote) + is PaymentSource.ClinkDebit -> payViaClinkDebit(account, source.wallet.pointer, bolt11) + null -> null + } + /** Pays [bolt11] via a CLINK debit pointer, mapping the kind-21002 reply to a [PayOutcome]. */ + private suspend fun payViaClinkDebit( + account: com.vitorpamplona.amethyst.model.Account, + pointer: NDebit, + bolt11: String, + ): PayOutcome { + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + return when { + response == null -> + PayOutcome(false, null, "CLINK debit wallet didn't respond within ${ClinkDebitPayer.DEFAULT_TIMEOUT_MS / 1000}s") + response.isOk() -> PayOutcome(true, response.preimage, null) + else -> + PayOutcome(false, null, response.error?.takeIf { it.isNotBlank() } ?: "debit declined (code ${response.code})") + } + } + + /** + * Pays [bolt11] via the default NWC wallet. The wallet's response can take a few + * seconds; bounded by [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall dispatch. + */ + private suspend fun payViaNwc( + account: com.vitorpamplona.amethyst.model.Account, + bolt11: String, + zappedNote: com.vitorpamplona.amethyst.model.Note?, + ): PayOutcome { val deferred = CompletableDeferred() - // sendZapPaymentRequestFor fires onResponse exactly once when - // the wallet replies (success, error, or NwcError). On timeout - // we discard the late response. + // sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies + // (success, error, or NwcError). On timeout we discard the late response. account.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> if (!deferred.isCompleted) deferred.complete(response) } val response = withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() } - ?: return NwcOutcome( + ?: return PayOutcome( success = false, preimage = null, errorMessage = @@ -1739,35 +1765,13 @@ class AmethystAppFunctions { return when (response) { is PayInvoiceSuccessResponse -> - NwcOutcome( - success = true, - preimage = response.result?.preimage, - errorMessage = null, - ) + PayOutcome(true, response.result?.preimage, null) is PayInvoiceErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an unspecified pay_invoice error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an unspecified pay_invoice error") is NwcErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an NWC error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an NWC error") else -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = "Unexpected NWC response type: ${response::class.simpleName}", - ) + PayOutcome(false, null, "Unexpected NWC response type: ${response::class.simpleName}") } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt new file mode 100644 index 0000000000..fed8c9f63b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.rankPriorityFirst +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Locks in the @-mention priority semantics: priority pubkeys only move + * users that already matched the search to the top of the list — they + * never inject new entries, never remove any, and never disturb the + * search's relevance order within the priority / non-priority groups. + */ +class UserSuggestionPriorityRankingTest { + // User eagerly pins a few addressable note shells on construction; + // empty shells are enough since the ranking never reads them. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private fun user(hex: String) = User(hex, noContext) + + private val alice = user("aa".repeat(32)) + private val bob = user("bb".repeat(32)) + private val carol = user("cc".repeat(32)) + private val dave = user("dd".repeat(32)) + + @Test + fun emptyPriorityKeepsTheListUntouched() { + val found = listOf(alice, bob, carol) + + assertSame(found, rankPriorityFirst(found, emptySet())) + } + + @Test + fun priorityUsersMoveToTheTop() { + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex)) + + assertEquals(listOf(carol, alice, bob, dave), ranked) + } + + @Test + fun relativeOrderIsPreservedWithinBothGroups() { + // findUsersStartingWith returns relevance order; the stable sort + // must keep alice-before-carol (priority) and bob-before-dave (rest). + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(alice.pubkeyHex, carol.pubkeyHex)) + + assertEquals(listOf(alice, carol, bob, dave), ranked) + } + + @Test + fun priorityKeysThatDidNotMatchTheSearchAreNotInjected() { + val found = listOf(alice, bob) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex, dave.pubkeyHex)) + + assertEquals(found, ranked) + } +} diff --git a/cli/README.md b/cli/README.md index b90d86d480..1cd4fe0f82 100644 --- a/cli/README.md +++ b/cli/README.md @@ -244,6 +244,21 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 | `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. | | `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. | +### CLINK Offers + +| Command | What it does | +|---|---| +| `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. | +| `amy offer request NOFFER [--amount SATS] [--timeout MS]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. | + +### CLINK Debits + +| Command | What it does | +|---|---| +| `amy debit info NDEBIT` | Decode an `ndebit1…` pointer (pubkey, relays, pointer id, session flag). Local, no network. | +| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. | +| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout MS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. | + ### Wait-for-condition (`await`) Every `await` verb blocks until the condition holds, then prints the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f8c4a21ee6..b50689a36d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.selects.select @@ -310,6 +311,43 @@ class Context( return collected } + /** + * Publish [request] to [relays], then wait for the FIRST event matching [responseFilter] + * — a live reply that arrives after our own EOSE, which [drain] would miss (it returns at + * EOSE). Verifies and stores the reply. Returns it, or null on timeout; always tears the + * subscription down. Used for request/response round-trips (e.g. a CLINK offer invoice). + */ + suspend fun requestResponse( + request: Event, + relays: Set, + responseFilter: Filter, + timeoutMs: Long = 15_000, + ): Event? { + if (relays.isEmpty()) return null + val reply = CompletableDeferred() + val subId = newSubId() + val filters = relays.associateWith { listOf(responseFilter) } + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (!reply.isCompleted) reply.complete(event) + } + } + client.subscribe(subId, filters, listener) + return try { + publish(request, relays) + val event = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + if (verifyAndStore(event)) event else null + } finally { + client.unsubscribe(subId) + } + } + /** * Verify [event]'s NIP-01 id+signature and, if valid, persist it * to [store]. Returns `true` when the event was accepted (and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1ea7b3d0e3..03b6ac97d8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -182,6 +182,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.zap(dataDir, tail) } + "offer" -> { + Commands.offer(dataDir, tail) + } + + "debit" -> { + Commands.debit(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -363,6 +371,19 @@ private fun printUsage() { | [--comment X] [--anon|--private] event (must be in local store) | [--timeout SECS] | + |CLINK Offers: + | offer info NOFFER decode a noffer1… pointer (local, no network) + | offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a + | [--timeout MS] fresh BOLT11 (amount required for spontaneous + | offers; defaults to the pointer's fixed price) + | + |CLINK Debits: + | debit info NDEBIT decode an ndebit1… pointer (local, no network) + | debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the + | [--timeout MS] invoice; prints the preimage or a GFY error + | debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency + | [--frequency day|week|month] [--timeout MS] for a one-time budget + | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles | [--timeout SECS] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index 60b27319af..7f30d19349 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -63,11 +63,14 @@ object Output { fun error( code: String, detail: String? = null, + extra: Map = emptyMap(), ): Int { + val cleanExtra = extra.filterValues { it != null } when (mode) { Mode.JSON -> { - val payload = mutableMapOf("error" to code) + val payload = mutableMapOf("error" to code) if (detail != null) payload["detail"] = detail + payload.putAll(cleanExtra) System.err.println(mapper.writeValueAsString(payload)) } @@ -75,7 +78,9 @@ object Output { val color = Ansi.forStream(isStderr = true) val prefix = color.bold(color.red("error")) val codePart = color.yellow(code) - System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart") + val base = if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart" + val suffix = if (cleanExtra.isEmpty()) "" else cleanExtra.entries.joinToString(", ", " (", ")") { "${it.key}=${it.value}" } + System.err.println(base + suffix) } } return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 446b3acf1c..527101e9ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -115,4 +115,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = ZapCommand.dispatch(dataDir, tail) + + suspend fun offer( + dataDir: DataDir, + tail: Array, + ): Int = OfferCommands.dispatch(dataDir, tail) + + suspend fun debit( + dataDir: DataDir, + tail: Array, + ): Int = DebitCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt new file mode 100644 index 0000000000..ba90f6f01c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit + +/** + * `amy debit …` — CLINK Debits (`ndebit1…`) from the command line, for headless interop + * testing against a real debit service (e.g. a Lightning.Pub that pre-authorized this + * account's npub). + * + * - `info ` decodes a pointer locally (no network). + * - `pay [--amount SATS]` runs the kind-21002 pay round-trip. + * - `budget --amount SATS [--frequency day|week|month]` authorizes a budget. + * + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `DebitClient`); the round-trip uses `Context.requestResponse`. + */ +object DebitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "debit ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "pay" -> pay(dataDir, rest) + "budget" -> budget(dataDir, rest) + else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)") + } + } + + /** Local decode of an `ndebit` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + + Output.emit( + mapOf( + "pubkey" to debit.pubKey, + "relays" to debit.relays.map { it.url }, + "pointer" to debit.pointer, + "session" to debit.isSession, + ), + ) + return 0 + } + + /** Ask the wallet to pay [bolt11] (kind-21002 round-trip). */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val bolt11 = args.positional(1, "bolt11") + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.payInvoice(bolt11, amount) } + } + + /** Ask the wallet to authorize a spending budget; omit --frequency for a one-time budget. */ + private suspend fun budget( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = + args.flag("amount")?.toLongOrNull() + ?: return Output.error("bad_args", "--amount SATS is required for a budget") + val frequency = parseFrequency(args.flag("frequency")) ?: return Output.error("bad_args", "unknown --frequency '${args.flag("frequency")}' (day|week|month)") + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency.value) } + } + + /** + * Shared 21002 round-trip: decode the pointer (positional 0), build the request via + * [buildRequest], publish, await the reply, and emit the preimage or GFY error. + */ + private suspend fun roundTrip( + dataDir: DataDir, + args: Args, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Int { + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) { + Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event") + is Settle.Replied -> emitDebit(outcome, debit.pubKey) + } + } finally { + ctx.close() + } + } + + /** Emit a [DebitResponse] as the standard `ok`+preimage success or a structured GFY error. */ + internal fun emitDebit( + outcome: Settle.Replied, + servicePubKey: String, + ): Int { + val response = outcome.response + return if (response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to response.preimage, + "request_id" to outcome.requestId, + "service" to servicePubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + gfyExtra(response), + ) + } + } + + /** Structured GFY extras (code + any actionable range/retry_after/delta) for error output. */ + internal fun gfyExtra(response: DebitResponse): Map = + mapOf( + "code" to response.code, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + "retry_after" to response.retry_after, + "delta" to response.delta?.let { mapOf("max_delta_ms" to it.max_delta_ms, "actual_delta_ms" to it.actual_delta_ms) }, + ) + + /** Result of a single 21002 round-trip, decoupled from how it is emitted. */ + internal sealed interface Settle { + data class Replied( + val requestId: String, + val response: DebitResponse, + ) : Settle + + data object Timeout : Settle + + data object BadReply : Settle + } + + /** + * Core 21002 round-trip against an already-decoded [debit] on an open [ctx]: build the + * request, publish, await the reply, decrypt. Reused by `debit pay/budget` and by + * `offer pay` (fetch invoice → settle via debit). + */ + internal suspend fun settle( + ctx: Context, + debit: NDebit, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Settle { + val client = DebitClient(debit, ctx.signer) + val requestEvent = buildRequest(client) + val reply = + ctx.requestResponse(requestEvent, debit.relays.toSet(), client.responseFilter(requestEvent.id), timeoutMs) + ?: return Settle.Timeout + val response = (reply as? DebitEvent)?.let { client.parseResponse(it) } ?: return Settle.BadReply + return Settle.Replied(requestEvent.id, response) + } + + /** Parses a `--frequency` value into a one-time (null) or recurring cadence. Null = invalid. */ + internal fun parseFrequency(raw: String?): Frequency? = + when (raw?.lowercase()) { + null, "once", "one-time" -> Frequency(null) + "day", "daily" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_DAY)) + "week", "weekly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_WEEK)) + "month", "monthly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + else -> null + } + + /** Wrapper so a valid "one-time" budget (null cadence) is distinguishable from an invalid flag. */ + internal data class Frequency( + val value: DebitFrequency?, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt new file mode 100644 index 0000000000..65971e9773 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id + +/** + * `amy offer …` — CLINK Offers (`noffer1…`) from the command line, for headless interop + * testing against a real offer service. + * + * - `info ` decodes a pointer locally (no network). + * - `discover ` resolves a profile's advertised offer from its NIP-05 `.well-known`. + * - `request [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip: + * publishes the request to the pointer's relays and prints the returned BOLT-11. With + * `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer. + * - `pay --with [--amount N]` fetches the invoice and settles it end-to-end + * through a CLINK debit pointer (offer round-trip → debit round-trip). + * + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `OfferClient`, `DebitClient`); the relay round-trips use + * `Context.requestResponse` (debit settlement is shared with [DebitCommands]). + */ +object OfferCommands { + private const val MAX_FOLLOW_HOPS = 3 + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "offer ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "discover" -> discover(dataDir, rest) + "request" -> request(dataDir, rest) + "pay" -> pay(dataDir, rest) + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)") + } + } + + /** + * Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer` + * (the app's discovery fallback). A profile's kind-0 `clink_offer` is readable via + * `amy profile show `. + */ + private suspend fun discover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val id = + Nip05Id.parse(args.positional(0, "nip05").trim()) + ?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val noffer = ctx.nip05Client.loadClinkOffer(id) + if (noffer == null) { + Output.emit(mapOf("nip05" to id.toDisplayValue(), "found" to false)) + return 0 + } + val offer = ClinkPointerParser.parse(noffer) as? NOffer + Output.emit( + mapOf( + "nip05" to id.toDisplayValue(), + "found" to true, + "noffer" to noffer, + "pubkey" to offer?.pubKey, + "relays" to offer?.relays?.map { it.url }, + "pointer" to offer?.pointer, + "price_type" to offer?.priceType?.name?.lowercase(), + "price_sats" to offer?.price, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** Local decode of a `noffer` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + + Output.emit( + mapOf( + "pubkey" to offer.pubKey, + "relays" to offer.relays.map { it.url }, + "pointer" to offer.pointer, + "price_type" to offer.priceType.name.lowercase(), + "price_sats" to offer.price, + ), + ) + return 0 + } + + /** Request a fresh BOLT-11 from the offer service (kind-21001 round-trip). */ + private suspend fun request( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + val follow = args.bool("follow") + + var offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + var hops = 0 + while (hops <= MAX_FOLLOW_HOPS) { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + + val client = OfferClient(offer, ctx.signer) + val requestEvent = client.requestInvoice(amountSats = amount) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + + val response = + (reply as? OfferEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") + + if (response.isSuccess()) { + Output.emit( + mapOf( + "bolt11" to response.bolt11, + "request_id" to requestEvent.id, + "service" to offer.pubKey, + "followed_hops" to hops, + ), + ) + return 0 + } + + // "Expired or Moved" (code 3) may carry a replacement `noffer`; chase it on --follow. + val moved = response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + if (follow && response.code == OfferErrorCode.EXPIRED_OR_MOVED && moved != null) { + offer = moved + hops++ + continue + } + + return Output.error( + "offer_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerErrorExtra(response), + ) + } + return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)") + } finally { + ctx.close() + } + } + + /** + * Pay an offer end-to-end: fetch a fresh BOLT-11 (kind-21001) and settle it through a + * CLINK debit pointer (kind-21002). The CLI is stateless, so the funding source is given + * explicitly with `--with `. + */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + val withFlag = + args.flag("with") + ?: return Output.error("bad_args", "offer pay needs --with to settle the fetched invoice") + val debit = + ClinkPointerParser.parse(withFlag.trim()) as? NDebit + ?: return Output.error("bad_args", "--with is not a valid ndebit pointer") + + val offerRelays = offer.relays.toSet() + if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + + // 1. fetch a fresh BOLT-11 from the offer service. + val offerClient = OfferClient(offer, ctx.signer) + val offerReq = offerClient.requestInvoice(amountSats = amount) + val offerReply = ctx.requestResponse(offerReq, offerRelays, offerClient.responseFilter(offerReq.id), timeoutMs) + if (offerReply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + val offerResp = + (offerReply as? OfferEvent)?.let { offerClient.parseResponse(it) } + ?: return Output.error("bad_response", "offer reply was not a kind-21001 event") + if (!offerResp.isSuccess()) { + return Output.error( + "offer_error", + offerResp.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${offerResp.code}", + offerErrorExtra(offerResp), + ) + } + val bolt11 = + offerResp.bolt11 + ?: return Output.error("bad_response", "offer succeeded but returned no bolt11") + + // 2. settle the invoice through the debit service (shared with `debit pay`). + return when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, amount) }) { + DebitCommands.Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + DebitCommands.Settle.BadReply -> Output.error("bad_response", "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to outcome.response.preimage, + "bolt11" to bolt11, + "offer_request_id" to offerReq.id, + "debit_request_id" to outcome.requestId, + "offer_service" to offer.pubKey, + "debit_service" to debit.pubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + outcome.response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${outcome.response.code}", + DebitCommands.gfyExtra(outcome.response), + ) + } + } + } finally { + ctx.close() + } + } + + /** Structured offer-error extras (code + moved `latest` pointer + acceptable range). */ + private fun offerErrorExtra(response: OfferResponse): Map = + mapOf( + "code" to response.code, + "latest" to response.latest, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 0b827167f0..170e792189 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -139,17 +141,23 @@ object ProfileCommands { val twitter = args.flag("twitter") val mastodon = args.flag("mastodon") val github = args.flag("github") + val clinkOffer = args.flag("clink-offer") val timeoutSecs = args.longFlag("timeout", 8L) + // A non-blank --clink-offer must be a real noffer; pass "" to clear the field. + if (!clinkOffer.isNullOrBlank() && ClinkPointerParser.parse(clinkOffer.trim()) !is NOffer) { + return Output.error("bad_args", "--clink-offer is not a valid noffer pointer (pass \"\" to clear)") + } + val touched = - listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) + listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github, clinkOffer) .any { it != null } if (!touched) { return Output.error( "bad_args", "profile edit needs at least one of " + "--name --display-name --about --picture --banner --website " + - "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github", + "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github --clink-offer", ) } @@ -182,6 +190,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -198,6 +207,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 8a9292f37a..6ef33e4557 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -51,8 +53,10 @@ import okhttp3.OkHttpClient * 4. POST it to the recipient's LNURL-pay callback via * [LightningAddressResolver] to receive a BOLT11 invoice. * - * The invoice is printed but **not** auto-paid — amy has no NWC wallet - * wired up yet. Paste the invoice into any LN wallet to settle. + * By default the invoice is printed but **not** auto-paid — paste it into any LN + * wallet to settle. Pass `--with ` to settle it in-place through a CLINK + * debit pointer (kind-21002), mirroring how the app routes a zap through its + * default payment source; each recipient then also reports `paid` + `preimage`. */ object ZapCommand { suspend fun dispatch( @@ -72,7 +76,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--with ] [--timeout SECS]") val userArg = rest[0] val sats = rest[1].toLongOrNull()?.takeIf { it > 0 } @@ -81,6 +85,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -103,7 +115,7 @@ object ZapCommand { zapType = zapType, ) - emitZapResult(ctx, sats, lnAddress, comment, request, zapType) + emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -114,7 +126,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--with ] [--timeout SECS]") val eventId = rest[0] if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)") val sats = @@ -124,6 +136,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -175,7 +195,7 @@ object ZapCommand { ) } - emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -189,6 +209,8 @@ object ZapCommand { comment: String, request: LnZapRequestEvent, zapType: LnZapEvent.ZapType, + timeoutMs: Long, + settleWith: NDebit?, zappedEventId: HexKey? = null, ) { // Reuse the same OkHttp instance the Context uses for nip-05 / WS; @@ -205,8 +227,8 @@ object ZapCommand { when (result) { is LightningAddressResolver.Result.Success -> { - Output.emit( - buildMap { + val base = + buildMap { put("ln_address", lnAddress) put("amount_sats", sats) put("zap_type", zapType.name.lowercase()) @@ -214,8 +236,9 @@ object ZapCommand { put("zap_request_id", request.id) if (zappedEventId != null) put("zapped_event_id", zappedEventId) put("invoice", result.invoice) - }, - ) + } + val settled = if (settleWith != null) settleEntry(ctx, settleWith, result.invoice, timeoutMs) else emptyMap() + Output.emit(base + settled) } is LightningAddressResolver.Result.Error -> { Output.error("invoice_failed", result.message) @@ -223,6 +246,32 @@ object ZapCommand { } } + /** + * Settles [bolt11] through a CLINK debit pointer (kind-21002, reusing [DebitCommands.settle]) + * and returns the result fields (`paid` + `preimage`/`pay_error`) to merge into the zap + * output. Per-recipient for splits. + */ + private suspend fun settleEntry( + ctx: Context, + debit: NDebit, + bolt11: String, + timeoutMs: Long, + ): Map = + when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, null) }) { + DebitCommands.Settle.Timeout -> mapOf("paid" to false, "pay_error" to "no response from the debit service") + DebitCommands.Settle.BadReply -> mapOf("paid" to false, "pay_error" to "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + mapOf("paid" to true, "preimage" to outcome.response.preimage, "debit_request_id" to outcome.requestId) + } else { + mapOf( + "paid" to false, + "pay_error" to (outcome.response.error?.takeIf { it.isNotBlank() } ?: "code ${outcome.response.code}"), + "debit_request_id" to outcome.requestId, + ) + } + } + /** * Multi-recipient (split-aware) event-zap result emitter. Fetches one * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a @@ -238,6 +287,8 @@ object ZapCommand { zappedEventId: HexKey, zapType: LnZapEvent.ZapType, requests: List, + timeoutMs: Long, + settleWith: NDebit?, ) { val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) @@ -260,8 +311,10 @@ object ZapCommand { "zap_request_id" to req.request.id, ) when (result) { - is LightningAddressResolver.Result.Success -> + is LightningAddressResolver.Result.Success -> { entry["invoice"] = result.invoice + if (settleWith != null) entry.putAll(settleEntry(ctx, settleWith, result.invoice, timeoutMs)) + } is LightningAddressResolver.Result.Error -> entry["invoice_error"] = result.message diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 1b89e7da4f..3d5dc27cf2 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -2,3 +2,4 @@ marmot/state/ marmot/state-headless/ dm/state-dm-headless/ nests/state/ +clink/state-clink-headless/ diff --git a/cli/tests/README.md b/cli/tests/README.md index 4b1aeeb171..0fc0100f90 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -25,6 +25,11 @@ cli/tests/ └── README.md # operator brief + per-test matrix ``` +The CLINK suite is local-only (no relay): `clink/clink-headless.sh` asserts that +`amy offer info` / `amy debit info` decode the canonical interop vectors to the +right fields, plus the argument-error paths. The round-trip verbs (`offer +request`, `debit pay/budget`) need a live CLINK service and aren't covered here. + The Marmot harnesses come in two flavours, same scenarios: - **`marmot/marmot-interop.sh`** — interactive. Drives B/C via `wn` and diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh new file mode 100755 index 0000000000..e27c2850e0 --- /dev/null +++ b/cli/tests/clink/clink-headless.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# clink-headless.sh — local-decode checks for `amy offer info` / `amy debit info`. +# +# These verbs are pure pointer decode (no network), so this suite needs no relay: +# it asserts that amy decodes the canonical CLINK interop vectors (the same fixtures +# the quartz ClinkInteropTest uses) to the right fields, in both success and error +# paths. The round-trip verbs (`offer request`, `debit pay`, `debit budget`) need a +# live CLINK service and are out of scope here. +# +# Usage: ./clink-headless.sh [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-clink-headless" +LOG_DIR="$STATE_DIR/logs" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +NO_BUILD=0 +[[ "${1:-}" == "--no-build" ]] && NO_BUILD=1 + +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" +# shellcheck source=../headless/helpers.sh +source "$TESTS_DIR/headless/helpers.sh" + +cleanup() { + local rc=$? + trap - EXIT INT TERM HUP + print_summary + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Canonical interop vectors (fixed pubkey/relay), from quartz ClinkInteropTest. +EXPECTED_PUB="7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" +NOFFER_FIXED="noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" +NOFFER_SPONT="noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" +NDEBIT_STATIC="ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + +banner "CLINK pointer decode headless ($RUN_TS)" + +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist >>"$LOG_FILE" 2>&1) || + { fail_msg "amy build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing: $AMY_BIN"; exit 1; } + +# Bare local account — `init` does no relay traffic, which is all we need. +rm -rf "${STATE_DIR:?}/.amy" +amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "amy init failed (see $LOG_FILE)"; exit 1; } + +# --- offer info: fixed-price --- +step "offer info decodes a fixed-price noffer" +OUT=$(amy_json offer info "$NOFFER_FIXED") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" offer.info.pubkey && + record_result offer.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "offer-id" offer.info.pointer && + record_result offer.info.pointer pass "pointer=offer-id" +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "fixed" offer.info.price_type && + record_result offer.info.price_type pass "price_type=fixed" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "21000" offer.info.price_sats && + record_result offer.info.price_sats pass "price_sats=21000" + +# --- offer info: spontaneous (no price) --- +step "offer info decodes a spontaneous noffer (no price)" +OUT=$(amy_json offer info "$NOFFER_SPONT") || true +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "spontaneous" offer.info.spont_type && + record_result offer.info.spont_type pass "price_type=spontaneous" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "null" offer.info.spont_price && + record_result offer.info.spont_price pass "price_sats=null" + +# --- offer info: bad pointer => non-zero exit --- +step "offer info rejects a non-noffer string" +if amy_a offer info "definitely-not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result offer.info.bad fail "bad pointer should exit non-zero" +else + record_result offer.info.bad pass "bad pointer exits non-zero" +fi + +# --- debit info: static pointer --- +step "debit info decodes a static ndebit" +OUT=$(amy_json debit info "$NDEBIT_STATIC") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" debit.info.pubkey && + record_result debit.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "pointer-7" debit.info.pointer && + record_result debit.info.pointer pass "pointer=pointer-7" +assert_eq "$(jq -r '.session' <<<"$OUT")" "false" debit.info.session && + record_result debit.info.session pass "session=false (no k1)" + +# --- debit budget: argument validation (no network needed) --- +step "debit budget rejects an unknown frequency" +if amy_a debit budget "$NDEBIT_STATIC" --amount 1000 --frequency fortnight >>"$LOG_FILE" 2>&1; then + record_result debit.budget.badfreq fail "unknown frequency should exit non-zero" +else + record_result debit.budget.badfreq pass "unknown frequency exits non-zero" +fi + +step "debit budget requires --amount" +if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then + record_result debit.budget.noamount fail "missing --amount should exit non-zero" +else + record_result debit.budget.noamount pass "missing --amount exits non-zero" +fi + +# --- offer pay: requires a --with funding pointer (validated before any network) --- +step "offer pay requires --with " +if amy_a offer pay "$NOFFER_SPONT" --amount 1000 >>"$LOG_FILE" 2>&1; then + record_result offer.pay.nowith fail "missing --with should exit non-zero" +else + record_result offer.pay.nowith pass "missing --with exits non-zero" +fi + +step "offer pay rejects a non-ndebit --with" +if amy_a offer pay "$NOFFER_SPONT" --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result offer.pay.badwith fail "bad --with should exit non-zero" +else + record_result offer.pay.badwith pass "bad --with exits non-zero" +fi + +# --- profile edit --clink-offer: validates the noffer locally before publishing --- +step "profile edit rejects a non-noffer --clink-offer" +if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result profile.clinkoffer.bad fail "bad --clink-offer should exit non-zero" +else + record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" +fi + +# --- zap --with: rejects a non-ndebit funding pointer (validated before any network) --- +step "zap user rejects a non-ndebit --with" +if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result zap.with.bad fail "bad --with should exit non-zero" +else + record_result zap.with.bad pass "bad --with exits non-zero" +fi + +# --- offer discover: rejects a malformed NIP-05 (validated before any network) --- +step "offer discover rejects a non-nip05 address" +if amy_a offer discover "not-a-nip05" >>"$LOG_FILE" 2>&1; then + record_result offer.discover.bad fail "bad nip05 should exit non-zero" +else + record_result offer.discover.bad pass "bad nip05 exits non-zero" +fi diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b4a0634e1f..12264f99e5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.utils.Log @@ -178,6 +180,11 @@ class MarmotManager( * `persistOwn = false`. Headless callers (CLI) should leave it at * the default. * + * [mentions] become p-tags on the inner kind:9 (users referenced via + * `nostr:npub…`/`nostr:nprofile…` in [text]), mirroring how NIP-17 + * chat messages tag mentioned users. They stay inside the MLS + * ciphertext — the outer kind:445 never carries member pubkeys. + * * @return the signed kind:445 outer event together with the inner kind:9 * rumor id, so the caller can reference it for replies/reactions. */ @@ -187,10 +194,12 @@ class MarmotManager( replyToEventId: HexKey? = null, replyToAuthorPubKey: HexKey? = null, persistOwn: Boolean = true, + mentions: List = emptyList(), ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers .eventTemplate(kind = 9, description = text) { + pTags(mentions) if (replyToEventId != null) { // Mirror ChatEvent.reply(): NIP-18 q-tag references the // parent inner kind:9 by id (+ optional author, no diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt new file mode 100644 index 0000000000..88ae93fe20 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.clink + +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A saved CLINK Debits pointer the user can spend from — the `ndebit` counterpart of + * [com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry]. + * + * Unlike NWC, a debit carries no secret (authorization is the account's own identity, + * pre-approved on the wallet service) and exposes no balance or transaction history — + * it is a spend-only payment source. The persisted form keeps the raw `ndebit1…` + * string; [normalize] decodes it for use. + */ +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class ClinkDebitWalletEntry( + val id: String = Uuid.random().toString(), + val name: String, + val ndebit: String, +) { + fun normalize(): ClinkDebitWalletEntryNorm? = (ClinkPointerParser.parse(ndebit) as? NDebit)?.let { ClinkDebitWalletEntryNorm(id, name, it) } +} + +data class ClinkDebitWalletEntryNorm( + val id: String, + val name: String, + val pointer: NDebit, +) { + fun denormalize(): ClinkDebitWalletEntry = ClinkDebitWalletEntry(id, name, pointer.encode()) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt new file mode 100644 index 0000000000..1c751d519e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * A user-configured way to pay a BOLT-11 — the unit the zap button (and any other + * "pay this invoice" path) selects a default from. Today either a NIP-47 NWC wallet + * or a CLINK Debits pointer; absence of any source means falling back to an external + * wallet app (intent). + * + * [canShowBalance] is the honest capability marker: NWC can report balance/history, + * a CLINK debit cannot, so the UI renders the two rows differently. + */ +sealed interface PaymentSource { + val id: String + val name: String + val canShowBalance: Boolean + + data class Nwc( + val wallet: NwcWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = true + } + + data class ClinkDebit( + val wallet: ClinkDebitWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = false + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt new file mode 100644 index 0000000000..90c9a79c1d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * Builds the unified list of configured [PaymentSource]s (NWC + CLINK debit) and + * resolves which one is the default for "pay this invoice" paths. + * + * The default is a single id spanning both lists (ids are random UUIDs, unique across + * types), so one selector picks the spend rail regardless of type. When no explicit + * default is set, the first configured source wins — NWC wallets are listed before + * debits, preserving today's "first NWC wallet" fallback. + */ +object PaymentSourceResolver { + fun all( + nwcWallets: List, + debitWallets: List, + ): List = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) } + + fun resolveDefault( + nwcWallets: List, + debitWallets: List, + defaultId: String?, + ): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId) + + fun resolveDefault( + sources: List, + defaultId: String?, + ): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bfd18c5985..bb6ddb4430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -389,6 +391,10 @@ class RichTextParser { if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word) + if (word.startsWith("noffer1", true)) { + (ClinkPointerParser.parse(word) as? NOffer)?.let { return ClinkOfferSegment(word, it) } + } + if (word.startsWith('#')) return parseHash(word, tags) if (EmojiCoder.isCoded(word)) return SecretEmoji(word) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 25d3eae75d..0c5e7c062a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -92,6 +93,12 @@ class CashuSegment( segment: String, ) : Segment(segment) +@Immutable +class ClinkOfferSegment( + segment: String, + val offer: NOffer, +) : Segment(segment) + @Immutable class EmailSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index eb5ddbacd3..42987dfb56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -28,6 +28,7 @@ class TorRelayEvaluation( val torSettings: TorRelaySettings, val trustedRelayList: Set, val dmRelayList: Set, + val moneyOpRelayList: Set = emptySet(), ) { fun useTor(relay: NormalizedRelayUrl): Boolean = if (torSettings.torType == TorType.OFF) { @@ -36,7 +37,14 @@ class TorRelayEvaluation( if (relay.isLocalHost()) { false } else if (relay.isOnion()) { + // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor + } else if (relay in moneyOpRelayList) { + // Relays used for money operations (NIP-47 wallets, CLINK offer/debit services) + // follow the dedicated money-operations preference, taking precedence over the + // generic DM/trusted/new classification so a payment never silently inherits a + // different Tor policy than the one the user set for money. + torSettings.moneyOperationsViaTor } else if (relay in dmRelayList) { torSettings.dmRelaysViaTor } else if (relay in trustedRelayList) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt index cc0e7e3143..781cb88211 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt @@ -26,4 +26,5 @@ data class TorRelaySettings( val dmRelaysViaTor: Boolean = false, val newRelaysViaTor: Boolean = false, val trustedRelaysViaTor: Boolean = false, + val moneyOperationsViaTor: Boolean = false, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt new file mode 100644 index 0000000000..9f4f317af5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaymentSourceResolverTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + + private fun nwc(id: String) = NwcWalletEntryNorm(id, "nwc-$id", Nip47WalletConnect.Nip47URINorm(pubKey, relay, secret = "ab".repeat(32))) + + private fun debit(id: String) = ClinkDebitWalletEntryNorm(id, "debit-$id", NDebit(pubKey, listOf(relay), "pointer-$id", null)) + + @Test + fun allListsNwcBeforeDebits() { + val sources = PaymentSourceResolver.all(listOf(nwc("a")), listOf(debit("b"))) + assertTrue(sources[0] is PaymentSource.Nwc) + assertTrue(sources[1] is PaymentSource.ClinkDebit) + } + + @Test + fun explicitDefaultSelectsAcrossEitherType() { + val nwcWallets = listOf(nwc("a")) + val debits = listOf(debit("b")) + + // a debit can be the unified default even when an NWC wallet exists + val resolved = PaymentSourceResolver.resolveDefault(nwcWallets, debits, defaultId = "b") + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun fallsBackToFirstNwcWhenNoExplicitDefault() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = null) + assertTrue(resolved is PaymentSource.Nwc) + assertEquals("a", resolved.id) + } + + @Test + fun fallsBackToFirstDebitWhenNoNwc() { + val resolved = PaymentSourceResolver.resolveDefault(emptyList(), listOf(debit("b"), debit("c")), defaultId = null) + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun staleDefaultIdFallsBackToFirst() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = "deleted") + assertEquals("a", resolved?.id) + } + + @Test + fun noSourcesResolvesToNull() { + assertNull(PaymentSourceResolver.resolveDefault(emptyList(), emptyList(), defaultId = null)) + } + + @Test + fun debitSourceCannotShowBalance() { + assertTrue(PaymentSource.Nwc(nwc("a")).canShowBalance) + assertTrue(!PaymentSource.ClinkDebit(debit("b")).canShowBalance) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt new file mode 100644 index 0000000000..a10963ede4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClinkOfferSegmentTest { + // generated by @shocknet/clink-sdk@1.5.5 (see quartz ClinkInteropTest) + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + + private fun words(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun detectsNofferInlineAsClinkOfferSegment() { + val segment = + words("Pay me here $offerFixed thanks") + .filterIsInstance() + .single() + + assertEquals(offerFixed, segment.segmentText) + assertEquals("offer-id", segment.offer.pointer) + assertEquals(OfferPriceType.FIXED, segment.offer.priceType) + assertEquals(21000L, segment.offer.price) + } + + @Test + fun malformedNofferIsNotDetected() { + assertTrue(words("nope noffer1notvalidbech32 end").none { it is ClinkOfferSegment }) + } + + @Test + fun plainTextHasNoOfferSegment() { + assertTrue(words("just a normal sentence").none { it is ClinkOfferSegment }) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt index c5e65775d1..a6b441a8e4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt @@ -33,6 +33,7 @@ class TorRelayEvaluationTest { private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + private val moneyRelay = NormalizedRelayUrl("wss://wallet.relay.com/") private fun buildEvaluation( torType: TorType = TorType.INTERNAL, @@ -40,8 +41,10 @@ class TorRelayEvaluationTest { dmViaTor: Boolean = true, newViaTor: Boolean = true, trustedViaTor: Boolean = false, + moneyViaTor: Boolean = false, dmRelays: Set = setOf(dmRelay), trustedRelays: Set = setOf(trustedRelay), + moneyOpRelays: Set = setOf(moneyRelay), ) = TorRelayEvaluation( torSettings = TorRelaySettings( @@ -50,9 +53,11 @@ class TorRelayEvaluationTest { dmRelaysViaTor = dmViaTor, newRelaysViaTor = newViaTor, trustedRelaysViaTor = trustedViaTor, + moneyOperationsViaTor = moneyViaTor, ), trustedRelayList = trustedRelays, dmRelayList = dmRelays, + moneyOpRelayList = moneyOpRelays, ) // --- Tor OFF --- @@ -107,6 +112,44 @@ class TorRelayEvaluationTest { @Test fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay)) + // --- Money-operation relays --- + @Test + fun money_enabled_returnsTrue() = assertTrue(buildEvaluation(moneyViaTor = true).useTor(moneyRelay)) + + @Test + fun money_disabled_returnsFalse() = assertFalse(buildEvaluation(moneyViaTor = false).useTor(moneyRelay)) + + @Test + fun money_takesPrecedenceOverNew() { + // A money-op relay not in any other list must NOT fall through to the new-relay policy. + val eval = buildEvaluation(moneyViaTor = false, newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet()) + assertFalse(eval.useTor(moneyRelay)) + } + + @Test + fun money_takesPrecedenceOverTrustedAndDm() { + // When the same relay is both a money-op relay and trusted/DM, money policy wins. + val both = NormalizedRelayUrl("wss://wallet-and-trusted.relay.com/") + val eval = + buildEvaluation( + moneyViaTor = true, + dmViaTor = false, + trustedViaTor = false, + dmRelays = setOf(both), + trustedRelays = setOf(both), + moneyOpRelays = setOf(both), + ) + assertTrue(eval.useTor(both)) + } + + @Test + fun money_onionStillWins() { + // .onion reachability check precedes the money classification. + val onionMoney = NormalizedRelayUrl("wss://wallet.onion/") + val eval = buildEvaluation(onionViaTor = false, moneyViaTor = true, moneyOpRelays = setOf(onionMoney)) + assertFalse(eval.useTor(onionMoney)) + } + // --- Priority --- @Test fun onionInDmList_treatedAsOnion() { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c59128bc26..a3ab990308 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,3 @@ -import de.undercouch.gradle.tasks.download.Download import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.nio.file.Files @@ -6,7 +5,6 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) - id("ir.mahozad.vlc-setup") version "0.1.0" } // RPM rejects dashes in version strings — replace with tilde (~) which RPM uses @@ -57,8 +55,14 @@ dependencies { implementation(libs.coil.okhttp) implementation(libs.coil.svg) - // Video playback - implementation(libs.vlcj) + // Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer) + implementation(libs.composemediaplayer) + + // Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess + // for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no + // wrapper library needed (see VideoThumbnailCache.runFfmpegToImage). + implementation(libs.jcodec) + implementation(libs.jcodec.javase) // EXIF stripping (lossless) implementation(libs.commons.imaging) @@ -94,9 +98,6 @@ compose.desktop { jvmArgs += "-Xmx2g" - // VLC plugin path fallback — used if JNA setenv and bundled discovery both fail - jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" - // Forward platform-preview overrides from the gradle invocation to the // launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME` // works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`). @@ -114,7 +115,7 @@ compose.desktop { "java.prefs", // java.util.prefs (desktop persistence) "java.sql", // JDBC metadata (Jackson, SQLite driver) "jdk.security.auth", // JAAS authentication callbacks - "jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory) + "jdk.unsupported", // sun.misc.Unsafe (secp256k1-kmp-jni-jvm, JNA) ) packageName = "Amethyst" @@ -138,7 +139,13 @@ compose.desktop { menuGroup = "Network" appCategory = "Network" debMaintainer = "vitor@vitorpamplona.com" - rpmLicenseType = "MIT" + // SPDX compound expression. Bundled components: + // MIT — Amethyst + kdroidFilter ComposeMediaPlayer + // LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) + + // GStreamer (Linux runtime dep, system-installed) + // BSD-2-Clause — JCodec + // Apache-2.0 — Jaffree + many transitive Java libraries + rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0" // RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering). rpmPackageVersion = appVersion.replace("-", "~") } @@ -149,8 +156,8 @@ compose.desktop { // problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum` // rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in // `compose-rules.pro` so the desktop release survives JNI callbacks - // (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy - // libraries (Jackson, JNA) without renaming. + // (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native) + // and reflection-heavy libraries (Jackson, JNA) without renaming. // // Shrink and optimize stay ON. One ProGuard optimize sub-pass is // disabled in `compose-rules.pro` to avoid a generated okio bridge @@ -163,61 +170,23 @@ compose.desktop { } } -vlcSetup { - // Pinned to 3.0.20 because the Linux VLC plugins on Maven Central - // (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the - // latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI. - vlcVersion.set("3.0.20") - shouldCompressVlcFiles.set(true) - shouldIncludeAllVlcFiles.set(true) - pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) - pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc")) - pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc")) -} - -tasks.named("spotlessKotlin") { - mustRunAfter("vlcSetup") -} - -// `ir.mahozad.vlc-setup` registers `vlcDownload` / `upxDownload` tasks that -// extend `de.undercouch.gradle.tasks.download.Download`. Defaults are 0 retries -// and a short read timeout, so a transient blip on get.videolan.org fails the -// whole desktop build on CI (Windows MSI, macOS DMG, Linux DEB). Configure all -// Download tasks in this project to retry with generous timeouts so flaky -// network conditions do not break packaging jobs. -tasks.withType().configureEach { - // 5 attempts total (initial + 4 retries) before failing the task. - retries(4) - // 30s to establish a TCP / TLS connection. - connectTimeout(30_000) - // 5 minutes per attempt for the body — VLC archives are 40-90 MB and - // get.videolan.org can be slow under load. - readTimeout(5 * 60_000) - // Stage to a temp file and rename only on full success, so a partial - // download from one attempt cannot poison the next. - tempAndMove(true) -} - // --- AppImage packaging (Linux) --- // // Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101). // Instead: wrap `createReleaseDistributable` output with `appimagetool`, which // just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here — // linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps, -// but jpackage already ships a self-contained tree we don't want it touching: -// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while -// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have -// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help. -// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those -// with "patchelf: no section headers" because they look like static ELFs. -// - Several VLC libs have RUNPATH that does not point at sibling libs in -// the same directory, so ldd errors with "Could not find dependency". -// appimagetool sidesteps all of this — it only embeds the AppDir into a -// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH -// at launch. +// but jpackage already ships a self-contained tree we don't want it touching +// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling +// libs use $ORIGIN RPATH — ldd can't resolve without help). +// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS, +// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch. +// +// kdroidFilter (video/audio) links against system GStreamer at runtime — the +// AppImage does not bundle GStreamer; the host system must have it installed. // // Build inputs live in desktopApp/packaging/appimage/: -// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC) +// - AppRun shell launcher // - amethyst.desktop XDG desktop entry // - amethyst.png 512x512 icon // diff --git a/desktopApp/packaging/appimage/AppRun b/desktopApp/packaging/appimage/AppRun index d4065af016..c42b59c001 100755 --- a/desktopApp/packaging/appimage/AppRun +++ b/desktopApp/packaging/appimage/AppRun @@ -1,11 +1,13 @@ #!/bin/bash # AppImage launcher for Amethyst Desktop. -# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime. -# jpackage puts app resources at usr/lib/app//vlc/ inside the AppDir. +# kdroidFilter ComposeMediaPlayer uses the host system's GStreamer (linked at +# runtime). Users need: +# sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ +# gstreamer1.0-plugins-bad gstreamer1.0-libav +# (Equivalent packages on Fedora/Arch.) set -eu HERE="$(dirname "$(readlink -f "${0}")")" -export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" -export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" export PATH="${HERE}/usr/bin:${PATH}" export APPDIR="${HERE}" exec "${HERE}/usr/bin/Amethyst" "$@" diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md new file mode 100644 index 0000000000..12bf3f3c7a --- /dev/null +++ b/desktopApp/packaging/flatpak/README.md @@ -0,0 +1,54 @@ +# Flathub packaging for Amethyst Desktop + +This directory contains the Flatpak manifest and associated metadata for +publishing Amethyst Desktop on Flathub. + +## Files + +- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest +- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata + (categories, license, screenshots — needs screenshots added before + submission) +- `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry +- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256 + PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before + submission + +## Build prerequisites + +- `./gradlew :desktopApp:createReleaseDistributable` — produces + `desktopApp/build/compose/binaries/main-release/app/Amethyst/` +- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` + +## Local build + +```bash +cd desktopApp/packaging/flatpak +flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak run com.vitorpamplona.amethyst.Desktop +``` + +## Submission to Flathub + +Follow https://docs.flathub.org/docs/for-app-authors/submission + +1. Fork `flathub/flathub` on GitHub. +2. Branch from `new-pr` (NOT `master`). +3. Copy this manifest + AppStream + desktop into a new directory matching + the app id. +4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". +5. After merge, a per-app repo is created with write access for ongoing + updates. + +## Codec coverage + +- HEVC / VP9 / AV1: covered via `org.freedesktop.Platform.ffmpeg-full` + add-extension declared in the manifest. Flatpak downloads it on install. +- HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in + `org.freedesktop.Platform 24.08` itself. + +## License metadata + +The manifest declares the binary as +`MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0`. This SPDX +expression validates via `appstreamcli validate`. diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop new file mode 100644 index 0000000000..cc52ce21e4 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Amethyst Desktop +Comment=Nostr client for desktop +Categories=Network;InstantMessaging; +Exec=amethyst-desktop +Icon=com.vitorpamplona.amethyst.Desktop +Terminal=false +StartupWMClass=Amethyst diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml new file mode 100644 index 0000000000..6ff45dc294 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml @@ -0,0 +1,53 @@ + + + com.vitorpamplona.amethyst.Desktop + CC0-1.0 + MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 + Amethyst Desktop + Nostr client for desktop + + +

+ Amethyst Desktop is a desktop client for the Nostr protocol. + Browse feeds, post notes, send zaps (Lightning Network), and + participate in NIP-53 live audio rooms. +

+

+ Video playback uses your system's GStreamer (Linux), AVFoundation + (macOS), or Media Foundation (Windows). On Linux, install the + GStreamer plugin packages (good, bad, libav) for full codec coverage. +

+
+ + com.vitorpamplona.amethyst.Desktop.desktop + + https://github.com/vitorpamplona/amethyst + https://github.com/vitorpamplona/amethyst/issues + https://github.com/vitorpamplona/amethyst + + + Vitor Pamplona + + + + + + Network + InstantMessaging + + + + + + + + + +
diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml new file mode 100644 index 0000000000..8ed6f86207 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml @@ -0,0 +1,77 @@ +# Flathub manifest for Amethyst Desktop. +# +# Pattern based on flathub/com.jetpackduba.Gitnuro (Kotlin Compose Desktop). +# Submission to Flathub is a separate operation — see +# desktopApp/packaging/flatpak/README.md for the workflow. + +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.openjdk21 +command: amethyst-desktop + +add-extensions: + org.freedesktop.Platform.ffmpeg-full: + directory: lib/ffmpeg + version: '24.08' + add-ld-path: . + autodownload: true + autodelete: false + +cleanup-commands: + - mkdir -p /app/lib/ffmpeg + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --filesystem=xdg-pictures + - --talk-name=org.freedesktop.Notifications + - --talk-name=org.freedesktop.secrets + # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). + - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 + +modules: + - name: openjdk + buildsystem: simple + build-commands: + - /usr/lib/sdk/openjdk21/install.sh + + - name: amethyst-desktop + buildsystem: simple + build-commands: + # Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst. + # Wrapper at /app/bin/amethyst-desktop forwards args. + - mkdir -p /app/lib/Amethyst + - cp -r ./Amethyst/* /app/lib/Amethyst/ + - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop + - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ + - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ + - install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/ + sources: + # Built artifact from `./gradlew :desktopApp:createReleaseDistributable`. + # Path matches Compose Multiplatform 1.11's output layout. + - type: dir + path: ../../build/compose/binaries/main-release/app + dest: ./ + + - type: script + dest-filename: amethyst-desktop.sh + commands: + - "#!/bin/sh" + - exec /app/lib/Amethyst/bin/Amethyst "$@" + + - type: file + path: com.vitorpamplona.amethyst.Desktop.metainfo.xml + + - type: file + path: com.vitorpamplona.amethyst.Desktop.desktop + + - type: file + path: icons/256/com.vitorpamplona.amethyst.Desktop.png diff --git a/desktopApp/src/jvmMain/appResources/common/NOTICE.md b/desktopApp/src/jvmMain/appResources/common/NOTICE.md new file mode 100644 index 0000000000..23547e8bf2 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/NOTICE.md @@ -0,0 +1,51 @@ +# Amethyst Desktop — Third-party Notices + +Amethyst Desktop is distributed under the MIT License (see `LICENSE-MIT-amethyst.txt`). +The distributed binary includes the following third-party components: + +## kdroidFilter ComposeMediaPlayer + +- License: MIT +- Version: 0.10.1 +- Upstream: https://github.com/kdroidFilter/ComposeMediaPlayer +- License text: `licenses/LICENSE-MIT-kdroidfilter.txt` + +## JCodec + +- License: BSD 2-Clause +- Version: 0.2.5 +- Upstream: https://github.com/jcodec/jcodec +- License text: `licenses/LICENSE-BSD-2-jcodec.txt` + +## FFmpeg (LGPL build, bundled per OS for thumbnail extraction) + +- License: LGPL-2.1-or-later +- Build: LGPL-only configuration (no `--enable-gpl`, no `--enable-nonfree`) +- Upstream: https://ffmpeg.org/ +- License text: `licenses/LICENSE-LGPL-2.1.txt` +- Source availability: https://github.com/vitorpamplona/amethyst (build tag matches + the binary tag); FFmpeg sources at https://github.com/FFmpeg/FFmpeg +- Per-OS binary source provenance documented in + `desktopApp/src/jvmMain/appResources//ffmpeg/README.md`. + +## GStreamer (Linux runtime dependency; not bundled) + +- License: LGPL-2.1-or-later (core + linked plugins) +- Required at runtime by ComposeMediaPlayer on Linux (linked via `pkg-config`). +- License text: `licenses/LICENSE-LGPL-2.1.txt` +- Users install via their distribution's package manager. We bundle no + GStreamer binaries. + +## Other Apache-2.0 / MIT transitive dependencies + +Compose Multiplatform (JetBrains, Apache-2.0), Kotlin stdlib (JetBrains, +Apache-2.0), OkHttp (Square, Apache-2.0), Jackson (FasterXML, Apache-2.0), +Coil (Apache-2.0), kmp-tor (MIT), and others, plus the bundled OpenJDK +runtime (GPLv2 + Classpath Exception). Their license texts are reproduced +under `licenses/` alongside this NOTICE. + +## SPDX combined expression + +``` +MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 +``` diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt new file mode 100644 index 0000000000..5d3569e640 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt @@ -0,0 +1,24 @@ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Copyright (c) JCodec authors. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt new file mode 100644 index 0000000000..319d0e6d79 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt @@ -0,0 +1,23 @@ +This file should contain the verbatim GNU LGPL-2.1 text from +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt + +The full text is 26 KB. Embed at packaging time (e.g. via a Gradle task +that fetches the canonical text), or paste it here verbatim before +releasing a build. Until populated, the binary release is non-compliant +with LGPL §6 obligations for the bundled FFmpeg binaries and Linux +GStreamer runtime dependency. + +Tracking: see plan docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md +"Phase 0 acceptance" — license text presence is part of the gate. + +Bundled / linked LGPL components: +- FFmpeg (LGPL build) — bundled per OS in appResources//ffmpeg/ +- GStreamer 1.x core + plugins-base/good/bad/libav — linked on Linux at runtime + (user-installed, not bundled) + +Written offer (LGPL §6 / §4): Source for both FFmpeg and GStreamer is available +from the projects' canonical git repositories (https://ffmpeg.org/download.html, +https://gstreamer.freedesktop.org/src/). The binary tags shipped in any +Amethyst Desktop release correspond to released upstream tags; this offer is +valid for three years from the date of distribution to anyone in receipt of +this binary. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt new file mode 100644 index 0000000000..988afa646d --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 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. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt new file mode 100644 index 0000000000..5d4359a9e1 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Elie Gambache (kdroidFilter) + +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. diff --git a/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md new file mode 100644 index 0000000000..784d736550 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md @@ -0,0 +1,16 @@ +# Linux bundled FFmpeg + +The DEB/RPM/AppImage packages **do not bundle FFmpeg** — they declare a +runtime dependency on system FFmpeg/GStreamer instead, which keeps the +binary's SPDX cleaner and reduces package size. + +The Flatpak manifest similarly relies on `org.freedesktop.Platform 24.08` +which ships an LGPL GStreamer + FFmpeg; thumbnail extraction falls through +to the host FFmpeg if installed. + +If you want a self-contained Linux build (e.g. AppImage with no host deps), +drop a static LGPL `ffmpeg` binary here. johnvansickle.com publishes LGPL +"release" builds for x86_64 and aarch64. Source: https://johnvansickle.com/ffmpeg/ + +The `VideoThumbnailCache` will prefer system `ffmpeg` on `$PATH` first, then +fall through to this bundled binary. diff --git a/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md new file mode 100644 index 0000000000..90a7dbf9aa --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md @@ -0,0 +1,28 @@ +# macOS bundled FFmpeg + +Drop a single universal (arm64 + x86_64) LGPL FFmpeg binary here as `ffmpeg` +(no extension) with `+x` permission. Used by `VideoThumbnailCache` for +non-H.264 / non-faststart thumbnail extraction (HEVC, VP9, AV1, HLS). + +## Recommended source + +osxexperts.net "FFmpeg static (LGPL)" build: +- Page: https://www.osxexperts.net/ +- Verify SPDX: LGPL-2.1-or-later (configure flags: `--disable-gpl --disable-nonfree`) +- After download: `lipo -info ffmpeg` should report `arm64 x86_64`. If + separate arch binaries, fuse with: + `lipo -create ffmpeg-arm64 ffmpeg-x86_64 -output ffmpeg` +- Make executable: `chmod +x ffmpeg` + +## Codesigning + +`jpackage --mac-sign` traverses `Contents/app/resources/` and signs nested +executables with the same Developer ID identity as the app bundle. The +process is launched from the JVM at runtime; hardened-runtime entitlements +(`allow-jit`, `disable-library-validation`) are documented in +`docs/plans/_macos-ffmpeg-signing-recipe.md`. + +## Size + +~30-40 MB universal binary. Acceptable trade-off given we shed ~95 MB of +bundled VLC by switching to kdroidFilter + this thin LGPL FFmpeg. diff --git a/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md new file mode 100644 index 0000000000..16a8a718d3 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md @@ -0,0 +1,16 @@ +# Windows bundled FFmpeg + +Drop a single LGPL Windows x64 FFmpeg binary here as `ffmpeg.exe`. + +## Recommended source + +Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264: +- Repo: https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 +- Confirm: SPDX LGPL-2.1-or-later, OpenH264 (BSD-2 with patent grant from Cisco). +- Verify: `ffmpeg.exe -version` from a `cmd.exe` prompt. + +Used as fallback by `VideoThumbnailCache` for non-H.264 / HLS thumbnails. + +## Size + +~20-30 MB. Acceptable given the ~95 MB VLC plugin tree this migration retires. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fe9d78fa6d..2805abe27b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -89,7 +89,7 @@ import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup -import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences @@ -240,15 +240,12 @@ fun main() { DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook( Thread { - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .shutdown() - VlcjPlayerPool.shutdown() + GlobalMediaPlayer.shutdown() // Stop Tor daemon if running — reference set by App composable activeTorManager?.stopSync() }, ) - // Pre-init VLC on background thread so first play is fast - Thread { VlcjPlayerPool.init() }.start() + // kdroidFilter lazy-loads the native player on first playback — no pre-init needed. application { val windowState = rememberWindowState( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt index dc59b159cd..7c0eddbbd9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -20,31 +20,21 @@ */ package com.vitorpamplona.amethyst.desktop.service.media -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.runtime.snapshotFlow import com.vitorpamplona.amethyst.desktop.ui.media.MediaType +import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.createVideoPlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat -import java.nio.ByteBuffer -import org.jetbrains.skia.Image as SkiaImage data class MediaPlaybackState( val url: String? = null, @@ -57,221 +47,202 @@ data class MediaPlaybackState( val aspectRatio: Float = 16f / 9f, val volume: Int = 100, val isMuted: Boolean = false, + /** Last observed error from the engine, or null. JVM emits only SourceError/UnknownError. */ + val errorReason: String? = null, ) +/** + * Singleton facade over kdroidFilter's [VideoPlayerState] (MIT, OS-native backends: + * Media Foundation on Windows, AVFoundation on macOS, GStreamer on Linux). + * + * Holds two engine instances (video + audio) for the lifetime of the JVM. The + * underlying [VideoPlayerState] exposes its state via Compose `mutableStateOf`; + * a [snapshotFlow] coroutine mirrors that into our public [MediaPlaybackState] + * `StateFlow`s so non-Compose consumers (and the existing UI) remain unchanged. + * + * UI surface for visible video frames is rendered by mounting + * `VideoPlayerSurface(playerState = [activeVideoPlayerState])` in the active + * `DesktopVideoPlayer` instance — see that file for the active/inactive dispatch. + */ object GlobalMediaPlayer { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - // Video state - private val _videoFrame = MutableStateFlow(null) - val videoFrame: StateFlow = _videoFrame.asStateFlow() + // Engine handles. Constructed lazily on first playback request so that app + // start does not load the native library if the user never plays media. + @Volatile private var videoPlayer: VideoPlayerState? = null + + @Volatile private var audioPlayer: VideoPlayerState? = null + + private val initLock = Any() + + /** + * The kdroidFilter player driving currently-active or last-played video, or + * `null` if the native player failed to initialize (e.g. missing GStreamer + * on Linux). UI code reads this lazily; consumers must handle null by + * showing the thumbnail/error fallback rather than mounting a surface. + */ + val activeVideoPlayerState: VideoPlayerState? + get() = ensureVideoPlayer() private val _videoState = MutableStateFlow(MediaPlaybackState()) val videoState: StateFlow = _videoState.asStateFlow() - // Audio state private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) val audioState: StateFlow = _audioState.asStateFlow() - // Fullscreen private val _isFullscreen = MutableStateFlow(false) val isFullscreen: StateFlow = _isFullscreen.asStateFlow() - // VLC players — kept alive between plays - private var videoPlayer: EmbeddedMediaPlayer? = null - private var audioPlayer: MediaPlayer? = null + // Stashed pre-mute volume (0..100). kdroidFilter has no isMuted concept, so + // we emulate by zeroing volume and remembering the prior value. + private var preMuteVideoVolume: Int = 100 + private var preMuteAudioVolume: Int = 100 - // Skia bitmap for video rendering - private var skBitmap: Bitmap? = null - private var pixelBytes: ByteArray? = null + private var videoSyncJob: Job? = null + private var audioSyncJob: Job? = null - // Position polling job - private var videoPollingJob: Job? = null - private var audioPollingJob: Job? = null + // --- Verbs --------------------------------------------------------------- fun playVideo( url: String, seekPosition: Float = 0f, ) { - // If already playing this URL, just seek val current = _videoState.value - if (current.url == url && videoPlayer != null) { - if (seekPosition > 0f) { - videoPlayer?.controls()?.setPosition(seekPosition) - } - if (!current.isPlaying) { - videoPlayer?.controls()?.play() + val player = + ensureVideoPlayer() ?: run { + _videoState.value = + MediaPlaybackState( + url = url, + type = MediaType.VIDEO, + isBuffering = false, + errorReason = "Video playback unavailable", + ) + return } + + if (current.url == url) { + if (seekPosition > 0f) player.seekTo(seekPosition * 1000f) + if (!current.isPlaying) player.play() return } - // Stop current video if different URL - if (current.url != null && current.url != url) { - videoPlayer?.controls()?.stop() - } + // Reset engine volume to match the UI's default for the new track. The + // kdroidFilter player retains `volume` across openUri calls, so a mute + // on the prior track would otherwise carry over while the UI shows the + // default 100% / unmuted state. + player.volume = 1f - _videoState.value = - MediaPlaybackState( - url = url, - type = MediaType.VIDEO, - isBuffering = true, - ) + _videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true) scope.launch(Dispatchers.IO) { - if (!VlcjPlayerPool.init()) { - _videoState.value = _videoState.value.copy(isBuffering = false) - return@launch + player.openUri(url) + // openUri auto-plays per InitialPlayerState.PLAY default. For an + // initial seek we wait for the first hasMedia=true emission then + // stop collecting (Flow.first terminates the collector cleanly, + // unlike `return@collect` which only exits the lambda). + if (seekPosition > 0f) { + snapshotFlow { player.hasMedia }.first { it } + player.seekTo(seekPosition * 1000f) } - - val player = - videoPlayer ?: VlcjPlayerPool.acquire() ?: run { - _videoState.value = _videoState.value.copy(isBuffering = false) - return@launch - } - - // Only set up surface on first acquisition - if (videoPlayer == null) { - setupVideoSurface(player) - setupVideoEventListener(player) - videoPlayer = player - } - - var didSeek = seekPosition <= 0f - - // Temporary listener for initial seek - if (!didSeek) { - val seekListener = - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - if (!didSeek) { - didSeek = true - mediaPlayer.controls().setPosition(seekPosition) - mediaPlayer.events().removeMediaPlayerEventListener(this) - } - } - } - player.events().addMediaPlayerEventListener(seekListener) - } - - val vol = _videoState.value.volume - player.media().play(url, ":start-volume=$vol") - startVideoPolling() } } fun playAudio(url: String) { val current = _audioState.value - if (current.url == url && audioPlayer != null) { - if (!current.isPlaying) { - audioPlayer?.controls()?.play() + val player = + ensureAudioPlayer() ?: run { + _audioState.value = + MediaPlaybackState( + url = url, + type = MediaType.AUDIO, + isBuffering = false, + errorReason = "Audio playback unavailable", + ) + return } + + if (current.url == url) { + if (!current.isPlaying) player.play() return } - if (current.url != null && current.url != url) { - audioPlayer?.controls()?.stop() - } + // Reset engine volume — see playVideo() for rationale. + player.volume = 1f - _audioState.value = - MediaPlaybackState( - url = url, - type = MediaType.AUDIO, - isBuffering = true, - ) + _audioState.value = MediaPlaybackState(url = url, type = MediaType.AUDIO, isBuffering = true) scope.launch(Dispatchers.IO) { - val player = - audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run { - _audioState.value = _audioState.value.copy(isBuffering = false) - return@launch - } - - if (audioPlayer == null) { - setupAudioEventListener(player) - audioPlayer = player - } - - val vol = _audioState.value.volume - player.media().play(url, ":start-volume=$vol") - startAudioPolling() + player.openUri(url) } } fun toggleVideoPlayPause() { val player = videoPlayer ?: return - val state = _videoState.value - if (state.url == null) return - - if (state.isPlaying) { - player.controls().pause() - } else { - if (state.position <= 0f && !player.status().isPlaying) { - state.url.let { player.media().play(it) } - } else { - player.controls().play() - } - } + if (_videoState.value.isPlaying) player.pause() else player.play() } fun toggleAudioPlayPause() { val player = audioPlayer ?: return - val state = _audioState.value - if (state.url == null) return - - if (state.isPlaying) { - player.controls().pause() - } else { - if (state.position <= 0f && !player.status().isPlaying) { - state.url.let { player.media().play(it) } - } else { - player.controls().play() - } - } + if (_audioState.value.isPlaying) player.pause() else player.play() } + /** UI passes position in 0..1. kdroidFilter wants 0..1000. */ fun seekVideo(position: Float) { - videoPlayer?.controls()?.setPosition(position) + videoPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f)) } fun seekAudio(position: Float) { - audioPlayer?.controls()?.setPosition(position) + audioPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f)) } + /** UI passes volume in 0..100. kdroidFilter wants 0..1. */ fun setVideoVolume(volume: Int) { - videoPlayer?.audio()?.setVolume(volume) - _videoState.value = _videoState.value.copy(volume = volume) + videoPlayer?.volume = volume.coerceIn(0, 100) / 100f + val muted = _videoState.value.isMuted && volume == 0 + _videoState.value = _videoState.value.copy(volume = volume, isMuted = muted) + if (volume > 0) preMuteVideoVolume = volume } fun setAudioVolume(volume: Int) { - audioPlayer?.audio()?.setVolume(volume) - _audioState.value = _audioState.value.copy(volume = volume) + audioPlayer?.volume = volume.coerceIn(0, 100) / 100f + val muted = _audioState.value.isMuted && volume == 0 + _audioState.value = _audioState.value.copy(volume = volume, isMuted = muted) + if (volume > 0) preMuteAudioVolume = volume } + /** kdroidFilter has no mute concept — emulate by stashing volume. */ fun toggleVideoMute() { - val muted = !_videoState.value.isMuted - videoPlayer?.audio()?.isMute = muted - _videoState.value = _videoState.value.copy(isMuted = muted) + val state = _videoState.value + if (state.isMuted) { + setVideoVolume(preMuteVideoVolume) + _videoState.value = _videoState.value.copy(isMuted = false) + } else { + preMuteVideoVolume = state.volume.coerceAtLeast(1) + videoPlayer?.volume = 0f + _videoState.value = _videoState.value.copy(isMuted = true, volume = 0) + } } fun toggleAudioMute() { - val muted = !_audioState.value.isMuted - audioPlayer?.audio()?.isMute = muted - _audioState.value = _audioState.value.copy(isMuted = muted) + val state = _audioState.value + if (state.isMuted) { + setAudioVolume(preMuteAudioVolume) + _audioState.value = _audioState.value.copy(isMuted = false) + } else { + preMuteAudioVolume = state.volume.coerceAtLeast(1) + audioPlayer?.volume = 0f + _audioState.value = _audioState.value.copy(isMuted = true, volume = 0) + } } fun stopVideo() { - videoPollingJob?.cancel() - videoPollingJob = null - videoPlayer?.controls()?.stop() + videoPlayer?.stop() _videoState.value = MediaPlaybackState() - _videoFrame.value = null _isFullscreen.value = false } fun stopAudio() { - audioPollingJob?.cancel() - audioPollingJob = null - audioPlayer?.controls()?.stop() + audioPlayer?.stop() _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) } @@ -283,215 +254,135 @@ object GlobalMediaPlayer { _isFullscreen.value = false } + /** Call on app exit. Disposes native handles owned by kdroidFilter. */ fun shutdown() { - videoPollingJob?.cancel() - audioPollingJob?.cancel() - - videoPlayer?.let { p -> - try { - p.controls().stop() - } catch (_: Exception) { - } - VlcjPlayerPool.release(p) - } + videoSyncJob?.cancel() + audioSyncJob?.cancel() + runCatching { videoPlayer?.stop() } + runCatching { videoPlayer?.dispose() } + runCatching { audioPlayer?.stop() } + runCatching { audioPlayer?.dispose() } videoPlayer = null - - audioPlayer?.let { p -> - try { - p.controls().stop() - } catch (_: Exception) { - } - VlcjPlayerPool.releaseAudioPlayer(p) - } audioPlayer = null - _videoState.value = MediaPlaybackState() _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) - _videoFrame.value = null _isFullscreen.value = false - scope.cancel() } - private fun setupVideoSurface(player: EmbeddedMediaPlayer) { - val bufferFormatCallback = - object : BufferFormatCallback { - override fun getBufferFormat( - sourceWidth: Int, - sourceHeight: Int, - ): BufferFormat { - if (sourceHeight > 0) { - _videoState.value = - _videoState.value.copy( - aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(), - ) - } - val bmp = Bitmap() - bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) - skBitmap = bmp - pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) - return RV32BufferFormat(sourceWidth, sourceHeight) - } + // --- Engine lifecycle ---------------------------------------------------- - override fun allocatedBuffers(buffers: Array) {} - } + /** + * Returns the video player, creating it on first call. Returns `null` if + * native initialization throws (e.g. missing GStreamer on Linux, broken + * `libNativeVideoPlayer.dylib` extraction). On failure, subsequent calls + * keep returning `null` until the JVM is restarted — re-trying mid-session + * is unlikely to recover from a missing native dependency. + */ + private fun ensureVideoPlayer(): VideoPlayerState? = + videoPlayer ?: synchronized(initLock) { + videoPlayer ?: runCatching { createVideoPlayerState() } + .onSuccess { startVideoSync(it) } + .onFailure { println("kdroidFilter: video engine init failed: ${it.message}") } + .getOrNull() + ?.also { videoPlayer = it } + } - val renderCallback = - RenderCallback { _, nativeBuffers, _ -> - val bmp = skBitmap ?: return@RenderCallback - val bytes = pixelBytes ?: return@RenderCallback - val buffer = nativeBuffers[0] - buffer.rewind() - buffer.get(bytes) - bmp.installPixels(bytes) - _videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() - } + private fun ensureAudioPlayer(): VideoPlayerState? = + audioPlayer ?: synchronized(initLock) { + audioPlayer ?: runCatching { createVideoPlayerState() } + .onSuccess { startAudioSync(it) } + .onFailure { println("kdroidFilter: audio engine init failed: ${it.message}") } + .getOrNull() + ?.also { audioPlayer = it } + } - val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) - player.videoSurface().set(surface) - } - - private fun setupVideoEventListener(player: EmbeddedMediaPlayer) { - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - val state = _videoState.value - _videoState.value = - state.copy( - isPlaying = true, - isBuffering = false, - duration = mediaPlayer.status().length(), - ) - } - - override fun paused(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isPlaying = false) - } - - override fun stopped(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false) - } - - override fun buffering( - mediaPlayer: MediaPlayer, - newCache: Float, - ) { - _videoState.value = _videoState.value.copy(isBuffering = newCache < 100f) - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - _videoState.value = - _videoState.value.copy( - position = newPosition, - currentTime = (newPosition * _videoState.value.duration).toLong(), - ) - } - - override fun finished(mediaPlayer: MediaPlayer) { - _videoState.value = - _videoState.value.copy( - isPlaying = false, - isBuffering = false, - position = 0f, - currentTime = 0L, - ) - } - - override fun error(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isBuffering = false) - println("VLC: playback error for ${_videoState.value.url}") - } - }, - ) - } - - private fun setupAudioEventListener(player: MediaPlayer) { - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - _audioState.value = - _audioState.value.copy( - isPlaying = true, - isBuffering = false, - duration = mediaPlayer.status().length(), - ) - } - - override fun paused(mediaPlayer: MediaPlayer) { - _audioState.value = _audioState.value.copy(isPlaying = false) - } - - override fun stopped(mediaPlayer: MediaPlayer) { - _audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false) - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - _audioState.value = - _audioState.value.copy( - position = newPosition, - currentTime = (newPosition * _audioState.value.duration).toLong(), - ) - } - - override fun finished(mediaPlayer: MediaPlayer) { - _audioState.value = - _audioState.value.copy( - isPlaying = false, - position = 0f, - currentTime = 0L, - ) - } - }, - ) - } - - private fun startVideoPolling() { - videoPollingJob?.cancel() - videoPollingJob = + private fun startVideoSync(player: VideoPlayerState) { + videoSyncJob?.cancel() + videoSyncJob = scope.launch { - while (true) { - delay(500) - val player = videoPlayer ?: break - val state = _videoState.value - if (state.isPlaying) { - try { - _videoState.value = - state.copy( - position = player.status().position(), - currentTime = player.status().time(), - ) - } catch (_: Exception) { + snapshotFlow { + EngineSnapshot( + isPlaying = player.isPlaying, + isLoading = player.isLoading, + hasMedia = player.hasMedia, + currentTime = player.currentTime, + duration = player.duration, + aspectRatio = player.aspectRatio, + errorMessage = player.error?.let(::describeError), + ) + }.collect { snap -> + val current = _videoState.value + val posFraction = + if (snap.duration > 0.0) { + (snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f) + } else { + current.position } - } + _videoState.value = + current.copy( + isPlaying = snap.isPlaying, + isBuffering = snap.isLoading, + duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L), + currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L), + position = posFraction, + aspectRatio = if (snap.aspectRatio > 0f) snap.aspectRatio else current.aspectRatio, + errorReason = snap.errorMessage, + ) } } } - private fun startAudioPolling() { - audioPollingJob?.cancel() - audioPollingJob = + private fun startAudioSync(player: VideoPlayerState) { + audioSyncJob?.cancel() + audioSyncJob = scope.launch { - while (true) { - delay(500) - val player = audioPlayer ?: break - val state = _audioState.value - if (state.isPlaying) { - try { - _audioState.value = - state.copy( - position = player.status().position(), - currentTime = player.status().time(), - ) - } catch (_: Exception) { + snapshotFlow { + EngineSnapshot( + isPlaying = player.isPlaying, + isLoading = player.isLoading, + hasMedia = player.hasMedia, + currentTime = player.currentTime, + duration = player.duration, + aspectRatio = player.aspectRatio, + errorMessage = player.error?.let(::describeError), + ) + }.collect { snap -> + val current = _audioState.value + val posFraction = + if (snap.duration > 0.0) { + (snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f) + } else { + current.position } - } + _audioState.value = + current.copy( + isPlaying = snap.isPlaying, + isBuffering = snap.isLoading, + duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L), + currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L), + position = posFraction, + errorReason = snap.errorMessage, + ) } } } + + private fun describeError(error: VideoPlayerError): String = + when (error) { + is VideoPlayerError.CodecError -> "Codec: ${error.message}" + is VideoPlayerError.NetworkError -> "Network: ${error.message}" + is VideoPlayerError.SourceError -> "Source: ${error.message}" + is VideoPlayerError.UnknownError -> error.message + } + + private data class EngineSnapshot( + val isPlaying: Boolean, + val isLoading: Boolean, + val hasMedia: Boolean, + val currentTime: Double, + val duration: Double, + val aspectRatio: Float, + val errorMessage: String?, + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt deleted file mode 100644 index 58d7f9fa54..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import com.sun.jna.Function -import com.sun.jna.NativeLibrary -import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil -import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy - -/** - * Discovers bundled VLC libraries on macOS. - * Must force-load libvlccore before libvlc to avoid link errors. - * Uses [VlcResourceResolver] to find the VLC directory from the Compose application - * resources or development fallback paths. - */ -class MacOsVlcDiscoverer : - BaseNativeDiscoveryStrategy( - arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), - arrayOf("%s/plugins"), - ) { - /** Plugin path discovered during [setPluginPath], available after discovery. */ - var discoveredPluginPath: String? = null - private set - - /** Whether [setPluginPath] successfully set the process env var. */ - var envVarSet: Boolean = false - private set - - override fun supported(): Boolean { - val os = System.getProperty("os.name").lowercase() - return "mac" in os - } - - override fun discoveryDirectories(): List { - val vlcDir = VlcResourceResolver.findVlcDir() ?: return emptyList() - return listOf(vlcDir.absolutePath) - } - - override fun onFound(path: String): Boolean { - NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path) - NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName()) - return true - } - - override fun setPluginPath(pluginPath: String?): Boolean { - if (pluginPath == null) return false - discoveredPluginPath = pluginPath - return try { - // Call setenv directly via JNA Function API. This bypasses vlcj's - // LibC interface binding which fails on macOS 13+ because dlsym - // can't resolve the versioned symbol `setenv$3b99ba0d`. - val setenv = Function.getFunction("c", "setenv") - val result = setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 - envVarSet = result - result - } catch (_: Throwable) { - // JNA Function call also failed — VlcjPlayerPool will use - // --plugin-path factory arg as fallback. - envVarSet = false - false - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 6ecaabe158..09cc733d95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -22,26 +22,90 @@ package com.vitorpamplona.amethyst.desktop.service.media import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat -import java.nio.ByteBuffer +import okhttp3.Request +import org.jcodec.api.FrameGrab +import org.jcodec.common.io.NIOUtils +import org.jcodec.common.model.ColorSpace +import org.jcodec.common.model.Picture +import org.jcodec.scale.AWTUtil +import org.jcodec.scale.ColorUtil +import org.jetbrains.skia.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import org.jetbrains.skia.Image as SkiaImage +import javax.imageio.ImageIO +/** + * Per-URL one-frame thumbnail extractor backing feed video posters. + * + * Cascade: + * 1. **JCodec** (`org.jcodec:jcodec` + `jcodec-javase`, BSD-2) — pure-Java + * H.264 baseline/main/high decode. Handles the bulk of Nostr feed media + * (MP4/H.264). + * 2. **LGPL FFmpeg subprocess** (driven via raw `ProcessBuilder`) — for + * everything else (HEVC, VP9, AV1, HLS, malformed faststart MP4s). + * Requires either a system `ffmpeg` on `$PATH` or a bundled binary at + * `src/jvmMain/appResources//ffmpeg/ffmpeg(.exe)`. + * + * Replaces the prior vlcj `RenderCallback` path. License moves from + * GPL-3.0 (vlcj) to BSD-2 + LGPL-2.1, MIT-dominant overall. + */ object VideoThumbnailCache { + private const val MAX_THUMB_BYTES = 4 * 1024 * 1024 // 4 MiB cap per thumbnail + private val cache = ConcurrentHashMap() private val pending = ConcurrentHashMap() + private val downloadCacheDir: File by lazy { + val base = + File(System.getProperty("user.home"), ".cache/amethyst-desktop/video-thumbs") + .also { it.mkdirs() } + base + } + + private val ffmpegBinary: String? by lazy { + // 1. System ffmpeg on PATH. Probe with `ffmpeg -version`; drain stdout + // so the child doesn't block on a full pipe, kill it if it overruns + // the probe budget so we don't leak the process when ffmpeg hangs. + val onPath = + runCatching { + val probe = + ProcessBuilder("ffmpeg", "-version") + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start() + val exited = probe.waitFor(2, TimeUnit.SECONDS) + if (!exited) { + probe.destroyForcibly() + return@runCatching false + } + probe.exitValue() == 0 + }.getOrDefault(false) + if (onPath) return@lazy "ffmpeg" + + // 2. Bundled ffmpeg under appResources//ffmpeg/. + // jpackage drops appResources at /lib/app/resources/ — equivalently, + // we can read from the working dir layout under desktopApp/src/jvmMain/appResources + // during `./gradlew :desktopApp:run`. Look for it in well-known locations. + val osName = System.getProperty("os.name").lowercase() + val isWin = "win" in osName + val binaryName = if (isWin) "ffmpeg.exe" else "ffmpeg" + val candidates = + listOf( + File(System.getProperty("compose.application.resources.dir") ?: "", "ffmpeg/$binaryName"), + File("desktopApp/src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"), + File("src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"), + ) + candidates.firstOrNull { it.exists() && it.canExecute() }?.absolutePath + } + fun getCached(url: String): ImageBitmap? = cache[url] suspend fun getThumbnail(url: String): ImageBitmap? { @@ -58,82 +122,177 @@ object VideoThumbnailCache { } private fun extractFirstFrame(url: String): ImageBitmap? { - if (!VlcjPlayerPool.init()) { - println("VLC thumbnail: init failed for $url") - return null - } - val player = VlcjPlayerPool.acquireForThumbnail() - if (player == null) { - println("VLC thumbnail: pool exhausted for $url") - return null - } + // For HLS we skip straight to ffmpeg — JCodec can't read m3u8. + val isHls = url.contains(".m3u8", ignoreCase = true) || url.contains("/hls/", ignoreCase = true) - var result: ImageBitmap? = null - val latch = CountDownLatch(1) - - val bufferFormatCallback = - object : BufferFormatCallback { - override fun getBufferFormat( - sourceWidth: Int, - sourceHeight: Int, - ): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight) - - override fun allocatedBuffers(buffers: Array) {} - } - - val renderCallback = - RenderCallback { _, nativeBuffers, bufferFormat -> - if (result != null) return@RenderCallback + if (!isHls) { + val downloaded = runCatching { downloadFirstChunk(url) }.getOrNull() + if (downloaded != null) { try { - if (nativeBuffers.isEmpty()) return@RenderCallback - val w = bufferFormat.width - val h = bufferFormat.height - if (w <= 0 || h <= 0) return@RenderCallback - val bmp = Bitmap() - bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) - val bytes = ByteArray(w * h * 4) - val buffer = nativeBuffers[0] - buffer.rewind() - buffer.get(bytes) - bmp.installPixels(bytes) - result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() - latch.countDown() - } catch (e: Exception) { - println("VLC thumbnail: render error for $url — ${e.message}") - latch.countDown() + tryJCodec(downloaded.file)?.let { return it } + tryFfmpegFile(downloaded.file)?.let { return it } + } finally { + // Origins that ignore Range: served the full body and we + // truncated to MAX_THUMB_BYTES; that file is unsuitable as + // a persistent cache hit (decoders may fail on every + // retry against a half-MP4). Discard so the next request + // re-downloads from scratch. + if (!downloaded.persistable) downloaded.file.delete() } } + } - val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) - if (surface == null) { - println("VLC thumbnail: surface creation failed for $url") - VlcjPlayerPool.release(player) + return tryFfmpegUrl(url) + } + + /** Local result of [downloadFirstChunk]: the bytes + whether they're a real Range slice. */ + private data class Download( + val file: File, + val persistable: Boolean, + ) + + /** + * Downloads up to [MAX_THUMB_BYTES] to a cache file, returning the file (or null on failure). + * + * Caps the copy regardless of whether the server honours `Range:` — some origins ignore it + * and serve a 200 with the full body, which would otherwise stream the entire video. + * + * Rejects responses whose `Content-Type` starts with `text/` (e.g. HTML error pages from + * broken origins) so we never persist non-video bytes into the cache. + * + * Cleans up zero-byte cache files on failure so a transient empty response isn't sticky. + */ + private fun downloadFirstChunk(url: String): Download? { + val hash = sha1Hex(url) + val cached = File(downloadCacheDir, "$hash.mp4") + if (cached.length() > 0L) return Download(cached, persistable = true) + if (cached.exists()) cached.delete() + + var wrote = false + var rangeHonored = false + DesktopHttpClient.currentClient().newCall(buildRangeRequest(url)).execute().use { resp -> + if (!resp.isSuccessful && resp.code != 206) return null + val contentType = resp.header("Content-Type")?.lowercase().orEmpty() + if (contentType.startsWith("text/") || "html" in contentType) return null + rangeHonored = resp.code == 206 + Files.newOutputStream(cached.toPath()).use { out -> + val copied = copyAtMost(resp.body.byteStream(), out, MAX_THUMB_BYTES.toLong()) + wrote = copied > 0L + } + } + if (!wrote || cached.length() == 0L) { + cached.delete() return null } - - player.videoSurface().set(surface) - player.audio().setVolume(0) - player.audio().isMute = true - - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun error(mediaPlayer: MediaPlayer) { - println("VLC thumbnail: playback error for $url") - latch.countDown() - } - }, - ) - - player.media().play(url) - - // Wait up to 8 seconds for first frame (network videos can be slow) - latch.await(8, TimeUnit.SECONDS) - - if (result == null) { - println("VLC thumbnail: timed out or failed for $url") - } - - VlcjPlayerPool.release(player) - return result + return Download(cached, persistable = rangeHonored) } + + private fun buildRangeRequest(url: String): Request = + Request + .Builder() + .url(url) + .header("Range", "bytes=0-${MAX_THUMB_BYTES - 1}") + .header("User-Agent", "Amethyst-Desktop/thumbnail") + .build() + + private fun copyAtMost( + src: java.io.InputStream, + dst: java.io.OutputStream, + limit: Long, + ): Long { + val buf = ByteArray(64 * 1024) + var copied = 0L + while (copied < limit) { + val toRead = minOf(buf.size.toLong(), limit - copied).toInt() + val n = src.read(buf, 0, toRead) + if (n < 0) break + dst.write(buf, 0, n) + copied += n + } + return copied + } + + private fun tryJCodec(mp4: File): ImageBitmap? = + runCatching { + NIOUtils.readableChannel(mp4).use { ch -> + val grab = FrameGrab.createFrameGrab(ch).seekToSecondSloppy(1.0) + val native: Picture = grab.nativeFrame ?: return null + val rgb = Picture.create(native.width, native.height, ColorSpace.RGB) + ColorUtil.getTransform(native.color, ColorSpace.RGB).transform(native, rgb) + bufferedImageToImageBitmap(AWTUtil.toBufferedImage(rgb)) + } + }.getOrNull() + + private fun tryFfmpegFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath) + + private fun tryFfmpegUrl(url: String): ImageBitmap? = runFfmpegToImage(url) + + /** + * Spawns `ffmpeg -ss 1 -i -frames:v 1 -f image2pipe -c:v png -an pipe:1`, + * reads PNG bytes from stdout, decodes with Skia. + * + * `redirectError(DISCARD)` so a chatty ffmpeg cannot fill the stderr pipe + * and stall our `copyTo`. `destroyForcibly()` runs on any unwind so we + * never leak a ffmpeg process. + */ + private fun runFfmpegToImage(input: String): ImageBitmap? { + val ffmpeg = ffmpegBinary ?: return null + val cmd = + listOf( + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-ss", + "1", + "-i", + input, + "-frames:v", + "1", + "-an", + "-f", + "image2pipe", + "-c:v", + "png", + "pipe:1", + ) + val process = + runCatching { + ProcessBuilder(cmd) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }.getOrNull() ?: return null + val out = ByteArrayOutputStream(256 * 1024) + try { + process.inputStream.use { it.copyTo(out) } + if (!process.waitFor(8, TimeUnit.SECONDS)) return null + if (process.exitValue() != 0 || out.size() == 0) return null + } catch (_: Exception) { + return null + } finally { + if (process.isAlive) process.destroyForcibly() + } + return runCatching { + Image.makeFromEncoded(out.toByteArray()).toComposeImageBitmap() + }.getOrNull() + } + + private fun bufferedImageToImageBitmap(img: BufferedImage): ImageBitmap { + val baos = ByteArrayOutputStream(64 * 1024) + ImageIO.write(img, "png", baos) + return Image.makeFromEncoded(baos.toByteArray()).toComposeImageBitmap() + } + + private fun sha1Hex(s: String): String { + val md = MessageDigest.getInstance("SHA-1") + val bytes = md.digest(s.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun osTag(osName: String): String = + when { + "mac" in osName -> "macos" + "win" in osName -> "windows" + else -> "linux" + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt deleted file mode 100644 index c53af47faa..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import java.io.File - -/** - * Resolves the bundled VLC directory, with fallbacks for development (Gradle run). - * - * Resolution order: - * 1. `compose.application.resources.dir` system property (set by packaged app) - * 2. Gradle `prepareAppResources` build output - * 3. Source `appResources` directory (platform-specific) - */ -object VlcResourceResolver { - private val currentPlatform: String by lazy { - val os = System.getProperty("os.name").lowercase() - when { - "mac" in os || "darwin" in os -> "macos" - "win" in os -> "windows" - else -> "linux" - } - } - - /** - * Returns the VLC directory if found, or null. - */ - fun findVlcDir(): File? { - // 1. Compose application resources dir (packaged app or plugin-provided) - System.getProperty("compose.application.resources.dir")?.let { dir -> - val vlcDir = File(dir, "vlc") - if (vlcDir.isDirectory) return vlcDir - } - - // 2. Gradle prepareAppResources build output (relative to working dir) - val buildOutput = File("desktopApp/build/compose/tmp/prepareAppResources/vlc") - if (buildOutput.isDirectory) return buildOutput - - // 3. Source appResources (platform-specific subdirectory) - val sourceResources = File("desktopApp/src/jvmMain/appResources/$currentPlatform/vlc") - if (sourceResources.isDirectory) return sourceResources - - return null - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt deleted file mode 100644 index 4c73e918cd..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ /dev/null @@ -1,325 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import uk.co.caprica.vlcj.factory.MediaPlayerFactory -import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Manages a pool of VLCJ media players to avoid costly create/destroy cycles. - * Keeps strong references to prevent GC crashes from native callbacks. - * - * IMPORTANT: Never let player instances be garbage collected while native - * callbacks are active — this causes JVM segfaults. - */ -object VlcjPlayerPool { - private val available = AtomicBoolean(false) - private val initAttempted = AtomicBoolean(false) - private val initLatch = CountDownLatch(1) - private var factory: MediaPlayerFactory? = null - - // Video player pool (for actual playback) - private val allPlayers = mutableListOf() - private val idlePlayers = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 1 - - // Thumbnail player pool (separate so thumbnails don't compete with playback) - private val allThumbPlayers = mutableListOf() - private val idleThumbPlayers = ConcurrentLinkedQueue() - private const val MAX_THUMB_POOL_SIZE = 2 - - // Cached plugin path for audio factory creation (set during init) - private var cachedPluginPath: String? = null - - // Audio player pool (shared factory with --no-video) - private var audioFactory: MediaPlayerFactory? = null - private val allAudioPlayers = mutableListOf() - private val idleAudioPlayers = ConcurrentLinkedQueue() - private const val MAX_AUDIO_POOL_SIZE = 1 - - /** - * Initialize the pool. Thread-safe — only runs once. - * Returns false if VLC is not installed. - */ - fun init(): Boolean { - if (available.get()) return true - - // Only one thread performs init; others wait - if (!initAttempted.compareAndSet(false, true)) { - initLatch.await(10, TimeUnit.SECONDS) - return available.get() - } - - return try { - // Try bundled VLC first, then fall through to system VLC - val macOsDiscoverer = MacOsVlcDiscoverer() - val discovery = - try { - val nd = - NativeDiscovery( - BundledVlcDiscoverer(), - macOsDiscoverer, - ) - val found = nd.discover() - if (found) { - println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}") - } else { - println("VLC: bundled discovery failed, falling back to system VLC") - } - found - } catch (e: Throwable) { - println("VLC: bundled discovery threw ${e.message}") - false - } - if (!discovery) { - // Try default system discovery - val systemDiscovery = NativeDiscovery().discover() - println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") - } - - // Delete stale VLC plugin cache on macOS to avoid spam warnings - if ("mac" in System.getProperty("os.name").lowercase()) { - try { - val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") - cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } - } catch (_: Throwable) { - // Best-effort cache cleanup - } - } - - // Build factory args — add --plugin-path fallback if env var wasn't set - val factoryArgs = - mutableListOf( - "--no-xlib", - "--avcodec-hw=none", // Disable VideoToolbox — avoids CVPN chroma failures on macOS - "--reset-plugins-cache", // Rebuild stale plugins cache on startup - ) - if (!macOsDiscoverer.envVarSet) { - val pluginPath = - macOsDiscoverer.discoveredPluginPath - ?: System.getProperty("vlc.plugin.path") - ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } - if (pluginPath != null) { - factoryArgs += "--plugin-path=$pluginPath" - println("VLC: using --plugin-path fallback: $pluginPath") - } - } - - cachedPluginPath = macOsDiscoverer.discoveredPluginPath - ?: System.getProperty("vlc.plugin.path") - - val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) - factory = f - available.set(true) - println("VLC: MediaPlayerFactory created successfully") - true - } catch (e: Throwable) { - println("VLC: init failed — ${e.message}") - available.set(false) - false - } finally { - initLatch.countDown() - } - } - - fun isAvailable(): Boolean = available.get() - - /** - * Create a callback video surface using the factory's API. - */ - fun createVideoSurface( - bufferFormatCallback: BufferFormatCallback, - renderCallback: RenderCallback, - ): VideoSurface? { - val f = factory ?: return null - return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true) - } - - /** - * Acquire a video player from the pool or create a new one. - * Returns null if VLC is not available or pool is at capacity. - */ - fun acquire(): EmbeddedMediaPlayer? { - if (!available.get()) return null - val f = factory ?: return null - - synchronized(allPlayers) { - idlePlayers.poll()?.let { return it } - if (allPlayers.size >= MAX_POOL_SIZE) return null - return try { - val player = f.mediaPlayers().newEmbeddedMediaPlayer() - allPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Acquire a player dedicated to thumbnail extraction. - * Separate pool so thumbnails don't compete with playback. - */ - fun acquireForThumbnail(): EmbeddedMediaPlayer? { - if (!available.get()) return null - val f = factory ?: return null - - synchronized(allThumbPlayers) { - idleThumbPlayers.poll()?.let { return it } - if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) { - // Fall back to main pool if thumb pool is full - return acquire() - } - return try { - val player = f.mediaPlayers().newEmbeddedMediaPlayer() - allThumbPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Acquire an audio-only player from the pool. - * Uses a separate factory with --no-video for efficiency. - */ - fun acquireAudioPlayer(): MediaPlayer? { - if (!init()) return null - - synchronized(allAudioPlayers) { - idleAudioPlayers.poll()?.let { return it } - if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null - - val af = - audioFactory ?: try { - val audioArgs = mutableListOf("--no-video", "--no-xlib") - cachedPluginPath?.let { audioArgs += "--plugin-path=$it" } - MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } - } catch (_: Throwable) { - return null - } - - return try { - val player = af.mediaPlayers().newMediaPlayer() - allAudioPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Return a video player to the pool for reuse. - */ - fun release(player: EmbeddedMediaPlayer) { - try { - player.controls().stop() - // Return to correct pool - synchronized(allThumbPlayers) { - if (player in allThumbPlayers) { - idleThumbPlayers.offer(player) - return - } - } - idlePlayers.offer(player) - } catch (_: Exception) { - // Player may already be disposed - } - } - - /** - * Return an audio player to the pool for reuse. - */ - fun releaseAudioPlayer(player: MediaPlayer) { - try { - player.controls().stop() - idleAudioPlayers.offer(player) - } catch (_: Exception) { - // Player may already be disposed - } - } - - /** - * Shut down the entire pool. Call on app exit. - */ - fun shutdown() { - synchronized(allPlayers) { - idlePlayers.clear() - for (player in allPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allPlayers.clear() - } - synchronized(allThumbPlayers) { - idleThumbPlayers.clear() - for (player in allThumbPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allThumbPlayers.clear() - } - synchronized(allAudioPlayers) { - idleAudioPlayers.clear() - for (player in allAudioPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allAudioPlayers.clear() - } - try { - factory?.release() - } catch (_: Exception) { - // Ignore - } - try { - audioFactory?.release() - } catch (_: Exception) { - // Ignore - } - factory = null - audioFactory = null - available.set(false) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index a71c4024df..1d809b8056 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -24,12 +24,14 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -46,8 +48,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache -import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface import kotlinx.coroutines.delay +import java.awt.Desktop +import java.net.URI @Composable fun DesktopVideoPlayer( @@ -60,16 +64,12 @@ fun DesktopVideoPlayer( onViewModeChange: ((ViewMode) -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null, ) { - // Check if this URL is the active video val videoState by GlobalMediaPlayer.videoState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() val isActiveVideo = videoState.url == url - // Thumbnail for inactive videos var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } - // Load thumbnail when not active LaunchedEffect(url, isActiveVideo) { if (!isActiveVideo && thumbnail == null) { for (attempt in 1..3) { @@ -83,23 +83,16 @@ fun DesktopVideoPlayer( } } - // Auto-play on mount if requested LaunchedEffect(url, autoPlay) { if (autoPlay) { GlobalMediaPlayer.playVideo(url, initialSeekPosition) } } - // Sync aspect ratio from global state when active if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { aspectRatio = videoState.aspectRatio } - if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) { - VlcNotAvailableMessage(url, modifier) - return - } - BoxWithConstraints(modifier = modifier) { val desiredHeight = maxWidth / aspectRatio val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight @@ -115,17 +108,29 @@ fun DesktopVideoPlayer( ), contentAlignment = Alignment.Center, ) { - val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail - displayBitmap?.let { bitmap -> - Image( - bitmap = bitmap, - contentDescription = "Video", - modifier = - Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small), + val errorReason = if (isActiveVideo) videoState.errorReason else null + val activePlayer = if (isActiveVideo) GlobalMediaPlayer.activeVideoPlayerState else null + + if (errorReason != null) { + PlaybackErrorMessage(url = url, reason = errorReason) + } else if (isActiveVideo && activePlayer != null) { + VideoPlayerSurface( + playerState = activePlayer, + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) + } else { + thumbnail?.let { bitmap: ImageBitmap -> + Image( + bitmap = bitmap, + contentDescription = "Video thumbnail", + modifier = + Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Fit, + ) + } } VideoControls( @@ -149,12 +154,8 @@ fun DesktopVideoPlayer( GlobalMediaPlayer.seekVideo(pos) } }, - onVolumeChange = { vol -> - GlobalMediaPlayer.setVideoVolume(vol) - }, - onMuteToggle = { - GlobalMediaPlayer.toggleVideoMute() - }, + onVolumeChange = { vol -> GlobalMediaPlayer.setVideoVolume(vol) }, + onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() }, onFullscreen = if (onFullscreen != null) { { @@ -172,25 +173,34 @@ fun DesktopVideoPlayer( } @Composable -private fun VlcNotAvailableMessage( +private fun PlaybackErrorMessage( url: String, - modifier: Modifier = Modifier, + reason: String, ) { Box( - modifier = - modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(8.dp), - ), + modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center, ) { - Text( - text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Can't play this video", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton( + onClick = { + runCatching { Desktop.getDesktop().browse(URI(url)) } + }, + ) { + Text("Open in default player") + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt index 18a59732f8..6a23a9fa2f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.media -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -42,12 +41,12 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface @Composable fun GlobalFullscreenOverlay() { val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState() val videoState by GlobalMediaPlayer.videoState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() if (!isFullscreen || videoState.url == null) return @@ -103,11 +102,12 @@ fun GlobalFullscreenOverlay() { }, contentAlignment = Alignment.Center, ) { - // Video frame - videoFrame?.let { frame -> - Image( - bitmap = frame, - contentDescription = "Video fullscreen", + // Video frame — same player state as feed card; kdroidFilter draws to Canvas. + // If the engine failed to initialize, render a blank backdrop instead of + // crashing the overlay. + GlobalMediaPlayer.activeVideoPlayerState?.let { player -> + VideoPlayerSurface( + playerState = player, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt index ffe53568a2..0040021f65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache import kotlinx.coroutines.launch enum class MediaType { AUDIO, VIDEO } @@ -59,7 +60,6 @@ enum class MediaType { AUDIO, VIDEO } fun NowPlayingBar(modifier: Modifier = Modifier) { val videoState by GlobalMediaPlayer.videoState.collectAsState() val audioState by GlobalMediaPlayer.audioState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() val hasVideo = videoState.url != null val hasAudio = audioState.url != null @@ -86,11 +86,18 @@ fun NowPlayingBar(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Mini video thumbnail or music icon - if (activeType == MediaType.VIDEO && videoFrame != null) { + // Mini thumbnail (cached frame, not a live VideoPlayerSurface). + // We deliberately render the cached thumbnail rather than mounting a + // second VideoPlayerSurface to avoid driving two simultaneous Skia + // draws against the same player's frame bitmap — kdroidFilter + // 0.10.0's macOS surface has a use-after-free race in that path + // (see PR review). The mini-preview UX is preserved via the + // poster frame extracted by VideoThumbnailCache. + val miniThumb = activeState.url?.let { VideoThumbnailCache.getCached(it) } + if (activeType == MediaType.VIDEO && miniThumb != null) { Image( - bitmap = videoFrame!!, - contentDescription = "Video thumbnail", + bitmap = miniThumb, + contentDescription = "Now playing", modifier = Modifier .size(width = 48.dp, height = 36.dp) diff --git a/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md new file mode 100644 index 0000000000..5401640a1d --- /dev/null +++ b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md @@ -0,0 +1,863 @@ +--- +title: Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree +type: feat +status: active +date: 2026-06-11 +deepened: 2026-06-11 +origin: docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md +--- + +# Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree + +## Enhancement Summary (Deepened 2026-06-11) + +**Research artifacts (companion files):** +- [`_kdroidfilter-api-notes.md`](_kdroidfilter-api-notes.md) — verified kdroidFilter public API (note: pinned to **0.10.0** in `libs.versions.toml`; the research agent referenced 0.10.1 which does not exist on Maven Central as of 2026-06-11. API contract is the same across 0.10.x.) +- [`_jcodec-thumbnail-recipe.md`](_jcodec-thumbnail-recipe.md) — concrete JCodec → Skia ImageBitmap path +- [`_macos-ffmpeg-signing-recipe.md`](_macos-ffmpeg-signing-recipe.md) — jpackage nested-binary codesign recipe +- [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md) — Gitnuro-style Compose Desktop manifest +- [`_notice-and-licenses-recipe.md`](_notice-and-licenses-recipe.md) — cashapp/licensee + AboutLibraries pattern +- [`_vlcj-migration-learnings.md`](_vlcj-migration-learnings.md) — institutional learnings from prior media work +- [`_vlcj-migration-codebase-shape.md`](_vlcj-migration-codebase-shape.md) — file inventory (if regenerated) + +### Material corrections to original plan + +These supersede the corresponding sections below — implementer should use the new specs: + +1. **kdroidFilter API names** (Phase 1 was wrong; corrected inline): + - `openMedia(url)` → **`openUri(url)`** + - `release()` → **`dispose()`** + - `setVolume()` → **`volume = X` (Float, 0..1)** + - `setMuted()` / `isMuted` → **does not exist; emulate by stashing/restoring volume** + - State exposed as Compose **`mutableStateOf`** (read with `snapshotFlow {}` from outside composition), **not** `StateFlow` — affects `stateSyncJob` design. + - `VideoPlayerState` is an interface — instantiate via `rememberVideoPlayerState()` (composable) or `createVideoPlayerState()` (non-composable; manual `dispose()`). + - JVM surface uses **Compose Canvas** (Skia ImageBitmap), **not** SwingPanel — overlays/z-order/AnimatedVisibility work normally (vs. vlcj/SwingPanel limitations). + - No `surfaceType` parameter on JVM (Android-only). + - Error states on JVM are only `SourceError` / `UnknownError` — **no codec-vs-network distinction**. Need a separate GStreamer-on-Linux probe. + +2. **macOS app bundle path correction** (Phase 2 was wrong): + - `appResourcesRootDir` lands at **`Contents/app/resources/`**, NOT `Contents/Resources/`. jpackage owns `Contents/Resources/`. Final ffmpeg path: `Amethyst.app/Contents/app/resources/ffmpeg/ffmpeg`. + +3. **Linux GStreamer requirements**: + - kdroidFilter on Linux requires system **GStreamer 1.16+** with `plugins-base`, `plugins-good`, `plugins-bad`, and `libav` (for actual codec decoding). HLS needs `plugins-bad`. Document as `Recommends:` line in DEB / `recommends:` in metainfo. + +4. **JCodec adjustments** (Phase 2): + - Need `org.jcodec:jcodec:0.2.5` **plus** `org.jcodec:jcodec-javase:0.2.5` (AWTUtil lives in `-javase`). + - Frame color space is **`YUV420J`** — must convert with `ColorUtil.getTransform(native.color, ColorSpace.RGB)` before `AWTUtil.toBufferedImage` (AWTUtil does not auto-convert). + - `ColorAlphaType.OPAQUE` (not `PREMUL` as I wrote). + - Cleanest `BufferedImage` → `ImageBitmap` is **ImageIO → PNG bytes → `Image.makeFromEncoded(bytes).toComposeImageBitmap()`**. No stable direct extension. + - Exception class: **`org.jcodec.api.UnsupportedFormatException extends JCodecException`** — also catch `IOException` + broad `RuntimeException` (decoder throws `AIOOBE` on malformed SPS/PPS). + - Use `seekToSecondSloppy(1.0)` for thumbnails (precise decodes up to 500 frames per call). + - Fast-reject path: `MP4Util.parseMovie()` + check `stsd` FourCC for `avc1` / `avc3` before opening FrameGrab. + +5. **Tooling for licenses** (Phase 0): + - Use **`app.cash.licensee`** Gradle plugin to enforce allow-list + generate JSON report at build time (compliance gate). + - Use **`com.mikepenz.aboutlibraries`** Gradle plugin to produce the in-app "Open source licenses" screen (CMP-ready, replaces my hand-rolled `OpenSourceLicensesScreen.kt`). + - Hand-author `NOTICE.md` only for native bundles outside Gradle's graph (FFmpeg, GStreamer runtime). + +6. **Flathub manifest precedent** (Phase 4): + - **Gitnuro** (`com.jetpackduba.Gitnuro`) is the existing Kotlin Compose Desktop precedent — use its structure verbatim where applicable. + - Manifest sketch in plan replaced by Gitnuro-style pattern in [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md). + - Submission branch is **`new-pr`** (not `master`). + - Patent-codec extension `org.freedesktop.Platform.ffmpeg-full` is the way to surface HEVC/AV1 on Flatpak. + +7. **SPDX expression acceptance**: + - Fedora `rpmLicenseType` accepts SPDX expressions (mandatory since F41 phase 4) → our compound is valid as-is. + - Flathub `` validates compound via `appstreamcli`. + - **Debian DEP-5 does NOT support compound expressions** — must group per-file with separate `License:` stanzas. + +### Key plan-level changes derived from research + +- Drop the `OpenSourceLicensesScreen.kt` hand-rolled file (replaced by AboutLibraries plugin). +- Add `app.cash.licensee` + allow-list in `desktopApp/build.gradle.kts`. +- Add `gradle/spdx-allowlist.txt` (or `desktopApp/licensee.gradle`) configuration. +- macOS entitlements file overrides `entitlementsFile.set(...)` and `runtimeEntitlementsFile.set(...)` with `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`. +- `stateSyncJob` uses `snapshotFlow {}` not `combine(StateFlow, ...)`. +- `VideoPlayerSurface` JVM uses Compose Canvas → fullscreen handoff is **simpler** than expected (risk #7 in original risk table is downgraded from M-M to L-L). +- Linux codec-missing UX: probe GStreamer via `gst-inspect-1.0 playbin` at app launch; if missing, show one-time install nag. +- Phase 4 adds a `add-extensions: org.freedesktop.Platform.ffmpeg-full` block to the Flathub manifest for patent codecs. + +## Overview + +Drop `uk.co.caprica:vlcj 4.8.3` (GPL-3.0-or-later) from `desktopApp` and ship +an MIT-dominant desktop binary. Replace the three vlcj subsystems with: + +| Subsystem | New library | License | +|-----------|-------------|---------| +| Video playback | `io.github.kdroidfilter:composemediaplayer:0.10.1` | MIT | +| Audio playback | same (audio-only mode of `VideoPlayerState`) | MIT | +| Thumbnail extraction | `org.jcodec:jcodec:0.2.5` + Jaffree fallback with LGPL FFmpeg | BSD-2 + Apache-2 (Java); LGPL-2.1 (FFmpeg native) | + +Resulting binary SPDX: **`MIT AND LGPL-2.1-or-later AND BSD-2-Clause`** (down +from today's effective `GPL-3.0-or-later AND LGPL-2.1-or-later AND MIT` that +the current `rpmLicenseType = "MIT"` misrepresents). + +Single migration PR. Hard cut — no feature flag. Phase 0 (NOTICE / SPDX +honesty patch) bundled in the same PR so the binary is never released +mislabeled. + +See brainstorms for *why*: +- [Licensing issues catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) +- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md) +- [Replacement-candidate research](../brainstorms/2026-06-11-vlcj-replacement-research.md) + +## Problem Statement + +Today's `desktopApp` integration: + +1. Builds Amethyst Desktop's binary by linking GPL-3.0 vlcj into the JVM. +2. Bundles LGPL-2.1 libvlc + a mixed-license VLC 3.0.20 plugin tree (~95 MB on macOS, ~90 MB on Windows, ~70 MB on Linux uncompressed) via the `ir.mahozad.vlc-setup` Gradle plugin. +3. Declares `rpmLicenseType = "MIT"` (`desktopApp/build.gradle.kts:141`) — **wrong** for the produced binary. +4. Ships no NOTICE, no per-component LICENSE files, no written GPL source offer, and no About-screen license listing. + +Consequences (full catalog in +[licensing brainstorm](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)): + +- The MIT badge on the repo + RPM is incorrect for distributed binaries. +- Forks of `desktopApp/` silently inherit GPL. +- Mac/MS App Store distribution is structurally blocked (anti-Tivoization conflict). +- Linux distros (Fedora, Debian) would fail license review with the current metadata. +- The bundled VLC plugin tree contains GPL-only plugins (`libdvdcss`, `x264`-built swscale) that drag GPL regardless of the Java binding. + +User-resolved priorities (from brainstorm 2026-06-11): +- Keep **MIT branding on the binary** — non-negotiable. +- Mac/MS App Store: **not** on roadmap; not the migration driver. +- **Hard cut** (no feature flag), single PR, Flathub manifest in scope. +- Android (`amethyst/`) out of scope — already uses `media3-exoplayer`, no vlcj. + +## Proposed Solution + +### High-level + +Swap the playback engine inside the existing `GlobalMediaPlayer` singleton +and the thumbnail engine inside `VideoThumbnailCache`, preserving the +public `StateFlow` surface that the rest of the UI consumes. Delete the +discoverer/pool layer (kdroidFilter handles its own native loading) and the +`ir.mahozad.vlc-setup` Gradle plugin (no more bundled VLC). + +The UI composables (`DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, +`NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay`) keep their +shapes; `DesktopVideoPlayer` switches its rendering path from +`Image(bitmap = videoFrame)` to kdroidFilter's `VideoPlayerSurface` +composable when the active video is playing, and stays on a thumbnail +`Image` for inactive instances (the "show poster until I tap play" pattern). + +### The cross-stack contract + +``` ++-----------------------------------------+ +| UI composables (kept) | +| DesktopVideoPlayer | AudioPlayer | +| VideoControls | NowPlayingBar | | +| GlobalFullscreenOverlay | LightboxOverlay | ++----------------------+------------------+ + | + v reads StateFlow + invokes verbs ++-----------------------------------------+ +| GlobalMediaPlayer (kept as singleton) | +| - exposes StateFlow| +| - exposes activeVideoPlayerState | +| - verbs: playVideo/playAudio/pause/... | ++----------------------+------------------+ + | + v delegates to ++----------------+ +-------------------+ +---------------+ +| kdroidFilter | | JCodec (primary) | | Jaffree (fallback) | +| VideoPlayerState | | thumbnail H.264 | | LGPL FFmpeg thumb | ++----------------+ +-------------------+ +---------------+ +``` + +### Why kdroidFilter + +Full rationale in the [research doc](../brainstorms/2026-06-11-vlcj-replacement-research.md). +Headline: + +- **MIT.** Binding + Maven coordinates `io.github.kdroidfilter:composemediaplayer:0.10.1`. +- **OS-native backends, no native bundle on Win/mac:** Media Foundation, AVFoundation, GStreamer (Linux system). +- **First-class Compose API:** `VideoPlayerSurface(playerState, contentScale, surfaceType, overlay)`. +- Bundle on macOS drops from ~95 MB → ~60-80 MB (we still bundle LGPL FFmpeg for the rare-codec thumbnail fallback; nothing for video). +- Active in 2026 (v0.10.1 May 2026, single maintainer Elie Gambache). + +## Technical Approach + +### Architecture + +**Module boundary:** Migration stays inside `desktopApp/`. `quartz/`, +`commons/`, and `amethyst/` are untouched. No new shared abstractions in +`commons/` — the player layer is desktop-specific and has no Android +counterpart in scope (`amethyst/` runs `media3-exoplayer`). + +**Concurrency:** kdroidFilter manages its own threading. We continue to +adapt its callbacks into the existing `MediaPlaybackState` StateFlow inside +`GlobalMediaPlayer` so UI consumers see the same shape. Thumbnail +extraction stays on `Dispatchers.IO`; we replace vlcj's `CountDownLatch` +gymnastics with a `suspendCancellableCoroutine` around JCodec's synchronous +API and `Jaffree.executeAsync().toCompletableFuture().asDeferred()` for the +fallback. + +**Native loading:** kdroidFilter uses JNI (not JNA). On macOS this avoids +the `setenv$3b99ba0d` versioned-symbol class of bug that +`MacOsVlcDiscoverer` worked around in May 2026 (see +`docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md`). FFmpeg +binaries for Jaffree are spawned as separate processes — no JNI native lib +to load, so no signing/notarization complications beyond marking the +ffmpeg binary as executable in the jpackage app bundle and ensuring it +ships inside `Contents/MacOS/` so macOS hardened runtime permits it. + +### Implementation Phases + +#### Phase 0 — License bridge (in-PR, lands first commit) (~1 day) + +Land alongside the migration code in the same PR so no released binary is +ever mislabeled. + +**File changes:** + +1. **`desktopApp/build.gradle.kts:141`** + - `rpmLicenseType = "MIT"` → `rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause"` + - (jpackage forwards verbatim into the .rpm `License:` field. SPDX + expression form is accepted by `rpm --query`; Fedora packagers parse it + as a compound license.) + +2. **New: `desktopApp/src/jvmMain/appResources/common/NOTICE.md`** + - Lists every third-party component shipping in the binary with version + SPDX + upstream URL. + - Includes FFmpeg LGPL build provenance + binary download URL. + - References JCodec, kdroidFilter, GStreamer (Linux runtime dep). + - Contains the GPLv3 source-availability written offer for the *interim* commit (the bridge commit lands while the migration is in flight; the same NOTICE is updated in Phase 3 to drop the vlcj/VLC lines once those are deleted). + +3. **New: `desktopApp/src/jvmMain/appResources/common/licenses/`** + - `LICENSE-MIT.txt` — Amethyst's MIT + - `LICENSE-MIT-kdroidfilter.txt` — verbatim from upstream + - `LICENSE-BSD-2-JCodec.txt` — verbatim + - `LICENSE-Apache-2-Jaffree.txt` — verbatim + - `LICENSE-LGPL-2.1.txt` — FFmpeg + libvlc (interim) + - `LICENSE-GPL-3.0.txt` — vlcj (interim, deleted in Phase 3) + - `WRITTEN-OFFER.txt` — GPL source-offer pointing to https://github.com/vitorpamplona/amethyst (valid for 3 years per GPLv3 §6c). Removed in Phase 3 once vlcj is gone. + +4. **New: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/about/OpenSourceLicensesScreen.kt`** + - Scrollable list of bundled components → name, version, license SPDX, "View license" expander. + - Reachable from existing settings (look up + wire into nav during implementation; flagged as plan question in brainstorm). + - Pure data-driven: a `LicenseEntry` data class list defined inline; no resource loader complexity. + +5. **`README.md` (root)** — add a single bullet under any "Desktop" section: + - "The desktop binary currently bundles GPL components (vlcj, parts of VLC plugins) during the in-flight migration to MIT-only dependencies. Source available at this repository." + - Removed in Phase 3. + +**Acceptance for Phase 0:** +- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces a .rpm whose `rpm -qpi` shows the SPDX combined string. +- About-dialog "Open source licenses" screen renders and is reachable from the desktop settings. +- `NOTICE.md`, `licenses/`, and `WRITTEN-OFFER.txt` are visible inside the produced DMG/MSI/DEB/RPM payloads. + +#### Phase 1 — kdroidFilter swap-in for video + audio (~1-2 weeks) + +##### 1.1 Gradle wiring + +**`gradle/libs.versions.toml`** — add: +```toml +[versions] +composemediaplayer = "0.10.1" + +[libraries] +composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" } +``` + +**`desktopApp/build.gradle.kts`** — add `implementation(libs.composemediaplayer)`. Leave `implementation(libs.vlcj)` in place until Phase 3. + +##### 1.2 GlobalMediaPlayer refactor + +Replace the engine but **keep the public StateFlow surface** so UI code is +unaffected. + +Current public surface (preserve): +```kotlin +val videoFrame: StateFlow // delete — DesktopVideoPlayer reads VideoPlayerSurface directly now +val videoState: StateFlow // keep +val audioState: StateFlow // keep +val isFullscreen: StateFlow // keep + +fun playVideo(url: String, seekPosition: Float = 0f) +fun playAudio(url: String) +fun toggleVideoPlayPause() +fun toggleAudioPlayPause() +fun seekVideo(position: Float) +fun seekAudio(position: Float) +fun setVideoVolume(volume: Int) +fun setAudioVolume(volume: Int) +fun toggleVideoMute() +fun toggleAudioMute() +fun stopVideo() +fun stopAudio() +fun toggleFullscreen() +fun exitFullscreen() +fun shutdown() +``` + +New private fields: +```kotlin +private val videoPlayerState: VideoPlayerState = VideoPlayerState() +private val audioPlayerState: VideoPlayerState = VideoPlayerState().apply { /* audio-only config */ } +private var stateSyncJob: Job? = null +``` + +New public field — exposed so `DesktopVideoPlayer` can pass it to +`VideoPlayerSurface(...)`: + +```kotlin +val activeVideoPlayerState: VideoPlayerState get() = videoPlayerState +val activeAudioPlayerState: VideoPlayerState get() = audioPlayerState +``` + +**State-sync coroutine.** Replace the vlcj `MediaPlayerEventAdapter` + +500ms polling loop with a single coroutine that mirrors +`videoPlayerState`'s `StateFlow`s into our `_videoState` mutable flow: + +```kotlin +private fun startStateSync(state: VideoPlayerState, target: MutableStateFlow) { + stateSyncJob?.cancel() + stateSyncJob = scope.launch { + // kdroidFilter exposes: isPlaying, currentTime, duration, isLoading, + // volume, isMuted, aspectRatio (as StateFlows or @Composable getters) + combine( + state.isPlayingFlow, // verify exact name in v0.10.1 API + state.currentTimeFlow, + state.durationFlow, + state.isLoadingFlow, + state.aspectRatioFlow, + ) { /* fold into MediaPlaybackState */ } + .collect { target.value = it } + } +} +``` + +(If kdroidFilter v0.10.1 exposes its state as `@Composable State` +getters rather than `Flow`, fall back to a `snapshotFlow { state.isPlaying }` +inside a `produceState`-equivalent coroutine. Confirm during Phase 1 +implementation by reading kdroidFilter source on github.) + +##### 1.3 DesktopVideoPlayer rewire + +Replace the `Image(bitmap = displayBitmap)` rendering path with conditional +`VideoPlayerSurface` when this composable instance owns the active video URL: + +```kotlin +val isActiveVideo = videoState.url == url + +Box(...) { + if (isActiveVideo) { + VideoPlayerSurface( + playerState = GlobalMediaPlayer.activeVideoPlayerState, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), + ) + } else { + thumbnail?.let { + Image(bitmap = it, contentDescription = "Video thumbnail", ...) + } + } + + VideoControls(...) // unchanged +} +``` + +Drop the `VlcNotAvailableMessage` composable — kdroidFilter never fails on +"VLC not installed." Replace it with `VlcCodecUnsupportedMessage(url, codec)` +shown when kdroidFilter emits a codec-error event (Windows HEVC/AV1 +without the MS Store extension). The message offers "Open in default +player" via `Desktop.getDesktop().browse(URI(url))`. + +##### 1.4 AudioPlayer rewire + +Drop the vlcj surface plumbing — `AudioPlayer.kt` currently doesn't touch +vlcj directly, it just reads `GlobalMediaPlayer.audioState`. No file +change beyond imports if the StateFlow shape is preserved. + +##### 1.5 VideoControls / NowPlayingBar / GlobalFullscreenOverlay / LightboxOverlay + +Review for vlcj-specific assumptions. Expected change: none (they consume +StateFlows). Plan question P6 — to be verified during implementation by +grepping for any `EmbeddedMediaPlayer`, `MediaPlayer`, or `videoFrame` +references in these files. + +##### 1.6 Phase 1 decision gate + +After 1.1–1.5 compile + launch, run the manual test set: + +| Test URL | Expected outcome | +|----------|------------------| +| `https://download.samplelib.com/mp4/sample-5s.mp4` (H.264 MP4) | Plays on Win/mac/Linux. | +| `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` (HLS H.264) | Plays on Win/mac/Linux. | +| `https://nostr.build/i/` (VP9 WebM) | Plays on mac+Linux. Win → codec-missing UX. | +| `https://void.cat/` (AV1 MP4) | Plays on macOS 13+ + Linux. Win → codec-missing UX. | +| `https://zap.stream/` (HLS livestream) | Plays on all 3 OSes. **Gate criterion: must not regress vs. vlcj.** | + +If the zap.stream live stream fails on any of the 3 OSes for reasons not +explained by the codec gap (i.e. legitimate HLS protocol issues), invoke +the fallback plan — switch primary engine to `gst1-java-core` keeping the +wrapper API intact (research doc §3 Recommended #2). Document the pivot +in `docs/decisions/` and update this plan's "Status" frontmatter. + +##### 1.7 Tests + +Unit tests in `desktopApp/src/jvmTest/`: +- `GlobalMediaPlayerStateTest.kt` — drives `MediaPlaybackState` derivations from a fake `VideoPlayerState` (build via constructor / setters). Verifies pause/play/seek transitions emit correctly. +- `DesktopVideoPlayerActiveDispatchTest.kt` (compose test) — given two `DesktopVideoPlayer` instances mounted with different URLs, asserts that only the active URL renders `VideoPlayerSurface` (use semantic test tags). +- No instrumented test that actually decodes a network video (too flaky for CI; covered in manual test sheet). + +#### Phase 2 — Thumbnail extraction (JCodec → Jaffree cascade) (~3-5 days) + +##### 2.1 Gradle wiring + +**`gradle/libs.versions.toml`** — add: +```toml +[versions] +jcodec = "0.2.5" +jaffree = "2024.08.29" + +[libraries] +jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" } +jaffree = { group = "com.github.kokorin.jaffree", name = "jaffree", version.ref = "jaffree" } +``` + +**`desktopApp/build.gradle.kts`** — add both. + +##### 2.2 LGPL FFmpeg per-OS bundling + +We need FFmpeg only for the *fallback* thumbnail path. The video player +doesn't shell out to it. Bundle one binary per OS in +`appResources//ffmpeg/`: + +| OS | Source | Path inside DMG/MSI/AppImage | +|----|--------|------------------------------| +| macOS arm64+x86_64 | https://www.osxexperts.net/ — LGPL build, code-signed | `Contents/Resources/ffmpeg/ffmpeg` | +| Windows x64 | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `app/resources/ffmpeg/ffmpeg.exe` | +| Linux x64 | https://johnvansickle.com/ffmpeg/ "release" tarball — LGPL config | `lib/resources/ffmpeg/ffmpeg` (DEB), AppImage equivalent | + +(Each binary's per-OS LICENSE.txt also ships in `licenses/` from Phase 0.) + +We do not invoke a per-build *download* step (no extra Gradle download +plugin). Binaries are checked into the repo under +`desktopApp/src/jvmMain/appResources//ffmpeg/` (~10 MB per OS, +acceptable for git LFS or direct check-in). The Phase 3 cleanup removes +the `ir.mahozad.vlc-setup` plugin, which was the big downloader. + +**macOS hardened-runtime entitlement:** spawning a child process from a +hardened-runtime app needs the `com.apple.security.cs.allow-jit` or +`com.apple.security.cs.disable-library-validation` entitlement, OR the +ffmpeg binary needs to be co-signed with the app's identity. We extend +the existing `entitlements.plist` to add: + +```xml +com.apple.security.cs.allow-unsigned-executable-memory + +com.apple.security.cs.disable-library-validation + +com.apple.security.cs.allow-jit + +``` + +The cleanest path is to co-sign ffmpeg with the same identity used for the +.app — verify during implementation that jpackage's signing step covers +nested executables in `Contents/Resources/` (older jpackage did not — may +need a post-pkg `codesign --deep`). + +##### 2.3 VideoThumbnailCache rewrite + +Replace the vlcj-based `extractFirstFrame` with a cascade: + +```kotlin +private suspend fun extractFirstFrame(url: String): ImageBitmap? { + val downloadedBytes = downloadVideoForThumb(url) ?: return null + return tryJCodec(downloadedBytes) + ?: tryJaffree(downloadedBytes) + ?: tryJaffreeFromUrl(url) // for HLS where the URL points to a manifest, not bytes +} +``` + +- **`downloadVideoForThumb(url)`** — uses existing OkHttp instance (which is wired in `desktopApp` already, line 50 in build.gradle.kts). Range-fetch first 4 MB for fast thumbnail. Cache to `~/Library/Caches/com.vitorpamplona.amethyst.desktop/thumbs/.mp4` (per OS — use `java.util.prefs`-style discovery for cache dir). +- **`tryJCodec(bytes)`** — `FrameGrab.createFrameGrab(NIOUtils.readableChannel(File))` → `seekToSecondPrecise(1.0).getNativeFrame()`. Convert YUV → RGB → Skia `Bitmap` → `ImageBitmap`. Wrap with `runCatching` and discard on any exception (unsupported codec, malformed MP4, HLS manifest in the bytes, etc.). +- **`tryJaffree(bytes)`** — spawn `ffmpeg -ss 1 -i -frames:v 1 -f image2pipe -c:v png pipe:1`, read stdout into ByteArray, decode with `Image.makeFromEncoded(bytes).toComposeImageBitmap()`. 5-second timeout. +- **`tryJaffreeFromUrl(url)`** — same as tryJaffree but with the URL directly as `-i` argument. For HLS or any streamed source. + +**Thread safety / dedup:** keep the existing `pending` `ConcurrentHashMap` +guard. Replace `CountDownLatch` with `suspendCancellableCoroutine` over +Jaffree's `executeAsync()` return type for clean cancellation on +recomposition. + +##### 2.4 Tests + +Unit tests: +- `VideoThumbnailCacheTest.kt` — given a known-good 5-second H.264 MP4 in `src/jvmTest/resources/sample.mp4`, asserts JCodec path produces a non-null `ImageBitmap` with expected dimensions. +- `JaffreeProbeTest.kt` — verifies the bundled ffmpeg binary is found in `appResources//ffmpeg/` at runtime and prints `ffmpeg -version` successfully. Skip on platforms where we don't ship a binary. +- No HEVC/VP9/AV1 unit test (codec coverage depends on the runtime ffmpeg build; manual test). + +#### Phase 3 — vlcj removal + build cleanup (~2-3 days) + +##### 3.1 Code deletions + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt` — delete (referenced by both discoverers) +- Any `import uk.co.caprica.vlcj.*` remaining in `GlobalMediaPlayer.kt` or `VideoThumbnailCache.kt` — delete +- Tests that exercise vlcj integration (if any) — delete + +##### 3.2 Gradle deletions + +`desktopApp/build.gradle.kts`: +- Remove `id("ir.mahozad.vlc-setup") version "0.1.0"` from plugins block +- Remove `implementation(libs.vlcj)` +- Remove the entire `vlcSetup { ... }` block +- Remove `tasks.named("spotlessKotlin") { mustRunAfter("vlcSetup") }` +- Remove `tasks.withType().configureEach { ... }` (was for `vlcDownload`) +- Remove `jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"` +- Remove `"jdk.unsupported"` from `modules(...)` if kdroidFilter doesn't need it (verify) +- Update the AppImage build doc comment that mentions VLC + +`gradle/libs.versions.toml`: +- Remove `vlcj = "4.8.3"` from `[versions]` +- Remove the `vlcj` entry from `[libraries]` + +##### 3.3 Bundled VLC tree deletion + +```bash +git rm -r desktopApp/src/jvmMain/appResources/macos/vlc +git rm -r desktopApp/src/jvmMain/appResources/windows/vlc +git rm -r desktopApp/src/jvmMain/appResources/linux/vlc +``` + +(Equivalent to the `ir.mahozad.vlc-setup` copy targets. If the directories +were generated and not checked in, no `git rm` needed — just confirm the +plugin removal stops emitting them.) + +##### 3.4 NOTICE.md / licenses/ trimming + +- Drop `LICENSE-GPL-3.0.txt`, `WRITTEN-OFFER.txt` +- Drop the vlcj + libvlc entries from `NOTICE.md` +- Keep entries for: kdroidFilter (MIT), JCodec (BSD-2), Jaffree (Apache-2), FFmpeg (LGPL-2.1), GStreamer (Linux runtime, LGPL-2.1) + +##### 3.5 README trim + +- Remove the interim "binary bundles GPL" disclaimer. +- Replace with "Built on kdroidFilter ComposeMediaPlayer + native OS media frameworks." + +##### 3.6 Acceptance for Phase 3 + +- `grep -r "vlcj\|caprica\|libvlc" desktopApp/` returns no source code matches (only doc/license mentions). +- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces installers that contain no `vlc/`, `libvlc.dylib`, `libvlccore.dylib`, or `plugins/*.dylib`. +- Built DMG is < 80 MB compressed (down from ~140 MB today; the FFmpeg LGPL binary is ~12-20 MB compressed). +- App launches and plays a sample MP4 with no log line containing "VLC", "vlcj", or "libvlc". + +#### Phase 4 — Flathub manifest + final SPDX (~2 days) + +##### 4.1 Flathub manifest + +New file: `desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml` + +Sketch (planner to refine during implementation): + +```yaml +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.openjdk21 +command: amethyst-desktop +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --talk-name=org.freedesktop.Notifications +modules: + - name: amethyst-desktop + buildsystem: simple + build-commands: + - install -Dm755 amethyst-desktop /app/bin/amethyst-desktop + # ... (jpackage Linux output copied in) + sources: + - type: file + path: ../../build/compose/binaries/main-release/app/Amethyst/bin/Amethyst +``` + +Runtime `org.freedesktop.Platform 24.08` ships GStreamer 1.24 with the +plugins kdroidFilter needs (`gst-plugins-good`, `gst-plugins-bad`, +`gst-libav` with LGPL config). No bundled FFmpeg needed for Flathub since +GStreamer is available — the thumbnail path uses Jaffree → spawns the +system `ffmpeg` if present, else falls back to JCodec-only. Document this +clearly in the Flathub manifest comments. + +##### 4.2 Final SPDX + +After Phase 3 deletions, set `rpmLicenseType = "MIT AND LGPL-2.1-or-later +AND BSD-2-Clause"`. (LGPL stays because the bundled FFmpeg for thumbnails is LGPL native.) + +Update `LICENSES.md` (root) to add a "Distributed binary" section listing +the SPDX expression + table of bundled components. + +##### 4.3 CI updates + +- Add a job that uploads the Flathub manifest to `flathub/com.vitorpamplona.amethyst.Desktop` repo (manual review by Flathub maintainers — not a one-button operation; document as a deferred follow-up). +- Add a job step that verifies the produced DMG/MSI/DEB contains no `vlc*`, `libvlc*` strings. + +### Architecture diagrams + +#### Old (today) + +``` +[User taps play on a feed video] + │ + ▼ +DesktopVideoPlayer composable + │ GlobalMediaPlayer.playVideo(url) + ▼ +GlobalMediaPlayer (singleton) + │ VlcjPlayerPool.init() + acquire() + ▼ +EmbeddedMediaPlayer ◄── BufferFormatCallback / RenderCallback + │ │ + │ libvlc events │ ByteBuffer of pixels + ▼ ▼ +MediaPlayerEventAdapter Skia Bitmap.installPixels + │ │ + ▼ ▼ + _videoState (StateFlow) _videoFrame (StateFlow) + │ │ + ▼ ▼ + VideoControls reads state Image(bitmap = videoFrame) +``` + +#### New + +``` +[User taps play on a feed video] + │ + ▼ +DesktopVideoPlayer composable + │ GlobalMediaPlayer.playVideo(url) + ▼ +GlobalMediaPlayer (singleton) + │ videoPlayerState.openMedia(url) + .play() + ▼ +VideoPlayerState (kdroidFilter) + │ delegates to OS backend + ▼ +[Media Foundation / AVFoundation / GStreamer] + │ │ + │ state flows │ frames piped to native surface + ▼ ▼ + stateSyncJob folds into VideoPlayerSurface(state) renders directly + _videoState (MediaPlaybackState) (no Compose ImageBitmap relay) + │ + ▼ + VideoControls reads state +``` + +## Alternative Approaches Considered + +(Full evaluation in [research doc §1 and §3](../brainstorms/2026-06-11-vlcj-replacement-research.md)) + +| Alternative | Why not | +|-------------|---------| +| Accept GPL on binary + only fix the metadata (Approach A in licensing brainstorm) | User priority: keep MIT brand on the binary. | +| Buy Caprica commercial vlcj license (Approach C) | Burns budget every year; doesn't address bundled VLC plugin GPL surface. | +| `gst1-java-core` + bundled GStreamer (Recommended #2 in research) | Binary is LGPL-3.0-dominant, not MIT. Reserved as Phase 1 decision-gate fallback. | +| JavaFX MediaPlayer | No HEVC, VP9, AV1, Opus, FLAC. SwingPanel z-order incompatible with our overlays. | +| Write our own JNA binding to libvlc | 3-6 weeks of native callback / GC-pinning work for **no material license gain** over kdroidFilter. | +| open-ani/mediamp | Desktop backend (`mediamp-vlc`) is GPLv3 — same problem with one more layer. | +| libmpv via Java binding | No maintained JVM binding exists. | +| HumbleVideo | Abandoned 2018, AGPL. | +| External player handoff only | Breaks in-feed playback UX. Kept as codec-missing fallback only. | + +## System-Wide Impact + +### Interaction Graph + +What fires when `playVideo(url)` is called in the new design: + +1. `DesktopVideoPlayer` composable's "play" button onClick → `GlobalMediaPlayer.playVideo(url)`. +2. `GlobalMediaPlayer` updates `_videoState` to `(url, isBuffering=true, ...)`. +3. `GlobalMediaPlayer` calls `videoPlayerState.openMedia(url)` then `.play()` on `Dispatchers.IO`. +4. kdroidFilter's backend (Media Foundation / AVFoundation / GStreamer) opens the URL. +5. kdroidFilter emits state changes through its flows; `stateSyncJob` folds them into `_videoState`. +6. Compose recomposes `DesktopVideoPlayer` (active branch) → `VideoPlayerSurface(playerState)` swaps from "loading" to live frames. +7. `NowPlayingBar` (which reads `_videoState`) updates simultaneously. +8. `VideoControls` recomposes (consumes `_videoState`). + +No callback marshalling between native and Compose threads on our side — +kdroidFilter handles that. No `Skia.Bitmap.installPixels` per frame in our +code path — `VideoPlayerSurface` writes directly to a native surface. + +### Error & Failure Propagation + +- **Codec unsupported (e.g. AV1 on stock Win10):** kdroidFilter emits an error state. `stateSyncJob` sets `_videoState.isBuffering = false` + a new `_videoState.errorReason: CodecError? = null` field. `DesktopVideoPlayer` checks this and renders `CodecUnsupportedMessage` instead of `VideoPlayerSurface`. +- **Network failure / 404:** Same path — kdroidFilter error state → `CodecError(reason=NETWORK)`. UI shows generic "Couldn't load" with retry button. +- **Thumbnail failure (JCodec then Jaffree both fail):** `VideoThumbnailCache.getThumbnail(url)` returns `null`. `DesktopVideoPlayer` already handles this (current code shows just the play-button overlay on null thumbnail). +- **kdroidFilter native loading failure on Linux (no system GStreamer):** caught at `GlobalMediaPlayer.init()`-equivalent. Show a one-time toast: "Install GStreamer to play videos: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav". + +### State Lifecycle Risks + +- `videoPlayerState` is a singleton on `GlobalMediaPlayer`. Reusing it across URLs is supported by kdroidFilter (`openMedia(newUrl)` resets). No risk of leaked native players (vlcj's pool was a workaround for vlcj's expensive `EmbeddedMediaPlayer` construction; kdroidFilter doesn't have that cost). +- App shutdown: `GlobalMediaPlayer.shutdown()` calls `videoPlayerState.release()` + `audioPlayerState.release()`. No native handle survives JVM exit. +- Thumbnail cache: same `ConcurrentHashMap` + `pending` deduper. No native handles. +- FFmpeg child processes (Jaffree): always have a 5-second timeout. `try-finally` ensures `Process.destroyForcibly()` on cancellation or exception. + +### API Surface Parity + +- `GlobalMediaPlayer`'s public API (verbs + StateFlows) is preserved. Callers in `DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, `NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay` need no breaking changes other than removing the `videoFrame` StateFlow (which only `DesktopVideoPlayer` reads). +- Drop: `GlobalMediaPlayer.videoFrame: StateFlow` (replaced by `VideoPlayerSurface(activeVideoPlayerState)` reading the player state directly in the consumer composable). +- Add: `GlobalMediaPlayer.activeVideoPlayerState: VideoPlayerState` for the surface consumer. +- Add: `MediaPlaybackState.errorReason: CodecError? = null` for codec-unsupported UX. + +### Integration Test Scenarios + +Manual (also captured in the testing sheet handed to the user post-implementation): + +1. **Resume across navigation.** Play a video in the feed, navigate to a different screen, return to the feed → video continues playing, position preserved. (Tests `GlobalMediaPlayer` singleton scope.) +2. **Switch URLs mid-play.** Tap play on video A, then tap play on video B → A stops, B starts from beginning. (Tests `openMedia` reset.) +3. **Now-playing bar sync.** Play audio from a feed item, scroll away → bar appears with playback state in sync. (Tests `_audioState` parity with the underlying engine.) +4. **Fullscreen ↔ feed handoff.** Enter fullscreen during playback, exit → playback continues uninterrupted, no reload. (Tests that `VideoPlayerSurface` instances can be re-attached / not destroyed across composable re-creation. **High-risk**: this may require a `key(...)` boundary to keep the surface stable.) +5. **Codec-missing Windows path.** Open an AV1 MP4 on stock Win10 → `CodecUnsupportedMessage` renders + "Open in default player" works. (Tests error propagation + handoff.) + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Feed videos (H.264 MP4) play on macOS / Windows / Linux desktop builds. +- [ ] Feed audio (MP3 / Opus / AAC) plays on all 3 OSes. +- [ ] HLS livestreams (zap.stream) play on all 3 OSes. +- [ ] VP9 WebM plays on macOS + Linux. Windows shows codec-missing UX with "open externally" handoff. +- [ ] AV1 plays on macOS 13+ + Linux. Windows shows codec-missing UX. +- [ ] HEVC plays on macOS + Linux. Windows shows codec-missing UX. +- [ ] Thumbnails are extracted and shown in the feed for H.264 MP4 (JCodec) and HEVC/VP9/AV1 (Jaffree LGPL FFmpeg) — same coverage as vlcj today. +- [ ] Existing in-feed playback UX (play/pause/seek/volume/mute/fullscreen) is preserved. +- [ ] Now-playing bar continues to work across navigation. +- [ ] Lightbox / fullscreen overlay continues to work. +- [ ] App startup: no "VLC not installed" path. Engine is always available. + +### Non-Functional Requirements + +- [ ] Binary SPDX in produced packages: `MIT AND LGPL-2.1-or-later AND BSD-2-Clause` (no `GPL-3.0` token). +- [ ] macOS DMG size ≤ today's size − 25 MB (target: ~70-80 MB compressed). +- [ ] No regression in feed-scroll FPS (kdroidFilter writes frames natively; should equal or exceed vlcj's `RenderCallback` path). +- [ ] App startup time ≤ today (vlcj init removed; kdroidFilter doesn't initialize until first playback). + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` green. +- [ ] `./gradlew :desktopApp:test` green. +- [ ] `./gradlew spotlessApply` clean. +- [ ] `grep -r "vlcj\|caprica\|libvlc" desktopApp/src/` finds zero hits. +- [ ] `./gradlew :desktopApp:packageDistributionForCurrentOS` succeeds on macOS arm64 (local) and produces a DMG; smoke-launches. +- [ ] NOTICE / licenses / About-screen-licenses-listing accurate and accessible from the running app. +- [ ] Manual test sheet completed by user (provided post-implementation). + +## Success Metrics + +- **Binary SPDX correctness** (boolean): packaged installers carry the SPDX expression listed above. Verified by `rpm -qpi`, DMG metadata, MSI summary. +- **Bundle size delta** (MB): macOS DMG and Linux DEB shrink by ≥25 MB after Phase 3. +- **Manual codec coverage matrix pass rate** (%): aim for ≥95% pass on H.264, AAC, MP3, HLS-H.264; ≥75% on VP9, AV1, HEVC (Windows excepted per documented gap). +- **Crash-free playback sessions in first 30 days post-release** (telemetry, if available): ≥ today's baseline. +- **Issue-tracker mentions of "VLC not installed"** post-release: 0. + +## Dependencies & Prerequisites + +- Maven Central artifacts: `io.github.kdroidfilter:composemediaplayer:0.10.1`, `org.jcodec:jcodec:0.2.5`, `com.github.kokorin.jaffree:jaffree:2024.08.29`. +- LGPL FFmpeg binaries to commit into `desktopApp/src/jvmMain/appResources//ffmpeg/` (planner to fetch + verify checksums during Phase 2). +- Linux runtime: GStreamer 1.20+ with `gst-plugins-good` and `gst-libav`. Already installed by default on Fedora 39+, Ubuntu 22.04+, Debian 12+. Documented as a `recommends`/`depends` line in the .deb/.rpm control files. +- Flathub: `org.freedesktop.Platform 24.08` runtime (Phase 4). +- JDK 21 (already in use). +- No new tooling required (jpackage, ProGuard, spotless already configured). + +## Risk Analysis & Mitigation + +| # | Risk | Probability | Impact | Mitigation | +|---|------|-------------|--------|------------| +| 1 | kdroidFilter HLS livestream failures on AVPlayer (strict mode) | M | H | Phase 1 decision gate against zap.stream + 2-3 alt streams. Pivot to gst1-java-core if it bites. | +| 2 | Windows stock-codec gap (HEVC/VP9/AV1) | H | M | Codec-detection + "open externally" handoff. Document in release notes. | +| 3 | kdroidFilter project goes dormant (single maintainer) | L | M | API surface we depend on is tiny; we own the wrapper. Pivot to gst1-java-core later behind same wrapper API. | +| 4 | JCodec doesn't decode some H.264 high-profile MP4s | M | L | Jaffree fallback handles it. Cost: an extra ffmpeg spawn for those URLs. | +| 5 | macOS hardened-runtime rejects nested ffmpeg binary | L | H | Co-sign nested binaries with the app identity in the existing `codesign` step. If jpackage doesn't, add a post-step `codesign --deep`. | +| 6 | Flathub review delays / rejects manifest | L | L | Manifest authoring is in scope; submitting to Flathub is a separate operation tracked outside this PR. | +| 7 | `Image(bitmap)` → `VideoPlayerSurface` swap reveals new z-order interaction with `GlobalFullscreenOverlay` | M | M | Test scenario #4 in integration tests; fix at implementation time. | +| 8 | `_videoState.errorReason` field break binary-state compat in tests | L | L | All affected tests are co-edited in this PR. | +| 9 | LGPL FFmpeg binaries grow git history | M | L | Use git LFS for the per-OS binaries; or fetch on first build via a one-shot Gradle task with checksum verification. | +| 10 | jpackage doesn't sign nested executables on macOS | M | M | Phase 2 acceptance verifies; fallback is post-step `codesign --deep --force --sign "..." Amethyst.app`. | + +## Resource Requirements + +- **People:** 1 engineer (you/Claude in `/ce:work` mode). +- **Time:** 2-3 weeks elapsed for code work. Plus user time for manual testing pass. +- **Infra:** Local macOS arm64 build, Windows VM for codec-gap testing (existing CI handles cross-OS DMG/MSI/DEB), Linux VM for Flathub manifest verification. + +## Future Considerations + +- Once kdroidFilter / `gst1-java-core` matures, consider lifting the player abstraction into `commons/commonMain/` so iOS desktop (if ever) can share. Out of scope for this PR. +- If Compose Multiplatform 1.9+ ships an official `VideoPlayer` composable backed by `androidx.media3` on desktop, evaluate migrating to that — but only if the codec story is at least equivalent. Likely 12-18 months out. +- DASH support — kdroidFilter doesn't list it. If we want it later, GStreamer fallback is the path. + +## Documentation Plan + +- `NOTICE.md` (new, in app bundle). +- `LICENSES.md` (root) — add "Distributed binary" section. +- `README.md` (root) — interim disclaimer added in Phase 0, removed in Phase 3. +- `desktopApp/packaging/flatpak/README.md` — explains the Flathub manifest, build steps, deps. +- About-dialog "Open source licenses" screen (in-app). +- Update `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` to add "superseded by 2026-06-11 plan" front-matter note (if appropriate — verify during execution). + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md). Decisions carried forward: + - Stack choice (kdroidFilter primary, gst1-java-core fallback) + - Single-PR bundling of license bridge + migration + - Hard cut, no feature flag + - Flathub manifest in scope + - Android out of scope +- **Companion research:** [docs/brainstorms/2026-06-11-vlcj-replacement-research.md](../brainstorms/2026-06-11-vlcj-replacement-research.md) — full candidate comparison matrix. +- **Companion licensing analysis:** [docs/brainstorms/2026-06-11-vlcj-licensing-brainstorm.md](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) — 10-issue catalog. + +### Internal References + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — pool architecture being deleted +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt` — engine swap target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt` — engine swap target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt:62-172` — UI rewire target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt` — minor changes +- `desktopApp/build.gradle.kts:9, 60-61, 97-98, 117, 141, 166-176` — build wiring to swap +- `gradle/libs.versions.toml` — `vlcj = "4.8.3"` to remove +- `LICENSE` (root) — Amethyst MIT +- `docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md` — recent VLC native-loading work that gets superseded +- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` — earlier desktop media plan +- `docs/plans/_vlcj-migration-learnings.md` — institutional learnings (compiled by research agent 2026-06-11) + +### External References + +- [kdroidFilter/ComposeMediaPlayer](https://github.com/kdroidFilter/ComposeMediaPlayer) — primary library +- [kdroidFilter/ComposeMediaPlayer LICENSE](https://github.com/kdroidFilter/ComposeMediaPlayer/blob/master/LICENSE) +- [`io.github.kdroidfilter:composemediaplayer` on Maven Central](https://central.sonatype.com/artifact/io.github.kdroidfilter/composemediaplayer) +- [jcodec/jcodec](https://github.com/jcodec/jcodec) +- [kokorin/Jaffree](https://github.com/kokorin/Jaffree) +- [Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264](https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264) — Windows LGPL ffmpeg +- [osxexperts.net LGPL FFmpeg](https://www.osxexperts.net/) — macOS LGPL ffmpeg +- [johnvansickle.com FFmpeg static builds](https://johnvansickle.com/ffmpeg/) — Linux LGPL ffmpeg +- [Flathub submission docs](https://docs.flathub.org/docs/for-app-authors/submission) +- [GStreamer licensing FAQ](https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html) +- [FFmpeg legal](https://www.ffmpeg.org/legal.html) — `--enable-gpl` semantics +- [GNU GPL FAQ §JavaJVM](https://www.gnu.org/licenses/gpl-faq.html#JavaJVM) — confirms JNI/JNA doesn't escape GPL +- [Apple anti-Tivoization vs GPLv3 (FSF/VLC 2011)](https://www.fsf.org/news/2010-05-app-store-compliance) — App Store incompatibility + +### Related Work + +- Brainstorms (this PR's ancestry): + - [Licensing catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) + - [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md) + - [Replacement research](../brainstorms/2026-06-11-vlcj-replacement-research.md) +- Prior plans: + - [Desktop media full parity plan](2026-03-16-feat-desktop-media-full-parity-plan.md) + - [Desktop media manual testing plan](2026-03-16-desktop-media-manual-testing-plan.md) + - [macOS VLC bundled discovery fix plan](2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) diff --git a/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md new file mode 100644 index 0000000000..b45ae2c1cb --- /dev/null +++ b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md @@ -0,0 +1,182 @@ +# Manual Testing Sheet — vlcj → kdroidFilter Migration + +**Plan:** [`2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md`](2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md) +**Branch / worktree:** `.claude/worktrees/vlcj-licensing-brainstorm` (branch `worktree-vlcj-licensing-brainstorm`) +**Date:** 2026-06-11 + +This sheet walks through every verification the user must perform locally +because the automated background session can't reach Maven Central or +exercise the JVM media stack across OSes. + +--- + +## 0 · Build verification — VERIFIED locally on macOS arm64 (2026-06-11) + +Build verified after network came online. Final dep pins: +- `io.github.kdroidfilter:composemediaplayer:0.10.0` (research-agent hallucinated 0.10.1 — actual latest on Maven Central is 0.10.0) +- `org.jcodec:jcodec:0.2.5` +- `org.jcodec:jcodec-javase:0.2.5` + +| # | Command | Result on macOS arm64 | +|---|---------|-----------------------| +| 0.1 | `./gradlew :desktopApp:compileKotlin` | ✅ `BUILD SUCCESSFUL` in 16s | +| 0.2 | `./gradlew :desktopApp:test` | ✅ `BUILD SUCCESSFUL` in 23s | +| 0.3 | `./gradlew :desktopApp:spotlessApply` | ✅ clean, no diff | +| 0.4 | `grep -rn 'vlcj\|caprica\|VlcjPlayerPool\|MacOsVlcDiscoverer\|BundledVlcDiscoverer\|VlcResourceResolver' desktopApp/src/ gradle/ desktopApp/build.gradle.kts` | ✅ Only doc-comment mentions in `VideoThumbnailCache.kt` header (intentional; describes what was replaced) | + +Re-verify on Windows + Linux at your convenience. + +--- + +## 1 · Bundled FFmpeg binaries (REQUIRED for thumbnail fallback) + +The agent created the directory structure but didn't check in binaries +(~30 MB each, not appropriate for git). Drop them here before packaging: + +| OS | Path | Source | Verify | +|----|------|--------|--------| +| macOS (arm64 + x86_64 universal) | `desktopApp/src/jvmMain/appResources/macos/ffmpeg/ffmpeg` | https://www.osxexperts.net/ LGPL build | `lipo -info ffmpeg` shows both arches; `chmod +x ffmpeg` | +| Windows (x64) | `desktopApp/src/jvmMain/appResources/windows/ffmpeg/ffmpeg.exe` | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `ffmpeg.exe -version` reports `--enable-version3 --disable-gpl` flags | +| Linux | _(skip — relies on host ffmpeg/gstreamer)_ | n/a | See `desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md` for self-contained AppImage option | + +Each per-OS subdirectory has a `README.md` with the exact source URL. + +--- + +## 2 · Smoke launch + +| # | Action | Expected | +|---|--------|----------| +| 2.1 | `./gradlew :desktopApp:run` | Window opens. No log line mentions VLC, vlcj, libvlc, or "plugin path". | +| 2.2 | Look at stdout / stderr | No `WARN`/`ERROR` from missing native libs. First time kdroidFilter extracts its native to `~/.cache/composemediaplayer/native/` — that's expected. | +| 2.3 | App responds to clicks; UI is layout-identical to pre-migration | Yes | + +--- + +## 3 · Codec / playback matrix + +Use the URLs below (substitute your own equivalents if any are dead). All +verified by mounting the URL via the feed; you don't need to post any +Nostr event. + +### Active video URLs to feed into the player (place into a draft note or open via the lightbox) + +| # | Test | URL pattern | macOS | Windows | Linux | +|---|------|------|--------|---------|-------| +| 3.1 | H.264 MP4 (golden path) | `https://download.samplelib.com/mp4/sample-5s.mp4` | ✅ plays | ✅ plays | ✅ plays | +| 3.2 | HLS livestream (zap.stream) | a live zap.stream m3u8 URL | ✅ plays | ✅ plays | ✅ plays | +| 3.3 | HLS VOD H.264+AAC | `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` | ✅ plays | ✅ plays | ✅ plays | +| 3.4 | VP9 WebM | a known nostr.build VP9 webm URL | ✅ plays | ⚠️ codec-missing UX, "open externally" works | ✅ plays (if gst-libav installed) | +| 3.5 | AV1 MP4 | a known void.cat / nostr.build AV1 URL | ✅ plays (macOS 13+ or M3+ HW) | ⚠️ codec-missing UX | ✅ plays (if gst-libav installed) | +| 3.6 | HEVC MP4 | a known iOS-recorded HEVC URL | ✅ plays | ⚠️ "Install HEVC extension" UX | ✅ plays (if gst-libav installed) | +| 3.7 | Audio: MP3 | a podcast MP3 from feed | ✅ plays | ✅ plays | ✅ plays | +| 3.8 | Audio: Opus | a known Opus URL | ✅ plays | ✅ plays | ✅ plays (if gst-plugins-base installed) | +| 3.9 | Audio: AAC | a known AAC URL | ✅ plays | ✅ plays | ✅ plays | +| 3.10 | Garbage URL (404) | `https://example.invalid/nope.mp4` | "Can't play this video" + `Source:` reason; "Open in default player" button does nothing harmful | same | same | + +**Phase 1 decision gate** (per plan): if 3.2 (zap.stream HLS livestream) +fails on any OS for reasons that aren't codec-related, pivot to the +gst1-java-core fallback (research doc §3 Recommended #2). Don't pivot for +3.4/3.5/3.6 — those gaps are expected on stock Win10 without the codec +extensions and are mitigated by the "open externally" UX. + +--- + +## 4 · UI cross-state behaviour + +| # | Scenario | Expected | +|---|----------|----------| +| 4.1 | Play feed video A → navigate to a different screen → return | Video continues playing, position preserved. (Singleton `GlobalMediaPlayer` retained.) | +| 4.2 | Play video A → tap play on video B | A stops, B starts from beginning. | +| 4.3 | Active video → tap fullscreen | Enters native fullscreen; same `VideoPlayerSurface` renders fullscreen; controls work; Esc / F exits. | +| 4.4 | Fullscreen video → press Spacebar | Toggles play/pause. | +| 4.5 | NowPlayingBar mini-preview | Mini 48×36 video preview renders inside the bottom bar while video is active. | +| 4.6 | Audio playback → navigate around | NowPlayingBar shows music icon (no video) + correct artist/title (URL filename suffices for now). | +| 4.7 | Mute → unmute | Volume restores to pre-mute value. | +| 4.8 | Volume slider → 0 | `volume = 0` reflects, but `isMuted` flag stays false (matches kdroidFilter's no-mute-flag semantics; explicit mute is separate). | +| 4.9 | Stop video / audio while playing | Engine cleanly stops; UI returns to neutral state. | +| 4.10 | Quit app while playing | No segfault, no lingering native processes. (kdroidFilter registers its own shutdown hook on Windows for MediaFoundation.) | + +--- + +## 5 · Thumbnail extraction + +| # | Scenario | Expected | +|---|----------|----------| +| 5.1 | H.264 MP4 in feed (inactive) | Thumbnail appears within 2-3s. JCodec handles this — confirms by speed. | +| 5.2 | VP9 WebM in feed (inactive) | Thumbnail appears (slower, ~5s) — JCodec rejects, falls through to ffmpeg. Verifies the cascade. | +| 5.3 | Same feed reopened | Thumbnails appear instantly from in-memory cache. | +| 5.4 | No bundled ffmpeg + no system ffmpeg | Thumbnails fail silently for non-H.264 sources; H.264 still works (JCodec). No crash. Document this UX in release notes. | +| 5.5 | HLS livestream thumbnail | Should NOT block playback. JCodec auto-skipped because URL ends `.m3u8` or contains `/hls/`. Falls through to `ffmpeg URL` directly. | + +--- + +## 6 · License metadata verification + +| # | Artifact | Verify | +|---|----------|--------| +| 6.1 | `./gradlew :desktopApp:packageRpm` → `rpm -qpi build/compose/binaries/main-release/rpm/*.rpm` | `License: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0` | +| 6.2 | `./gradlew :desktopApp:packageDmg` → mount DMG | Inside `Amethyst.app/Contents/app/resources/common/` — `NOTICE.md`, `licenses/LICENSE-MIT-amethyst.txt`, `licenses/LICENSE-MIT-kdroidfilter.txt`, `licenses/LICENSE-BSD-2-jcodec.txt`, `licenses/LICENSE-LGPL-2.1.txt` | +| 6.3 | `grep -rn 'vlcj\|GPL-3' desktopApp/src/jvmMain/appResources/common/` | Zero hits — confirms no stale GPL references shipped | +| 6.4 | `LICENSE-LGPL-2.1.txt` | **Currently a placeholder.** Replace with verbatim GNU LGPL-2.1 text (~26 KB) from https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt before any release. The plan acceptance gate flags this. | + +--- + +## 7 · Flathub manifest (deferred submission) + +| # | Action | Expected | +|---|--------|----------| +| 7.1 | `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` | OK | +| 7.2 | `./gradlew :desktopApp:createReleaseDistributable` | Produces `desktopApp/build/compose/binaries/main-release/app/Amethyst/` | +| 7.3 | `cd desktopApp/packaging/flatpak && flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml` | OK (may need icon at `icons/256/com.vitorpamplona.amethyst.Desktop.png` — copy from `desktopApp/src/jvmMain/resources/icon.png` first) | +| 7.4 | `flatpak run com.vitorpamplona.amethyst.Desktop` | App launches; video playback works (host org.freedesktop.Platform ships GStreamer 1.24) | +| 7.5 | Actual Flathub submission | Out of scope for this PR; follow steps in `desktopApp/packaging/flatpak/README.md` | + +--- + +## 8 · macOS code signing (deferred — only if you sign before this PR merges) + +| # | Action | Expected | +|---|--------|----------| +| 8.1 | jpackage with `--mac-sign --mac-signing-key-user-name "Developer ID Application: …"` | `codesign --verify --deep --strict Amethyst.app` is clean; nested `Contents/app/resources/macos/ffmpeg/ffmpeg` is signed with the same identity | +| 8.2 | Entitlements include `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.disable-library-validation` | Required for HotSpot + sibling dylib loading | +| 8.3 | `xcrun notarytool submit Amethyst.dmg` succeeds | Apple notarizes the DMG including nested ffmpeg | +| 8.4 | `xcrun stapler staple Amethyst.dmg` succeeds | Ticket stapled | + +Detailed recipe: `docs/plans/_macos-ffmpeg-signing-recipe.md`. + +--- + +## 9 · Known caveats / follow-ups for after this PR + +These are intentional gaps, not bugs: + +1. **In-app "Open source licenses" screen** — not yet wired into desktop settings UI. NOTICE.md and licenses/ ship as resources but a Compose composable reading them isn't included. Recommended follow-up: add `mikepenz/AboutLibraries` Gradle plugin (auto-discovers Maven licenses) + a `LicensesScreen.kt` rendering the report; integrate into the settings nav. +2. **LGPL-2.1 license text is a placeholder** — flagged in 6.4. The release-gate fix is "paste the verbatim 26 KB text into `LICENSE-LGPL-2.1.txt`". +3. **`tryJaffree*` and `runFfmpegToImage`** — the Jaffree dependency was dropped in favor of raw ProcessBuilder for simplicity. Renaming the internal `tryJaffree*` functions to `tryFfmpeg*` is a cosmetic follow-up. +4. **No GStreamer probe on Linux** — kdroidFilter's `SourceError` is indistinguishable from "missing gst-libav" without probing. Follow-up: add a one-time `gst-inspect-1.0 avdec_h264` probe on Linux startup; show a non-blocking install hint if missing. +5. **Windows codec-extension detection** — currently we just rely on kdroidFilter's `SourceError` and our error UX. A nicer follow-up: probe `MFTEnumEx` to detect HEVC/AV1 codec availability up front and prompt for the MS Store extension. +6. **Flathub icon file** — manifest references `icons/256/com.vitorpamplona.amethyst.Desktop.png` which doesn't exist. Copy/resize from `desktopApp/src/jvmMain/resources/icon.png` before the first Flathub submission. + +--- + +## 10 · Roll-back path + +If anything in 0-5 blocks shipping: + +- **Code revert:** `git revert ` of this PR is clean — vlcj files were deleted, new files are isolated to `desktopApp/`. +- **Branch:** `git branch -D worktree-vlcj-licensing-brainstorm` (or whatever the merged branch was). `git checkout main && git pull` returns to vlcj-based desktop. +- **No data migration:** nothing on disk format changed. Settings, account state, downloaded thumbnails — all forward+backward compatible. + +--- + +## Self-check before opening the PR + +- [ ] 0.1 BUILD SUCCESSFUL on my machine +- [ ] 0.2 tests green +- [ ] 0.4 no vlcj source code references +- [ ] 1 ffmpeg binaries in place for the OSes I'm packaging +- [ ] 2.1 app launches without VLC warnings +- [ ] 3.1, 3.2, 3.3, 3.7 pass on at least my primary OS +- [ ] 6.1 RPM license metadata correct +- [ ] 6.4 LGPL text placeholder noted in release checklist diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1ad815bc7..ea4bcc99a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,8 @@ playServicesCast = "22.3.1" vico-charts-compose = "3.1.0" zelory = "3.0.1" zoomable = "2.12.0" -vlcj = "4.8.3" +composemediaplayer = "0.10.0" +jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" thumbnailator = "0.4.21" zxing = "3.5.4" @@ -147,7 +148,9 @@ coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "c commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } thumbnailator = { group = "net.coobird", name = "thumbnailator", version.ref = "thumbnailator" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } -vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } +composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" } +jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" } +jcodec-javase = { group = "org.jcodec", name = "jcodec-javase", version.ref = "jcodec" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md new file mode 100644 index 0000000000..d7b91db15e --- /dev/null +++ b/quartz/plans/2026-06-09-clink.md @@ -0,0 +1,298 @@ +# CLINK on Quartz + Amethyst + +Status: implemented (Phases 0–3 + receive side + CLI) — see "Final +implementation state" at the bottom for what actually shipped, the audit +results, and the spec-vs-SDK gotchas. The body below is the original +(proposed) design and is kept for context; where it disagrees with the final +state, the final state wins. +Date: 2026-06-09 +Owner: TBD + +## Decisions (locked) + +1. **Scope:** implement all three CLINK specs (Offers, Debits, Manage). +2. **Sidedness:** Quartz implements **both client and server** for every spec + (so `amy` and interop tests can drive both ends). Amethyst is + **consume-only** — it never hosts offers or approves incoming debits. +3. **Wallet model:** CLINK plugs into Amethyst's wallet layer *like NWC*. The + only spec that backs a spendable wallet is **Debits** — a stored `ndebit` + pointer is the CLINK analogue of an NWC connection string. It lives in the + same wallet list and adds a payment route to `ZapPaymentHandler`. `noffer` + (pay others) and `nmanage` (offer admin) are not wallet connections. +4. **Pointer parsing:** `noffer` / `ndebit` / `nmanage` are parsed by a + **dedicated `ClinkPointerParser`**, NOT folded into `Nip19Parser`. We do not + loosen the NIP-19 regexes app-wide for these prefixes. + +## What CLINK is + +"Common Lightning Interface for Nostr Keys" — three Nostr-native Lightning +protocols from ShockNet. Where NWC (NIP-47) is RPC-style remote control of your +*own* wallet, CLINK is peer-to-peer / app-to-service: no HTTPS callbacks, no +LNURL web server, no onion messages. Each interaction is a NIP-44-encrypted +ephemeral request→response event over a relay, addressed by a bech32 pointer. + +| Spec | Kind | Pointer | Purpose | LN analogue | +|---------|---------|--------------|-----------------------------------------------------|----------------------| +| Offers | `21001` | `noffer1…` | Static code → request a fresh BOLT-11 over Nostr | LNURL-Pay / BOLT-12 | +| Debits | `21002` | `ndebit1…` | Authorize a counterparty to pull a payment | LNURL-withdraw | +| Manage | `21003` | `nmanage1…` | Delegate CRUD of offers to an external app | — | + +Shared envelope: ephemeral kind, `["p", recipient]` + `["clink_version","1"]` +tags, `["e", reqId]` on responses, NIP-44 JSON content, GFY error codes +(`{"res":"GFY","code":1..6,...}`), 30s freshness window. + +### Pointer TLV fields (verify against @shocknet/clink-sdk in Phase 0) + +- **noffer:** 0=receiver pubkey, 1=relay, 2=offer-id, 3=price-type + (0 fixed / 1 variable / 2 spontaneous), 4=price sats, 5=currency. +- **ndebit:** 0=service pubkey, 1=relay, 2=pointer-id (opt), 3=32-byte `k1` + session id (opt; single-use). +- **nmanage:** 0=server pubkey, 1=relay, 2=pointer-id (opt). + +## Reuse matrix + +| Component | Status | Location | Action | +|-----------------------------------|------------|----------------------------------------------------------------------|----------------------------------------------------| +| NIP-44 encrypt/decrypt | ✅ Reuse | `quartz/.../nip44Encryption/` + `signer.nip44Encrypt/Decrypt` | Use as-is | +| Bech32 + TLV codec | ✅ Reuse | `nip19Bech32/bech32/`, `tlv/TlvBuilder.kt`, `tlv/Tlv.kt` | Use the codec; do NOT reuse `Nip19Parser` | +| Entity TLV pattern (reference) | 📦 Mirror | `nip19Bech32/entities/NProfile.kt` | Copy shape into new Clink entities | +| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/LnZapPaymentRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server | +| Request/response over relay | ✅ Reuse | `Nip47Client.responseFilter()`, `relayClient/reqCommand/nwc/` | Same filter-by-`e`-tag pattern | +| BOLT-11 parsing | ✅ Reuse | `quartz/.../lightning/LnInvoiceUtil.kt` | Use as-is | +| LN payment execution | ✅ Reuse | `amethyst/.../service/ZapPaymentHandler.kt`, `NwcSignerState` | Add CLINK routes | +| Wallet/zap account settings | 📦 Extend | `AccountSettings.kt` (`nwcWallets`, `defaultNwcWalletId`) | Add CLINK debit pointers to the wallet list | +| Rich-text inline detect/render | 📦 Extend | `commons/.../richtext/RichTextParser.kt`, `RichTextViewer.kt`, `InvoicePreview.kt` | Add `noffer1…` token → pay card | +| EventFactory registry | 📦 Extend | `quartz/.../utils/EventFactory.kt` | Register 21001/21002/21003 | + +Genuinely new: 3 event classes, 3 bech32 entities + `ClinkPointerParser`, JSON +DTOs, `ClinkClient` + `ClinkServer`, the debit-pointer wallet store, UI surfaces. + +## Phase 0 — Quartz foundation (client + server, all three) + +New package `quartz/.../nipClink/` (mirroring the single-package NWC layout), +with `events/`, `pointers/`, `rpc/` subfolders. + +1. **TLV constants** — CLINK-local TLV indices (do not overload NIP-19 + `TlvTypes`, which is NIP-19-specific). +2. **Pointers** — `NOffer`, `NDebit`, `NManage` data classes with + `parse(bytes)` / `create(...)` built like `NProfile.kt` (`Tlv.parse` + + `TlvBuilder`). A standalone `ClinkPointerParser` decodes/encodes the three + prefixes — NOT wired into `Nip19Parser`. Verify HRP + checksum and the + TLV namespace against `@shocknet/clink-sdk` with a round-trip test. +3. **Events** — `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + each `isContentEncoded() = true`, with `createRequest()` / `createResponse()` + companions that NIP-44-encrypt JSON and set `p` / `clink_version` / `e` tags + (direct analogue of `LnZapPaymentRequestEvent`). Register in `EventFactory`. +4. **DTOs + errors** — Jackson request/response classes per spec, shared + `GfyError(code, message, range?, retryAfter?, delta?)`, Offers error model + (`code` 1..5, `range`, `latest`). +5. **ClinkClient / ClinkServer** — high-level: `decode(pointer)`, + `buildRequest(...)`, `responseFilter(reqId)`, `parseResponse(...)`; server + side validates freshness, `k1` single-use, app-scoped offer ownership. +6. **Tests** — quartz unit tests with spec TLV vectors + round-trip against + clink-sdk fixtures; `amy clink decode|offer-pay|debit|manage` verbs (thin + assembly only). + +## Phase 1 — Offers consume (Amethyst) + +Ship order: scan/paste → inline card → profile button. + +- **Scan/paste-to-pay:** QR scanner + clipboard handle `noffer1…` → decode → + send 21001 → await BOLT-11 → existing send sheet → `ZapPaymentHandler`. + Smallest correct slice; proves the client end-to-end. +- **Inline feed card (headline):** `RichTextParser` recognizes a `noffer1…` + token; render a "⚡ Pay" card next to the existing BOLT-11 `InvoicePreview`. + Tap reuses the same decode→21001→pay path. +- **Profile pay button:** read `noffer` from kind-0 metadata; profile Zap + button prefers CLINK offer over LNURL when present. + +## Phase 2 — Debits as a wallet (Amethyst, consume-only) + +This is the "add a wallet via CLINK" answer. + +- **Account store:** extend the wallet list so a saved `ndebit` pointer sits + alongside NWC connections (`AccountSettings`, parallel to `nwcWallets`; + selectable as default funding source). +- **Payment route:** `ZapPaymentHandler` gains a CLINK-debit route — send a + 21002 request, await `{"res":"ok",preimage}`, handle GFY. Always behind an + explicit confirmation; honor `k1` single-use; never auto-approve. +- **Out of scope (server side):** receiving/approving incoming 21002 requests + and session-`k1` scan-to-pull. Quartz has the server code; Amethyst does not + expose it (consume-only decision). + +## Phase 3 — Manage (Quartz only) + +Full client + server in Quartz + `amy clink manage …` (create/update/get/ +list/delete, app-scoped ownership, GFY errors). **No Amethyst UI** — Amethyst +is an offer *consumer*, not a host. Revisit an in-app "my offers" minter only +if users want to mint offers from inside the app later. + +## Risks / to verify in Phase 0 + +- Exact bech32 HRP + whether CLINK uses its own TLV namespace vs NIP-19's — + pin against `@shocknet/clink-sdk` source, not the client-rendered docs. +- Relay selection for ephemeral req/resp (pointer relay vs account relays). +- Response-timeout + retry UX (30s freshness window, GFY code 3 deltas). + +--- + +## Final implementation state + +Everything below reflects what is on the branch now, not the proposal above. +Read this section first if you're touching CLINK. + +### What shipped + +**Phase 0 — Quartz foundation** (`quartz/.../experimental/clink/`) +- Pointers (`pointers/NOffer.kt`, `NDebit.kt`, `NManage.kt`) decoded/encoded + via a dedicated `ClinkPointerParser` (bech32 + TLV), **not** wired into + `Nip19Parser`. Round-trip tested against the canonical interop vectors + (`ClinkPointerTest`, `ClinkInteropTest`). +- Events `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + refactored to the codebase convention: `eventTemplate(KIND, content, …){…}` + + tag-class DSL (`pTag`, `eTag`, `alt`, `clinkVersion`) and typed accessors + (`PTag::parseKey`, `ETag::parseId`). Registered in `EventFactory`. +- High-level clients `OfferClient` / `DebitClient` / `ManageClient` build the + request event, expose a `responseFilter` (filtered by **both** `e=reqId` + and `p=self`), and parse the NIP-44-decrypted response DTO. +- Shared `clink_version` is its own tag class (`tags/ClinkVersionTag.kt`, + `CURRENT="1"`) reused by all three events, with a `clinkVersion()` builder + extension. The old `Clink.kt` constants object was retired. + +**Phase 1 — Offers consume** (Amethyst): inline feed card +(`ClinkOfferPreview.kt`) renders a payable "⚡" card for a `noffer1…` token, +with a variable-amount field for SPONTANEOUS offers, moved-offer follow +(GFY code 3 `latest`), and the resolved `activeOffer` price. Payment routes +through the shared `InvoicePaymentDispatcher` (confirm-then-pay) into +`ZapPaymentHandler`. **Zappable offers were explicitly reverted** — do not +re-add NIP-57 zaps to offer payment. + +**Phase 2 — Debits as a wallet** (Amethyst, consume-only): a stored `ndebit` +pointer is a first-class payment source alongside NWC. `PaymentSource` +(sealed: `Nwc` / `ClinkDebit`), `PaymentSourceResolver`, and +`AccountSettings.defaultPaymentSource()` unify default selection; +`defaultNwcWalletId` was migrated to `defaultPaymentSourceId`. +`ZapPaymentHandler` dispatches on the resolved source. Budgets/recurring via +`DebitClient.requestBudget` + `DebitFrequency` units (day/week/month). + +**Phase 3 — Manage** (Quartz only, no Amethyst UI): nested request/response +shape — `ManageRequest(resource, action, pointer, offer: ManageOffer?)`, +`ManageOffer(id, fields: OfferFields)`, `OfferData(...)`, `ManageResponse(...)`. + +**Receive side** (advertise your own `noffer`): kind-0 `clink_offer` field +(`UserMetadata.clinkOffer` + dual-written `clink_offer` tag via +`ClinkOfferTag`, NIP-1770 pattern) **and** NIP-05 `.well-known` discovery +(`Nip05Parser.parseClinkOffer`, `INip05Client.loadClinkOffer`). The profile +header (`DisplayClinkOffer`) prefers whichever is present, with a +256-entry `LruCache` over the NIP-05 lookups. + +**CLI** (`amy`): `offer info|request`, `debit info|pay|budget` (thin assembly +only). New `Context.requestResponse(...)` does subscribe→publish→await-first- +matching-live-reply (vs `drain`, which returns at EOSE). + +### Audit findings & resolutions + +- **Offer price is an UNSIGNED 4-byte BE integer.** `NOffer.price` is `Long?`; + decode reads the 4 bytes as unsigned (SDK does `parseInt(hex)`), encode + writes the low 32 bits. The earlier `Int` typing produced a negative price + for any amount ≥ 2^31. Regression: + `ClinkPointerTest.offerLargePriceRoundTripIsUnsigned` (3_000_000_000L). +- **Decrypt guard.** `OfferEvent`/`DebitEvent`/`ManageEvent` replaced the old + self-fallback `talkingWith()` with `conversationPeer(myPubKey)` that returns + `null` when the signer is neither author nor recipient; `decryptContent` + then throws `UnauthorizedDecryptionException`. Regression: + `ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient`. +- **Payer hang fix.** `ClinkOfferPayer` / `ClinkDebitPayer` wrap `parseResponse` + in try/catch and return `null` on a decode failure — an uncaught + `SerializationException` previously hung the UI waiting on a coroutine that + never completed. `payInvoiceViaClinkDebit` now delivers `onResult` on + `Dispatchers.Main`. +- **NIP-05 cache correctness.** The offer cache distinguishes a cache-miss + from a cached-`null` (explicit presence check), so a profile with no offer + isn't re-fetched on every recomposition. + +### CRITICAL — spec vs SDK (do NOT "fix" these) + +The `@shocknet/clink-sdk` (1.5.5) **lags the published spec**. Two things look +like bugs against the SDK but are correct against the spec +(`raw.githubusercontent.com/shocknet/CLINK/main/specs/clink-*.md`) and were +verified there directly: + +1. **Offer moved → GFY `code 3` carries `latest`** (a fresh pointer). The + client follows it. The SDK omits this; the spec defines it. Keep the + follow logic in `ClinkOfferPreview` / `OfferClient`. +2. **ndebit session `k1` lives at TLV index 3.** The SDK doesn't read it; the + spec defines it as the optional single-use session id. Keep decoding it. + +Other shape notes for future maintainers: +- **Manage uses the nested `offer.fields` shape** (above), not a flat object. +- **`ManageResponse.details` is parsed as a single object**, not an array — + Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY` is OFF in this repo, and the + reference service returns one object. Documented as a known limitation in + `ManageMessages.kt`; revisit if a service returns a list. + +### Verification matrix + +| Level | What it covers | Status | +|-------|----------------|--------| +| JVM unit tests | pointer codecs (incl. unsigned price), decrypt guard, Manage nested shape, DTOs, metadata `clink_offer`, NIP-05 discovery, `PaymentSourceResolver` | green | +| `amy` CLI | `offer info`/`request`, `debit info`/`pay`/`budget` — local decode verified end-to-end | green | +| Shell harness | `cli/tests/clink/clink-headless.sh` — decode of canonical vectors + arg-error paths | 12/12 | + +**Remaining gap:** the live NIP-44 request→response round-trip over a relay +against a real CLINK service is not automated (needs a device or a +mock/live service). Feasible later via quartz's in-process relay server +(`nip01Core/relay/server/`) plus a mock CLINK responder; flagged but not +built. + +--- + +## Interop review & spec-conformance pass (2026-06-10) + +Reviewed against the whole `shocknet/CLINK` ecosystem (Lightning.Pub, clink-sdk, +ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) and re-audited every +spec file line-by-line against the code. **Verdict: the consume-only +payer/requestor role is conformant and interoperable; no real correctness bugs.** + +**Interop fixes shipped:** +1. Manage `details` single-object responses parse (Jackson + `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — Lightning.Pub returns a bare object for + create/update/get, an array only for `list`. +2. `NOffer.encode()` always emits the price-type TLV (3); decode defaults an + absent/unknown type to SPONTANEOUS. The SDK + bridgelet decoders throw on a + missing TLV 3, so omitting it made our pointers undecodable by JS consumers. +3. `Nip05Parser.parseClinkOffer` accepts bridgelet's flat-string shape as well + as the spec's per-name map. +4. Offer receipts are a parseable primitive (`OfferEvent.createReceipt` / + `decryptReceipt`, `OfferClient.parseReceipt`, `OfferReceipt.isOk`). +5. `ClinkOfferPayer` signs offer requests with an ephemeral key (privacy parity + with the SDK/Zeus/Stacker News). Debits keep the persistent account key + (budgets need a stable app identity). + +**Conformance hardening shipped:** `NDebit.parse` rejects a non-32-byte k1; +`DebitClient.requestBudget` validates `frequency.unit ∈ {day,week,month}`; +`OfferClient` caps `description` at 100 chars; `DebitResponse.failureDetail()` +surfaces GFY `range`/`retry_after` in the debit zap error path. + +**New tests:** `ClinkWireShapeTest` (golden JSON payloads from the public-domain +specs), a clink-demo/SDK canonical `DEFAULT_NOFFER` vector, plus regressions for +each fix above. + +**Three "CRITICAL" review findings were verified FALSE — do NOT re-chase them:** +- *Offer price encoding for ≥2³¹ sats* is correct: `addInt` writes the low 32 + bits, which are bit-identical to the unsigned 4-byte BE (proven by + `offerLargePriceRoundTripIsUnsigned`, 3e9 sats). Only ≥2³² loses data, which + the spec's 4-byte field can't represent anyway. +- *Parser "wrongly accepts `nostr:`/`lightning:` wrappers"* is not a violation: + the MUST-NOT-wrap rule governs producing QR (our `encode()` is bare); lenient + decode is Postel-legal. +- *Manage "create shape deviation"* — the spec's inline-create example is the + outlier; Lightning.Pub/SDK/clink-demo all use nested `offer.fields` for create, + which we match. + +**Remaining non-bugs (intentional / out-of-role):** we don't reject responses +lacking `clink_version` (Lightning.Pub omits it — rejecting breaks interop); the +offer Payment Receipt UI subscription is unwired (primitive ships); `clink_debit` +discovery and the unrestricted-access debit request are creditor/app-side +features outside the consume-only role; noffer TLV 5 currency is dormant +(unimplemented by the SDK and Lightning.Pub too). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt new file mode 100644 index 0000000000..b6b5fd2704 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Debits requestor. + * + * Wraps a decoded [NDebit] pointer. A session pointer's single-use `k1` (TLV 3) is + * carried automatically into every request; static pointers send no `k1`, per spec. + */ +class DebitClient( + val pointer: NDebit, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** Asks the service to pay [bolt11] from the pointed-to wallet (kind 21002). */ + suspend fun payInvoice( + bolt11: String, + amountSats: Long? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + bolt11 = bolt11, + description = description, + k1 = pointer.k1, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Requests a spending budget; omit [frequency] for a one-time budget. */ + suspend fun requestBudget( + amountSats: Long, + frequency: DebitFrequency? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + // Per spec the recurring cadence unit is one of day/week/month; reject anything else + // rather than sending a request a node service will GFY. + require(frequency == null || frequency.unit in DebitFrequency.VALID_UNITS) { + "Invalid budget frequency unit: ${frequency?.unit}" + } + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + description = description, + k1 = pointer.k1, + frequency = frequency, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(DebitEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: DebitEvent): DebitResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt new file mode 100644 index 0000000000..a27d5fe985 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -0,0 +1,109 @@ +/* + * 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.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields +import com.vitorpamplona.quartz.experimental.clink.pointers.NManage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Manage app client for delegated offer CRUD (kind 21003) against a + * wallet server addressed by an [NManage] pointer. + */ +class ManageClient( + val pointer: NManage, + val signer: NostrSigner, +) { + val serverPubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + suspend fun createOffer( + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: List? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + pointer = pointer.pointer, + offer = ManageOffer(fields = OfferFields(label, priceSats, callbackUrl, payerData)), + ), + createdAt, + ) + + suspend fun updateOffer( + id: String, + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: List? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_UPDATE, + offer = ManageOffer(id = id, fields = OfferFields(label, priceSats, callbackUrl, payerData)), + ), + createdAt, + ) + + suspend fun getOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, offer = ManageOffer(id = id)), createdAt) + + suspend fun listOffers(createdAt: Long = TimeUtils.now()): ManageEvent = + send( + ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_LIST, pointer = pointer.pointer), + createdAt, + ) + + suspend fun deleteOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, offer = ManageOffer(id = id)), createdAt) + + private suspend fun send( + request: ManageRequest, + createdAt: Long, + ): ManageEvent = ManageEvent.createRequest(request, serverPubKey, signer, createdAt) + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(ManageEvent.KIND), + authors = listOf(serverPubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: ManageEvent): ManageResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt new file mode 100644 index 0000000000..cd7fc87edf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Offers payer. + * + * Wraps a decoded [NOffer] pointer: builds the request event, exposes the relays to + * publish on, the filter to await the reply, and the response parser. + * + * ```kotlin + * val offer = ClinkPointerParser.parse("noffer1...") as NOffer + * val client = OfferClient(offer, signer) + * val request = client.requestInvoice(amountSats = 1000) + * // publish `request` to client.relays, then subscribe with client.responseFilter(request.id) + * val response = client.parseResponse(replyEvent) // -> OfferResponse(bolt11=...) or error + * ``` + */ +class OfferClient( + val pointer: NOffer, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** + * Builds the kind-21001 request asking the service for a fresh BOLT-11. When + * [amountSats] is null it falls back to the pointer's embedded price (for fixed offers). + */ + suspend fun requestInvoice( + amountSats: Long? = null, + description: String? = null, + payerData: Map? = null, + zap: String? = null, + expiresInSeconds: Long? = null, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val request = + OfferRequest( + offer = pointer.pointer, + amount_sats = amountSats ?: pointer.price, + payer_data = payerData, + zap = zap, + expires_in_seconds = expiresInSeconds, + // The spec caps the invoice description at 100 chars; trim so an over-long + // value doesn't get the whole request rejected by the service. + description = description?.take(100), + ) + return OfferEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Subscribe with this on [relays] after publishing the request to await its reply. */ + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(OfferEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: OfferEvent): OfferResponse = event.decryptResponse(signer) + + /** + * Parses a post-settlement receipt — the optional second kind-21001 event the service + * sends (on the same `e`+`p` filter as [responseFilter]) once the returned invoice is paid. + */ + suspend fun parseReceipt(event: OfferEvent): OfferReceipt = event.decryptReceipt(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt new file mode 100644 index 0000000000..a5827690fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt @@ -0,0 +1,48 @@ +/* + * 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.quartz.experimental.clink.common + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** Inclusive sats range returned with "Invalid Amount" errors so the payer can correct and retry. */ +class SatRange( + var min: Long? = null, + var max: Long? = null, +) : OptimizedSerializable + +/** Timing detail attached to GFY "Expired Request" (code 3) errors. */ +class GfyDelta( + var max_delta_ms: Long? = null, + var actual_delta_ms: Long? = null, +) : OptimizedSerializable + +/** + * Shared "GFY" failure codes used by Debits (21002) and Manage (21003). + * The accompanying payload may carry [SatRange] (5), `retry_after` (4), or [GfyDelta] (3). + */ +object GfyErrorCode { + const val REQUEST_DENIED = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_REQUEST = 3 + const val RATE_LIMITED = 4 + const val INVALID_AMOUNT = 5 + const val INVALID_REQUEST = 6 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt new file mode 100644 index 0000000000..d5dc713ef3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -0,0 +1,128 @@ +/* + * 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.quartz.experimental.clink.debits + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Debits event (kind 21002). The same kind carries both the request (requestor → + * service) and the response (service → requestor); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +class DebitEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): DebitRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): DebitResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21002 + const val ALT = "CLINK debit" + + /** Builds a request event (requestor side) addressed to the debit service. */ + suspend fun createRequest( + request: DebitRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: DebitResponse, + requestEvent: DebitEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val requestorPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), requestorPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(requestorPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt new file mode 100644 index 0000000000..8d4fd67381 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.experimental.clink.debits + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request to a CLINK Debits service (kind 21002). Two shapes: + * - direct payment: [bolt11] (+ optional [amount_sats]); [k1] required for session pointers + * - budget approval: [amount_sats] + [frequency] (omit [frequency] for a one-time budget) + */ +class DebitRequest( + var pointer: String? = null, + var amount_sats: Long? = null, + var bolt11: String? = null, + var description: String? = null, + var k1: String? = null, + var frequency: DebitFrequency? = null, +) : OptimizedSerializable + +/** Recurring-budget cadence; [unit] is one of [UNIT_DAY], [UNIT_WEEK], [UNIT_MONTH]. */ +class DebitFrequency( + var number: Int? = null, + var unit: String? = null, +) : OptimizedSerializable { + companion object { + const val UNIT_DAY = "day" + const val UNIT_WEEK = "week" + const val UNIT_MONTH = "month" + + /** The only cadence units the spec defines. */ + val VALID_UNITS = setOf(UNIT_DAY, UNIT_WEEK, UNIT_MONTH) + } +} + +/** + * Decrypted response from a CLINK Debits service. [res] is `"ok"` on success (with + * [preimage] for Lightning payouts, absent for internal settlements/budget approvals) + * or `"GFY"` on failure, in which case [code] (see + * [com.vitorpamplona.quartz.experimental.clink.common.GfyErrorCode]) and [error] are set. + */ +class DebitResponse( + var res: String? = null, + var preimage: String? = null, + var code: Int? = null, + var error: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + /** + * A failure detail string for a GFY response: the service's [error] plus the actionable + * structured extra the spec attaches per code — the allowed [range] (code 5) or + * [retry_after] timestamp (code 4). Returns null for a success ([isOk]) response. The + * text is protocol-neutral (English, like the service's own `error`), meant to be shown + * as the detail line under a localized "payment failed" title. + */ + fun failureDetail(): String? { + if (isOk()) return null + val base = error?.takeIf { it.isNotBlank() } + val extra = + when { + range != null -> "allowed ${range?.min ?: "?"} to ${range?.max ?: "?"} sats" + retry_after != null -> "retry after $retry_after" + else -> null + } + return listOfNotNull(base, extra?.let { "($it)" }).joinToString(" ").ifBlank { null } + } + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt new file mode 100644 index 0000000000..c2901a8669 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -0,0 +1,128 @@ +/* + * 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.quartz.experimental.clink.manage + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Manage event (kind 21003). The same kind carries both the request (app → + * wallet server) and the response (server → app); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +class ManageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): ManageRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): ManageResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21003 + const val ALT = "CLINK manage" + + /** Builds a request event (app side) addressed to the wallet server. */ + suspend fun createRequest( + request: ManageRequest, + serverPubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), serverPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(serverPubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (server side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: ManageResponse, + requestEvent: ManageEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val appPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), appPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(appPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt new file mode 100644 index 0000000000..42dc289b8d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -0,0 +1,108 @@ +/* + * 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.quartz.experimental.clink.manage + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * The editable fields of a managed offer. `payer_data` is a list of field NAMES the offer + * requests from the payer (e.g. `["email", "shipping_address"]`), per the CLINK Manage spec. + */ +class OfferFields( + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * The `offer` envelope on a Manage request: [id] identifies the target for update/get/delete, + * [fields] carries the values for create/update. + */ +class ManageOffer( + var id: String? = null, + var fields: OfferFields? = null, +) : OptimizedSerializable + +/** + * Decrypted request to a CLINK Manage service (kind 21003) for delegated offer CRUD. + * The offer data is NESTED under [offer] (not flat): `create` sets `offer.fields`, `update` + * sets `offer.id` + `offer.fields`, `get`/`delete` set `offer.id`, and `list` filters by + * [pointer]. (The published spec shows `create` with fields directly under `offer`; the + * reference SDK nests both create and update under `offer.fields`, which we follow for + * interop with SDK-built services.) + */ +class ManageRequest( + var resource: String? = null, + var action: String? = null, + var pointer: String? = null, + var offer: ManageOffer? = null, +) : OptimizedSerializable { + companion object { + const val RESOURCE_OFFER = "offer" + const val ACTION_CREATE = "create" + const val ACTION_UPDATE = "update" + const val ACTION_GET = "get" + const val ACTION_LIST = "list" + const val ACTION_DELETE = "delete" + } +} + +/** A managed offer as returned by the service: [OfferFields] plus its [id] and server-generated [noffer]. */ +class OfferData( + var id: String? = null, + var noffer: String? = null, + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` and [details] + * carries the affected offer(s); on failure [res] is `"GFY"` with [code]/[error] (and [field] + * naming the invalid input for validation errors). + * + * NOTE: the spec types `details` as `OfferData | OfferData[]` — a single object for + * create/update/get, an array for list. We model it as a list and rely on the JSON mapper + * coercing a lone object into a one-element list (Jackson `ACCEPT_SINGLE_VALUE_AS_ARRAY` on + * JVM/Android), so both shapes parse. + */ +class ManageResponse( + var res: String? = null, + var resource: String? = null, + var details: List? = null, + var code: Int? = null, + var error: String? = null, + var field: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt new file mode 100644 index 0000000000..fb35b0b57f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.offers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Offers event (kind 21001). The same kind carries both the request (payer → + * service) and the response (service → payer); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +class OfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The `p` tag — the counterparty this message is addressed to. */ + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The `e` tag — present only on responses, referencing the request event id. */ + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): OfferRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): OfferResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptReceipt(signer: NostrSigner): OfferReceipt = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21001 + const val ALT = "CLINK offer" + + /** Builds a request event (payer side) addressed to the offer service. */ + suspend fun createRequest( + request: OfferRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: OfferResponse, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), payerPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + + /** + * Builds a post-settlement receipt event (service side). Like [createResponse] it + * references the original [requestEvent] by `e` tag and is addressed to the payer, but + * carries an [OfferReceipt] (`{"res":"ok"[,"preimage"]}`) sent after the invoice is paid. + */ + suspend fun createReceipt( + receipt: OfferReceipt, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(receipt), payerPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt new file mode 100644 index 0000000000..075aa723f0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt @@ -0,0 +1,78 @@ +/* + * 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.quartz.experimental.clink.offers + +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request payload sent to a CLINK Offers service (kind 21001) to obtain a + * fresh BOLT-11 invoice. `offer` echoes the pointer's offer-id; `amount_sats` is + * required for spontaneous/variable offers. `description` is capped at 100 chars. + */ +class OfferRequest( + var offer: String? = null, + var amount_sats: Long? = null, + var payer_data: Map? = null, + var zap: String? = null, + var expires_in_seconds: Long? = null, + var description: String? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Offers service. Either an invoice ([bolt11] set) or + * an error ([code] set). On "Expired or Moved" (code 3) the service may include + * [latest] with a replacement `noffer1…`; on "Invalid Amount" (code 5) it includes [range]. + */ +class OfferResponse( + var bolt11: String? = null, + var error: String? = null, + var code: Int? = null, + var range: SatRange? = null, + var latest: String? = null, +) : OptimizedSerializable { + fun isSuccess(): Boolean = bolt11 != null +} + +/** + * Optional post-settlement receipt (kind 21001): a second event the service sends after the + * invoice it returned is paid, confirming receipt out-of-band. `preimage` is absent for + * internal (non-Lightning) settlements — the payer's own wallet already holds it for LN pays. + */ +class OfferReceipt( + var res: String? = null, + var preimage: String? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + } +} + +/** Error codes returned by a CLINK Offers service in [OfferResponse.code]. */ +object OfferErrorCode { + const val INVALID_OFFER = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_OR_MOVED = 3 + const val UNSUPPORTED_FEATURE = 4 + const val INVALID_AMOUNT = 5 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt new file mode 100644 index 0000000000..79589453d6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt @@ -0,0 +1,69 @@ +/* + * 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.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * CLINK ("Common Lightning Interface for Nostr Keys") addresses a remote Lightning + * service through a bech32-encoded pointer that carries a small TLV payload. There + * are three pointer kinds — one per CLINK spec — and they all share the first three + * TLV fields (pubkey, relay, pointer-id). See https://github.com/shocknet/clink. + * + * Pointers use **standard** bech32 (not bech32m) with their own human-readable + * prefixes (`noffer`, `ndebit`, `nmanage`), so they are intentionally parsed by + * [ClinkPointerParser] rather than the NIP-19 [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]. + */ +sealed interface ClinkPointer { + /** TLV 0 — 32-byte public key of the service that listens for requests. */ + val pubKey: HexKey + + /** TLV 1 — relay(s) where the service listens. May be empty when shared out-of-band. */ + val relays: List + + /** TLV 2 — opaque routing pointer the service uses to disambiguate (optional). */ + val pointer: String? + + /** Re-encodes this pointer to its `noffer1…` / `ndebit1…` / `nmanage1…` string. */ + fun encode(): String +} + +/** + * Shared TLV field indices across the CLINK pointer specs. Indices 0–2 are common; + * 3–4 are reused with different meanings per spec (priceType/price for offers, k1 + * for debit sessions), mirroring `@shocknet/clink-sdk`. + */ +internal object ClinkTlv { + const val PUBKEY: Byte = 0 + const val RELAY: Byte = 1 + const val POINTER: Byte = 2 + + // Offers + const val PRICE_TYPE: Byte = 3 + const val PRICE: Byte = 4 + + // Debits + const val K1: Byte = 3 +} + +/** Encodes a small unsigned value as a single-byte hex string for one-byte TLV values. */ +internal fun Int.toSingleByteHex(): String = (this and 0xFF).toString(16).padStart(2, '0') diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt new file mode 100644 index 0000000000..5b39369124 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +/** + * Decodes CLINK bech32 pointers (`noffer1…`, `ndebit1…`, `nmanage1…`). + * + * Kept deliberately separate from [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]: + * these prefixes are not NIP-19 entities, so we don't widen the app-wide NIP-19 + * matching to recognize them. + */ +object ClinkPointerParser { + /** All three HRPs, anchored at a `1` separator. Used to spot pointers inside free text. */ + val clinkRegex: Regex = + Regex( + "(noffer1|ndebit1|nmanage1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + RegexOption.IGNORE_CASE, + ) + + /** + * Parses a single pointer string. Tolerates a leading `nostr:`/`lightning:` scheme + * and surrounding whitespace. Returns null on any malformed input. + */ + fun parse(pointer: String): ClinkPointer? { + val cleaned = + pointer + .trim() + .removePrefix("nostr:") + .removePrefix("lightning:") + .trim() + + return try { + val (hrp, bytes, _) = Bech32.decodeBytes(cleaned) + when (hrp.lowercase()) { + NOffer.HRP -> NOffer.parse(bytes) + NDebit.HRP -> NDebit.parse(bytes) + NManage.HRP -> NManage.parse(bytes) + else -> null + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.d("ClinkPointerParser") { "Failed to decode CLINK pointer $cleaned: ${e.message}" } + null + } + } + + /** Finds and decodes every CLINK pointer embedded in [content]. */ + fun parseAll(content: String): List = clinkRegex.findAll(content).mapNotNull { parse(it.value) }.toList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt new file mode 100644 index 0000000000..3e884ed7a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Debits pointer (`ndebit1…`, kind 21002): authorizes a counterparty to pull a + * payment from the pointed-to wallet. A pointer is either *static* (TLV 0–2, long-lived, + * app-initiated) or a single-use *session* carrying a 32-byte [k1] (TLV 3), used for the + * LNURL-withdraw-like scan-to-pull flow. + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +data class NDebit( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** TLV 3 — 32-byte single-use session id (lowercase hex). Null for static pointers. */ + val k1: HexKey?, +) : ClinkPointer { + /** True when this is a single-use session pointer (carries [k1]). */ + val isSession: Boolean get() = k1 != null + + // Note: SDK 1.5.5 does not encode k1; it is a spec-level session extension at TLV index 3. + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + addHexIfNotNull(ClinkTlv.K1, k1) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "ndebit" + + fun parse(bytes: ByteArray): NDebit? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + val k1 = tlv.firstAsHex(ClinkTlv.K1) + // A session id (TLV 3) MUST be exactly 32 bytes (64 hex chars) per the spec; a + // wrong-length value means a malformed session pointer, so reject the whole thing + // rather than silently treating it as static or sending a bad k1. + if (k1 != null && k1.length != 64) return null + + return NDebit(pubKey, relays, pointer, k1) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt new file mode 100644 index 0000000000..8f11f875c7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt @@ -0,0 +1,68 @@ +/* + * 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.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Manage pointer (`nmanage1…`, kind 21003): a shareable token that grants an + * external app delegated CRUD over a user's offers on a wallet server. + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +data class NManage( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "nmanage" + + fun parse(bytes: ByteArray): NManage? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + + return NManage(pubKey, relays, pointer) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt new file mode 100644 index 0000000000..3e1956f8a2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Offers pointer (`noffer1…`, kind 21001): a static payment code that lets a + * payer request a fresh BOLT-11 invoice over Nostr — the Nostr-native analogue of + * LNURL-Pay. See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +data class NOffer( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** + * TLV 3 — how the offer is priced. Always a concrete type: when the wire field is + * absent it is [OfferPriceType.SPONTANEOUS], per the CLINK spec. + */ + val priceType: OfferPriceType, + /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ + val price: Long?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + // Always emit TLV 3, even for spontaneous offers: the reference SDK and + // bridgelet decoders throw on a missing price-type field, so an absent TLV 3 + // would make our pointers undecodable by every JS consumer. + addHex(ClinkTlv.PRICE_TYPE, priceType.code.toSingleByteHex()) + // addInt writes the low 32 bits big-endian; for an unsigned price up to + // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. + price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "noffer" + + fun parse(bytes: ByteArray): NOffer? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + // Per the CLINK Offers spec, an absent (or unrecognized) price-type defaults to + // spontaneous — the payer chooses the amount. + val priceType = + tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { + OfferPriceType.fromCode(it.toInt() and 0xFF) + } ?: OfferPriceType.SPONTANEOUS + // The SDK decodes price as an UNSIGNED big-endian integer (parseInt of the hex); + // reading it as a signed Int would turn prices >= 2^31 sats into negative amounts. + val price = + tlv.data[ClinkTlv.PRICE] + ?.firstOrNull() + ?.takeIf { it.size == 4 } + ?.let { + ((it[0].toLong() and 0xFF) shl 24) or + ((it[1].toLong() and 0xFF) shl 16) or + ((it[2].toLong() and 0xFF) shl 8) or + (it[3].toLong() and 0xFF) + } + + return NOffer(pubKey, relays, pointer, priceType, price) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt similarity index 59% rename from desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt index ee1930fb01..a9157d7ad3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt @@ -18,27 +18,28 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.desktop.service.media - -import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy +package com.vitorpamplona.quartz.experimental.clink.pointers /** - * Discovers bundled VLC libraries on Windows and Linux. - * Uses [VlcResourceResolver] to find the VLC directory from the Compose application - * resources or development fallback paths. + * How a CLINK Offer is priced, encoded in TLV field 3 of a `noffer` pointer. + * + * When the field is absent the pointer defaults to [SPONTANEOUS] (payer chooses + * the amount), per the CLINK Offers spec. */ -class BundledVlcDiscoverer : NativeDiscoveryStrategy { - override fun supported(): Boolean { - val os = System.getProperty("os.name").lowercase() - return "mac" !in os +enum class OfferPriceType( + val code: Int, +) { + /** A fixed amount the payer must match; the amount travels in TLV 4. */ + FIXED(0), + + /** A fiat-denominated amount that the service converts to sats at request time. */ + VARIABLE(1), + + /** No preset amount; the payer specifies `amount_sats` in the request. */ + SPONTANEOUS(2), + ; + + companion object { + fun fromCode(code: Int): OfferPriceType? = entries.firstOrNull { it.code == code } } - - override fun discover(): String { - val vlcDir = VlcResourceResolver.findVlcDir() ?: return "" - return vlcDir.absolutePath - } - - override fun onFound(path: String): Boolean = true - - override fun onSetPluginPath(path: String): Boolean = true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt new file mode 100644 index 0000000000..8f8f2b09b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt @@ -0,0 +1,107 @@ +/* + * 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.quartz.experimental.clink.server + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.abs + +/** + * Service-side helpers for the three CLINK protocols. A CLINK service listens for + * requests addressed to it (`#p` = its pubkey) on its relays, validates freshness, + * then replies with `Event.createResponse(...)`. + * + * Amethyst is consume-only and does not run a service; these live in quartz so `amy` + * and interop tests can exercise the responder side. + */ +object ClinkServer { + /** Requests older/newer than this (vs. the service clock) must be rejected as stale. */ + const val MAX_REQUEST_AGE_SECONDS = 30L + + fun isFresh( + requestCreatedAt: Long, + now: Long = TimeUtils.now(), + ): Boolean = abs(now - requestCreatedAt) <= MAX_REQUEST_AGE_SECONDS + + /** + * Subscribes for incoming requests addressed to [servicePubKey]. Note the kind also + * carries responses, so the caller should skip events where [OfferEvent.isResponse] + * is true (they reference a request via `e`). + */ + fun offerRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(OfferEvent.KIND, servicePubKey, since) + + fun debitRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(DebitEvent.KIND, servicePubKey, since) + + fun manageRequestFilter( + serverPubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(ManageEvent.KIND, serverPubKey, since) + + private fun requestFilter( + kind: Int, + recipient: HexKey, + since: Long?, + ): Filter = + Filter( + kinds = listOf(kind), + tags = mapOf("p" to listOf(recipient)), + since = since, + ) +} + +/** + * Tracks single-use Debit session identifiers (`k1`). Per the Debits spec, a `k1` is + * consumed once the service accepts a request for it, scoped per `pointer` (or per + * service pubkey when no pointer is present). Structural/validation failures should + * NOT consume it, so callers consume only after a request validates. + * + * In-memory and not synchronized; a real service should back this with durable, + * concurrency-safe storage. + */ +class K1Tracker { + private val consumed = mutableSetOf() + + private fun key( + scope: String, + k1: HexKey, + ) = "$scope:$k1" + + fun isConsumed( + scope: String, + k1: HexKey, + ): Boolean = consumed.contains(key(scope, k1)) + + /** Marks [k1] consumed for [scope]; returns false if it was already consumed. */ + fun consume( + scope: String, + k1: HexKey, + ): Boolean = consumed.add(key(scope, k1)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt new file mode 100644 index 0000000000..4d3c727cda --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** + * Protocol-version tag shared by all three CLINK message kinds (21001-3). Every CLINK + * request and response carries a `["clink_version", "1"]` tag alongside its `p` tag, + * with the content NIP-44 encrypted between the two parties. + */ +class ClinkVersionTag { + companion object { + const val TAG_NAME = "clink_version" + const val CURRENT = "1" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8c0d2da7b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * 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.quartz.experimental.clink.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.clinkVersion(version: String = ClinkVersionTag.CURRENT) = addUnique(ClinkVersionTag.assemble(version)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index d7084aad9e..08f00d54ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -127,6 +128,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -145,6 +147,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -182,6 +185,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -200,6 +204,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -233,6 +238,7 @@ class MetadataEvent( lnAddress: String? = null, lnURL: String? = null, pronouns: String? = null, + clinkOffer: String? = null, ) { name?.let { addIfNotBlank(currentMetadata, NameTag.TAG_NAME, it) } displayName?.let { addIfNotBlank(currentMetadata, DisplayNameTag.TAG_NAME, it) } @@ -244,6 +250,7 @@ class MetadataEvent( nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it) } lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it) } lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it) } + clinkOffer?.let { addIfNotBlank(currentMetadata, ClinkOfferTag.TAG_NAME, it) } } // For https://github.com/nostr-protocol/nips/pull/1770 @@ -258,6 +265,7 @@ class MetadataEvent( currentMetadata[Nip05Tag.TAG_NAME]?.let { nip05(it.text) } ?: run { remove(Nip05Tag.TAG_NAME) } currentMetadata[Lud16Tag.TAG_NAME]?.let { lud16(it.text) } ?: run { remove(Lud16Tag.TAG_NAME) } currentMetadata[Lud06Tag.TAG_NAME]?.let { lud06(it.text) } ?: run { remove(Lud06Tag.TAG_NAME) } + currentMetadata[ClinkOfferTag.TAG_NAME]?.let { clinkOffer(it.text) } ?: run { remove(ClinkOfferTag.TAG_NAME) } } private fun addIfNotBlank( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt index 51e37b3d84..163e0df3de 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -51,3 +52,5 @@ fun TagArrayBuilder.lud06(lud06: String) = addUnique(Lud06Tag.ass fun TagArrayBuilder.banner(banner: String) = addUnique(BannerTag.assemble(banner)) fun TagArrayBuilder.pronouns(pronouns: String) = addUnique(PronounsTag.assemble(pronouns)) + +fun TagArrayBuilder.clinkOffer(offer: String) = addUnique(ClinkOfferTag.assemble(offer)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 6ebd2ad25c..b902f1604b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -56,12 +56,18 @@ class UserMetadata { var lud06: String? = null var lud16: String? = null + /** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */ + @SerialName("clink_offer") + var clinkOffer: String? = null + var twitter: String? = null fun anyName(): String? = displayName ?: name fun lnAddress(): String? = lud16 ?: lud06 + fun clinkOffer(): String? = clinkOffer?.takeIf { it.isNotBlank() } + fun bestName(): String? = displayName ?: name fun firstName(): String? { @@ -93,6 +99,7 @@ class UserMetadata { if (name?.isNotEmpty() == true) name = name?.trim() if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + if (clinkOffer?.isNotEmpty() == true) clinkOffer = clinkOffer?.trim() if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim() if (banner?.isNotEmpty() == true) banner = banner?.trim() @@ -105,6 +112,7 @@ class UserMetadata { if (name?.isBlank() == true) name = null if (lud06?.isBlank() == true) lud06 = null if (lud16?.isBlank() == true) lud16 = null + if (clinkOffer?.isBlank() == true) clinkOffer = null if (banner?.isBlank() == true) banner = null if (website?.isBlank() == true) website = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt new file mode 100644 index 0000000000..a744501813 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** CLINK Offers pointer (`noffer1…`) advertised in a kind-0 profile, mirroring the NIP-05 `clink_offer` key. */ +class ClinkOfferTag { + companion object { + const val TAG_NAME = "clink_offer" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(offer: String) = arrayOf(TAG_NAME, offer) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 33877f0f88..d1159083b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -212,12 +212,27 @@ open class BasicRelayClient( } override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { - if (!isConnectionStarted() && !connectingMutex.load()) { - // waits 60 seconds to reconnect after disconnected. - if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { - upRelayDelayToConnect() + if (connectingMutex.load()) return + + if (isConnectionStarted()) { + // A socket already exists. Normally leave it alone, but if it was opened for the wrong + // transport — e.g. the relay's Tor classification changed since (a clearnet relay now routed + // through the Tor proxy, or vice-versa) — tear it down and rebuild on the current transport. + // Without this an in-flight dial on the wrong transport can never be preempted: a still- + // connecting socket leaves isConnectionStarted() true (so the old guard skipped it) yet + // isConnected() false (so RelayPool.reconnectIfNeedsTo's connected-relay branch never runs), + // and the request blocks until that hung dial finally times out. + if (socket?.needsReconnect() == true) { + disconnect() connect() } + return + } + + // waits 60 seconds to reconnect after disconnected. + if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { + upRelayDelayToConnect() + connect() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt index b47acf1f26..1991c87660 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt @@ -33,6 +33,9 @@ interface INip05Client { suspend fun load(nip05: Nip05Id): KeyInfoSet? suspend fun list(domain: String): KeyInfoSet + + /** The CLINK Offers pointer advertised in the NIP-05 `.well-known/nostr.json`, if any. */ + suspend fun loadClinkOffer(nip05: Nip05Id): String? = null } class EmptyNip05Client : INip05Client { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c7520138d9..c4c3b1944f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -73,6 +73,14 @@ class Nip05Client( override suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain)) + override suspend fun loadClinkOffer(nip05: Nip05Id): String? = + try { + parser.parseClinkOffer(nip05, fetchNip05Data(nip05)) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + suspend fun fetchNip05Data(nip05: Nip05Id): String { val url = nip05.toUserUrl() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt index 1614c056d3..bc80d7c79b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -43,6 +45,33 @@ class Nip05Parser { ?.jsonPrimitive ?.content + /** + * Reads a CLINK Offers pointer (`noffer1…`) from a NIP-05 `.well-known/nostr.json`. + * + * Two shapes are seen in the wild and both are accepted: + * - the spec/SDK map, keyed by the local name like `names` — + * `{"clink_offer": {"bob": "noffer1…"}}` (queried as `?name=`); + * - bridgelet's flat top-level string — `{"clink_offer": "noffer1…"}` (one alias per file). + * + * A missing or differently-shaped entry simply yields null. + */ + fun parseClinkOffer( + nip05: Nip05Id, + json: String, + ): String? = + try { + val element = Json.parseToJsonElement(json).jsonObject["clink_offer"] + val raw = + when (element) { + is JsonObject -> element[nip05.name]?.jsonPrimitive?.content + is JsonPrimitive -> element.content + else -> null + } + raw?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + fun parseHexKeyAndRelays( nip05: Nip05Id, json: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt index dca8125f51..486753cef2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag @Immutable -abstract class ReplaceableVideoEvent( +abstract class AddressableVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -45,7 +45,7 @@ abstract class ReplaceableVideoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, RootScope { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 2facb76e93..dbd5434a59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -40,7 +40,7 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34235 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index e293129f49..0f46b0433e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -40,7 +40,7 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34236 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6356a1667a..8e4e1747cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -462,6 +465,9 @@ class EventFactory { LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + OfferEvent.KIND -> OfferEvent(id, pubKey, createdAt, tags, content, sig) + DebitEvent.KIND -> DebitEvent(id, pubKey, createdAt, tags, content, sig) + ManageEvent.KIND -> ManageEvent(id, pubKey, createdAt, tags, content, sig) NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt new file mode 100644 index 0000000000..083c015cdc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -0,0 +1,129 @@ +/* + * 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.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer +import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClinkClientServerTest { + private val signer = NostrSignerInternal(KeyPair()) + private val servicePubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerClientExposesPointerRoutingAndResponseFilter() { + val client = OfferClient(NOffer(servicePubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null), signer) + + assertEquals(servicePubKey, client.servicePubKey) + assertEquals(listOf(relay), client.relays) + + val filter = client.responseFilter("d".repeat(64)) + assertEquals(listOf(OfferEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.authors) + assertEquals(listOf("d".repeat(64)), filter.tags?.get("e")) + } + + @Test + fun offerReceiptRoundTripsFromServiceToPayer() = + kotlinx.coroutines.test.runTest { + val payer = NostrSignerInternal(KeyPair()) + val service = NostrSignerInternal(KeyPair()) + val offer = NOffer(service.pubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null) + val client = OfferClient(offer, payer) + + // payer asks, service settles and sends a receipt referencing the request + val request = client.requestInvoice(amountSats = 1000) + val receiptEvent = OfferEvent.createReceipt(OfferReceipt(res = OfferReceipt.OK, preimage = "ab".repeat(32)), request, service) + + assertEquals(request.id, receiptEvent.requestId()) + val receipt = client.parseReceipt(receiptEvent) + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + + @Test + fun requestBudgetRejectsInvalidFrequencyUnit() = + kotlinx.coroutines.test.runTest { + val client = DebitClient(NDebit(servicePubKey, listOf(relay), null, null), signer) + kotlin.test.assertFailsWith { + client.requestBudget(1000, DebitFrequency(1, "fortnight")) + } + // valid units do not throw + client.requestBudget(1000, DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + } + + @Test + fun offerRequestTruncatesDescriptionTo100Chars() = + kotlinx.coroutines.test.runTest { + val service = NostrSignerInternal(KeyPair()) + val client = OfferClient(NOffer(service.pubKey, listOf(relay), "o", OfferPriceType.SPONTANEOUS, null), signer) + val event = client.requestInvoice(amountSats = 100, description = "x".repeat(150)) + val decrypted = event.decryptRequest(service) + assertEquals(100, decrypted.description?.length) + } + + @Test + fun serverRequestFilterTargetsRecipientByPTag() { + val filter = ClinkServer.debitRequestFilter(servicePubKey, since = 100L) + + assertEquals(listOf(DebitEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.tags?.get("p")) + assertEquals(100L, filter.since) + } + + @Test + fun freshnessHonors30SecondWindow() { + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1000)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1030)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1030, now = 1000)) + assertFalse(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1031)) + } + + @Test + fun k1TrackerIsSingleUsePerScope() { + val tracker = K1Tracker() + val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + + assertFalse(tracker.isConsumed("pointer-7", k1)) + assertTrue(tracker.consume("pointer-7", k1)) + assertTrue(tracker.isConsumed("pointer-7", k1)) + // second consume of the same scope+k1 fails + assertFalse(tracker.consume("pointer-7", k1)) + // same k1 under a different scope is independent + assertTrue(tracker.consume("pointer-8", k1)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt new file mode 100644 index 0000000000..8fb62cb979 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -0,0 +1,188 @@ +/* + * 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.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure-logic + JSON (de)serialization tests for the CLINK message kinds. The actual + * NIP-44 encrypt/decrypt round-trip lives in androidDeviceTest because NIP-44 crypto + * requires lazysodium, which is unavailable in JVM unit tests. + */ +class ClinkEventTest { + private val payer = NostrSignerInternal(KeyPair()) + private val service = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private fun buildEvent( + author: String, + recipient: String, + requestId: String? = null, + ) = OfferEvent( + id = "a".repeat(64), + pubKey = author, + createdAt = 1L, + tags = + buildList { + add(arrayOf("p", recipient)) + if (requestId != null) add(arrayOf("e", requestId)) + add(ClinkVersionTag.assemble()) + }.toTypedArray(), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + + // --- tag logic --- + + @Test + fun requestHasNoEventTag() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertFalse(request.isResponse()) + assertNull(request.requestId()) + assertEquals(service.pubKey, request.recipientPubKey()) + assertEquals(ClinkVersionTag.CURRENT, request.version()) + } + + @Test + fun responseReferencesRequest() { + val response = buildEvent(service.pubKey, payer.pubKey, requestId = "c".repeat(64)) + assertTrue(response.isResponse()) + assertEquals("c".repeat(64), response.requestId()) + } + + @Test + fun canDecryptOnlyByEitherParty() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertTrue(request.canDecrypt(payer)) + assertTrue(request.canDecrypt(service)) + assertFalse(request.canDecrypt(stranger)) + } + + @Test + fun cannotDecryptAuthoredEventMissingRecipient() { + // A malformed event I authored but with no `p` tag: my own key must NOT be used as + // the conversation peer (no self-fallback), so neither party can derive a key. + val noRecipient = + OfferEvent( + id = "a".repeat(64), + pubKey = payer.pubKey, + createdAt = 1L, + tags = arrayOf(ClinkVersionTag.assemble()), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + assertFalse(noRecipient.canDecrypt(payer)) + assertFalse(noRecipient.canDecrypt(service)) + } + + // --- JSON DTOs --- + + @Test + fun offerRequestJsonRoundTrip() { + val request = OfferRequest(offer = "abc", amount_sats = 1500, description = "coffee") + val parsed = OptimizedJsonMapper.fromJsonTo(OptimizedJsonMapper.toJson(request)) + + assertEquals("abc", parsed.offer) + assertEquals(1500, parsed.amount_sats) + assertEquals("coffee", parsed.description) + } + + @Test + fun offerResponseInvoiceParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"bolt11":"lnbc1..."}""") + assertTrue(parsed.isSuccess()) + assertEquals("lnbc1...", parsed.bolt11) + } + + @Test + fun offerResponseInvalidAmountParsesRange() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"error":"Invalid Amount","code":5,"range":{"min":1000,"max":50000}}""", + ) + assertFalse(parsed.isSuccess()) + assertEquals(5, parsed.code) + assertEquals(1000, parsed.range?.min) + assertEquals(50000, parsed.range?.max) + } + + @Test + fun debitGfyResponseParses() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1717000000}""", + ) + assertFalse(parsed.isOk()) + assertEquals(4, parsed.code) + assertEquals(1717000000, parsed.retry_after) + } + + @Test + fun debitOkResponseParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"ok","preimage":"deadbeef"}""") + assertTrue(parsed.isOk()) + assertEquals("deadbeef", parsed.preimage) + } + + @Test + fun manageCreateRequestSerializesNested() { + val request = + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + offer = ManageOffer(fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name"))), + ) + val json = OptimizedJsonMapper.toJson(request) + + // Offer data is nested under offer.fields (not flat). + assertTrue(json.contains("\"offer\""), json) + assertTrue(json.contains("\"fields\""), json) + + val parsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("Coffee", parsed.offer?.fields?.label) + assertEquals(1500L, parsed.offer?.fields?.price_sats) + // payer_data is a list of field names, not a map. + assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) + } + + @Test + fun manageFailureResponseParsesField() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""") + assertFalse(parsed.isOk()) + assertEquals("price_sats", parsed.field) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt new file mode 100644 index 0000000000..11182bf76a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt @@ -0,0 +1,304 @@ +/* + * 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.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Golden wire-shape fixtures: the literal decrypted JSON payload bodies documented in the + * CLINK specs (`shocknet/CLINK/specs/clink-{offers,debits,manage}.md`, declared public domain) + * must deserialize into our DTOs with the right fields. These guard the request/response shapes + * we exchange with every CLINK service against drift — they are the encrypted-content half that + * the bech32 pointer vectors (`ClinkInteropTest`) don't cover. + * + * Placeholders shown as `<…>` in the spec markdown are substituted with realistic concrete + * values; numeric placeholders (`actual_delta_ms`, `retry_after`) use numbers, not the + * markdown's quoted strings, matching what real services emit. + */ +class ClinkWireShapeTest { + private inline fun parse(json: String): T = OptimizedJsonMapper.fromJsonTo(json) + + // ---------- Offers (kind 21001) ---------- + + @Test + fun offerRequest() { + val req = + parse( + """{"offer":"coffee","amount_sats":21000,"payer_data":{},"zap":"{...}","expires_in_seconds":3600,"description":"A coffee"}""", + ) + assertEquals("coffee", req.offer) + assertEquals(21000L, req.amount_sats) + assertEquals(3600L, req.expires_in_seconds) + assertEquals("A coffee", req.description) + assertEquals("{...}", req.zap) + assertNotNull(req.payer_data) + } + + @Test + fun offerSuccessBolt11() { + val res = parse("""{"bolt11":"lnbc10u1pexample"}""") + assertTrue(res.isSuccess()) + assertEquals("lnbc10u1pexample", res.bolt11) + } + + @Test + fun offerErrorInvalidOffer() { + val res = parse("""{"error":"Invalid Offer","code":1}""") + assertFalse(res.isSuccess()) + assertEquals(1, res.code) + assertEquals("Invalid Offer", res.error) + assertNull(res.bolt11) + } + + @Test + fun offerErrorExpiredNoForwarding() { + val res = parse("""{"error":"Offer has expired.","code":3}""") + assertEquals(3, res.code) + assertNull(res.latest) + } + + @Test + fun offerErrorMovedWithLatest() { + val res = + parse( + """{"error":"Offer has been replaced or moved.","code":3,"latest":"noffer1qqsmoved"}""", + ) + assertEquals(3, res.code) + assertEquals("noffer1qqsmoved", res.latest) + } + + @Test + fun offerErrorInvalidAmountRange() { + val res = parse("""{"error":"Invalid Amount","code":5,"range":{"min":10,"max":10000000}}""") + assertEquals(5, res.code) + assertEquals(10L, res.range?.min) + assertEquals(10000000L, res.range?.max) + } + + @Test + fun offerReceiptWithPreimage() { + val receipt = parse("""{"res":"ok","preimage":"${"ab".repeat(32)}"}""") + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + + @Test + fun offerReceiptInternalSettlement() { + val receipt = parse("""{"res":"ok"}""") + assertTrue(receipt.isOk()) + assertNull(receipt.preimage) + } + + // ---------- Debits (kind 21002) ---------- + + @Test + fun debitDirectPaymentRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":10000,"bolt11":"lnbc100n1pexample","description":"zap","k1":"${"4c".repeat(32)}"}""", + ) + assertEquals("app-7", req.pointer) + assertEquals(10000L, req.amount_sats) + assertEquals("lnbc100n1pexample", req.bolt11) + assertEquals("4c".repeat(32), req.k1) + assertNull(req.frequency) + } + + @Test + fun debitBudgetRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":50000,"frequency":{"number":1,"unit":"month"},"description":"sub"}""", + ) + assertEquals(50000L, req.amount_sats) + assertEquals(1, req.frequency?.number) + assertEquals(DebitFrequency.UNIT_MONTH, req.frequency?.unit) + assertNull(req.bolt11) + } + + @Test + fun debitSuccessWithPreimage() { + val res = parse("""{"res":"ok","preimage":"${"cd".repeat(32)}"}""") + assertTrue(res.isOk()) + assertEquals("cd".repeat(32), res.preimage) + } + + @Test + fun debitSuccessInternalOrBudgetApproval() { + val res = parse("""{"res":"ok"}""") + assertTrue(res.isOk()) + assertNull(res.preimage) + } + + @Test + fun debitGfyRequestDenied() { + val res = parse("""{"res":"GFY","code":1,"error":"Request Denied"}""") + assertFalse(res.isOk()) + assertEquals(1, res.code) + assertEquals("Request Denied", res.error) + } + + @Test + fun debitGfyExpiredWithDelta() { + val res = + parse( + """{"res":"GFY","code":3,"error":"Expired Request","delta":{"max_delta_ms":30000,"actual_delta_ms":31200}}""", + ) + assertEquals(3, res.code) + assertEquals(30000L, res.delta?.max_delta_ms) + assertEquals(31200L, res.delta?.actual_delta_ms) + } + + @Test + fun debitGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""") + assertEquals(4, res.code) + assertEquals(1750000000L, res.retry_after) + } + + @Test + fun debitGfyInvalidAmountRange() { + val res = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""") + assertEquals(5, res.code) + assertEquals(1000L, res.range?.min) + assertEquals(500000L, res.range?.max) + } + + @Test + fun debitFailureDetailSurfacesRangeAndRetryAfter() { + val range = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""").failureDetail()!! + assertTrue(range.contains("Invalid Amount")) + assertTrue(range.contains("1000") && range.contains("500000")) + + val rateLimited = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""").failureDetail()!! + assertTrue(rateLimited.contains("Rate Limited")) + assertTrue(rateLimited.contains("1750000000")) + + assertNull(parse("""{"res":"ok"}""").failureDetail()) + } + + @Test + fun debitGfyInvalidRequest() { + val res = parse("""{"res":"GFY","code":6,"error":"Invalid Request: K1 already processed"}""") + assertEquals(6, res.code) + assertEquals("Invalid Request: K1 already processed", res.error) + } + + // ---------- Manage (kind 21003) ---------- + // Requests use the nested `offer.fields` shape (the form the reference SDK, Lightning.Pub, + // and clink-demo exchange — see ManageMessages.kt), not the spec's inline-create example. + + @Test + fun manageUpdateRequestNestedFields() { + val req = + parse( + """{"resource":"offer","action":"update","offer":{"id":"off-1","fields":{"label":"Updated Product X","price_sats":23456,"callback_url":"https://m.app/cb","payer_data":["email","shipping_address"]}}}""", + ) + assertEquals(ManageRequest.RESOURCE_OFFER, req.resource) + assertEquals(ManageRequest.ACTION_UPDATE, req.action) + assertEquals("off-1", req.offer?.id) + assertEquals("Updated Product X", req.offer?.fields?.label) + assertEquals(23456L, req.offer?.fields?.price_sats) + assertEquals(listOf("email", "shipping_address"), req.offer?.fields?.payer_data) + } + + @Test + fun manageListRequest() { + val req = parse("""{"resource":"offer","action":"list"}""") + assertEquals(ManageRequest.ACTION_LIST, req.action) + assertNull(req.offer) + } + + @Test + fun manageDeleteRequest() { + val req = parse("""{"resource":"offer","action":"delete","offer":{"id":"off-1"}}""") + assertEquals(ManageRequest.ACTION_DELETE, req.action) + assertEquals("off-1", req.offer?.id) + } + + @Test + fun manageSuccessSingleDetailsObjectCoercesToList() { + // create/update/get return a bare object for `details`; it must coerce into our list. + val res = + parse( + """{"res":"ok","resource":"offer","details":{"id":"off-1","label":"Product X","price_sats":12345,"callback_url":"https://m.app/cb","payer_data":["email"],"noffer":"noffer1qqsabc"}}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + assertEquals("Product X", res.details?.first()?.label) + assertEquals(12345L, res.details?.first()?.price_sats) + assertEquals("noffer1qqsabc", res.details?.first()?.noffer) + } + + @Test + fun manageSuccessListDetailsArray() { + val res = + parse( + """{"res":"ok","resource":"offer","details":[{"id":"off-1","label":"Product X","price_sats":12345,"noffer":"noffer1qqsabc"}]}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + } + + @Test + fun manageDeleteSuccessNoDetails() { + val res = parse("""{"res":"ok","resource":"offer"}""") + assertTrue(res.isOk()) + assertNull(res.details) + } + + @Test + fun manageGfyInvalidFieldWithFieldAndRange() { + val res = + parse( + """{"res":"GFY","code":5,"error":"Invalid Field/Value","field":"price_sats","range":{"min":1000,"max":1000000}}""", + ) + assertFalse(res.isOk()) + assertEquals(5, res.code) + assertEquals("price_sats", res.field) + assertEquals(1000L, res.range?.min) + assertEquals(1000000L, res.range?.max) + } + + @Test + fun manageGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":600}""") + assertEquals(4, res.code) + assertEquals(600L, res.retry_after) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt new file mode 100644 index 0000000000..bc313a356e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Cross-implementation interop vectors. The bech32 strings below were produced by the + * reference TypeScript implementation @shocknet/clink-sdk@1.5.5 (`nofferEncode`, + * `ndebitEncode`, `nmanageEncode`) for a fixed pubkey/relay. + * + * TLV is order-independent on decode, and the two implementations emit their fields in + * different orders (we ascend 0→4, the SDK descends), so we assert *functional* interop + * rather than byte-identical strings: + * + * 1. Decode: our parser reads the SDK's bytes into the expected fields. + * 2. Round-trip: re-encoding then re-parsing our output preserves every field. + * + * The reverse direction — that the SDK decodes our (ascending-order) output back to the + * same fields — was verified out-of-band by feeding our encoding to `decodeBech32`. + */ +class ClinkInteropTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = "wss://relay.shocknet.dev" + + // --- noffer --- + + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + private val offerSpontaneous = + "noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" + private val offerVariable = + "noffer1qszqqqqp7spszqgzq9mqzxrhwden5te0wfjkccte9eeksmmrddhx2apwv3jhvqpq0elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qhv7h2j" + + @Test + fun decodesAndRoundTripsOfferFixed() { + val offer = ClinkPointerParser.parse(offerFixed) as NOffer + assertEquals(pubKey, offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single()) + assertEquals("offer-id", offer.pointer) + assertEquals(OfferPriceType.FIXED, offer.priceType) + assertEquals(21000L, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferSpontaneous() { + val offer = ClinkPointerParser.parse(offerSpontaneous) as NOffer + assertEquals("shop1", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + // The canonical example offer shipped by @shocknet/clink-sdk: it is both the README usage + // example (MIT) and clink-demo / clinkme.dev's `DEFAULT_NOFFER` (public domain). A + // real-world spontaneous, relay-bearing, no-price offer whose offer-id is a 64-char hex string. + private val clinkDemoDefaultOffer = + "noffer1qvqsyqjqxuurvwpcxc6rvvrxxsurqep5vfjk2wf4v33nsenrxumnyvesxfnrswfkvycrwdp3x93xydf5xg6rzce4vv6xgdfh8quxgct9x5erxvspremhxue69uhhgetnwskhyetvv9ujumrfva58gmnfdenjuur4vgqzpccxc30wpf78wf2q78wg3vq008fd8ygtl4qy06gstpye3h5unc47xmee6z" + + @Test + fun decodesClinkDemoDefaultOffer() { + val offer = ClinkPointerParser.parse(clinkDemoDefaultOffer) as NOffer + assertEquals("e306c45ee0a7c772540f1dc88b00f79d2d3910bfd4047e910584998de9c9e2be", offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull("wss://test-relay.lightning.pub"), offer.relays.single()) + assertEquals("786886460f480d4bee95dc8fc772302f896a07411bb54241c5c4d5788dae5232", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferVariable() { + val offer = ClinkPointerParser.parse(offerVariable) as NOffer + assertEquals("v", offer.pointer) + assertEquals(OfferPriceType.VARIABLE, offer.priceType) + assertEquals(500L, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + // --- ndebit --- + + private val debitWithPointer = + "ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + private val debitWithoutPointer = + "ndebit1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wcrvl9u" + + @Test + fun decodesAndRoundTripsDebitWithPointer() { + val debit = ClinkPointerParser.parse(debitWithPointer) as NDebit + assertEquals(pubKey, debit.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), debit.relays.single()) + assertEquals("pointer-7", debit.pointer) + assertNull(debit.k1) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + @Test + fun decodesAndRoundTripsDebitWithoutPointer() { + val debit = ClinkPointerParser.parse(debitWithoutPointer) as NDebit + assertNull(debit.pointer) + assertEquals(pubKey, debit.pubKey) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + // --- nmanage --- + + private val manageWithPointer = + "nmanage1qgrxzurs956ryqgcwaehxw309aex2mrp0yh8x6r0vd4kuet59ejx2asqypl8a8zz4ydlauvl4y57tldpkuhqa0q6fsg5zee7y72zxnvx4h05ufx6vef" + private val manageWithoutPointer = + "nmanage1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wr57t3u" + + @Test + fun decodesAndRoundTripsManageWithPointer() { + val manage = ClinkPointerParser.parse(manageWithPointer) as NManage + assertEquals(pubKey, manage.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), manage.relays.single()) + assertEquals("app-42", manage.pointer) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } + + @Test + fun decodesAndRoundTripsManageWithoutPointer() { + val manage = ClinkPointerParser.parse(manageWithoutPointer) as NManage + assertNull(manage.pointer) + assertEquals(pubKey, manage.pubKey) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt new file mode 100644 index 0000000000..0b494dcef4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -0,0 +1,133 @@ +/* + * 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.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ClinkPointerTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerSpontaneousRoundTrip() { + // A spontaneous offer always carries TLV 3 on the wire and decodes back to SPONTANEOUS. + val offer = NOffer(pubKey, listOf(relay), "my-offer-id", OfferPriceType.SPONTANEOUS, null) + val encoded = offer.encode() + + assertTrue(encoded.startsWith("noffer1"), "expected noffer1 prefix, got $encoded") + assertEquals(offer, NOffer.parse(Bech32.decodeBytes(encoded).second)) + assertEquals(offer, ClinkPointerParser.parse(encoded)) + } + + @Test + fun offerFixedPriceRoundTrip() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, 21_000) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(OfferPriceType.FIXED, parsed.priceType) + assertEquals(21_000, parsed.price) + assertEquals(offer, parsed) + } + + @Test + fun offerLargePriceRoundTripIsUnsigned() { + // A price with the high bit set (> Int.MAX_VALUE) must round-trip as a positive + // Long — the price is an unsigned 4-byte big-endian integer, so reading it signed + // would wrap it negative. + val price = 3_000_000_000L + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, price) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(price, parsed.price) + assertEquals(offer, parsed) + } + + @Test + fun debitStaticRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), "pointer-7", null) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(debit, parsed) + assertTrue(!parsed.isSession) + } + + @Test + fun debitSessionRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), null, k1) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(k1, parsed.k1) + assertTrue(parsed.isSession) + assertEquals(debit, parsed) + } + + @Test + fun debitRejectsMalformedK1Length() { + // A session id (TLV 3) must be exactly 32 bytes; a wrong-length k1 is a malformed + // session pointer and the parser must reject it rather than accept a bad session. + val shortK1 = "abcd" + val encoded = NDebit(pubKey, listOf(relay), null, shortK1).encode() + assertNull(ClinkPointerParser.parse(encoded)) + } + + @Test + fun manageRoundTrip() { + val manage = NManage(pubKey, listOf(relay), null) + val encoded = manage.encode() + + assertTrue(encoded.startsWith("nmanage1")) + assertEquals(manage, ClinkPointerParser.parse(encoded)) + } + + @Test + fun parserStripsSchemeAndWhitespace() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null) + val encoded = offer.encode() + + assertEquals(offer, ClinkPointerParser.parse(" nostr:$encoded ")) + assertEquals(offer, ClinkPointerParser.parse("lightning:$encoded")) + } + + @Test + fun parserRejectsGarbageAndForeignPrefixes() { + assertNull(ClinkPointerParser.parse("not-a-pointer")) + assertNull(ClinkPointerParser.parse("npub1xxxxxxxxxxxxx")) + assertNull(ClinkPointerParser.parse("")) + } + + @Test + fun parseAllFindsEmbeddedPointers() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null).encode() + val debit = NDebit(pubKey, listOf(relay), null, null).encode() + val text = "Pay me at $offer or pull from $debit thanks" + + val found = ClinkPointerParser.parseAll(text) + assertEquals(2, found.size) + assertTrue(found[0] is NOffer) + assertTrue(found[1] is NDebit) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 18b408439d..86aa586878 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -197,6 +197,30 @@ class UpdateMetadataTest { assertEquals(expected3, test3) } + @Test + fun clinkOfferRoundTrip() { + val noffer = "noffer1qqsxexampleclinkofferpointer" + val event = signer.sign(MetadataEvent.createNew(name = "Vitor", clinkOffer = noffer, createdAt = 1740669816)) + + // written into kind-0 content under the spec's `clink_offer` key + assertEquals(true, event.content.contains("\"clink_offer\":\"$noffer\"")) + + // and dual-written as a kind-0 tag (NIP-1770 pattern), like the other fields + assertContentEquals(arrayOf("clink_offer", noffer), event.tags.firstOrNull { it.firstOrNull() == "clink_offer" }) + + // and parses back out of the content + val metadata = event.contactMetaData() + assertNotNull(metadata) + assertEquals(noffer, metadata.clinkOffer) + assertEquals(noffer, metadata.clinkOffer()) + } + + @Test + fun parseClinkOffer() { + val metadata = JsonMapper.fromJson("""{"name":"Test","clink_offer":"noffer1abc"}""") + assertEquals("noffer1abc", metadata.clinkOffer) + } + @Test fun parseBirthdayFull() { val json = """{"name":"Test","birthday":{"year":1990,"month":6,"day":15}}""" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 2f9e17c34b..fac6835a5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -45,6 +45,44 @@ class Nip05Test { } """.trimIndent() + @Test + fun `parse clink_offer for a name`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": { "bob": "$noffer" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer as a flat top-level string (bridgelet shape)`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": "$noffer" }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer absent yields null`() = + runTest { + val json = """{ "names": { "bob": "abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer for a missing name yields null`() = + runTest { + val json = """{ "clink_offer": { "alice": "noffer1abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + @Test fun `test with matching case on user name`() = runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt new file mode 100644 index 0000000000..0f878d0ee0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -0,0 +1,99 @@ +/* + * 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.quartz.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Kinds 34235/34236 (legacy NIP-71 videos) are parameterized replaceable + * events: their address MUST include the `d` tag. A wrong (empty-dTag) + * address makes LocalCache consume the event into a different + * AddressableNote than the one `a` tags point to, so quotes/reposts of + * these videos never resolve on screen. + */ +class AddressableVideoEventAddressTest { + // Fixture from a real kind-34236 event published by the Divine client + // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the + // same data synthetically. + private val pubkey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2" + private val dTag = "7af7cae314483a84dcc204824cef10aace246a69c819734412330e2a25f459a1" + + private fun assertAddressUsesDTag( + kind: Int, + event: AddressableVideoEvent, + ) { + assertEquals(dTag, event.dTag()) + assertEquals(Address(kind, pubkey, dTag), event.address()) + // The kind:pubkey:dTag wire format is fixed by NIP-01, so it is + // asserted literally instead of via Address.assemble (which is + // what addressTag() calls internally). + assertEquals("$kind:$pubkey:$dTag", event.addressTag()) + } + + @Test + fun verticalVideoAddressUsesDTag() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag), arrayOf("title", "Lunchtime for our Koi")), + content = "Lunchtime for our Koi", + sig = "", + ) + + assertAddressUsesDTag(VideoVerticalEvent.KIND, event) + } + + @Test + fun horizontalVideoAddressUsesDTag() { + val event = + VideoHorizontalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "", + ) + + assertAddressUsesDTag(VideoHorizontalEvent.KIND, event) + } + + @Test + fun videoWithoutDTagFallsBackToEmptyAddress() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("title", "No d tag")), + content = "", + sig = "", + ) + + assertEquals("", event.dTag()) + assertEquals(Address(VideoVerticalEvent.KIND, pubkey, ""), event.address()) + assertEquals("${VideoVerticalEvent.KIND}:$pubkey:", event.addressTag()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt new file mode 100644 index 0000000000..f6dd98567b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the kind-range vs class-hierarchy invariant across the whole + * EventFactory: parameterized replaceables (30000..39999) must derive their + * address from the `d` tag, while plain replaceables (10000..19999, plus + * kinds 0 and 3) must ignore stray `d` tags. A typed class extending the + * wrong base (the kind-34235/34236 bug, fixed in this branch) splits the + * cache between the event's own address and the address `a` tags reference. + * + * Known offenders that predate this guard are allowlisted below so the test + * catches NEW mismatches; shrink the lists as they get fixed. + */ +class EventFactoryKindRangeTest { + /** + * NIP-87 ecash kinds that are addressable per spec but currently extend + * plain Event, so they have no address at all and never replace older + * versions in the cache. + */ + private val knownNonAddressable = setOf(38000, 38172, 38173) + + /** + * NIP-51-style list kinds whose shared PrivateTagArrayEvent hierarchy + * reads `d` tags even though plain replaceables must ignore them; a + * stray `d` tag on a malformed event fragments their cache address. + */ + private val knownDTagReaders = + setOf( + 10004, + 10005, + 10006, + 10007, + 10009, + 10012, + 10013, + 10015, + 10017, + 10018, + 10020, + 10023, + 10040, + 10054, + 10081, + 10086, + 10087, + 10088, + 10089, + 10090, + 10101, + 10102, + ) + + private val probeDTag = "probe-d-tag" + + private fun probe(kind: Int) = EventFactory.create("", "", 0L, kind, arrayOf(arrayOf("d", probeDTag)), "", "") + + @Test + fun addressableKindsReadTheirDTag() { + val violations = + (30000 until 40000).mapNotNull { kind -> + val event = probe(kind) + when { + // Unknown kinds parse as a bare Event; only typed classes are checked. + event::class == Event::class -> null + kind in knownNonAddressable -> null + event !is AddressableEvent -> "kind $kind (${event::class.simpleName}) does not implement AddressableEvent" + event.dTag() != probeDTag -> "kind $kind (${event::class.simpleName}) ignores its d tag" + else -> null + } + } + + assertEquals(emptyList(), violations) + } + + @Test + fun plainReplaceableKindsIgnoreStrayDTags() { + val violations = + (listOf(0, 3) + (10000 until 20000)).mapNotNull { kind -> + val event = probe(kind) + if (kind !in knownDTagReaders && event::class != Event::class && event is AddressableEvent && event.dTag() != "") { + "kind $kind (${event::class.simpleName}) addresses itself by a stray d tag" + } else { + null + } + } + + assertEquals(emptyList(), violations) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index b8abc79d82..b73047c0e5 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -75,6 +75,10 @@ class JacksonMapper { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, false) .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, false) + // Tolerate a single JSON object where a list is declared. Several Nostr-native + // RPCs (e.g. CLINK Manage `details`, typed `OfferData | OfferData[]`) return a + // bare object for single-item results and an array for lists; accept both. + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) .setDefaultPrettyPrinter(defaultPrettyPrinter) .registerModule(