diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index f9bc30f72a..2bb00d90e9 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -15,6 +15,18 @@ Extract string resource keys from the default `values/strings.xml` that are abse - Preparing a batch of strings for a translator - Checking translation coverage after adding new features +## Background: Crowdin strip-identical behavior + +This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated. + +Consequences for this skill: + +1. **A "missing" key on disk is not always actionable.** It may be Crowdin-stripped (translator already chose source-identical and Crowdin didn't export it) rather than genuinely new. +2. **Don't add source-identical fallbacks locally.** Android's resource resolution falls back to `values/strings.xml` at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway. +3. **The only actionable cases are keys Crowdin has never exported.** Whether the translator picked "use English" or simply hasn't translated the key yet, both states are owned by Crowdin and look identical on disk. The local repo cannot distinguish them. + +The Step 2.5 filter below uses the **most recent Crowdin export commit reachable from `HEAD`** (subject: `"New Crowdin translations by GitHub Action"`) as the cutoff: any key added to `values/strings.xml` after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the `l10n_crowdin_translations` branch after merging. + ## Target Locales The default set of locales (unless the user specifies otherwise): @@ -51,6 +63,59 @@ comm -23 \ This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales. +> **Caveat:** Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5. + +### 2.5. Filter out keys Crowdin has already seen (sync-timestamp check) + +A missing key is **only actionable if Crowdin has never exported it**. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported `strings.xml`. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical. + +The reliable signal is **time**: compare when the key was added to `values/strings.xml` against the timestamp of the **most recent Crowdin export that has been merged into the current branch**. Crowdin's GitHub Action produces commits with the literal subject `New Crowdin translations by GitHub Action`; finding the latest such commit reachable from `HEAD` works even if the `l10n_crowdin_translations` branch has been deleted post-merge (a common cleanup workflow). + +- Key added **before** that commit → Crowdin saw it on a prior export; translator made a decision; the absence on disk is a deliberate "use English" or "leave blank" choice. **Skip.** +- Key added **after** → Crowdin has not exported it yet; genuinely new and actionable. + +```bash +# Latest Crowdin export reachable from HEAD (survives branch deletion). +sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null) +if [ -z "$sync_ts" ]; then + echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2 + sync_ts=0 +else + echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')" +fi + +# For each locale, list only keys added after the Crowdin sync (truly new). +for locale in cs-rCZ de-rDE sv-rSE; do + echo "=== $locale: genuinely new (post-sync) keys ===" + comm -23 \ + <(grep '` misses other resource types -- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Treating every missing key as actionable** — Crowdin strips on export any translation the translator marked as "use English", and we cannot distinguish that from "never seen" by looking at disk. Use the Step 2.5 sync-timestamp filter: only keys added to `values/strings.xml` after the last `l10n_crowdin_translations` sync are genuinely new. +- **Trying to detect "stripped" from git history alone** — the on-disk locale file only sees keys the translator typed a non-identical value for. The "translator opened the key and picked English from the start" case never touches disk, so a history-only check misses it. Use the sync-timestamp cutoff instead. +- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so there is no user-visible bug to fix. +- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale, then apply the sync-timestamp filter. - **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately - **Hardcoding `"1"` in a `` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text - **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c158668108..d9ed078f30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Linter (gradle) - run: ./gradlew spotlessCheck + run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity build-desktop: needs: lint @@ -186,6 +186,65 @@ jobs: name: ${{ matrix.desktop-artifact-name }} path: ${{ matrix.desktop-artifact-path }} + test-quartz-ios: + # Phase 1 of the iOS support plan + # (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS + # so JVM-only imports can't sneak into commonMain unnoticed. The + # `verifyKmpPurity` task in the lint job is the fast pre-check (Linux, + # ~1s); this job is the real one — compiles for the device variant + # and actually runs the simulator test suite. + needs: lint + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Two tasks, two purposes: + # - iosSimulatorArm64Test runs the existing iosTest suite on the + # simulator (NIP-04 / NIP-17 / NIP-19 / NIP-49 / AES-GCM / + # Chatroom keys), exercising secp256k1 and CryptoKit-backed + # primitives on a real Apple toolchain. + # - compileTestKotlinIosArm64 catches any device-only compile drift + # (iosArm64 = aarch64-apple-ios) without needing a physical + # device to run on. Compile-only is enough — running on-device + # would require xcodebuild + a provisioning profile. + - name: Test Quartz on iOS + run: | + ./gradlew \ + :quartz:iosSimulatorArm64Test \ + :quartz:compileTestKotlinIosArm64 + + # :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the + # iOS plan. Compile-only for now — actual UI / lifecycle wiring will + # land with the iosApp module in Phase 3. The container that runs Claude + # Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit), + # so this is the first place commonMain Compose code is actually + # type-checked against an Apple Native frontend. + - name: Compile Commons for iOS + run: | + ./gradlew \ + :commons:compileKotlinIosSimulatorArm64 \ + :commons:compileKotlinIosArm64 + + - name: Upload iOS Test Reports + uses: actions/upload-artifact@v7 + if: failure() + with: + name: Quartz iOS Test Reports + path: quartz/build/reports + test-and-build-android: needs: lint runs-on: ubuntu-latest diff --git a/PRIVACY.md b/PRIVACY.md index cfed3b5e56..08ef1e94ea 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,54 +1,114 @@ # Amethyst Privacy Policy and Terms of Use -## Privacy Policy +**App:** Amethyst (Android Nostr client)
+**Publisher:** Vitor Pamplona
+**Contact:** amethyst@vitorpamplona.com
+**Last updated:** 2026-05-24 -Effective as of Jun 12, 2023 +Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device. -The Amethyst app for Android does not collect or process any personal information from its users. +Amethyst lets you browse content from third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies. -The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. +This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app. -The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. +## Privacy -Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use. +### Data sent off-device -### Privacy with Relay services +Using the app causes the following data to leave your phone: -Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online. +- **Nostr events** you publish, sent to the relays you have configured. +- **Subscriptions** (filters describing what you want to read), sent to those relays. +- **Media uploads** (images, audio, video), sent to the media server you select. +- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app. +- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy). -The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters. +The developer does not run any server that aggregates or stores this data. -Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs. +### Data stored on your device -While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link. +Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling. -### Visibility & Permanence of Your Content on Nostr Relays +### What relays can see -#### Information Visibility +A relay you connect to sees: -Content that you share can be shared with other relays by any user of the network. -The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you. +- Your IP address (or the Tor exit node when using it). +- Your public key. +- The events you publish (posts, reactions, reposts, reports, etc.). +- The filters you subscribe to. -#### Information Permanence +A relay does **not** see the plaintext of: -Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted. +- Private Direct Messages (encrypted to the recipient under NIP-17 / NIP-44). +- Private Zaps. -## Child safety standards +A relay can still see *that* you and another user are exchanging DMs even though it cannot read them. To reduce what a relay can correlate to you, route the app over a VPN or Tor. -Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user. +### Media uploads + +Uploads go to the media server you select. That server is independent of Amethyst and has its own policy. Anyone holding the resulting link — including media attached to a DM — can fetch the file. + +### Public content is effectively permanent + +Anything you publish to a relay can be copied to other relays or clients. Once published, you should assume it cannot be reliably deleted from the network. + +## Child Safety Standards + +These are the published Child Safety Standards for **Amethyst**, the Android Nostr client published on Google Play by **Vitor Pamplona**. They are published under Google Play's Child Safety Standards policy. + +They are a community standard, not a license restriction. Amethyst's source code remains licensed under the MIT License in `LICENSE`. + +### Prohibition + +Using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material — including child sexual abuse material (CSAM) — or to groom, exploit, or harm a minor is prohibited and is illegal in essentially every jurisdiction. + +### In-app tools + +Amethyst provides: + +- **Report Post** and **Report Account** — publish a signed Nostr report (including the "Illegal Content" reason) so relays and other clients can act on it. +- **Block Post** / **Block Account** — hide content locally on your device. +- **Block Relay** — add a relay to your NIP-51 Blocked Relay List so the app stops fetching from or publishing to it. This is the strongest tool the app offers against a relay that refuses to moderate. +- **Mute Words / Hashtags** — filter unwanted content from your feeds. + +### Addressing CSAM + +Amethyst does not host content, so the app cannot remove CSAM. Only the relay hosting the content can remove it. In the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children (NCMEC). + +If you encounter CSAM through Amethyst: + +1. Report the content in-app and select "Illegal Content." +2. Add the hosting relay to your Blocked Relay List. +3. Report directly to **NCMEC** at https://report.cybertip.org/ (United States) or to an **INHOPE** hotline at https://www.inhope.org/ (other jurisdictions). These bodies can compel the hosting provider to act. +4. You may also email **amethyst@vitorpamplona.com** with the relay URL and event ID. The developer cannot remove content from third-party relays, but may forward the report to relay operators it is in contact with and may stop recommending the offending relay in any list shipped with the app. + +### Compliance + +Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law. Obligations attached to the **hosting** of content rest with relay operators. + +### Age rating + +Amethyst's Google Play listing is rated 17+. The app does not request or store age information. ## Terms of Use -### For versions downloaded from Google's Play Store +### Google Play build -You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. +You agree not to use the Google Play build of Amethyst to submit Objectionable Content to relays. Objectionable Content includes: -### For versions downloaded from F-Droid +- Sexually explicit material. +- Obscene, defamatory, libelous, slanderous, violent, or unlawful content. +- Content that infringes third-party rights (copyright, trademark, privacy, publicity). +- Content that is deceptive or fraudulent. +- Content promoting illegal drugs, tobacco, firearms, ammunition, or illegal gambling. -We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid. +These Terms apply only to the Google Play distribution of Amethyst. -## Other Notes +### F-Droid and other source-built distributions -We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use. +The MIT License in `LICENSE` is the only instrument governing your right to use, study, modify, and redistribute the software. No additional terms are imposed on these builds. Any dispute over distribution through F-Droid is between you and F-Droid. -If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com +## Updates + +This document may change. The current version is published at https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md. diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 7bf0cda845..a38aa8c62e 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.googleServices) alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.serialization) + alias(libs.plugins.googleKsp) } fun getCurrentBranch(): String = @@ -268,9 +269,36 @@ android { testOptions { unitTests.isReturnDefaultValues = true + // Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android") + // find the desktop-host build of our Arti JNI shim. The Android .so + // variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded + // on-device — this Linux x86_64 .so is just for JVM unit-test runs. + // -Pamethyst.arti.integration=true opts the (slow, network-dependent) + // tests in; see TorArtiNativeIntegrationTest.kdoc. + unitTests.all { test -> + test.systemProperty( + "java.library.path", + "${projectDir}/src/test/native-libs/x86_64-linux", + ) + project + .findProperty("amethyst.arti.integration") + ?.let { test.systemProperty("amethyst.arti.integration", it.toString()) } + } } } +// androidx.appfunctions-compiler runs in a per-module mode by default, +// emitting only the dispatcher Kotlin code. The aggregator that builds +// the `app_functions.xml` asset (which the system reads to discover our +// @AppFunction methods) is gated behind this KSP argument — without it, +// the manifest's `android.app.appfunctions` property points at a file +// that doesn't exist and the System UI logs "Unable to resolve +// AppFunctionMetadata." Set on the app module only; library modules +// (commons/quartz) would set it to "false". +ksp { + arg("appfunctions:aggregateAppFunctions", "true") +} + // TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5 configurations.all { val tink = "com.google.crypto.tink:tink-android:1.17.0" @@ -413,6 +441,15 @@ dependencies { // on de-Googled / GrapheneOS devices that ship the F-Droid build. "playImplementation"(libs.play.services.cast.framework) + // androidx.appfunctions — Gemini App Functions adapter. Pre-stable + // (alpha) as of May 2026 — scoped to the play channel so the F-Droid + // build stays free of Google AI dependencies. Surface is an + // AppFunctionService registered in amethyst/src/play/AndroidManifest.xml, + // generated at compile time by the KSP-driven appfunctions-compiler. + "playImplementation"(libs.androidx.appfunctions) + "playImplementation"(libs.androidx.appfunctions.service) + "kspPlay"(libs.androidx.appfunctions.compiler) + // Charts implementation(libs.vico.charts.compose) implementation(libs.vico.charts.m3) diff --git a/amethyst/plans/2026-05-24-ios-support.md b/amethyst/plans/2026-05-24-ios-support.md new file mode 100644 index 0000000000..340b2eeb24 --- /dev/null +++ b/amethyst/plans/2026-05-24-ios-support.md @@ -0,0 +1,390 @@ +# iOS Support for Amethyst + +**Date:** 2026-05-24 +**Status:** Phase 1 complete; Phase 2 in flight +**Owner:** TBD + +Plan to incrementally bring Amethyst to iOS by extending the existing +KMP layers from the bottom up. Each phase is independently shippable — +we can pause between any two phases without leaving the tree in a +broken state. + +## Why this is tractable today + +The structural work that usually dooms a KMP-to-iOS effort is already +done: + +- `quartz/` has `iosArm64` + `iosSimulatorArm64` targets configured, + a working `Platform.ios.kt` actual, and 6 iOS test files that pass. +- Jackson and OkHttp — the two big JVM-only dependencies — are *already + isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`, + `quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except + for the obvious `kotlinx.*` stack. +- `commons/commonMain` has exactly **one** Jackson reference + (`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest + of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`, + which is where it belongs. +- Compose Multiplatform 1.10.3 is in use, which supports iOS officially. +- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and + `androidx.lifecycle.viewmodel.compose` are KMP since 2.8. + +What this means: we are not embarking on a months-long "purify +commonMain" migration before any iOS code can compile. Phase 1 is +mostly **add iOS to CI** and **patch the last few leaks**. + +## Module-by-module dep matrix + +Status legend: +- ✅ iOS-ready (targets configured, no JVM-only deps in shared code) +- 🟡 Partial (intermediate source sets need adding, but no major dep blockers) +- 🔴 Blocked (significant native work required) +- ⛔ Out of scope (won't ship on iOS) + +| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | +|---|---|---|---|---|---|---| +| `quartz/` | ✅ | CI + audit | — | — | — | — | +| `commons/` (non-UI) | 🟡 | — | ✅ | — | — | — | +| `commons/` (UI) | 🟡 | — | — | ✅ | — | — | +| `iosApp/` (new) | n/a | — | — | scaffold | feature-complete | — | +| `quic/` | 🔴 | — | — | — | — | iOS actuals | +| `nestsClient/` | 🔴 | — | — | — | — | iOS actuals | +| `amethyst/` (app) | ⛔ | — | — | — | — | — | +| `desktopApp/` | ⛔ | — | — | — | — | — | +| `cli/` | ⛔ | — | — | — | — | — | + +## Source-set diagram (target end state) + +``` +commons/src/ +├── commonMain/ ── all targets +│ ├── coreMain/ ── ViewModels, state, DAL (no Compose) +│ │ ├── jvmAndroidCore/ ── Android + Desktop +│ │ │ ├── androidCore/ +│ │ │ └── jvmCore/ +│ │ └── nativeCore/ ── iOS +│ │ ├── iosArm64Core/ +│ │ └── iosSimArm64Core/ +│ └── uiMain/ ── Compose UI, icons, resources +│ ├── jvmAndroidUi/ +│ │ ├── androidUi/ +│ │ └── jvmUi/ +│ └── nativeUi/ ── iOS Compose +``` + +(Names sketched for clarity; in practice we'll fold `coreMain` / +`uiMain` together once *every* file in `uiMain` compiles for iOS — +the split is a transitional scaffold for Phase 2 ↔ Phase 3.) + +`quartz/`, `quic/`, `nestsClient/` already use a `jvmAndroid` shared +source set; we'll add a sibling `nativeMain` (or just `iosMain` where +that's simpler) when each module turns on iOS. + +--- + +## Phase 1 — Lock down Quartz on iOS + +**Duration estimate:** 1–2 weeks +**Deliverable:** `./gradlew :quartz:iosSimulatorArm64Test` runs in CI on every PR. + +### Tasks + +1. **Add iOS to CI for `:quartz`.** + - GitHub Actions macOS runner step: `iosSimulatorArm64Test` + + `iosArm64SourceSetTest` (compile only). + - This is the single most valuable change in the entire plan — it + prevents anyone from accidentally re-adding a JVM-only import to + `commonMain`. + +2. **Audit the `jvmAndroid` boundary.** + - Confirm everything Jackson/OkHttp-related lives in `jvmAndroid` + (it does today — keep it that way). + - Add a checkstyle / detekt rule, or a simple grep gate in CI, that + fails the build if `com.fasterxml.jackson` or `okhttp3` shows up + in `commonMain`. + +3. **Validate `secp256k1` iOS path.** + - Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green + on `iosSimulatorArm64Test`. + - The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we + just need to surface them in CI. + +4. **Plan the `expect`/`actual` for iOS HTTP.** + - Phase 1 only sketches the design; the actual `Ktor-darwin` wiring + lands in Phase 2 when `:commons` needs it. + - Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS. + **Recommendation:** keep OkHttp on JVM/Android (we use OkHttp-specific + features in relay reconnect logic) and add an iOS-only Ktor actual. + +### Risks + +- None major. The work here is mostly defensive. + +--- + +## Phase 2 — Bring `:commons` to iOS, non-UI first + +**Duration estimate:** 2–3 weeks +**Deliverable:** `./gradlew :commons:iosSimulatorArm64Test` compiles every +shared ViewModel and state class. + +### Tasks + +1. **Add iOS targets to `commons/build.gradle.kts`.** + - `iosArm64()` + `iosSimulatorArm64()`. + - Introduce intermediate source sets `coreMain` (all targets) and + `uiMain` (JVM + Android only, for now). + +2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.** + - Move to `kotlinx.serialization`, OR + - Push it down into `jvmAndroidCore` and create a `nativeCore` actual. + **Recommendation:** migrate. It's one file; one-time cost is small; + reduces split-actual surface area forever. + +3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.** + + | Concern | JVM/Android | iOS actual | + |---|---|---| + | HTTP client | OkHttp | Ktor + `Ktor-darwin` | + | Secure key storage | Android Keystore / java-keyring | Keychain Services | + | EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) | + | File I/O paths | `java.io.File` | `NSFileManager` / `okio` | + | Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start | + +4. **Compile-only iOS for `:commons` ViewModels.** + - At the end of Phase 2 we have ViewModels, account state, LocalCache + wrappers, filter assemblers, and `ComposeSubscriptionManager` building + on iOS — but no UI yet. + - Smoke test: write a small `commonTest` that constructs an `Account`, + subscribes to a stub relay, and verifies a follow event lands in + `LocalCache`. Run it on iOS simulator. + +### Risks + +- **Ktor migration scope creep.** Hold the line: Phase 2 only wraps HTTP + behind `expect`. Don't refactor the relay pool. That's a separate PR. +- **Coroutines dispatcher differences.** `Dispatchers.IO` does not exist on + Kotlin/Native by default — code that explicitly references it needs a + `KmpDispatchers.IO` shim. Audit before Phase 2 starts. + +### Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory + +Audit of all 335 .kt files in `commons/src/commonMain/`. Better than feared +— most files are already KMP-clean. The actual blockers are 21 files +across ~6 distinct concerns. Each row below is a small mergeable PR. + +**By blocker category:** + +| Blocker | Files | Fix | +|---|---|---| +| `java.util.Base64` | 1 (`Base64Image.kt`) | `kotlin.io.encoding.Base64` (stdlib since 1.8) | +| `AtomicLong` / `AtomicInteger` | 2 (`ChessLobbyState.kt`, `SigningState.kt`) | `kotlinx.atomicfu.atomic` | +| `ConcurrentHashMap` | 4 (`ChessRelayFetchHelper.kt`, `ChessEventCollector.kt`, `ComposeSubscriptionManager.kt`, `MutableComposeSubscriptionManager.kt`) | `androidx.collection.MutableScatterMap` (KMP) — synchronization most likely already provided by enclosing scope; audit per file | +| `SortedSet` + `ConcurrentSkipListSet` | 2 (`EventListMatchingFilter.kt`, `NoteListMatchingFilter.kt`) | Switch to `mutableListOf` + sort-on-access, or `androidx.collection.MutableScatterSet` with manual order | +| `WeakReference` | 5 (`Channel.kt`, `Chatroom.kt`, `MarmotGroupChatroom.kt`, `UserRelaysCache.kt`, **+1**) | `expect class KmpWeakReference` actuals: JVM `java.lang.ref.WeakReference`; iOS `kotlin.native.ref.WeakReference` | +| `BigDecimal` | 1 (`Note.kt`) | Either KMP bignum lib (`com.ionspin:bignum`) or move the BigDecimal-using helper to `jvmAndroid` and stub on iOS | +| `java.io.File` | 1 (`MediaContentModels.kt`) | Replace with `String` path, or `okio.Path` | +| `java.net.URI` / `MalformedURLException` | 2 (`RichTextParser.kt`, `UrlInfoItem.kt`) | KMP URL lib (`io.ktor:ktor-http`) or stay JVM via expect/actual `parseUrl()` | +| `java.nio.charset.Charset` | 1 (`HtmlCharsetParser.kt`) | `kotlin.text.Charsets` for UTF-8/16; for arbitrary charsets, expect/actual | +| `:nestsClient` project dep | 2 (`NestViewModel.kt`, `ActiveSubscription.kt`) | Move both files to `jvmAndroid` source set (audio rooms are Phase 5 anyway) | +| `com.halilibo.richtext.*` | 1 (`RenderMarkdown.kt`) | Verify iOS artifact; if missing, move to `jvmAndroid` until Phase 3 markdown decision | + +**Files that look scary but aren't:** +- 183 files import `androidx.compose.*` — these all map to JetBrains Compose + Multiplatform's iOS artifacts (identical package paths). No work needed. +- 7 files import `androidx.lifecycle.*` — KMP since 2.8.0. No work needed. +- 0 files import `coil3.network.okhttp` (Coil network is already isolated). +- 0 files import `javax.*` or `android.*` directly from commonMain. + +**Recommended PR order** (ascending cost, descending obviousness): + +1. ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration). +2. **Base64** (1 file, ~2 LOC change). Demonstrates the pattern. +3. **Atomics** (2 files, atomicfu plugin + ~10 LOC). +4. **ConcurrentHashMap** (4 files; needs concurrency audit per file). +5. **WeakReference** (5 files + 1 new expect/actual). +6. **`:nestsClient` files → jvmAndroid** (2 files; pure source-set move). +7. **`RenderMarkdown.kt` → jvmAndroid OR iOS verification** (1 file; depends on lib check). +8. **URL parsing** (2 files; either ktor-http dep or expect/actual). +9. **Charsets, BigDecimal, File** (3 files; small per-file decisions). +10. After ~9 lands: add iOS targets to `:commons`, expect failures to be down + to ~zero, run `compileKotlinIosSimulatorArm64` to confirm. +11. Then proceed with the original Phase 2 plan items (Ktor for HTTP, + SecureKeyStore expect/actual, etc.) for the cross-cutting deps. + +--- + +## Phase 3 — Compose Multiplatform UI on iOS + +**Duration estimate:** 3–4 weeks +**Deliverable:** A read-only iOS `.ipa` on TestFlight internal that connects +to relays and renders a feed. + +### Tasks + +1. **Flip `uiMain` to target = all (including iOS).** + - Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font + and other Compose Resources already work cross-platform. + +2. **Audit UI deps for iOS.** + + | Dep | Status | Action | + |---|---|---| + | `jetbrains.compose.*` (1.10.3) | ✅ | None | + | `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None | + | `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split | + | `markdown-ui` / `markdown-ui-material3` | ⚠️ Verify | Likely OK on iOS; if not, fall back to commonmark + custom renderer | + | `kotlinx-collections-immutable` | ✅ | None | + | Material Symbols font | ✅ | None (already via Compose Resources) | + +3. **Create the `iosApp/` module.** + - SwiftUI `App` + `UIViewControllerRepresentable` hosting + `ComposeUIViewController { App() }`. + - Tab bar (UIKit) for top-level navigation, Compose for each tab's + content area. **Same split philosophy as Desktop**: native shell, + shared content. + - Add Xcode project + Gradle Kotlin/Native framework wiring (no + CocoaPods; use the JetBrains-recommended `embedAndSignAppleFrameworkForXcode`). + +4. **Ship a "read-only Nostr browser" first cut.** + - Profile view, single-feed home, NoteCard rendering, image loading, + basic navigation. + - No posting, no DMs, no audio rooms. + - This validates the *entire* stack — relay client, LocalCache, feed DAL, + NoteCard composable, Coil 3, Compose Resources, font rendering — without + touching signing. + +### Risks + +- **Compose iOS performance on large feeds.** Profile early with a realistic + `LocalCache` (10k+ notes) before locking screen architecture. If recomposition + storms appear, lean harder on `compose-stability-diagnostics` and + `compose-state-deferred-reads` skills. +- **Touch interactions vs Android conventions.** Pull-to-refresh, swipe + back, long-press menus all differ on iOS. Some screens may need + platform-specific gesture handling. +- **Markdown rendering library iOS support.** If `markdown-ui-material3` + doesn't ship iOS artifacts, this is a half-week detour to switch + renderers. Verify in week 1 of Phase 3. + +--- + +## Phase 4 — Write paths: signing, posting, settings + +**Duration estimate:** 2–3 weeks +**Deliverable:** Fully read/write iOS client, minus audio rooms. + +### Tasks + +1. **Wire `NostrSignerInternal` to Keychain.** + - The signer is already KMP — only the key storage actual needs + adding (done in Phase 2's `SecureKeyStore` abstraction). + +2. **Make `NostrSignerRemote` (NIP-46 bunker) work on iOS.** + - Should be KMP-clean once Ktor migration is done. Audit for any + stray Jackson / OkHttp inside the NIP-46 path. + +3. **NIP-55 alternative.** + - **There is no Amber on iOS.** Plan replacements: + - Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker, + remote nostr-connect URIs). + - URL-scheme handoff to native iOS signers (`nos2x-fhe`, `Nostore`) + *if* they expose a sign API. Track separately. + - Onboarding screen needs an iOS-specific copy variant. + +4. **Posting, reactions, zaps.** + - Mostly free — ViewModels already in `:commons`. Wire UI buttons and + test end-to-end on TestFlight. + +5. **Settings UI.** + - Share via Compose. iOS-native preference screens are a polish item + for later. + +### Risks + +- **Apple App Review on cryptocurrency / zaps.** Lightning zaps via LNURL + are fine (no in-app crypto purchase). Anything that looks like an + in-app wallet or onchain send may need legal review and / or feature + gating per-region. Start review conversations early. +- **Push notifications.** APNs is the only path on iOS. Nostr DM push + relays don't speak APNs natively. Likely needs a small relay-proxy + (similar to `notify.damus.io`'s architecture). Design doc in + `amethyst/plans/` before Phase 4 ends. + +--- + +## Phase 5 — `:quic` + `:nestsClient` for audio rooms (optional) + +**Duration estimate:** 4–6 weeks +**Status:** Defer until 1–4 are solid. App is shippable on iOS without +audio rooms. + +### Tasks + +1. **`:quic` — add iOS actuals.** + - UDP socket via `Network.framework` (`NWConnection` with `.udp`). + - AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit + (`AES.GCM.SealedBox`, `ChaChaPoly`). + - TLS state machine is already pure Kotlin in `commonMain` — no change. + +2. **`:nestsClient` — add iOS actuals.** + - Opus encode/decode: `libopus` via cinterop, or pull `opus.framework` + from a Swift Package / CocoaPods spec. + - Mic + speaker: `AVAudioEngine` (input/output nodes) instead of + `AudioRecord` / `AudioTrack`. + +3. **moq-lite listener path first** (the production path per CLAUDE.md), + then speaker. + +### Risks + +- **Background audio on iOS.** Audio rooms in the background need a + proper `AVAudioSession` category + the `audio` background mode in + `Info.plist`. Apple sometimes rejects apps that abuse this. Worth a + separate audit before submission. +- **Opus framework distribution.** `libopus` via Swift Package is + cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby. + Decide before Phase 5 starts. + +--- + +## Phase 6 — Ship polish (ongoing, post-Phase 4) + +- App Store metadata, screenshots, privacy manifest + (`NSPrivacyAccessedAPI*` declarations — file access, user defaults). +- Localizations carry over automatically via Compose Resources. +- Background fetch limits — iOS is far stricter than Android. Tune + feed prefetch + relay reconnect for background launch budgets. +- TestFlight beta → public release. + +--- + +## Cross-cutting risks (track from day one) + +| Risk | Mitigation | First chance to catch | +|---|---|---| +| `commonMain` regresses with a JVM-only import | Add iOS to CI on every PR | Phase 1, task 1 | +| Coroutines `Dispatchers.IO` ergonomics on iOS | Audit + introduce `KmpDispatchers` shim | Phase 2, task 3 | +| Compose iOS performance on big feeds | Early profiling with realistic `LocalCache` | Phase 3 risk section | +| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section | +| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 | +| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase | +| Background audio policy | `AVAudioSession` audit + `Info.plist` review | Phase 5 risk section | + +## Suggested first PR + +Smallest useful start: **Phase 1, tasks 1 + 2** — add `:quartz` iOS to +CI and add the import-gate that prevents Jackson / OkHttp regressions +in `commonMain`. That single PR de-risks the rest of the plan without +touching any product code. + +## Open questions + +- Do we want a `:cli` analogue on iOS (a "headless" Nostr daemon)? Out + of scope for this plan, but iosArm64 *could* host one if we ever need + a CLI-on-phone story. +- Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We + could theoretically also ship Catalyst from the iOS build, but that's + three "desktop"-ish targets to maintain. Recommendation: punt. +- iPad layout: do we want a separate split-view UI like `desktopApp`, + or just scale up the iPhone layout? Phase 3 keeps the iPhone layout; + iPad polish is a Phase 6 item. diff --git a/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md new file mode 100644 index 0000000000..787b30bf26 --- /dev/null +++ b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md @@ -0,0 +1,239 @@ +# AppFunctions signer prompts — design + +**Date:** 2026-05-25 +**Status:** Draft — no code yet + +How write verbs invoked from background Gemini context (via +`androidx.appfunctions` 1.0.0-alpha09 → `PlatformAppFunctionService`) +acquire a signature from each of Amethyst's three signer types. This +is the gating concern that has us only exposing read-only verbs so far +(`searchProfiles`, `searchNotes`, `getFollowing`). + +## The three signer types and what each needs + +| Signer | Where the private key lives | Sign call latency | Needs user interaction? | +|---|---|---|---| +| **`NostrSignerInternal`** | In-process keypair, loaded at login | Synchronous, microseconds | No | +| **`NostrSignerRemote`** (NIP-46 bunker) | Remote process — a wallet app, browser tab, separate device | Network round-trip via relays, seconds | Yes — the bunker app pops a confirmation on the user's other device | +| **`NostrSignerExternal`** (NIP-55, e.g. Amber) | Another Android app on the same device | Bound-service IPC + activity bounce | Yes — Amber shows an activity in the foreground asking the user to approve | + +Each signer surfaces the same `suspend fun sign(...)` API. The difference is +**what happens to the foreground UI** while the sign is in flight. + +## What App Functions gives us to work with + +From the alpha09 artifact (`androidx.appfunctions:appfunctions-service`): + +- **Suspending dispatch.** `executeFunction` is a suspend function — a slow + signer (NIP-46 round-trip) doesn't block the system shell. +- **Typed exceptions.** `AppFunctionPermissionRequiredException`, + `AppFunctionDeniedException`, `AppFunctionCancelledException`, + `AppFunctionAppException`. The non-default constructors take a `Bundle` — + the system shell can interpret known keys (e.g. a `PendingIntent` to launch + an in-app confirmation). Concrete bundle contract is undocumented in + alpha09; needs a sample-app check or an experiment. +- **`PendingIntent`** is listed as a supported parameter type, which strongly + implies a returned `PendingIntent` can prompt the system to launch the + app's UI for follow-up. +- **No streaming.** Functions return one value or throw. There's no native + "in progress" / "user is approving" signal back to Gemini. + +## Per-signer approach + +### NostrSignerInternal — just works + +Verb runs end-to-end inside the dispatch coroutine. `signer.sign(...)` is +synchronous. Publish via `client.publish(...)`. Return success. + +**Verbs this covers immediately:** post, follow/unfollow, search-relay +list updates, kind:10002 changes — anything where the signed event is +sent and forgotten. + +**Edge case — background `app.client`.** When the app process is +foreground-bound but the user is in Gemini, the client should be +connected. When the user has killed Amethyst recently, the service +process might be cold-started and the client not yet connected to any +relay. The verb needs to either: +- Wait for `client.connect()` (~hundreds of ms once the WebSocket is + established) — acceptable inside the 5-10s window. +- Use `INostrClient.publish(...)` which queues the publish for when the + connection comes up. Quartz needs to confirm this is the actual + behavior; might require `withTimeout` around the publish. + +### NostrSignerRemote — the cleanest async case + +The bunker sends a NIP-46 request to a relay, the bunker app sees it on +the user's other device, the user approves, the signed event comes back. +`signer.sign(...)` suspends until the response arrives or its internal +timeout fires (default 30s). + +**Approach:** call `signer.sign(...)` from within the verb, with a +`withTimeout` budget aligned to App Functions UX expectations (Gemini +typically waits ~30s before showing the user "no response"). On +timeout, throw `AppFunctionCancelledException`. On success, publish and +return. + +**Open question — concurrent foreground signing.** If the user is also +trying to send a post from the foreground UI at the same moment, the +bunker app gets two simultaneous requests. NIP-46 handles this — each +request has a unique id — but Amber-like bunker apps may queue both +prompts confusingly. Worth a manual test. + +### NostrSignerExternal — the hard case + +NIP-55 bounces to a separate Android app's activity. From a background +`PlatformAppFunctionService`, we can't directly `startActivity(...)` — +there's no foreground intent stack to attach to. + +**Two viable approaches:** + +#### Option A — throw a typed exception with a PendingIntent + +```kotlin +@AppFunction +suspend fun postNote(ctx: AppFunctionContext, text: String): PostResult { + val account = activeAccount() ?: throw AppFunctionDeniedException("not signed in") + if (account.signer is NostrSignerExternal) { + // Build a PendingIntent that opens Amethyst at a "approve this + // post" screen, with the draft text passed through extras. + val approvalIntent = buildApprovePostPendingIntent(account, text) + throw AppFunctionPermissionRequiredException( + message = "Amethyst needs to launch the external signer to approve this post.", + extras = bundleOf("pending_intent" to approvalIntent), + ) + } + // … happy path for the in-process signer +} +``` + +The system shell renders "Open Amethyst to continue", user taps, +Amethyst opens, user approves through Amber's activity, post lands. The +Gemini conversation doesn't see the final result — the user has to come +back to Gemini and re-confirm. + +**UX gap.** No way to communicate the eventual outcome back to Gemini's +chat. Acceptable for v1. + +#### Option B — refuse write verbs when the signer is NIP-55 + +Throw `AppFunctionNotSupportedException` immediately. User configures a +different signer (local or NIP-46) to enable Gemini-driven writes. +Simpler, cleaner, but limits the audience — many Amethyst users on +Amber would lose the feature. + +**Recommendation:** start with Option B, ship Internal + Remote support, +then add Option A behind a feature flag in a follow-up. Option B +unblocks the feature for ~70% of users today; Option A is more work +and has the unresolved "result doesn't get back to Gemini" wrinkle. + +## Per-write-verb concerns + +### postNote(text) +- Internal: sign → publish to outbox. Done. +- Remote: sign (suspends) → publish. Done. +- External: throw NotSupported, or PendingIntent dance. +- Side concern: should this go into the user's drafts vs immediately + publish? Gemini-issued posts feel like they should publish (the + user asked for it), but a "review before post" screen via PendingIntent + is a nice safety net even for the local-signer path. + +### follow(npub) / unfollow(npub) +- Same signer paths as postNote, simpler payload. +- Reads the current kind:3, modifies, signs, publishes — `FollowActions` + is ready. +- **No** "preview" step needed — follow/unfollow is reversible. + +### sendDm(recipient, text) +- Same signer paths. +- `DmActions.buildTextDm` is ready, plus `resolveDmRelays`. +- **Concern**: strict mode (default) refuses to send when recipient has + no kind:10050. Should Gemini's `sendDm` default to strict or + permissive? Argument for strict: it's NIP-17 spec behavior. Argument + for permissive: Gemini users won't know what kind:10050 is and will + see confusing failures. **Lean: permissive by default**, surface the + source in the result. + +### zapUser(npub, sats, comment?) +- Same signer paths for the kind:9734 zap request. +- But there's a *second* signing-like step: an LN payment via NWC + (if configured). NWC has its own permission model and can also fail. +- For v1: build the zap request, fetch the BOLT11 invoice, return the + invoice in the result. User pays via their wallet. Skip NWC + auto-payment. + +### zapEvent(eventId, sats, comment?) +- Same as zapUser but uses `ZapActions.buildEventZapRequestsForSplits` + so multi-party notes route correctly. +- May return multiple invoices (one per split recipient). + +## Account selection + +All verbs read `Amethyst.instance.sessionManager.loggedInAccount()` once +at entry. **Multi-account question**: should Gemini be able to specify +*which* account to act as? Two answers: + +- v1: no — always act as the currently-active account. Matches what the + user sees in the foreground UI. Simpler. +- Later: add an optional `accountNpub: String?` parameter to each write + verb. Defaults to the active account. + +Start with v1. + +## Permissions surfaced to Gemini + +The App Functions schema XML (auto-generated by KSP) lists each verb +plus its parameters. Gemini's tool picker shows these to the user. We +should add a `description` (via `isDescribedByKDoc = true`, which we +already do) that makes write verbs sound consequential — "Publishes a +note to your Nostr followers", not "Calls postNote". + +## Open questions for an experiment day + +1. What concrete `Bundle` keys does the system shell respect on + `AppFunctionPermissionRequiredException`? Run a tiny test app, throw + the exception with various bundle contents, observe what Gemini + surfaces. +2. Can the user approve a Gemini-issued write from within the Gemini + chat (inline confirmation) or only by opening Amethyst? Affects + Option A's UX. +3. Does `INostrClient.publish(...)` actually queue when the relay + pool is disconnected, or does it return immediately with no + delivery? Determines whether the verb needs an explicit + "wait for at least one OK" gate. +4. NWC and Gemini: if the user has a NWC wallet configured, should + `zapUser` auto-pay? Adds another consent layer. + +## Minimum viable first write verb + +Pick **`postNote(text)`** as the pilot. + +Why: +- Simplest: one signed event, one publish, one ack. +- Read-back is straightforward: return the event id + the relays it + landed on. Gemini can compose "Posted! Here's the link: nostr:nevent…". +- Failure modes are well-bounded (signer error, no outbox relays, all + relays rejected). +- No multi-party complexity (zap splits, DM strict mode). + +Scope of the pilot: +- `NostrSignerInternal` only (Option B for NIP-55, Remote in a + follow-up). Document the cutoff in kdoc. +- Returns `PostNoteResult(eventId, publishedTo, rejectedBy)` — an + `@AppFunctionSerializable`. +- Builds on the existing `commons/.../quartz/.../TextNoteEvent.build` + and `client.publish` — no new actions needed. +- ~50 lines of new code in `AmethystAppFunctions.kt`, plus the result + class. + +Once shipped, follow-ups in order: +1. `follow(npub)` / `unfollow(npub)` — same signer caveat. +2. NIP-46 (Remote) signer support — change the gate from "signer is + internal" to "signer can sign in-process". +3. `sendDm(recipient, text)` — first write verb that uses encryption. +4. `zapUser` / `zapEvent` — non-trivial because of LN flow. +5. NIP-55 support via PendingIntent (Option A) once the system-shell + contract is understood. + +No write-verb code lands until question (1) above is answered — +otherwise the NIP-55 path is undefined and we ship something that +"sort of works" for half our users. diff --git a/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md new file mode 100644 index 0000000000..e66007e512 --- /dev/null +++ b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md @@ -0,0 +1,131 @@ +# Verifying Gemini-side AppFunctions discovery + +**Date:** 2026-05-26 +**Status:** Active — answers the open question from +`2026-05-25-appfunctions-signer-prompts.md` + +The Phase 2 work proves the app side: 21 `@AppFunction` verbs are +registered, indexed by `AppFunctionManagerService`, and dispatchable +via `adb shell cmd app_function execute-app-function`. The remaining +unknown is whether **Gemini's chat UI** actually surfaces our verbs to +the user — that's a separate layer (model-side tool picker) we can't +exercise from the test command. + +## What we know + +* **Library state.** Built against `androidx.appfunctions + 1.0.0-alpha09`. Schemas (`@AppFunctionSchemaDefinition`) are + optional and the official Google sample (`android/appfunctions` + ChatApp) doesn't use them — meaning we're not at a structural + disadvantage by not defining our own. There's no canonical + `nostr.social` schema registry yet. +* **Discovery strategy.** Without schemas, Gemini's tool picker + matches on the function's natural-language description (KDoc, via + `@AppFunction(isDescribedByKDoc = true)`) and the parameter + descriptions. We've reworked every verb's first sentence to be a + use-when imperative — "Find a person on Nostr by name…" — instead + of an implementation description ("Searches kind:0 metadata…"). + +## What we don't know yet + +* Whether Gemini's model picks up our verbs at all from a typical + user query. +* Whether Gemini's `AppFunctionSearchSpec` filters by + `schemaCategory` / `schemaName` (in which case we're invisible + until we annotate) or by description (in which case we should + surface). +* What feature flags / Gemini-app versions are required. App + Functions is generally available on Android 16+, but Gemini's + third-party tool picker has shipped in waves. + +## Verification protocol + +### 1. Confirm the device is set up + +```bash +# Pixel 8 or newer on Android 16 QPR1+ +adb shell getprop ro.build.version.release +adb shell pm list packages | grep -i gemini # com.google.android.apps.bard +``` + +### 2. Reinstall the Play debug APK with the new descriptions + +```bash +./gradlew :amethyst:assemblePlayDebug +adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk +adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity +# sign in if needed, give Amethyst a few seconds to register +``` + +### 3. Confirm metadata is indexed end-to-end + +```bash +adb shell cmd app_function list-app-functions | grep -c amethyst +# should print ≥ 21 — one entry per @AppFunction across our class +``` + +### 4. Test prompts in Gemini + +These are deliberately mapped to one specific verb each. Run them in +order, take notes on which surface a tool call and which don't. + +| Prompt to Gemini | Should pick | +|---|---| +| "Find vitorpamplona on Nostr" | searchProfiles | +| "What's happening on Nostr today?" | getRecentFromFollows | +| "Who am I logged in as on Nostr?" | getActiveAccountInfo | +| "Did anyone DM me on Nostr recently?" | getRecentDms | +| "How many sats did I earn on Nostr this week?" | getZapsReceived | +| "Show me Nostr posts about bitcoin" | searchByHashtag | +| "Tell me about npub1xq5eqwlhxy3ldakahsfglccvzy4j6ayyxje5a92zu90hc05dxn7qrsns90" | getProfile | +| "What are people I follow saying on Nostr?" | getRecentFromFollows | +| "Catch me up on what Snowden's been posting" | getNotesByUser | + +For each: did Gemini offer to call the tool? Did it call the right +one? Did it render the result? + +### 5. Diagnose any miss + +If Gemini doesn't surface a verb: + +1. **Check Gemini's tools view.** In the Gemini app: + Settings → Apps. Our package should appear in the list of apps + the assistant can interact with. If it's not there at all, the + system hasn't told Gemini about us yet — wait a few minutes after + install or force-reindex by clearing AppSearch. +2. **Force a re-index.** + ```bash + adb shell pm clear --user 0 com.android.appsearch || true + adb shell am force-stop com.vitorpamplona.amethyst.debug + adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity + ``` +3. **Verify per-prompt.** If the package is listed but a specific + prompt doesn't trigger a tool call, the issue is description + matching — our use-when phrasing isn't catching that query. + Adjust the kdoc and rebuild. + +## When schemas become worth doing + +We'll move from "skipped" to "implement" if: + +1. Step 4 above shows Gemini consistently fails to surface verbs that + should obviously match (suggesting it's filtering by schema, not + description), OR +2. Another Nostr Android client ships AppFunctions and wants to + co-implement a shared schema namespace (so a Nostr-aware agent + could route to whichever client is installed). + +Until either of those happens, the simpler description-matching path +is in place and is what every public AppFunctions sample uses today. + +## Open follow-ups (independent of this verification) + +* NIP-55 (Amber) signer support — write verbs currently refuse with + `AppFunctionNotSupportedException` because we can't launch Amber's + approval activity from a background dispatch. The PendingIntent + escape hatch (Option A in the signer-prompt plan) is the next move + if NIP-55 usage matters. +* NWC auto-pay for `zapUser` / `zapEvent` — today we return the + BOLT11 invoice; the caller pastes it into a wallet. With NWC + configured we could pay automatically. +* Schema definitions if step 4 above shows we need them. diff --git a/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md new file mode 100644 index 0000000000..f149d00818 --- /dev/null +++ b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md @@ -0,0 +1,191 @@ +# All Amethyst screens as AppFunctions / MCP endpoints + +**Date:** 2026-05-26 +**Status:** Active — informs the v1 read-verb surface and guides +future MCP work + +## The principle + +Every screen in Amethyst has a dedicated `FeedContentState` driven by +a `*FeedFilter` that reads from `LocalCache`. The list is in +`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt` +— there are ~30 entries today. + +> Every Amethyst screen → one AppFunction verb. The verb invokes the +> same `*FeedFilter` the screen uses, runs `feed()` against +> `LocalCache`, and projects the result into a Gemini-friendly +> `NoteHit` / `ProfileHit` / etc. + +This keeps the agent surface in sync with what the user sees, with no +duplicate filtering logic. + +## Why this works + +* `*FeedFilter.feed()` is stateless and idempotent — it reads + `LocalCache` (a global singleton) and `account` state. Safe to + invoke from any thread, any process state, no UI lifecycle + required. +* `AccountFeedContentStates` itself is owned by `AccountViewModel`, + but we don't need the precached state — we just need the filter + class. Invoking it on each AppFunction call is acceptable (a few + ms even on large caches). +* The catch: `LocalCache` only contains what the foreground app + subscriptions have already fetched. If the user hasn't opened + Amethyst in days, the cache may be sparse. Acceptable trade-off: + the agent reflects "what's on your screen now", not "what exists + on Nostr right now". For freshness, the user can open the app or + the verb can fall back to a relay drain. + +## Existing verb → feed mapping + +| AppFunction verb | Feed source | Notes | +|---|---|---| +| `getFeedDigest` | `HomeNewThreadFeedFilter` | Matches the home page (new threads only, all kinds, mute-filtered) | +| `getRecentFromFollows` | direct `INostrClient.fetchAll` (kind:1 only) | Pure kind:1 from kind:3 follows. Different shape than home — keeping both: `getRecentFromFollows` is fast / always fresh, `getFeedDigest` is "what's on my screen" | +| `getMyMentions` | direct relay drain | Could move to `NotificationFeedFilter` | +| `getRecentDms` | direct relay drain + decrypt | Could move to `ChatroomListKnownFeedFilter` / `ChatroomListNewFeedFilter` | +| `getLiveStreams` | direct relay drain | Could move to `LiveStreamsFeedFilter` | +| `searchArticles` | direct relay drain (NIP-50) | Read-side only; users already in cache via `ArticlesFeedFilter` could be merged | + +## Unmapped feeds (proposed verbs) + +These all have existing `FeedContentState`s. Adding a verb each is +~30 lines of glue. + +| Screen | FeedContentState | Proposed verb name | User intent | +|---|---|---|---| +| Home — replies | `homeReplies` | `getRecentReplies` | "what conversations am I in?" | +| Home — everything | `homeEverything` | `getEverythingFeed` | "the full firehose of my follows" | +| Home — live | `homeLive` | `getLiveActivityFromFollows` | "what's live from my follows?" | +| Video | `videoFeed` | `getVideoFeed` | "show me Nostr videos" | +| Pictures | `picturesFeed` | `getPictureFeed` | "what photos are people posting?" | +| Shorts | `shortsFeed` | `getShortVideoFeed` | NIP-71 short video | +| Long-form (your follows) | `longsFeed` | `getLongFormFromFollows` | "what articles are my follows publishing?" | +| Long-form (discover) | `discoverReads` | `discoverArticles` | "find interesting Nostr articles" | +| Marketplace | `discoverMarketplace` | `discoverMarketplaceListings` | "what's for sale on Nostr?" | +| Communities (discover) | `discoverCommunities` | `discoverCommunities` | "find Nostr communities" | +| Communities (list) | `communitiesList` | `getMyCommunities` | "communities I'm a member of" | +| Public chats (discover) | `discoverPublicChats` | `discoverPublicChats` | "find Nostr chat channels" | +| Public chats (list) | `publicChatsFeed` | `getMyPublicChats` | "chats I'm in" | +| DVMs | `discoverDVMs` | `discoverDvms` | "what compute services are available?" | +| Follow sets | `discoverFollowSets` | `discoverFollowSets` | "find curated follow lists" | +| Live streams | `liveStreamsFeed` | (replace `getLiveStreams`) | already exists | +| Nests | `nestsFeed` | `getNests` | "audio rooms" | +| Articles (mine + follows) | `articlesFeed` | `getMyArticles` | combined long-form | +| Polls (open) | `openPollsFeed` | `getOpenPolls` | "what should I vote on?" | +| Polls (closed) | `closedPollsFeed` | `getRecentPollResults` | "what did people vote on?" | +| All polls | `pollsFeed` | (combined; less useful as a verb) | — | +| Badges | `badgesFeed` | `getBadges` | "show me my Nostr badges" | +| Software apps | `softwareAppsFeed` | `discoverNostrApps` | "what apps exist on Nostr?" | +| Emoji packs | `browseEmojiSetsFeed` | `discoverEmojiPacks` | "find custom emoji" | +| Follow packs | `followPacksFeed` | `discoverFollowPacks` | "find people to follow by topic" | +| Products | `productsFeed` | `getProductListings` | "what products are listed?" | +| Calendar appointments | `calendarAppointmentsFeed` | `getUpcomingEvents` | "what Nostr events are coming up?" | +| Calendar collections | `calendarCollectionsFeed` | `getEventCollections` | "what conferences are happening?" | +| Notifications (all) | `notifications` | `getRecentNotifications` | "what's happened to me on Nostr?" | +| Notifications (follows) | `notificationsFollowing` | (variant param) | "notifications from follows" | +| Notifications (everyone) | `notificationsEveryone` | (variant param) | "all notifications" | +| Drafts | `drafts` | `getMyDrafts` | "what did I start writing?" | +| Web bookmarks | `webBookmarks` | `getMyBookmarks` | "what did I bookmark?" | + +That's ~25 unmapped feeds. Each verb is a ~30-line wrapper following +the `getFeedDigest` shape — read filter, project to result type, +return. + +## Implementation pattern + +```kotlin +@AppFunction(isDescribedByKDoc = true) +suspend fun getMyBookmarks( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 168, + maxNotes: Int = 50, +): SearchNotesResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() + ?: return SearchNotesResult.empty() + val sinceSecs = TimeUtils.now() - hoursBack.coerceIn(1, 24 * 365).toLong() * 3600L + + val feed = WebBookmarkFeedFilter(account).feed() + .asSequence() + .mapNotNull { it.event } + .filter { it.createdAt >= sinceSecs } + .sortedByDescending { it.createdAt } + .take(maxNotes.coerceIn(1, 200)) + .map { it.toFeedNoteHit() } + .toList() + + return SearchNotesResult(matches = feed) +} +``` + +The pattern is genuinely uniform. Most verbs would even share a +helper like `feedAsResult(filter, sinceHours, max) -> SearchNotesResult`. + +## Result-type strategy + +Most feeds project to `SearchNotesResult` since the screen is "a list +of notes." A few need bespoke types: +* Notifications — could return a `NotificationHit` carrying the + notification kind (reply, mention, zap, repost, reaction) since + the LLM needs to know "you got 3 zaps and 1 reply". +* Calendar events — natural fit for an `EventHit` with start/end + times. +* Communities / public chats — list-of-rooms more than list-of-notes. + +Default to reusing `NoteHit` (now carries `kind`); add bespoke types +only when the LLM needs structure the LLM can't derive from `kind` + +`content`. + +## What doesn't fit cleanly + +Some screens are too interactive for a single AppFunction call: +* **Chats / DMs** — sending and reading a stream of messages is + more conversational; the agent loop should handle it. `sendDm` + + `getRecentDms` cover the basics. +* **Profile pages** — already covered by `getProfile` + + `getNotesByUser` rather than a "profile feed." +* **Settings screens** — out of scope; the agent shouldn't mutate + user prefs. + +## When the cache is cold + +For verbs backed by `LocalCache` (the screens), the result is sparse +when the user hasn't opened the app recently. The mitigation strategy +is: + +1. The relay-drain verbs (`searchProfiles`, `searchNotes`, + `searchByHashtag`, `searchArticles`, `getRecentFromFollows`, + `getNotesByUser`, `getProfile`, `getRecentDms`, + `getZapsReceived`, `getLiveStreams`) all do their own fetch. + Use these when freshness matters. +2. The screen-mirror verbs (`getFeedDigest` and the proposed + additions) reflect what the foreground saw. Use these when + "what was the user looking at?" is the semantic. + +Both shapes have value. The agent's prompt-matching kdoc decides +which gets called. + +## MCP angle + +When we add an MCP server for Amethyst (separate effort), the same +`*FeedFilter.feed()` calls power the MCP tool implementations. The +AppFunctions adapter and the MCP server share the projection +helpers (`toFeedNoteHit`, `toProfileHit`, etc.) and the result +types. The transport layer is the only difference. + +The screen-feed mapping above is the source of truth for both +surfaces. + +## Concrete next steps + +Highest user-value follow-ups (each ~30 min): + +1. `getRecentNotifications` — answers "what's been happening to me + on Nostr?" without a per-kind walk. +2. `getMyBookmarks` — agent recall over saved Nostr content. +3. `getOpenPolls` — "what should I vote on?" +4. `getMyDrafts` — "what was I writing?" +5. `getUpcomingEvents` — calendar / agenda integration. + +After these, the rest are mostly "discover X" variants that follow +the same template. diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt new file mode 100644 index 0000000000..fcbc90080c --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.tor + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.vitorpamplona.amethyst.ui.tor.TorService +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +/** + * Real-Arti bootstrap + SOCKS round trip on-device. Verifies that the self-heal / + * destroy / re-init paths work end-to-end against the actual native lib. + * + * **This test is [Ignore]'d by default** because: + * - It needs network egress to the Tor network from the device/emulator. Many CI + * environments don't have it. + * - Bootstrap on a cold device can take 30-120s; the test costs real wall-clock time. + * - It depends on `check.torproject.org` being reachable. + * + * **To run manually:** + * 1. Connect a device or start an emulator that has internet egress to Tor. + * 2. Remove the `@Ignore` annotation below. + * 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest` + * + * **What it covers that [TorManagerTest] does not:** + * - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind. + * - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump). + * - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds. + * - OkHttp routing traffic through the SOCKS port and Arti exiting through the + * Tor network. + * + * **Companion fast tests:** `amethyst/src/test/.../tor/TorManagerTest.kt` covers the + * Kotlin-side self-heal logic (watchdog, cooldown, network change, status routing) + * with virtual time and in-memory fakes — no Arti required. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.") +class TorBootstrapInstrumentedTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val torService = TorService(context) + + @After + fun tearDown() = + runBlocking { + // Drop the native client so this test's state file lock doesn't bleed into + // the next instrumented run on the same device. + torService.reset() + } + + /** + * Cold-start bootstrap. The whole point of the custom Arti build is that this + * works at all — if create_bootstrapped panics (e.g., because we forgot to install + * a rustls CryptoProvider after an arti bump) the test catches it. + */ + @Test + fun `bootstraps to Active within 120s`() = + runBlocking(Dispatchers.IO) { + val elapsed = + measureTimeMillis { + torService.start() + val active = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + assertTrue("SOCKS port should be > 0", active.port > 0) + } + // Logged via assertEquals failure-on-too-slow; an actual `Log.i` would be invisible. + // Bootstrap should comfortably fit in 120s on a healthy network. + assertTrue("Bootstrap took ${elapsed}ms, expected < ${BOOTSTRAP_TIMEOUT_MS}ms", elapsed < BOOTSTRAP_TIMEOUT_MS) + } + + /** + * SOCKS round-trip through Tor. Hits `check.torproject.org` which returns a JSON + * payload including `"IsTor":true` when the request actually exited via Tor. + * Catches regressions where the listener binds but no traffic flows (e.g., a + * broken handler-spawn race, or a crypto provider mismatch on the TLS handshake). + */ + @Test + fun `proxies HTTPS through Tor and reports IsTor true`() = + runBlocking(Dispatchers.IO) { + torService.start() + val active = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + + val client = + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", active.port))) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val request = + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build() + + val body = + client.newCall(request).execute().use { resp -> + assertEquals("HTTP 200", 200, resp.code) + resp.body.string() + } + assertTrue( + "Response should report IsTor:true — actual body: $body", + body.contains("\"IsTor\":true"), + ) + } + + /** + * Verifies the destroy → re-init cycle that backs the self-heal path. After + * [TorService.reset], the next [TorService.start] must rebuild the TorClient and + * bring SOCKS back to Active — without a "state file already locked" error from + * the still-alive previous client. + */ + @Test + fun `reset then re-start brings SOCKS back to Active`() = + runBlocking(Dispatchers.IO) { + torService.start() + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } + + torService.reset() + assertEquals(TorServiceStatus.Off, torService.status.value) + + torService.start() + val second = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + assertTrue("Second bootstrap port valid", second.port > 0) + } + + companion object { + private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L + } +} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt new file mode 100644 index 0000000000..65bf2ce996 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Composable + +// F-Droid distributes Amethyst as MIT-licensed free software; the build must +// not surface links to external (e.g. GitHub-hosted) policy documents. +@Composable +fun LegalSettingsSection() { +} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt new file mode 100644 index 0000000000..736b2dc0d5 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt @@ -0,0 +1,35 @@ +/* + * 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.loggedOff.legal + +import androidx.compose.runtime.Composable + +// F-Droid distributes Amethyst as MIT-licensed free software; there is no +// terms-of-use acceptance layered on top of the source license, and the build +// must not link to any external (e.g. GitHub) policy document. +@Composable +@Suppress("UNUSED_PARAMETER") +fun TermsGate( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + showError: Boolean, +) { +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 976bec0c14..233155b80c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.amethyst.ui.tor.TorService import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -190,12 +191,12 @@ class AppModules( UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } - val torManager = TorManager(torPrefs, appContext, applicationIOScope) + val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope) - // Whenever the underlying network identity changes (wifi↔cellular, regained from - // offline, etc.) we clear any active Tor session bypass so the manager re-attempts - // bootstrap on the new network. The remembered-approval window is unaffected: if Tor - // stays stuck we will silently bypass again after the timeout fires. + // Network identity change (wifi↔cellular, regained from offline, captive portal + // cleared) — the old network's guards/circuits are dead, and Arti's in-memory + // client + on-disk state/ both need a fresh start. onNetworkChange drops the + // TorClient, clears the bypass + persisted approval, and triggers a full re-init. init { applicationIOScope.launch { connManager.status @@ -203,7 +204,7 @@ class AppModules( .filterNotNull() .distinctUntilChanged() .drop(1) - .collect { torManager.clearSessionBypass() } + .collect { torManager.onNetworkChange() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index daebe61dc4..900cc3e78f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -29,12 +29,14 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -48,10 +50,13 @@ class TorSharedPreferences( prefs: TorSettings, val context: Context, val scope: CoroutineScope, -) { +) : TorPreferencesPort { // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs val value = TorSettingsFlow.build(prefs) + override val torType: StateFlow get() = value.torType + override val externalSocksPort: StateFlow get() = value.externalSocksPort + @OptIn(FlowPreview::class) val saving = value.propertyWatchFlow @@ -66,9 +71,9 @@ class TorSharedPreferences( value.toSettings(), ) - suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) + override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) - suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) + override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) companion object { // loads faster when individualized diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 4988276dde..7e4a5fc789 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -261,6 +261,8 @@ fun AllSettingsScreen( ) } + LegalSettingsSection() + SettingsSection(R.string.danger_zone, isDanger = true) { if (hasPrivateKey) { SettingsItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index 6aef504782..eef969c214 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -72,8 +72,8 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -197,18 +197,11 @@ fun LoginPage( } if (loginViewModel.isFirstLogin) { - AcceptTerms( + TermsGate( checked = loginViewModel.acceptedTerms, onCheckedChange = loginViewModel::updateAcceptedTerms, + showError = loginViewModel.termsAcceptanceIsRequiredError, ) - - if (loginViewModel.termsAcceptanceIsRequiredError) { - Text( - text = stringRes(R.string.acceptance_of_terms_is_required), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } } Spacer(modifier = Modifier.height(Size10dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index 35d4bfff56..ef7e79f0bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -68,7 +69,7 @@ class LoginViewModel : ViewModel() { ) { clear() this.isFirstLogin = isFirstLogin - acceptedTerms = !isFirstLogin + acceptedTerms = !isFirstLogin || BuildConfig.FLAVOR != "play" if (newAccountKey != null) { key = TextFieldValue(newAccountKey) } @@ -79,7 +80,7 @@ class LoginViewModel : ViewModel() { password = TextFieldValue("") errorManager.clearErrors() - acceptedTerms = false + acceptedTerms = BuildConfig.FLAVOR != "play" processingLogin = false isTemporary = false offerTemporaryLogin = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 62616886ce..72797b6abe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -187,19 +187,12 @@ fun SignUpPage( }, ) - AcceptTerms( + TermsGate( checked = signUpViewModel.acceptedTerms, onCheckedChange = signUpViewModel::updateAcceptedTerms, + showError = signUpViewModel.termsAcceptanceIsRequiredError, ) - if (signUpViewModel.termsAcceptanceIsRequiredError) { - Text( - text = stringRes(R.string.acceptance_of_terms_is_required), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.height(Size10dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt index 6f87efac7a..206878f39c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager @@ -40,7 +41,7 @@ class SignUpViewModel : ViewModel() { var displayName by mutableStateOf(TextFieldValue("")) - var acceptedTerms by mutableStateOf(false) + var acceptedTerms by mutableStateOf(BuildConfig.FLAVOR != "play") var termsAcceptanceIsRequiredError by mutableStateOf(false) fun init(accountSessionManager: AccountSessionManager) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt index 6d066d1d08..9a1551f77e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt @@ -62,6 +62,15 @@ object ArtiNative { * @return 0 on success. */ external fun stopSocksProxy(): Int + + /** + * Drop the in-process TorClient so the next [initialize] call rebuilds + * it from scratch (fresh bootstrap, new guards/circuits). Aborts the + * SOCKS listener and all in-flight connection handlers so the state + * file lock can be released. + * @return 0 on success. + */ + external fun destroy(): Int } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt new file mode 100644 index 0000000000..3ccf01df15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -0,0 +1,40 @@ +/* + * 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.tor + +import kotlinx.coroutines.flow.StateFlow + +/** + * The slice of [TorService] that [TorManager] drives. Extracted so the manager + * can be unit-tested without booting Arti via JNI — production wires + * `TorService(context)`, tests wire an in-memory fake. + */ +interface TorBackend { + val status: StateFlow + + suspend fun start() + + suspend fun stop() + + suspend fun reset() + + suspend fun resetWithCleanState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index ade8fa2213..b0d750f83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -20,10 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.tor -import android.content.Context import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,20 +40,26 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** * There should be only one instance of the Tor binding per app. * * Tor will connect as soon as status is listened to. + * + * [service] and [torPrefs] are constructor-injected so the manager can be unit-tested + * with in-memory fakes — see `TorManagerTest`. [ioDispatcher] is the dispatcher for + * background I/O (DataStore reads/writes, [TorBackend] calls); tests pass a + * `TestDispatcher` so virtual time controls scheduling. */ class TorManager( - private val torPrefs: TorSharedPreferences, - app: Context, + private val torPrefs: TorPreferencesPort, + val service: TorBackend, private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val nowMs: () -> Long = System::currentTimeMillis, ) { - val service = TorService(app) - /** * In-memory only — when true, the manager emits [TorServiceStatus.Off] regardless of * the persisted [TorType]. Cleared on process death, on network change, and on any @@ -69,26 +74,56 @@ class TorManager( */ @Volatile private var lastBypassApprovalMs: Long = 0L + /** + * Bumped by self-heal paths ([onNetworkChange], stuck-Connecting watcher) so the + * [status] combine re-fires and re-enters the [TorType.INTERNAL] branch — which + * calls [TorService.start] again and, because [TorService.reset] flipped + * `initialized` back to false, runs full Arti re-initialization with a fresh + * bootstrap, new guards, new circuits. + */ + private val resetEpoch = MutableStateFlow(0) + + /** Wall-clock of the last automatic self-heal — rate-limits the stuck-Connecting reset. */ + @Volatile private var lastSelfHealAtMs: Long = 0L + + /** + * Flipped the first time [status] reaches [TorServiceStatus.Active] in this process. Before + * that, the stuck-Connecting watchdog uses the gentler [TorService.reset] (drop client only) + * rather than [TorService.resetWithCleanState] — because on a slow legitimate first + * bootstrap there is no stale state to wipe, and wiping just forces an unnecessary + * re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game + * for the recovery to wipe. + */ + @Volatile private var hasEverBootstrapped: Boolean = false + init { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs() } - // Any user-initiated change to torType clears the in-memory bypass so the - // explicit user action wins over the implicit override. - torPrefs.value.torType + // Any user-initiated change to torType clears the in-memory bypass AND the + // remembered-approval window. Otherwise a single past "Use regular connection" + // traps the user in a silent-bypass loop: every Connecting span >60s + // auto-flips sessionBypass without showing the dialog, force-stop preserves + // the DataStore-backed approval, and toggling Tor off/on only clears the + // in-memory half — so wiping app data becomes the only recovery path. + torPrefs.torType .drop(1) - .onEach { sessionBypass.value = false } - .launchIn(scope) + .onEach { + sessionBypass.value = false + lastBypassApprovalMs = 0L + torPrefs.saveLastBypassApprovalMs(0L) + }.launchIn(scope) } @OptIn(ExperimentalCoroutinesApi::class) val status = combine( - torPrefs.value.torType, - torPrefs.value.externalSocksPort, + torPrefs.torType, + torPrefs.externalSocksPort, sessionBypass, - ) { torType, externalSocksPort, bypass -> + resetEpoch, + ) { torType, externalSocksPort, bypass, _ -> Triple(torType, externalSocksPort, bypass) }.transformLatest { (torType, externalSocksPort, bypass) -> if (bypass) { @@ -119,7 +154,7 @@ class TorManager( }.catch { e -> Log.e("TorManager") { "Tor service error: ${e.message}" } emit(TorServiceStatus.Off) - }.flowOn(Dispatchers.IO) + }.flowOn(ioDispatcher) .stateIn( scope, SharingStarted.WhileSubscribed(30000), @@ -164,28 +199,92 @@ class TorManager( false, ) + /** + * Fires once after [SELF_HEAL_AFTER_MS] of continuous [TorServiceStatus.Connecting]. + * Drives the watchdog wired up below. `transformLatest` cancels the pending delay + * whenever the status changes, so a brief Connecting blip never fires. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val selfHealSignal = + status.transformLatest { s -> + if (s is TorServiceStatus.Connecting) { + delay(SELF_HEAL_AFTER_MS) + emit(Unit) + } + } + + init { + // Self-heal watchdog. When status sits at Connecting for longer than + // SELF_HEAL_AFTER_MS, the in-memory Arti state is likely stuck — bad guards, + // broken circuits, expired consensus. Drop the TorClient and bump resetEpoch + // so the status combine re-fires and re-enters the INTERNAL branch, which + // runs full Arti re-init. Rate-limited so a permanently broken network + // doesn't loop us. Fires BEFORE the 60s connectionFailure dialog so most + // users never see it. + // + // Pre-first-bootstrap: gentle reset (drop client, keep state). On a slow + // legitimate first bootstrap there's nothing on disk worth wiping, and + // wiping just costs another full bootstrap cycle. + // Post-first-bootstrap: full reset (drop client + wipe state). Once we've + // seen Tor work once, a stuck Connecting almost certainly means stale on-disk + // state from a different network needs to go. + status + .onEach { + if (it is TorServiceStatus.Active) hasEverBootstrapped = true + }.launchIn(scope) + + selfHealSignal + .onEach { + val now = nowMs() + if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach + lastSelfHealAtMs = now + if (hasEverBootstrapped) { + Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms — self-healing (drop client + wipe state)" } + service.resetWithCleanState() + } else { + Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms on first bootstrap — self-healing (drop client only)" } + service.reset() + } + resetEpoch.update { it + 1 } + }.launchIn(scope) + } + fun rememberedApprovalActive(): Boolean { val ts = lastBypassApprovalMs - return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS + return ts > 0 && (nowMs() - ts) < APPROVAL_REMEMBER_MS } /** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */ fun approveBypassForOneHour() { - val now = System.currentTimeMillis() + val now = nowMs() lastBypassApprovalMs = now sessionBypass.value = true - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { torPrefs.saveLastBypassApprovalMs(now) } } /** - * Re-attempt Tor on this session — used on network change. Does not clear the - * remembered-approval window: if Tor stays stuck, we will silently bypass again - * after the timeout fires. + * Network identity changed (wifi↔cellular, captive portal cleared, regained from + * offline). The old network's guards and circuits are dead, but Arti's in-memory + * TorClient doesn't always notice — and even if it does, on-disk `state/` can hold + * unreachable guards that the next process load will pick up again. Drop the + * client, clear `sessionBypass`, clear the persisted approval, and bump + * [resetEpoch] so the status flow re-enters the INTERNAL branch with + * `initialized=false` — forcing a full Arti re-init with fresh bootstrap. */ - fun clearSessionBypass() { + fun onNetworkChange() { sessionBypass.value = false + lastBypassApprovalMs = 0L + // Prevent the stuck-Connecting watchdog from firing a second reset while the + // network-change bootstrap is still legitimately in progress (initial bootstrap + // on a new network can take ~10–30s, sometimes longer). + lastSelfHealAtMs = nowMs() + scope.launch(ioDispatcher) { + torPrefs.saveLastBypassApprovalMs(0L) + service.reset() + resetEpoch.update { it + 1 } + } } fun isSocksReady() = status.value is TorServiceStatus.Active @@ -195,5 +294,9 @@ class TorManager( companion object { const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L + + /** Self-heal kicks in BEFORE the 60s [BOOTSTRAP_TIMEOUT_MS] dialog so most users never see it. */ + const val SELF_HEAL_AFTER_MS: Long = 45_000L + const val SELF_HEAL_COOLDOWN_MS: Long = 5L * 60L * 1000L } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt new file mode 100644 index 0000000000..3188f15f81 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt @@ -0,0 +1,38 @@ +/* + * 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.tor + +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.flow.StateFlow + +/** + * The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the + * manager can be unit-tested without an Android `Context` (and without DataStore). + * Production wires `TorSharedPreferences`; tests wire an in-memory fake. + */ +interface TorPreferencesPort { + val torType: StateFlow + val externalSocksPort: StateFlow + + suspend fun loadLastBypassApprovalMs(): Long + + suspend fun saveLastBypassApprovalMs(value: Long) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index b74f567535..6b27c59acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -46,13 +46,13 @@ private const val MAX_PORT_RETRIES = 10 */ class TorService( val context: Context, -) { +) : TorBackend { private var socksPort = DEFAULT_SOCKS_PORT private val initialized = AtomicBoolean(false) private val proxyRunning = AtomicBoolean(false) private val _status = MutableStateFlow(TorServiceStatus.Off) - val status: StateFlow = _status.asStateFlow() + override val status: StateFlow = _status.asStateFlow() private fun artiDataDir() = File(context.filesDir, "arti") @@ -86,7 +86,7 @@ class TorService( * Initialize the TorClient (once) and start the SOCKS proxy. * Must be called from a coroutine on [Dispatchers.IO]. */ - suspend fun start() { + override suspend fun start() { if (proxyRunning.get()) { if (_status.value is TorServiceStatus.Active) return _status.value = TorServiceStatus.Connecting @@ -167,7 +167,7 @@ class TorService( * Stop the SOCKS proxy and release the port. * The TorClient stays alive — no file lock issues on restart. */ - suspend fun stop() { + override suspend fun stop() { if (!proxyRunning.compareAndSet(true, false)) return withContext(Dispatchers.IO) { @@ -177,4 +177,36 @@ class TorService( _status.value = TorServiceStatus.Off } + + /** + * Drop the native TorClient so the next [start] runs full initialization + * with a fresh bootstrap, new guards, and new circuits. Used by self-heal + * paths in [TorManager] — network identity change, stuck-Connecting + * recovery — when the in-memory Arti state is suspected of being broken. + * The `arti/state/` directory on disk is preserved. + */ + override suspend fun reset() { + withContext(Dispatchers.IO) { + if (proxyRunning.compareAndSet(true, false)) { + ArtiNative.stopSocksProxy() + } + ArtiNative.destroy() + initialized.set(false) + Log.d("TorService") { "Tor service reset — next start will re-initialize" } + } + _status.value = TorServiceStatus.Off + } + + /** + * Like [reset] but additionally wipes `arti/state/` so the next + * initialization rebuilds guard selection from scratch. Used when stale + * on-disk state (e.g. unreachable guards persisted from a previous + * network) is the suspected cause of a bootstrap that never completes. + */ + override suspend fun resetWithCleanState() { + reset() + withContext(Dispatchers.IO) { + clearAllArtiData() + } + } } diff --git a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so index 8fc5ef3339..b29a78dbe1 100755 Binary files a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so and b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/amethyst/src/main/jniLibs/x86_64/libarti_android.so b/amethyst/src/main/jniLibs/x86_64/libarti_android.so index 3ee9f24e99..4a473042a9 100755 Binary files a/amethyst/src/main/jniLibs/x86_64/libarti_android.so and b/amethyst/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8f751e8640..f6f46d9a22 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -14,6 +14,12 @@ Tento příspěvek má více než %1$d hashtagů Tento příspěvek má více než %1$d hashtagů
+ + %1$d majetek je zbalený + %1$d majetky jsou zbalený + %1$d majetků jsou zbalený + %1$d majetků jsou zbalený + Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací 👀 Obrázek kanálu @@ -562,6 +568,11 @@ Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu. Zatím jste neobdrželi žádné odznaky. Obrázky + Aplikace + Aplikace + Zdroj: %1$s + v%1$s + Stáhnout Kalendáře Krátká videa Veřejné chaty @@ -1199,6 +1210,8 @@ Politika příspěvků Zásady ochrany soukromí Podmínky & ujednání + Standardy ochrany dětí + O aplikaci a právní informace N/A Chyby a upozornění z tohoto relé Zprávy monitoru relé @@ -1770,6 +1783,7 @@ %1$s · končí %2$s Sdílet událost kalendáře Exportovat do kalendáře (.ics) + calendar_reminders Připomenutí kalendáře Upozornění, když je událost, které se účastníte, blízko začátku. Událost kalendáře @@ -1797,6 +1811,12 @@ Oznámení se spustí, když je událost, které se účastníte, blízko začátku. Doba předstihu připomenutí Kolik minut před událostí chcete být upozorněni. + + %1$d min + %1$d min + %1$d min + %1$d min + Sdílet jako Nostr odkaz Sdílet odkaz na kalendář Všechny kalendáře @@ -1828,6 +1848,7 @@ Zvýšit nebo citovat Olajkovat Zap + On-chain Bitcoin zap Čeká na potvrzení Změnit rychlé reakce Spodní navigační lišta @@ -2788,6 +2809,7 @@ Nowhere Obchod Nowhere Petice Nowhere Zpráva + Nowhere Drop Nowhere Umění Nowhere Fórum diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index cd69d1a59f..f4ef6844e3 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -12,6 +12,10 @@ Dieser Beitrag hat mehr als %1$d Hashtag Dieser Beitrag hat mehr als %1$d Hashtags + + %1$d Asset gebündelt + %1$d Assets bündelt + Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden 👀 Kanalbild @@ -556,6 +560,11 @@ anz der Bedingungen ist erforderlich
Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen. Du hast noch keine Abzeichen erhalten. Bilder + Apps + Apps + Quelle: %1$s + v%1$s + Herunterladen Kalender Kurzvideos Öffentliche Chats @@ -1190,6 +1199,8 @@ anz der Bedingungen ist erforderlich Veröffentlichungsrichtlinie Datenschutzerklärung Allgemeine Geschäftsbedingungen + Kinderschutzstandards + Über & Rechtliches N/A Fehler und Hinweise von diesem Relais Relay-Überwachungsberichte @@ -1681,6 +1692,7 @@ anz der Bedingungen ist erforderlich Kalender bearbeiten Termine in diesem Kalender (%1$d) Du hast noch keine Kalendertermine erstellt. + Feed Monat Woche Tag @@ -1746,6 +1758,7 @@ anz der Bedingungen ist erforderlich Komme Vielleicht Komme nicht + RSVPs (%1$d) Noch keine Antworten. Teilnehmer (%1$d) In Kalendern (%1$d) @@ -1755,6 +1768,7 @@ anz der Bedingungen ist erforderlich %1$s · endet %2$s Kalendertermin teilen In Kalender exportieren (.ics) + calendar_reminders Kalendererinnerungen Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt. Kalendertermin @@ -1991,8 +2005,10 @@ anz der Bedingungen ist erforderlich Geschlossen Entwurf Übersicht + Tickets Patches und PRs Über + Links Maintainer Themen Persönlicher Fork @@ -2757,8 +2773,12 @@ anz der Bedingungen ist erforderlich Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt. Nowhere-Seite + Nowhere Ereignis Nowhere Spendenaktion Nowhere Shop + Nowhere Petition Nowhere Nachricht + Nowhere Drop Nowhere Kunst + Nowhere Forum diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 96415b3792..a991324f8f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -12,6 +12,10 @@ Ez a bejegyzés több mint %1$d kulcsszót tartalmaz Ez a bejegyzés több mint %1$d kulcsszót tartalmaz + + %1$d asset egybecsomagolva + %1$d asset egybecsomagolva + Az esemény épp betöltődik vagy nem található az átjátszólistában 👀 Csatorna profilképe @@ -552,6 +556,11 @@ Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban. Ön még nem kapott kitűzőt. Képek + Alkalmazások + Alkalmazások + Forrás: %1$s + v%1$s + Letöltés Naptárak Rövidek Nyilvános csevegések @@ -1189,6 +1198,8 @@ Közzétételi szabályzat Adatvédelmi irányelvek Általános szerződési feltételek + Gyermekbiztonsági szabványok + Névjegy & Jogi információk Nem érhető el Hibák és megjegyzések ettől az átjátszótól Átjátszófigyelési jelentések diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 5f9ee02a4d..b25b0ffc3d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu. Nie otrzymałeś jeszcze żadnych odznak. Zdjęcia + Aplikacje + Aplikacje + Źródło: %1$s + v%1$s + Pobierz Kalendarze Filmiki Czaty publiczne @@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Polityka publikowania Polityka Prywatności Zasady i warunki użytkowania + Normy bezpieczeństwa dzieci + O Nas & Informacje Prawne Nie dotyczy Błędy i powiadomienia z tego transmitera Raporty z monitoringu transmitera diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 010fb91c5a..1f6d0679ed 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -12,6 +12,10 @@ Det här inlägget har fler än %1$d hashtagg Det här inlägget har fler än %1$d hashtaggar + + %1$d tillgång paketerad + %1$d tillgångar buntade + Inlägg hittades inte 👀 Kanal bild @@ -550,6 +554,11 @@ Välj vilka av märkena du fått som ska visas på din profil. Du har inte fått några märken ännu. Bilder + Appar + Appar + Källa: %1$s + v%1$s + Ladda ner Kalendrar Kortfilmer Offentliga chattar @@ -1184,6 +1193,8 @@ Publiceringspolicy Integritetspolicy Villkor & bestämmelser + Standarder för barnsäkerhet + Om och juridiskt N/A Fel och meddelanden från detta relä Reläövervakningsrapporter @@ -1751,6 +1762,7 @@ %1$s · slutar %2$s Dela kalenderhändelse Exportera till kalender (.ics) + calendar_reminders Kalenderpåminnelser Påminnelse när en händelse du deltar i snart ska börja. Kalenderhändelse @@ -1776,6 +1788,10 @@ En notis visas när en händelse du deltar i snart ska börja. Påminnelsetid Hur många minuter före händelsen du vill bli notifierad. + + %1$d min + %1$d min + Dela som Nostr-länk Dela kalenderlänk Alla kalendrar @@ -2756,5 +2772,7 @@ Nowhere Butik Nowhere Namninsamling Nowhere Meddelande + Nowhere Drop Nowhere Konst + Nowhere Forum diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 1656bf9fde..0df7e136ba 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -11,6 +11,9 @@ 此帖有超过 %1$d 个话题标签 + + 捆绑了 %1$d 个资产 + 事件正在加载或无法在你的中继列表中找到 👀 频道图片 @@ -546,6 +549,11 @@ 选择要在个人资料中显示的已收到的徽章。 您还没有收到任何徽章。 图片 + 应用 + 应用 + 来源: %1$s + v%1$s + 下载 日历 短视频 公共聊天 @@ -1182,6 +1190,8 @@ 发布政策 隐私政策 使用条款 + 儿童安全标准 + 关于 & 法律 N/A 该中继的错误和通知 中继监视器报告 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 827d73674a..e9897200d3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1318,6 +1318,8 @@ Posting policy Privacy Policy Terms & Conditions + Child Safety Standards + About & Legal N/A Errors and Notices from this Relay Relay Monitor Reports diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index aafa18bfb6..60c2dfb7a1 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -38,6 +38,23 @@ android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" /> + + + \ No newline at end of file diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt new file mode 100644 index 0000000000..7c862eb866 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -0,0 +1,2663 @@ +/* + * 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.appfunctions + +import androidx.appfunctions.AppFunctionContext +import androidx.appfunctions.AppFunctionInvalidArgumentException +import androidx.appfunctions.AppFunctionNotSupportedException +import androidx.appfunctions.AppFunctionSerializable +import androidx.appfunctions.service.AppFunction +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.DmActions +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.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed +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.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android + * App Functions runtime, which Gemini and other system agents can drive. + * + * **Status — pre-stable.** Built against androidx.appfunctions 1.0.0-alpha09. + * The API is still moving; treat every release as ABI-breaking until 1.0.0 + * ships. Scoped to the `play` build flavor only — the F-Droid channel + * ships without any Google AI dependencies. + * + * Plain class, no inheritance — the KSP compiler discovers `@AppFunction` + * methods and generates the dispatcher glue (see + * `amethyst/build/generated/ksp/playDebug/.../$AmethystAppFunctions_AppFunctionInvoker.kt`). + * The generated invoker constructs this class via its default no-arg + * constructor, so no `AppFunctionConfiguration.Provider` is required on + * the Application. If we ever add an @AppFunction host class with + * constructor parameters, we'll need to register a factory via Provider — + * the docs nudge that direction, but the runtime does not require it for + * default-constructed classes. + * + * Read verbs work with any account state. Write verbs (post / follow / + * unfollow / sendDm) require a signer that can sign in-process — i.e. + * a local [NostrSignerInternal] or a remote NIP-46 bunker. NIP-55 + * external signers (Amber) are refused with + * [AppFunctionNotSupportedException] for now because the agent + * dispatch happens outside the foreground task stack — the user can't + * see the Amber approval activity from inside Gemini. See + * `amethyst/plans/2026-05-25-appfunctions-signer-prompts.md` for the + * design and the planned PendingIntent escape hatch. + * + * Account scoping uses the currently active account from + * [com.vitorpamplona.amethyst.Amethyst.instance.sessionManager] — the same + * Account the foreground UI is bound to. When no account is signed in, + * every function returns an empty result rather than failing the call. + */ +class AmethystAppFunctions { + /** + * Find a person on Nostr by name, handle, or NIP-05. Use when the user + * wants to look someone up on Nostr ("find vitor on nostr", "search for + * jack dorsey", "who is alice@damus on nostr"), translate a display + * name to an npub, or discover a user before following / DMing / + * zapping them. + * + * Backed by NIP-50 full-text search across the active account's + * configured search relays (kind:10007), with a fallback to + * Amethyst's curated default search-relay set. + * + * Results carrying a NIP-05 claim are verified in parallel and dropped + * when the claim explicitly fails — the listed domain doesn't sign for + * that pubkey, or returns a different one. Profiles with no NIP-05 at + * all are kept (no claim, nothing to refute). This is the + * anti-impersonation guard for the downstream write verbs: if Gemini + * asks the user "do you mean alice@damus.io?" the user should be able + * to trust that the npub actually controls that handle. + * + * @param query free-form search text (display name, NIP-05 handle, etc.) + * @param limit max number of profiles to return — capped to 50. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchProfiles( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 10, + ): SearchProfilesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty() + + // Snapshot the active account + relay set + client at function entry + // and never touch sessionManager again from this dispatch. If the + // user switches account mid-fetch, this snapshot keeps the request + // routed to the relays we originally queried — caller still gets a + // coherent result rather than events mixed across accounts. + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty() + val client = Amethyst.instance.client + + // SearchRelayListState's flow already resolves to a concrete relay + // set: NIP-44-decrypted private entries + public entries, or the + // curated default set when the user has no kind:10007. Same source + // of truth the foreground UI uses. + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchProfilesResult.empty() + + // Quartz's INostrClient.fetchAll handles subscribe → drain on + // EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort + // newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs) + // so a slow relay can't stall the dispatch. + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val candidates = + events + .mapNotNull { it as? MetadataEvent } + .distinctBy { it.pubKey } + .sortedByDescending { it.createdAt } + .take(cappedLimit) + + // Verify NIP-05 claims in parallel with a bounded timeout, then + // drop only the ones that explicitly fail (the domain returns a + // different pubkey, or no entry for the local part). Network + // errors / DNS failures / timeouts are inconclusive — keep those + // so a flaky .well-known doesn't censor legitimate users. + val verified = + withTimeoutOrNull(NIP05_FILTER_TIMEOUT_MS) { + coroutineScope { + candidates + .map { meta -> + async { meta to nip05IsExplicitlyFailed(meta) } + }.awaitAll() + } + } ?: candidates.map { it to false } + + val hits = + verified + .filterNot { (_, failed) -> failed } + .map { (meta, _) -> meta.toProfileHit() } + + return SearchProfilesResult(matches = hits) + } + + /** + * Returns true only when the recipient's kind:0 carries a NIP-05 claim + * that the listed domain actively refuses to sign for. Returns false + * for "no claim", "unparseable claim", "network error", "namecoin + * unreachable" — those are inconclusive, not refutations. + */ + private suspend fun nip05IsExplicitlyFailed(meta: MetadataEvent): Boolean { + val claim = + meta + .contactMetaData() + ?.nip05 + ?.trim() + .orEmpty() + if (claim.isEmpty()) return false + val parsed = Nip05Id.parse(claim) ?: return false + return runCatching { + !Amethyst.instance.nip05Client.verify(parsed, meta.pubKey) + }.getOrElse { + // verify() throws IllegalStateException on network / parse + // errors — treat as inconclusive rather than a failure. + false + } + } + + /** + * Read the user's Nostr timeline / home feed. Use when the user asks + * "what's new on Nostr", "what's happening on Nostr today", "catch me + * up on my Nostr feed", or wants a summary of recent posts from + * people they follow. + * + * Drains recent kind:1 short text notes from the people the active + * account follows; the same query the Amethyst home-feed UI runs, + * truncated to one batch. Queries the account's home relays (NIP-65 + * outbox + any private storage + local relays). + * + * @param limit max notes to return, capped to 200. Default 30. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRecentFromFollows( + appFunctionContext: AppFunctionContext, + limit: Int = 30, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 200) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + + val events = fetchFollowFeed(account = account, sinceSecs = null, limit = cappedLimit) + return SearchNotesResult(matches = events.map { it.toNoteHit() }) + } + + /** + * Build a structured digest of the user's Nostr home feed for an + * AI summary. Use when the user asks "summarize my Nostr feed", + * "give me a digest of what my follows posted today", "recap + * Nostr for me", "what have people been talking about on Nostr", + * "summarize what's on my Nostr home screen", or any "summary / + * digest / recap of my Nostr timeline" intent. + * + * **Mirrors the home page exactly.** Runs the same + * `HomeNewThreadFeedFilter` the Amethyst home screen uses against + * the local event cache — so the LLM sees what the user would see + * if they opened the app: short text notes, reposts (deduped), + * long-form articles, polls, comments, audio, classifieds, + * highlights, and the rest. Respects the user's currently selected + * NIP-51 follow list (not just plain kind:3), filters muted users, + * and excludes replies (top-level threads only). + * + * Because the feed is read from the local cache rather than drained + * fresh from relays, the digest reflects what the foreground app + * has previously gathered — sparse if the app hasn't been opened + * recently. Open Amethyst before asking the agent to summarise if + * you want the freshest possible result. + * + * Returns the raw notes plus pre-extracted signals the LLM needs + * to write a useful summary without re-deriving them: total note + * count, unique author count, top hashtags in the window, and the + * most-mentioned users (display names resolved from local kind:0 + * cache). The LLM composes the natural-language summary from + * these. + * + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 12. Events older than this are excluded from both the + * stats and the body. + * @param maxNotes max notes returned in the body. Capped to 200. + * Default 60 — big enough for a meaningful summary, small enough + * to fit comfortably in the LLM's prompt. Stats are computed + * over the full in-window set, not just the trimmed body. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getFeedDigest( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 12, + maxNotes: Int = 60, + ): FeedDigestResult { + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val cappedMaxNotes = maxNotes.coerceIn(1, 200) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FeedDigestResult.empty() + + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + + // Use the same filter the home screen uses, so the digest + // mirrors what the user actually sees in the UI. The filter + // reads from LocalCache (already maintained by the foreground + // subscriptions) and applies the user's selected follow list, + // mutes, repost dedup, and new-thread-only rule. + val feed = + HomeNewThreadFeedFilter(account) + .feed() + .asSequence() + .mapNotNull { it.event } + .filter { it.createdAt >= sinceSecs } + .toList() + + // Hashtag frequencies — case-folded so `#Bitcoin` and + // `#bitcoin` collapse to one bucket. + val hashtagCounts = HashMap() + // Mention frequencies, keyed by mentioned pubkey hex. + val mentionCounts = HashMap() + val uniqueAuthors = HashSet() + + for (ev in feed) { + uniqueAuthors.add(ev.pubKey) + for (tag in ev.tags) { + if (tag.size < 2) continue + when (tag[0]) { + "t" -> { + val cleaned = tag[1].trim().removePrefix("#").lowercase() + if (cleaned.isNotEmpty()) { + hashtagCounts.merge(cleaned, 1, Int::plus) + } + } + "p" -> { + // Skip self-mentions (the author tags themself + // in some clients) — not useful for the digest. + if (tag[1].length == 64 && tag[1] != ev.pubKey) { + mentionCounts.merge(tag[1], 1, Int::plus) + } + } + } + } + } + + val topHashtags = + hashtagCounts.entries + .sortedByDescending { it.value } + .take(TOP_HASHTAGS_LIMIT) + .map { HashtagFrequency(tag = it.key, noteCount = it.value) } + + val topMentions = + mentionCounts.entries + .sortedByDescending { it.value } + .take(TOP_MENTIONS_LIMIT) + .map { (pub, count) -> + MentionFrequency( + npub = NPub.create(pub), + pubkeyHex = pub, + displayName = displayNameOf(pub), + mentionCount = count, + ) + } + + return FeedDigestResult( + windowHours = cappedHours, + totalNoteCount = feed.size, + uniqueAuthorCount = uniqueAuthors.size, + topHashtags = topHashtags, + topMentions = topMentions, + // Truncate to the caller-requested limit for the body — + // the LLM has the stats either way and doesn't need every + // note quoted. Sorted newest-first. + notes = + feed + .sortedByDescending { it.createdAt } + .take(cappedMaxNotes) + .map { it.toFeedNoteHit() }, + ) + } + + /** + * Shared core of [getRecentFromFollows] and [getFeedDigest]: drain + * recent kind:1 notes from the active account's follow set, + * optionally filtered by `since`. Returns empty when there's no + * account, no follows, or no relays configured. + */ + private suspend fun fetchFollowFeed( + account: com.vitorpamplona.amethyst.model.Account, + sinceSecs: Long?, + limit: Int, + ): List { + val authors = account.kind3FollowList.flow.value.authors + if (authors.isEmpty()) return emptyList() + + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return emptyList() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = authors.toList(), + since = sinceSecs, + limit = limit, + ) + return Amethyst.instance.client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? TextNoteEvent } + .take(limit) + } + + /** + * Read recent Nostr posts from a specific user. Use when the user + * asks "what did Snowden post recently on Nostr", "catch me up on + * what Jack has been posting", "show me Alice's latest notes", or + * wants to see one specific Nostr user's activity. + * + * Pass the target user's npub or hex pubkey — use [searchProfiles] + * first if you only have a display name. Queries the target's + * NIP-65 write relays when cached, falling back to the active + * account's home relays. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getNotesByUser( + appFunctionContext: AppFunctionContext, + user: String, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val pubkey = decodeUserOrThrow(user) + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + + val targetWriteRelays = + account.cache + .checkGetOrCreateUser(pubkey) + ?.outboxRelays() + ?.toSet() + .orEmpty() + val relays = + targetWriteRelays + .ifEmpty { account.homeRelays.flow.value } + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(pubkey), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.pubKey == pubkey } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Look up one Nostr profile by npub or hex pubkey. Use when the user + * asks "who is npub1…", "tell me about [npub]", "what's [user]'s + * Nostr profile", or wants the bio / NIP-05 / Lightning address of a + * specific Nostr user. + * + * Returns the latest kind:0 metadata — cache-first, with a short + * network fallback when the user's profile hasn't been observed + * locally yet. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getProfile( + appFunctionContext: AppFunctionContext, + user: String, + ): GetProfileResult { + val pubkey = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return GetProfileResult.notFound(pubkey) + + // Cache-first: the foreground UI keeps observed metadata around. + val cached = + account.cache + .checkGetOrCreateUser(pubkey) + ?.metadataOrNull() + if (cached != null) { + val info = cached.flow.value?.info + return GetProfileResult( + found = true, + profile = + ProfileHit( + npub = NPub.create(pubkey), + pubkeyHex = pubkey, + displayName = cached.bestName(), + about = info?.about, + nip05 = cached.nip05(), + picture = cached.profilePicture(), + lnAddress = cached.lnAddress(), + ), + ) + } + + // Cache miss: drain bootstrap relays for the latest kind:0. + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return GetProfileResult.notFound(pubkey) + + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(pubkey), + limit = 1, + ) + val event = + client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? MetadataEvent } + .filter { it.pubKey == pubkey } + .maxByOrNull { it.createdAt } + ?: return GetProfileResult.notFound(pubkey) + + return GetProfileResult(found = true, profile = event.toProfileHit()) + } + + /** + * Find Nostr posts about a topic via hashtag. Use when the user asks + * "show me Nostr posts about Bitcoin", "find Nostr discussion of + * #Tor", "what's the Nostr take on [topic]", or wants to browse + * conversation about a specific subject. + * + * Pass the tag value without the leading `#` — "bitcoin", not + * "#bitcoin". The hashtag is lowercased before matching (the + * convention most Nostr clients follow). + * + * @param hashtag the tag value without the leading `#`. + * @param limit max notes to return, capped to 100. Default 30. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchByHashtag( + appFunctionContext: AppFunctionContext, + hashtag: String, + limit: Int = 30, + ): SearchNotesResult { + val tag = hashtag.trim().removePrefix("#").lowercase() + if (tag.isEmpty()) throw AppFunctionInvalidArgumentException("hashtag must not be blank") + val cappedLimit = limit.coerceIn(1, 100) + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("t" to listOf(tag)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Report who the user is signed in as on Nostr. Use when the user + * asks "who am I logged in as on Nostr", "what's my npub", "what's + * my Nostr identity", "how many people do I follow on Nostr", or + * any other "tell me about my Nostr account" query. + * + * Returns the active account's npub, display name, NIP-05 handle, + * follow count, and how many relays are configured for outbox / + * DM inbox. Use this for Nostr-side diagnostics rather than as a + * general "who am I" answer. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getActiveAccountInfo(appFunctionContext: AppFunctionContext): AccountInfoResult { + val account = + Amethyst.instance.sessionManager.loggedInAccount() ?: return AccountInfoResult.signedOut() + + val myPub = account.signer.pubKey + val myUser = account.cache.checkGetOrCreateUser(myPub) + val meta = myUser?.metadataOrNull() + + return AccountInfoResult( + signedIn = true, + npub = NPub.create(myPub), + pubkeyHex = myPub, + displayName = meta?.bestName(), + nip05 = meta?.nip05(), + followCount = account.kind3FollowList.userList.value.size, + outboxRelayCount = account.homeRelays.flow.value.size, + dmRelayCount = account.dmRelays.flow.value.size, + ) + } + + /** + * Most recent kind:1 notes the active account itself published. For + * "what did I post recently?" — drains the user's own outbox relays + * filtered to their own pubkey. + * + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getMyRecentNotes( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val myPub = account.signer.pubKey + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(myPub), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.pubKey == myPub } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Notes where someone tagged the active account with a `p` tag — + * the Nostr equivalent of being @-mentioned. Use this for "did + * anyone mention me recently?". + * + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getMyMentions( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val myPub = account.signer.pubKey + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Replies to a specific note (kind:1 events with an `e` tag + * pointing at [eventId]). Used for "did anyone respond to my last + * post?" — pass `getMyRecentNotes(1).matches.first().eventId` + * from a previous call, or any other note you want to track. + * + * @param eventId 64-character hex id of the note being replied to. + * @param limit max replies to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRepliesToNote( + appFunctionContext: AppFunctionContext, + eventId: String, + limit: Int = 20, + ): SearchNotesResult { + if (eventId.length != 64) { + throw AppFunctionInvalidArgumentException("eventId must be 64-character hex (nevent bech32 not yet supported)") + } + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("e" to listOf(eventId)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.id != eventId } // self-reference safety + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Report how many sats the user earned on Nostr in a time window. + * Use when the user asks "did I get any zaps today", "how many sats + * did I earn on Nostr this week", "did anyone zap my last post", + * or wants a summary of incoming NIP-57 Lightning zaps. + * + * Drains kind:9735 zap receipts addressed to the user in the window + * and parses the bolt11 invoice from each to compute total sats. + * Returns total + per-window zap count + unique zapper count. + * + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 24. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getZapsReceived( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 24, + ): ZapsReceivedResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return ZapsReceivedResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return ZapsReceivedResult.empty() + + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + val myPub = account.signer.pubKey + + val filter = + Filter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + since = sinceSecs, + limit = 500, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val receipts = events.mapNotNull { it as? LnZapEvent } + val zapperIds = mutableSetOf() + var totalSats = 0L + var unparseable = 0 + for (z in receipts) { + val bolt11 = + z.tags + .firstOrNull { it.size > 1 && it[0] == "bolt11" } + ?.get(1) + val sats = + bolt11 + ?.let { runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull() } + ?: run { + unparseable++ + 0L + } + totalSats += sats + // Zap sender is recorded in the description's signed kind:9734; + // we only have it as a pubkey-id mention via `P` tag on some + // receipts. Best-effort: + z.tags + .firstOrNull { it.size > 1 && (it[0] == "P" || it[0] == "p" && it[1] != myPub) } + ?.get(1) + ?.let { zapperIds.add(it) } + } + + return ZapsReceivedResult( + windowHours = cappedHours, + totalSats = totalSats, + zapCount = receipts.size, + uniqueZapperCount = zapperIds.size, + unparseableInvoiceCount = unparseable, + ) + } + + /** + * Read recent Nostr direct messages. Use when the user asks "did I + * get any Nostr DMs", "what did Alice DM me", "show me my recent + * Nostr messages", "summarize my unread Nostr DMs", or wants + * decrypted message content (not just notifications) from Nostr. + * + * Drains NIP-17 gift wraps from the active account's DM-inbox + * relays, decrypts each in-process (Amethyst is the only place + * the user's NIP-44 keys live), and returns the inner kind:14 + * messages with sender display names attached. File-attachment + * DMs (kind:15) are filtered out for now to keep the response + * small. + * + * @param peer optional npub/hex; when set, only returns messages + * to/from that specific peer. When null, returns conversations + * with anyone. + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 24. + * @param limit max messages to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRecentDms( + appFunctionContext: AppFunctionContext, + peer: String?, + hoursBack: Int = 24, + limit: Int = 20, + ): DmsResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return DmsResult.empty() + val client = Amethyst.instance.client + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val cappedLimit = limit.coerceIn(1, 100) + val peerPub = peer?.takeIf { it.isNotBlank() }?.let { decodeUserOrThrow(it) } + + // DM-inbox relays per kind:10050; fall back to home relays if the + // user never published a kind:10050 (interop with stale clients). + val relays = + account.dmRelays.flow.value + .ifEmpty { account.homeRelays.flow.value } + if (relays.isEmpty()) return DmsResult.empty() + + val myPub = account.signer.pubKey + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + + // NIP-59 gift wraps randomise their `created_at` up to two days + // in the past, so we widen the filter by 2 days. Same trick the + // foreground client and amy use. + val filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + since = sinceSecs - TimeUtils.twoDays(), + limit = 200, + ) + val wraps = + client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? GiftWrapEvent } + + val seen = HashSet() + val messages = mutableListOf() + for (wrap in wraps) { + val inner = wrap.unwrapAndUnsealOrNull(account.signer) ?: continue + if (inner !is BaseDMGroupEvent) continue + if (inner !is ChatMessageEvent) continue // skip file headers for v1; keep payload small + if (!seen.add(inner.id)) continue + // After widening for randomised `created_at`, drop anything + // outside the requested window so the result honours the + // caller's hoursBack. + if (inner.createdAt < sinceSecs) continue + if (peerPub != null && peerPub !in inner.groupMembers()) continue + + messages.add( + DmMessage( + fromNpub = NPub.create(inner.pubKey), + fromPubkeyHex = inner.pubKey, + fromDisplayName = displayNameOf(inner.pubKey), + sentByMe = inner.pubKey == myPub, + content = inner.content, + createdAt = inner.createdAt, + ), + ) + } + + return DmsResult( + windowHours = cappedHours, + messages = + messages + .sortedByDescending { it.createdAt } + .take(cappedLimit), + ) + } + + /** + * NIP-50 search restricted to NIP-23 long-form articles + * (kind:30023). Use for "find Nostr articles about [topic]" when + * the user wants written-up posts rather than short notes. + * + * @param query free-form search text. + * @param limit max articles to return, capped to 50. Default 10. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchArticles( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 10, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = + SearchActions.searchNotesFilter( + query = query, + kinds = listOf(LongTextNoteEvent.KIND), + limit = cappedLimit, + ) ?: return SearchNotesResult.empty() + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchNotesResult.empty() + + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? LongTextNoteEvent } + .take(cappedLimit) + .map { (it as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Live audio/video streams currently broadcasting on Nostr (NIP-53 + * kind:30311 events with `status=live`). Use for "what's live on + * Nostr right now?". + * + * @param limit max streams to return, capped to 50. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getLiveStreams( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): LiveStreamsResult { + val cappedLimit = limit.coerceIn(1, 50) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return LiveStreamsResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return LiveStreamsResult.empty() + + // NIP-53 has no `since` semantics — a live activity can have an + // arbitrarily old createdAt. We over-fetch and post-filter for + // `isLive()`, which also applies the 8-hour staleness guard + // (status=live + recent createdAt) baked into quartz. + val filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + limit = cappedLimit * 4, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val streams = + events + .mapNotNull { it as? LiveActivitiesEvent } + .filter { it.isLive() } + .take(cappedLimit) + .map { ev -> + val hostPub = ev.host()?.pubKey + LiveStreamHit( + eventId = ev.id, + title = ev.title(), + summary = ev.summary(), + streamingUrl = ev.streaming(), + hostNpub = hostPub?.let { NPub.create(it) }, + hostPubkeyHex = hostPub, + hostDisplayName = hostPub?.let { displayNameOf(it) }, + startsAt = ev.starts(), + createdAt = ev.createdAt, + ) + } + + return LiveStreamsResult(streams = streams) + } + + // ------------------------------------------------------------------ + // Write verbs — gated on a signer that can sign without launching a + // foreground activity. See requireInProcessSigner below. + // ------------------------------------------------------------------ + + /** + * Publish a short text note on Nostr. Use when the user asks "post + * this to Nostr", "tweet this on Nostr", "share [X] on Nostr", + * "publish a Nostr note saying [X]", or any other "send to Nostr" + * intent for plain-text content. + * + * Publishes a NIP-10 kind:1 short text note as the signed-in user, + * broadcast to the account's configured outbox relays. Returns per- + * relay ack so the caller can confirm the post landed. + * + * @param text the note body. Cannot be blank; capped at 8000 + * characters so an accidentally-pasted document doesn't try to + * become a Nostr post. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun postNote( + appFunctionContext: AppFunctionContext, + text: String, + ): WriteResult { + val body = text.trim() + if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank") + if (body.length > MAX_NOTE_LENGTH) { + throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH") + } + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val template = TextNoteEvent.build(body) + val signed = account.signer.sign(template) + // Mirror the foreground UI: cache the freshly-signed event so + // subsequent reads see it without waiting for a relay echo. + account.cache.justConsumeMyOwnEvent(signed) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(signed, relays, PUBLISH_TIMEOUT_SECS) + + return WriteResult.from(signed.id, ack) + } + + /** + * Follow a user on Nostr. Use when the user asks "follow [X] on + * Nostr", "add [npub] to my Nostr follows", or "subscribe to + * [user]" with a Nostr context. Idempotent — re-following someone + * already followed is a safe no-op. + * + * Adds [user] to the signed-in account's NIP-02 kind:3 follow list + * and publishes the updated list. [WriteResult.changed] reports + * `false` when the user is already followed. + * + * **BEFORE INVOKING:** the agent MUST resolve [user] via + * [searchProfiles] / [getProfile] first and present the recipient + * to the human with all three identity signals — display name, + * npub, and NIP-05 handle — and wait for explicit confirmation + * ("yes, follow Vitor (vitor@vitorpamplona.com, npub1…)"). Nostr + * has no global namespace, so multiple users can share a display + * name; the NIP-05 + npub disambiguates. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + * @param expectedDisplayName when set, the verb cross-checks that + * the resolved profile's display name / NIP-05 contains (or is + * contained by) this string. Pass the same name you showed the + * user during the confirmation prompt — a mismatch aborts the + * write with a typed error so the agent can re-prompt. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun followUser( + appFunctionContext: AppFunctionContext, + user: String, + expectedDisplayName: String? = null, + ): WriteResult { + val target = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + if (target == account.signer.pubKey) { + throw AppFunctionInvalidArgumentException("cannot follow yourself") + } + requireInProcessSigner(account.signer) + verifyExpectedRecipient(account, target, expectedDisplayName, requireFollow = false) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val currentList = account.kind3FollowList.getFollowListEvent() + if (currentList != null && currentList.isTaggedUser(target)) { + return WriteResult.unchanged() + } + + // Relay hint from cached kind:10002 so the follow tag points + // readers at where the target publishes. + val relayHint = + account.cache + .checkGetOrCreateUser(target) + ?.outboxRelays() + ?.firstOrNull() + + val newList = + FollowActions.buildFollow( + signer = account.signer, + pubkeyToFollow = target, + currentContactList = currentList, + relayHint = relayHint, + ) + account.cache.justConsumeMyOwnEvent(newList) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS) + return WriteResult.from(newList.id, ack) + } + + /** + * Unfollow a user on Nostr. Use when the user asks "unfollow [X] + * on Nostr", "remove [npub] from my Nostr follows", or "stop + * following [user]" with a Nostr context. Idempotent — unfollowing + * someone the user wasn't following is a safe no-op. + * + * Removes [user] from the signed-in account's NIP-02 kind:3 + * follow list and publishes the updated list. [WriteResult.changed] + * reports `false` when the user wasn't followed. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun unfollowUser( + appFunctionContext: AppFunctionContext, + user: String, + ): WriteResult { + val target = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val currentList = account.kind3FollowList.getFollowListEvent() + if (currentList == null || !currentList.isTaggedUser(target)) { + return WriteResult.unchanged() + } + + val newList = + FollowActions.buildUnfollow( + signer = account.signer, + pubkeyToUnfollow = target, + currentContactList = currentList, + ) ?: return WriteResult.unchanged() + account.cache.justConsumeMyOwnEvent(newList) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS) + return WriteResult.from(newList.id, ack) + } + + /** + * Send a direct message to a user on Nostr. Use when the user asks + * "DM [X] on Nostr", "send a Nostr message to [user] saying [Y]", + * "message [npub] on Nostr", or any other "send a private message" + * intent in a Nostr context. + * + * The message is gift-wrapped (kind:1059) per NIP-59 — only the + * recipient (and the signed-in user, who keeps their own copy) + * can decrypt it. Recipients without a published kind:10050 + * DM-inbox list fall back through NIP-65 read relays then + * bootstrap relays. + * + * **BEFORE INVOKING:** the agent MUST resolve [recipient] via + * [searchProfiles] / [getProfile] first and confirm with the user + * using all three identity signals — display name, npub, and + * NIP-05 handle ("DM Alice (alice@damus.io, npub1…)?"). Nostr has + * no global namespace and impersonation is trivial; the npub + + * NIP-05 are the only cryptographic disambiguators. + * + * By default, the verb refuses to message anyone not in the user's + * follow list — a near-zero-cost guard against same-name + * impersonators. Pass `requireFollow = false` only after the user + * explicitly approves messaging a stranger. + * + * @param recipient npub (`npub1…`) or 64-character hex pubkey. + * @param text the message body. Cannot be blank; capped at 8000 + * characters. + * @param expectedDisplayName when set, the verb cross-checks that + * the resolved profile's display name / NIP-05 contains (or is + * contained by) this string. Pass the same name you showed the + * user during the confirmation prompt — a mismatch aborts the + * send with a typed error. + * @param requireFollow when true (the default), the verb refuses + * to send to a pubkey the user doesn't already follow on Nostr. + * Override to false only when the user explicitly confirmed they + * want to DM a stranger. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun sendDm( + appFunctionContext: AppFunctionContext, + recipient: String, + text: String, + expectedDisplayName: String? = null, + requireFollow: Boolean = true, + ): SendDmResult { + val body = text.trim() + if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank") + if (body.length > MAX_NOTE_LENGTH) { + throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH") + } + val recipientPub = decodeUserOrThrow(recipient) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + verifyExpectedRecipient(account, recipientPub, expectedDisplayName, requireFollow) + + val client = Amethyst.instance.client + val result = DmActions.buildTextDm(account.signer, recipientPub, body) + + // One wrap per recipient — for a 1:1 DM that's two (the recipient's + // copy + the sender's own copy on the sender's inbox). + val deliveries = mutableListOf() + for (wrap in result.wraps) { + val target = wrap.recipientPubKey() ?: continue + // Fetch the recipient's kind:10050 / 10051 / 10002 fresh — local + // cache may be stale for users we rarely interact with, and the + // cost is one short drain on already-warmed sockets. + val lists = + RecipientRelayFetcher.fetchRelayLists(client, target, account.outboxRelays.flow.value) + val resolution = + DmActions.resolveDmRelays( + recipientLists = lists, + bootstrap = account.outboxRelays.flow.value, + allowFallback = true, + ) + if (resolution.relays.isEmpty()) { + deliveries.add( + DmDelivery( + recipientNpub = NPub.create(target), + recipientPubkeyHex = target, + wrapId = wrap.id, + publishedTo = emptyList(), + rejectedBy = emptyList(), + relaySource = resolution.source.name.lowercase(), + ), + ) + continue + } + val ack = client.publishAndConfirmDetailed(wrap, resolution.relays, PUBLISH_TIMEOUT_SECS) + deliveries.add( + DmDelivery( + recipientNpub = NPub.create(target), + recipientPubkeyHex = target, + wrapId = wrap.id, + publishedTo = ack.filterValues { it }.keys.map { it.url }, + rejectedBy = ack.filterValues { !it }.keys.map { it.url }, + relaySource = resolution.source.name.lowercase(), + ), + ) + } + // Cache the inner kind:14 so the foreground UI sees the message + // immediately in the relevant DM thread. + account.cache.justConsumeMyOwnEvent(result.msg) + + return SendDmResult( + messageEventId = result.msg.id, + deliveries = deliveries, + ) + } + + /** + * Tip a Nostr user with Bitcoin sats. Use when the user asks "zap + * [X] on Nostr", "tip [user] [N] sats", "send a Lightning tip to + * [npub]", "send [N] sats onchain to [user]", "send Alice + * [N] sats via Bitcoin", or "thank [user] with sats". + * + * Two rails are supported: + * + * * **Lightning** (default) — NIP-57 zap. Builds the kind:9734 + * request, fetches a BOLT11 invoice from the recipient's + * Lightning service, and — when the user has a Nostr Wallet + * Connect wallet configured — pays automatically over NIP-47. + * Falls back to returning the invoice for manual payment. + * + * * **Onchain** (NIP-BC kind:8333) — when [chain] is "onchain", + * builds and broadcasts a Bitcoin transaction paying the + * recipient's derived Taproot address, then publishes a + * kind:8333 zap receipt. Requires the user to have a Bitcoin + * chain backend configured in Amethyst (an Esplora-compatible + * API like mempool.space — see Settings → Bitcoin). + * + * Defaults to 21 sats Lightning. Cap is 1,000,000 sats so an + * accidental tip can't drain a wallet. + * + * **BEFORE INVOKING:** zaps move real money, so the agent MUST + * resolve [user] via [searchProfiles] / [getProfile] first and + * confirm with the human using all three identity signals — + * display name, npub, and NIP-05 handle ("Zap Alice + * (alice@damus.io, npub1…) 21 sats?"). Nostr has no global + * namespace; a same-name impersonator is the most likely way for + * a zap to go to the wrong person. + * + * By default, the verb refuses to zap anyone not in the user's + * follow list. This is the strongest guard against same-name + * impersonators — even if Gemini picked the wrong Alice, the user + * almost certainly isn't following her. Pass `requireFollow = + * false` only after the user explicitly approves zapping a + * stranger (e.g. a public-figure npub they read out themselves). + * + * @param user npub (`npub1…`) or 64-character hex pubkey of the + * zap recipient. + * @param sats amount to zap, in whole sats. Capped at 1,000,000 + * sats. Default 21. + * @param comment optional message to attach to the zap. Capped at + * 280 characters. + * @param chain transport rail: "lightning" (default) or "onchain". + * Null / blank is treated as Lightning. + * @param feeRateSatPerVByte miner fee rate for onchain zaps in + * sats per virtual byte. Ignored for Lightning. Default 5.0, + * which targets fast confirmation under typical mempool + * conditions without being aggressive. + * @param expectedDisplayName when set, the verb cross-checks that + * the resolved profile's display name / NIP-05 contains (or is + * contained by) this string. Pass the same name you showed the + * user during the confirmation prompt — a mismatch aborts the + * zap before any money moves. + * @param requireFollow when true (the default), the verb refuses + * to zap a pubkey the user doesn't already follow on Nostr. + * Override to false only when the user explicitly confirmed + * they want to zap a stranger. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun zapUser( + appFunctionContext: AppFunctionContext, + user: String, + sats: Long = 21, + comment: String? = null, + chain: String? = null, + feeRateSatPerVByte: Double = 5.0, + expectedDisplayName: String? = null, + requireFollow: Boolean = true, + ): ZapResult { + val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS) + val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH) + val recipientPub = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + verifyExpectedRecipient(account, recipientPub, expectedDisplayName, requireFollow) + + val rail = chain?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: "lightning" + return when (rail) { + "lightning", "ln" -> zapUserViaLightning(account, recipientPub, cappedSats, trimmedComment) + "onchain", "btc", "bitcoin" -> + zapUserViaOnchain(account, recipientPub, cappedSats, trimmedComment, feeRateSatPerVByte) + else -> + throw AppFunctionInvalidArgumentException( + "chain must be \"lightning\" or \"onchain\" (got \"$chain\").", + ) + } + } + + private suspend fun zapUserViaLightning( + account: com.vitorpamplona.amethyst.model.Account, + recipientPub: HexKey, + sats: Long, + comment: String, + ): ZapResult { + val client = Amethyst.instance.client + + // Pull the recipient's kind:0 — needs lnAddress to receive the zap. + val metadata = + account.cache + .checkGetOrCreateUser(recipientPub) + ?.metadataOrNull() + ?.flow + ?.value + ?.info + ?.let { extractLnAddressFromMetadata(it) } + ?: fetchProfileForZap(client, account, recipientPub) + ?: throw AppFunctionInvalidArgumentException( + "No kind:0 metadata for ${NPub.create(recipientPub)} — recipient must have a Nostr profile first.", + ) + val lnAddress = + metadata.takeIf { it.isNotBlank() } + ?: throw AppFunctionInvalidArgumentException( + "Recipient has no lud16 or lud06 in their profile — they can't receive Lightning zaps. " + + "Try chain=\"onchain\" instead if they have NIP-BC enabled on their account.", + ) + + val zapRequest = + ZapActions.buildUserZapRequest( + signer = account.signer, + recipientPubkey = recipientPub, + amountMillisats = ZapActions.satsToMillisats(sats), + inboxRelays = account.nip65RelayList.inboxFlow.value, + comment = comment, + zapType = LnZapEvent.ZapType.PUBLIC, + ) + + val invoice = fetchInvoiceOrThrow(lnAddress, sats, comment, zapRequest) + + // If the user has a Nostr Wallet Connect wallet configured, pay + // the invoice automatically over NIP-47 so Gemini can answer + // "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) + + return ZapResult( + chain = "lightning", + recipientNpub = NPub.create(recipientPub), + recipientPubkeyHex = recipientPub, + recipientDisplayName = displayNameOf(recipientPub), + lnAddress = lnAddress, + amountSats = sats, + comment = comment, + invoice = invoice, + zapRequestId = zapRequest.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, + onchainTxid = null, + onchainFeeSats = null, + onchainChangeSats = null, + onchainReceiptEventId = null, + onchainError = null, + onchainStage = null, + ) + } + + private suspend fun zapUserViaOnchain( + account: com.vitorpamplona.amethyst.model.Account, + recipientPub: HexKey, + sats: Long, + comment: String, + feeRateSatPerVByte: Double, + ): ZapResult { + // The Account.sendOnchainZap call returns Failure when the + // backend isn't configured; we surface that as a typed + // NotSupported error so the LLM can tell the user "you need to + // set up a Bitcoin backend in Amethyst Settings → Bitcoin." + if (account.cache.onchainBackend == null) { + throw AppFunctionNotSupportedException( + "Amethyst has no Bitcoin chain backend configured. " + + "Open Settings → Bitcoin and add an Esplora-compatible endpoint (e.g. mempool.space) " + + "before sending onchain zaps.", + ) + } + if (feeRateSatPerVByte < 0.1) { + throw AppFunctionInvalidArgumentException( + "feeRateSatPerVByte must be at least 0.1 (got $feeRateSatPerVByte).", + ) + } + if (feeRateSatPerVByte > 1000) { + throw AppFunctionInvalidArgumentException( + "feeRateSatPerVByte must be ≤ 1000 (got $feeRateSatPerVByte) — anything higher is almost certainly a mistake.", + ) + } + + val result = + account.sendOnchainZap( + recipientPubKey = recipientPub, + amountSats = sats, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + // No zappedEvent: this is a profile zap. Event zaps via + // onchain would need a separate verb that takes an + // event id (and resolves splits) — deferred. + zappedEvent = null, + ) + + return when (result) { + is com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult.Success -> + ZapResult( + chain = "onchain", + recipientNpub = NPub.create(recipientPub), + recipientPubkeyHex = recipientPub, + recipientDisplayName = displayNameOf(recipientPub), + // Onchain doesn't go through an LN service. + lnAddress = "", + amountSats = sats, + comment = comment, + // Onchain has no BOLT11; pre-fill empty to satisfy + // the non-null result-class contract. + invoice = "", + zapRequestId = "", + nwcAttempted = false, + nwcPaid = false, + nwcPreimage = null, + nwcError = null, + onchainTxid = result.txid, + onchainFeeSats = result.feeSats, + onchainChangeSats = result.changeSats, + onchainReceiptEventId = result.receiptEventId, + onchainError = null, + onchainStage = null, + ) + + is com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult.Failure -> + ZapResult( + chain = "onchain", + recipientNpub = NPub.create(recipientPub), + recipientPubkeyHex = recipientPub, + recipientDisplayName = displayNameOf(recipientPub), + lnAddress = "", + amountSats = sats, + comment = comment, + invoice = "", + zapRequestId = "", + nwcAttempted = false, + nwcPaid = false, + nwcPreimage = null, + nwcError = null, + // When stage == PUBLISHING, the tx WAS broadcast + // but the receipt couldn't be published — surface + // the txid even on failure so the user can verify + // on a block explorer. + onchainTxid = result.broadcastTxid, + onchainFeeSats = null, + onchainChangeSats = null, + onchainReceiptEventId = null, + onchainError = result.message, + onchainStage = result.stage.name.lowercase(), + ) + } + } + + /** + * Zap a specific Nostr note (NIP-57 event zap). Use when the user + * asks "zap this Nostr post", "tip the author of [event id]", + * "send sats for that Nostr note about [X]", or "boost this Nostr + * post with sats". + * + * Honors NIP-57 zap-split tags — a post with multiple `zap` tags + * produces one invoice per recipient, proportional to weight, so + * a multi-party collab post pays everyone correctly. When the user + * has a Nostr Wallet Connect wallet configured, every split is + * paid automatically over NIP-47 and the result reports per- + * recipient success / failure. Without NWC, the verb returns the + * BOLT11 invoices for manual payment. + * + * @param eventId 64-character hex id of the note to zap. Must be + * in the local cache — get it via [getNotesByUser] / + * [getRecentFromFollows] / [searchByHashtag] / [searchNotes] + * first. + * @param sats total amount to zap, in whole sats. Capped at + * 1,000,000. + * @param comment optional message attached to every zap request. + * Capped at 280 characters. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun zapEvent( + appFunctionContext: AppFunctionContext, + eventId: String, + sats: Long = 21, + comment: String? = null, + ): ZapEventResult { + if (eventId.length != 64) { + throw AppFunctionInvalidArgumentException("eventId must be 64-character hex") + } + val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS) + val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + + val note = + account.cache.getNoteIfExists(eventId) + ?: throw AppFunctionInvalidArgumentException( + "Event $eventId not in local cache. Fetch it via getNotesByUser or " + + "getRecentFromFollows first, or open the note in Amethyst.", + ) + val event = + note.event + ?: throw AppFunctionInvalidArgumentException( + "Event $eventId is referenced locally but its content hasn't been observed yet.", + ) + + val client = Amethyst.instance.client + val totalMsats = ZapActions.satsToMillisats(cappedSats) + + // Lookups for the split resolver — first try the local cache, + // then fall back to a one-shot network drain. + val lookupLnAddress: suspend (HexKey) -> String? = { pk -> + account.cache + .checkGetOrCreateUser(pk) + ?.metadataOrNull() + ?.lnAddress() + ?: fetchProfileForZap(client, account, pk) + } + val lookupInboxRelays: suspend (HexKey) -> Set = { pk -> + account.cache + .checkGetOrCreateUser(pk) + ?.inboxRelays() + ?.toSet() + .orEmpty() + } + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = account.signer, + zappedEvent = event, + totalAmountMillisats = totalMsats, + senderInboxRelays = account.nip65RelayList.inboxFlow.value, + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, + comment = trimmedComment, + zapType = LnZapEvent.ZapType.PUBLIC, + ) + if (requests.isEmpty()) { + throw AppFunctionInvalidArgumentException( + "No payable recipients — neither the author nor any zap-split recipient has a usable Lightning address.", + ) + } + + val invoices = + requests.map { req -> + val shareSats = req.amountMillisats / 1000 + val invoiceResult = + runCatching { + fetchInvoiceOrThrow( + lnAddress = req.recipient.lnAddress, + sats = shareSats, + comment = trimmedComment, + zapRequest = req.request, + ) + } + val invoice = invoiceResult.getOrNull() + // 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) } + ZapInvoice( + recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, + recipientPubkeyHex = req.recipient.pubkey, + recipientDisplayName = req.recipient.pubkey?.let { displayNameOf(it) }, + lnAddress = req.recipient.lnAddress, + weight = req.recipient.weight, + amountSats = shareSats, + invoice = invoice, + invoiceError = invoiceResult.exceptionOrNull()?.message, + zapRequestId = req.request.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, + ) + } + + return ZapEventResult( + zappedEventId = eventId, + requestedSats = cappedSats, + billedSats = invoices.sumOf { it.amountSats }, + comment = trimmedComment, + invoices = invoices, + ) + } + + /** Read lnAddress out of an already-resolved UserMetadata. */ + private fun extractLnAddressFromMetadata(info: com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata): String? = info.lnAddress() + + /** + * Cache miss path for zap recipient profile lookup. Drain the + * recipient's NIP-65 outbox / our home relays for their kind:0; + * returns the lnAddress directly so callers don't have to re-parse + * the metadata blob. + */ + private suspend fun fetchProfileForZap( + client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient, + account: com.vitorpamplona.amethyst.model.Account, + pubkey: HexKey, + ): String? { + val relays = + account.cache + .checkGetOrCreateUser(pubkey) + ?.outboxRelays() + ?.toSet() + ?.ifEmpty { account.homeRelays.flow.value } + ?: account.homeRelays.flow.value + if (relays.isEmpty()) return null + + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey), limit = 1) + return client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? MetadataEvent } + .maxByOrNull { it.createdAt } + ?.contactMetaData() + ?.lnAddress() + } + + /** Internal result of [payViaNwcOrNull]. */ + private data class NwcOutcome( + 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. + */ + private suspend fun payViaNwcOrNull( + account: com.vitorpamplona.amethyst.model.Account, + bolt11: String, + zappedNote: com.vitorpamplona.amethyst.model.Note?, + ): NwcOutcome? { + if (!account.nip47SignerState.hasWalletConnectSetup()) return null + + val deferred = CompletableDeferred() + // 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( + success = false, + preimage = null, + errorMessage = + "NWC wallet didn't respond within ${NWC_PAYMENT_TIMEOUT_MS / 1000}s, " + + "or returned a malformed reply we couldn't decrypt", + ) + + return when (response) { + is PayInvoiceSuccessResponse -> + NwcOutcome( + success = true, + preimage = response.result?.preimage, + errorMessage = null, + ) + is PayInvoiceErrorResponse -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = + 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", + ) + else -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = "Unexpected NWC response type: ${response::class.simpleName}", + ) + } + } + + /** + * LNURL-pay round-trip: resolves the LN address to a callback URL, + * posts the zap request, returns the BOLT11 invoice. Uses + * Amethyst's roleBasedHttpClientBuilder so the request honors the + * user's Tor / money-routing preferences. + */ + private suspend fun fetchInvoiceOrThrow( + lnAddress: String, + sats: Long, + comment: String, + zapRequest: com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent, + ): String { + // Compute the LNURL-pay endpoint so we can ask the privacy-aware + // HttpClient builder for the right OkHttpClient for that host. + val endpointUrl = + LightningAddressResolver(httpClient = okhttp3.OkHttpClient()).assembleUrl(lnAddress) + ?: throw AppFunctionInvalidArgumentException("Couldn't resolve LN address '$lnAddress' to an LNURL-pay URL.") + val client = Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForMoney(endpointUrl) + val resolver = LightningAddressResolver(httpClient = client) + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = ZapActions.satsToMillisats(sats), + message = comment, + zapRequest = zapRequest, + ) + return when (result) { + is LightningAddressResolver.Result.Success -> result.invoice + is LightningAddressResolver.Result.Error -> + throw AppFunctionInvalidArgumentException("Lightning service rejected the zap: ${result.message}") + } + } + + /** + * Anti-impersonation guard for write verbs. Two checks, both + * defensive against an agent that misroutes "DM Alice" to the wrong + * Alice: + * + * 1. If [expectedDisplayName] is provided, verify it loosely + * matches the cached display name, name, or NIP-05 handle of + * [target]. The match is case-insensitive and bidirectional + * ("vitor" matches "Vitor Pamplona" and vice-versa) so the + * agent can pass whatever the user said without having to + * reconstruct the exact metadata string. + * + * 2. If [requireFollow] is true, verify the target is in the + * active account's kind:3 follow list. This is the strongest + * lever against wrong-recipient writes: even if Gemini picked + * a same-name impersonator, the user is overwhelmingly + * unlikely to be following them. Caller can pass + * `requireFollow = false` to override (e.g. zapping a stranger + * the user explicitly named by npub). + * + * Throws [AppFunctionInvalidArgumentException] with the npub + + * cached NIP-05 + display name on mismatch so the agent can render + * a clarifying prompt to the user. + */ + private fun verifyExpectedRecipient( + account: com.vitorpamplona.amethyst.model.Account, + target: HexKey, + expectedDisplayName: String?, + requireFollow: Boolean, + ) { + val user = account.cache.checkGetOrCreateUser(target) + val meta = + user + ?.metadataOrNull() + ?.flow + ?.value + ?.info + val cachedName = meta?.bestName() + val cachedNip05 = meta?.nip05?.trim()?.takeIf { it.isNotEmpty() } + val npub = NPub.create(target) + val expected = expectedDisplayName?.trim()?.takeIf { it.isNotEmpty() } + + if (expected != null) { + val normalized = expected.lowercase() + val candidates = + listOfNotNull( + cachedName?.lowercase(), + meta + ?.displayName + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotEmpty() }, + meta + ?.name + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotEmpty() }, + cachedNip05?.lowercase(), + cachedNip05?.substringBefore('@')?.lowercase()?.takeIf { it.isNotEmpty() }, + ) + val matches = + candidates.any { it == normalized || it.contains(normalized) || normalized.contains(it) } + if (!matches) { + throw AppFunctionInvalidArgumentException( + "Recipient mismatch: expectedDisplayName=\"$expected\" doesn't match the resolved " + + "Nostr profile (npub=$npub, displayName=${cachedName ?: ""}, " + + "nip05=${cachedNip05 ?: ""}). Ask the user to confirm with the agent " + + "before retrying — there may be multiple users with the same name on Nostr.", + ) + } + } + + if (requireFollow && target !in account.kind3FollowList.flow.value.authors) { + throw AppFunctionInvalidArgumentException( + "Recipient is not in the user's Nostr follow list (npub=$npub, " + + "displayName=${cachedName ?: ""}, nip05=${cachedNip05 ?: ""}). " + + "Ask the user to confirm they want to act on this stranger, then retry with " + + "requireFollow=false. This is a guard against acting on a same-name impersonator.", + ) + } + } + + /** + * Reject the call when the active signer can't sign in-process — + * NIP-55 external signers (Amber) need a foreground activity to + * show the user an approval prompt, which we can't launch from a + * background AppFunctionService dispatch. + * + * Throws [AppFunctionNotSupportedException] when the user's signer + * is read-only, and a typed [AppFunctionNotSupportedException] + * with a clarifying message when it's an external signer. + */ + private fun requireInProcessSigner(signer: NostrSigner) { + if (!signer.isWriteable()) { + throw AppFunctionNotSupportedException( + "Active Amethyst account is read-only (npub login). Sign in with a private key or NIP-46 bunker to publish.", + ) + } + // NostrSignerExternal lives in quartz/androidMain and isn't visible + // to commonMain — but we're already in android-app code, so the + // class is on the classpath. Reflective name-check keeps the + // dependency edge clean and avoids hard-coupling the bridge to + // the NIP-55 implementation class. + val klass = signer::class.qualifiedName + if (klass == "com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal") { + throw AppFunctionNotSupportedException( + "Amethyst is configured to use an external NIP-55 signer (Amber). " + + "Write actions from Gemini aren't supported with this signer yet — " + + "they require Amber's approval activity which can't launch from a " + + "background dispatch. Open Amethyst directly to complete the action.", + ) + } + } + + private fun notSignedIn(): AppFunctionNotSupportedException = AppFunctionNotSupportedException("No Amethyst account is signed in.") + + /** + * Accepts either an npub bech32 (`npub1…`) or 64-character hex + * pubkey and returns the 64-char lowercase hex. Throws + * [AppFunctionInvalidArgumentException] on anything else so the + * caller sees a typed error rather than a generic crash. + */ + private fun decodeUserOrThrow(input: String): HexKey = + runCatching { decodePublicKey(input.trim()).toHexKey() } + .getOrElse { + throw AppFunctionInvalidArgumentException( + "Could not decode user '$input' — expected npub1… or 64-char hex pubkey.", + ) + } + + /** + * Look up the cached display name for a pubkey. Returns null when no + * kind:0 has been observed for this user yet — caller renders the + * npub instead. + * + * Cheap in-memory read against the same LocalCache the foreground UI + * uses; no relay round-trip, no allocation beyond the lookup. + */ + private fun displayNameOf(pubkey: HexKey): String? = + Amethyst.instance.cache + .checkGetOrCreateUser(pubkey) + ?.metadataOrNull() + ?.bestName() + + private fun TextNoteEvent.toNoteHit(): NoteHit = (this as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() + + /** + * Project any home-feed-eligible event into a [NoteHit]. Covers + * the broader event set the home filter accepts (kind:1, kind:6 + * reposts, kind:30023 long-form, polls, comments, etc.), so a + * digest can carry whatever the user actually sees. + * + * Long content is snippet-truncated so a book-length article + * doesn't blow up the AppFunctions response — Gemini can ask the + * user whether to fetch the full article through a follow-up + * verb call. + */ + private fun com.vitorpamplona.quartz.nip01Core.core.Event.toFeedNoteHit(): NoteHit { + val snippet = + if (content.length > LONG_FORM_SNIPPET_LIMIT) { + content.take(LONG_FORM_SNIPPET_LIMIT) + "…" + } else { + content + } + return NoteHit( + eventId = id, + kind = kind, + npub = NPub.create(pubKey), + pubkeyHex = pubKey, + authorDisplayName = displayNameOf(pubKey), + createdAt = createdAt, + content = snippet, + ) + } + + private fun MetadataEvent.toProfileHit(): ProfileHit { + val meta = contactMetaData() + return ProfileHit( + npub = NPub.create(pubKey), + pubkeyHex = pubKey, + displayName = meta?.bestName(), + about = meta?.about, + nip05 = meta?.nip05, + picture = meta?.picture, + lnAddress = meta?.lnAddress(), + ) + } + + /** + * Searches Nostr notes for [query] via NIP-50 full-text search across the + * active account's configured search relays (kind:10007), falling back to + * Amethyst's curated default search-relay set when none is configured. + * + * Defaults to short text notes (kind:1) only. Currently no way to widen + * to long-form or other kinds — add a parameter when the need is real; + * App Functions doesn't support `List` parameters in alpha09. + * + * @param query free-form search text. + * @param limit max number of notes to return — capped to 50. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchNotes( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = SearchActions.searchNotesFilter(query, limit = cappedLimit) ?: return SearchNotesResult.empty() + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchNotesResult.empty() + + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + // Sorted newest-first by fetchAll already, but the cast may + // have dropped non-kind:1 events from a relay that ignored + // our kinds filter. + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Lists the active account's current follow set — the people the signed-in + * user follows per their latest NIP-02 kind:3 contact list. + * + * Returned entries include best-effort display names sourced from each + * user's cached kind:0; users with no cached metadata appear with + * [FollowedUser.displayName] null. The order matches the on-disk follow + * list (which is the order the user followed them in). + * + * @param limit cap on entries returned — capped to 500. Set to 0 for the + * full list when there's no specific bound. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getFollowing( + appFunctionContext: AppFunctionContext, + limit: Int = 100, + ): FollowingResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FollowingResult.empty() + + // userList resolves authors through LocalCache so display names / + // pictures / nip05 are filled in for anyone whose kind:0 we've seen. + val users = account.kind3FollowList.userList.value + val effectiveLimit = if (limit <= 0) users.size else limit.coerceIn(1, 500) + + val out = + users + .take(effectiveLimit) + .map { user -> + val meta = user.metadataOrNull() + FollowedUser( + npub = NPub.create(user.pubkeyHex), + pubkeyHex = user.pubkeyHex, + displayName = meta?.bestName(), + nip05 = meta?.nip05(), + picture = meta?.profilePicture(), + ) + } + + return FollowingResult(totalFollowing = users.size, returned = out) + } + + companion object { + /** + * 6-second fetch window. App Functions invocations are user-initiated + * foreground requests in the Gemini UI — anything beyond a few seconds + * is a poor user experience. + */ + private const val GEMINI_FETCH_TIMEOUT_MS = 6_000L + + /** + * Cap on the content payload returned from [searchArticles] — NIP-23 + * articles can be book-length; truncate so the AppFunctions response + * stays bounded. Gemini can show the snippet and ask the user + * whether to fetch the full article. + */ + private const val LONG_FORM_SNIPPET_LIMIT = 2_000 + + /** + * Cap on the body of a Gemini-driven write (postNote / sendDm). + * Anything larger is almost certainly an accidentally-pasted + * document; bail out with a typed error instead of silently + * publishing a wall of text to relays. + */ + private const val MAX_NOTE_LENGTH = 8_000 + + /** + * Per-publish ack window. We wait this long for OK responses + * from each relay; relays that don't answer in time are + * reported as `rejectedBy` (no ack, no event). 15 s lines up + * with what `cli/Context.publish` uses. + */ + private const val PUBLISH_TIMEOUT_SECS = 15L + + /** Upper bound on a single zap. Anything above this is almost + * certainly a typo; bail out instead of letting Gemini bill + * the user a million sats by accident. */ + private const val MAX_ZAP_SATS = 1_000_000L + + /** LN providers typically reject longer comments — capping at + * 280 keeps us under the most aggressive ceilings while still + * fitting a tweet-length thank-you note. */ + private const val MAX_ZAP_COMMENT_LENGTH = 280 + + /** Max time to wait for an NWC wallet to respond to a + * pay_invoice request. Mobile wallets typically settle in + * a few seconds; 30s is generous without letting a stuck + * wallet stall the dispatch indefinitely. */ + private const val NWC_PAYMENT_TIMEOUT_MS = 30_000L + + /** Cap on the number of distinct hashtags surfaced in a feed + * digest. Picked to fit a one-paragraph summary without + * noise — the long tail won't help the LLM. */ + private const val TOP_HASHTAGS_LIMIT = 10 + + /** Cap on the number of mentioned users surfaced in a feed + * digest. Same rationale as TOP_HASHTAGS_LIMIT. */ + private const val TOP_MENTIONS_LIMIT = 10 + + /** Overall budget for the NIP-05 verification pass inside + * [searchProfiles]. We fan out one HTTP request per result so + * the wall-clock is dominated by the slowest .well-known. + * 4s is short enough to keep search snappy and long enough to + * catch most genuine refutations. On timeout we keep all + * candidates rather than censoring them. */ + private const val NIP05_FILTER_TIMEOUT_MS = 4_000L + } +} + +/** + * Single match in [SearchProfilesResult]. Nullable fields let callers + * render whatever subset of metadata the profile happens to publish. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ProfileHit( + /** Bech32 npub identifier (`npub1…`) for the matched profile. */ + val npub: String, + /** Hex-encoded pubkey (same identity as [npub], non-bech32 form). */ + val pubkeyHex: String, + /** Best-effort display name (display_name then name). */ + val displayName: String?, + /** Profile bio / about. */ + val about: String?, + /** NIP-05 verified handle, e.g. `alice@example.com`. */ + val nip05: String?, + /** Avatar image URL. */ + val picture: String?, + /** Lightning address (lud16 preferred, otherwise lud06 LNURL). */ + val lnAddress: String?, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class SearchProfilesResult( + /** Matched profiles, deduplicated by pubkey and sorted newest-first. */ + val matches: List, +) { + companion object { + fun empty() = SearchProfilesResult(matches = emptyList()) + } +} + +/** One hashtag and how many notes in the digest window carried it. + * Lowercased and stripped of the leading `#`. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class HashtagFrequency( + /** The hashtag value without the leading `#`, lowercased. */ + val tag: String, + /** Number of notes in the digest window that carried this tag. */ + val noteCount: Int, +) + +/** One pubkey that was mentioned via `p` tags in the digest window, + * with display name resolved from the local kind:0 cache when known. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class MentionFrequency( + /** Bech32 npub of the mentioned user. */ + val npub: String, + /** Hex pubkey of the mentioned user. */ + val pubkeyHex: String, + /** Best-effort display name from the local kind:0 cache. Null when + * the user's profile hasn't been seen yet — caller falls back to + * the npub. */ + val displayName: String?, + /** Number of notes in the digest window that mention this user. */ + val mentionCount: Int, +) + +/** + * Structured snapshot of the active account's Nostr feed for + * [AmethystAppFunctions.getFeedDigest]. The LLM uses the aggregate + * signals (counts + top hashtags + top mentions) to write a one- or + * two-paragraph summary; the raw [notes] list is included for + * follow-up questions ("which post was about X?"). + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class FeedDigestResult( + /** Window size in hours actually queried (after capping). */ + val windowHours: Int, + /** Total notes scanned for stats. May exceed [notes].size when the + * body was truncated to fit the LLM prompt. */ + val totalNoteCount: Int, + /** Distinct authors who posted in the window. */ + val uniqueAuthorCount: Int, + /** Top hashtags by note count — at most 10. */ + val topHashtags: List, + /** Most-mentioned users by note count — at most 10. */ + val topMentions: List, + /** Notes themselves (truncated to the caller's maxNotes). Newest- + * first; same fields as [NoteHit] returned by the search verbs. */ + val notes: List, +) { + companion object { + fun empty() = + FeedDigestResult( + windowHours = 0, + totalNoteCount = 0, + uniqueAuthorCount = 0, + topHashtags = emptyList(), + topMentions = emptyList(), + notes = emptyList(), + ) + } +} + +/** Single match in [SearchNotesResult] or entry in a feed result. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class NoteHit( + /** Hex event id of the note. */ + val eventId: String, + /** Nostr event kind. 1 = short text note, 6 = repost, 30023 = + * long-form article, 1111 = comment, 9802 = highlight, 1068 = + * poll, etc. Lets the LLM distinguish "Alice posted a note" + * from "Alice published an article" or "Alice ran a poll". */ + val kind: Int, + /** Bech32 npub of the note's author. */ + val npub: String, + /** Hex pubkey of the note's author. */ + val pubkeyHex: String, + /** Best-effort display name of the author from the local kind:0 cache. + * Null when the author's profile hasn't been seen yet — caller renders + * the npub instead. */ + val authorDisplayName: String?, + /** Unix-seconds timestamp the note was created at. */ + val createdAt: Long, + /** Raw content of the note (plain text, may contain Nostr URIs / + * hashtags). Truncated at ~2000 chars for very long content; the + * full event is reachable by its [eventId] via a follow-up verb. */ + val content: String, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class SearchNotesResult( + /** Matched notes, sorted newest-first by created_at. */ + val matches: List, +) { + companion object { + fun empty() = SearchNotesResult(matches = emptyList()) + } +} + +/** Single entry in [FollowingResult]. Metadata fields may be null when the + * user's kind:0 hasn't been cached locally. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class FollowedUser( + /** Bech32 npub of the followed user. */ + val npub: String, + /** Hex pubkey of the followed user. */ + val pubkeyHex: String, + /** Best-effort display name (display_name then name). */ + val displayName: String?, + /** NIP-05 verified handle, e.g. `alice@example.com`. */ + val nip05: String?, + /** Avatar image URL. */ + val picture: String?, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class FollowingResult( + /** Total number of follows in the active account's kind:3 — may exceed + * [returned] when the caller passed a limit. */ + val totalFollowing: Int, + /** Subset of follows returned to the caller, in original on-disk order. */ + val returned: List, +) { + companion object { + fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList()) + } +} + +/** + * Result of [AmethystAppFunctions.getProfile]. Distinguishes "user has no + * cached + observable kind:0 metadata" (`found = false`) from "user has a + * stub profile with empty fields" — the latter shouldn't normally happen + * but the explicit flag keeps callers from rendering a hollow card. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class GetProfileResult( + /** True when a kind:0 was found (cache or relay). False means the + * user exists as a pubkey but no profile event was reachable. */ + val found: Boolean, + /** Resolved profile when [found] is true, otherwise a stub with + * pubkey-only fields populated. */ + val profile: ProfileHit?, +) { + companion object { + fun notFound(pubkeyHex: String) = + GetProfileResult( + found = false, + profile = + ProfileHit( + npub = NPub.create(pubkeyHex), + pubkeyHex = pubkeyHex, + displayName = null, + about = null, + nip05 = null, + picture = null, + lnAddress = null, + ), + ) + } +} + +/** + * Aggregate of NIP-57 zaps received in a recent time window. Returned + * by [AmethystAppFunctions.getZapsReceived]. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapsReceivedResult( + /** Window size in hours that was actually queried (after capping). */ + val windowHours: Int, + /** Sum of sats from every parseable bolt11 invoice in the window. */ + val totalSats: Long, + /** Total kind:9735 receipts observed — includes ones with unparseable invoices. */ + val zapCount: Int, + /** Distinct zapping pubkeys, best-effort from the `P` / second-`p` tag. */ + val uniqueZapperCount: Int, + /** Receipts whose bolt11 couldn't be parsed and didn't contribute to [totalSats]. */ + val unparseableInvoiceCount: Int, +) { + companion object { + fun empty() = + ZapsReceivedResult( + windowHours = 0, + totalSats = 0L, + zapCount = 0, + uniqueZapperCount = 0, + unparseableInvoiceCount = 0, + ) + } +} + +/** One decrypted NIP-17 direct message returned by [AmethystAppFunctions.getRecentDms]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmMessage( + /** Bech32 npub of the sender. */ + val fromNpub: String, + /** Hex pubkey of the sender. */ + val fromPubkeyHex: String, + /** Best-effort display name of the sender from the local kind:0 cache. */ + val fromDisplayName: String?, + /** True when the active account sent this message — useful for the + * caller to distinguish "Alice said X" from "I said Y" when both + * appear in the same thread snapshot. */ + val sentByMe: Boolean, + /** Plaintext message body. */ + val content: String, + /** Unix-seconds timestamp of the inner kind:14 event. */ + val createdAt: Long, +) + +/** Decrypted recent NIP-17 DMs in a time window. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmsResult( + /** Window size in hours that was actually queried. */ + val windowHours: Int, + /** Messages, newest first. Capped to the caller's limit. */ + val messages: List, +) { + companion object { + fun empty() = DmsResult(windowHours = 0, messages = emptyList()) + } +} + +/** Single hit from [AmethystAppFunctions.getLiveStreams]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class LiveStreamHit( + /** Hex event id of the kind:30311 announcement. */ + val eventId: String, + /** Stream title from the `title` tag, or null when absent. */ + val title: String?, + /** Short description from the `summary` tag, or null when absent. */ + val summary: String?, + /** The URL where the stream is playable (HLS / WebRTC / etc.) from + * the `streaming` tag. Null when the announcement carries no + * streaming endpoint — caller has nothing to play. */ + val streamingUrl: String?, + /** Bech32 npub of the host, when a host tag is present. */ + val hostNpub: String?, + /** Hex pubkey of the host, when a host tag is present. */ + val hostPubkeyHex: String?, + /** Best-effort display name of the host from the local kind:0 cache. */ + val hostDisplayName: String?, + /** Unix-seconds timestamp the stream's `starts` tag points to. */ + val startsAt: Long?, + /** Unix-seconds timestamp of the kind:30311 event itself. */ + val createdAt: Long, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class LiveStreamsResult( + /** Currently-live streams, in the order they were observed. */ + val streams: List, +) { + companion object { + fun empty() = LiveStreamsResult(streams = emptyList()) + } +} + +/** + * Summary of the active Nostr account on this device. Returned by + * [AmethystAppFunctions.getActiveAccountInfo]. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class AccountInfoResult( + /** False when no account is currently logged into Amethyst. */ + val signedIn: Boolean, + /** Bech32 npub of the active account, or null when signed out. */ + val npub: String?, + /** Hex pubkey of the active account, or null when signed out. */ + val pubkeyHex: String?, + /** Best-effort display name from cached kind:0. */ + val displayName: String?, + /** NIP-05 verified handle. */ + val nip05: String?, + /** Number of pubkeys in the user's current kind:3 follow list. */ + val followCount: Int, + /** Number of NIP-65 outbox / home relays configured. */ + val outboxRelayCount: Int, + /** Number of NIP-17 DM-inbox relays (kind:10050) configured. */ + val dmRelayCount: Int, +) { + companion object { + fun signedOut() = + AccountInfoResult( + signedIn = false, + npub = null, + pubkeyHex = null, + displayName = null, + nip05 = null, + followCount = 0, + outboxRelayCount = 0, + dmRelayCount = 0, + ) + } +} + +/** + * Result of a single-event write verb (postNote / followUser / + * unfollowUser). When the verb is a no-op — already following, not + * following, content unchanged — [changed] is false and [eventId] is + * null; the relay lists are empty for the same reason. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class WriteResult( + /** True when a new event was actually signed and published. */ + val changed: Boolean, + /** Hex event id of the signed event, or null when the verb was a no-op. */ + val eventId: String?, + /** Relays that ACK'd the publish. */ + val publishedTo: List, + /** Relays that rejected the event or didn't answer in time. */ + val rejectedBy: List, +) { + companion object { + fun unchanged() = + WriteResult( + changed = false, + eventId = null, + publishedTo = emptyList(), + rejectedBy = emptyList(), + ) + + fun from( + eventId: String, + ack: Map, + ) = WriteResult( + changed = true, + eventId = eventId, + publishedTo = ack.filterValues { it }.keys.map { it.url }, + rejectedBy = ack.filterValues { !it }.keys.map { it.url }, + ) + } +} + +/** + * Per-recipient delivery status for a NIP-17 DM send. A 1:1 DM + * produces two entries — the recipient's wrap and the sender's own + * copy on their own DM-inbox relays. [relaySource] reports which + * bucket the relays were drawn from: `kind_10050`, `nip65_read`, + * `bootstrap`, or `none`. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmDelivery( + /** Bech32 npub of the recipient this wrap was addressed to. */ + val recipientNpub: String, + /** Hex pubkey of the recipient. */ + val recipientPubkeyHex: String, + /** Hex event id of the kind:1059 gift wrap published to this recipient. */ + val wrapId: String, + /** Relays that ACK'd this wrap. */ + val publishedTo: List, + /** Relays that rejected this wrap or didn't answer in time. */ + val rejectedBy: List, + /** Bucket the relays were resolved from: kind_10050 / nip65_read / bootstrap / none. */ + val relaySource: String, +) + +/** Result of [AmethystAppFunctions.sendDm]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class SendDmResult( + /** Hex event id of the inner kind:14 (the plaintext message — only the + * signer and the recipient know it; relays only see the kind:1059 wraps). */ + val messageEventId: String, + /** One entry per gift-wrap delivery. */ + val deliveries: List, +) + +/** + * Result of [AmethystAppFunctions.zapUser]. Carries either the + * Lightning BOLT11 invoice (+ NWC payment outcome when configured) or + * the onchain Bitcoin broadcast result, depending on the rail used. + * The [chain] field tells callers which set of fields to read. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapResult( + /** Transport rail actually used: "lightning" or "onchain". */ + val chain: String, + /** Bech32 npub of the zap recipient. */ + val recipientNpub: String, + /** Hex pubkey of the recipient. */ + val recipientPubkeyHex: String, + /** Best-effort display name from the local kind:0 cache. */ + val recipientDisplayName: String?, + /** LN address the invoice was fetched from. Empty for onchain zaps. */ + val lnAddress: String, + /** Amount actually billed (after capping). For onchain, the + * miner fee is reported separately in [onchainFeeSats]. */ + val amountSats: Long, + /** Comment attached to the zap (truncated to 280 chars). */ + val comment: String, + /** BOLT11 invoice — populated for Lightning, empty for onchain. + * Pay manually if [nwcPaid] is false. */ + val invoice: String, + /** Hex event id of the signed kind:9734 zap request. Empty for onchain. */ + val zapRequestId: String, + /** True when the user has NWC configured and we tried to auto-pay. + * Always false for onchain. */ + val nwcAttempted: Boolean, + /** True only when an NWC wallet confirmed the payment. */ + val nwcPaid: Boolean, + /** Payment preimage from the wallet on success, otherwise null. */ + val nwcPreimage: String?, + /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */ + val nwcError: String?, + /** Bitcoin transaction id on a successful onchain zap, or — when + * the tx was broadcast but the receipt failed to publish — the + * txid so the user can verify the payment manually. Null for + * Lightning zaps. */ + val onchainTxid: String?, + /** Miner fee paid in sats on a successful onchain zap. */ + val onchainFeeSats: Long?, + /** Change returned to the sender's address on an onchain zap. */ + val onchainChangeSats: Long?, + /** Hex event id of the kind:8333 onchain zap receipt. */ + val onchainReceiptEventId: String?, + /** Failure reason when an onchain zap failed. */ + val onchainError: String?, + /** Stage at which an onchain zap failed: "loading_utxos", + * "building", "signing", "broadcasting", or "publishing" + * (last means the tx is on-chain but the receipt didn't land + * on relays — the payment is still real). */ + val onchainStage: String?, +) + +/** + * Per-recipient BOLT11 invoice for an event zap. Multiple invoices + * appear when the zapped note carries NIP-57 zap-split tags. When NWC + * is configured we try to pay each invoice automatically; per-split + * NWC results are reported in the `nwc*` fields. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapInvoice( + /** Bech32 npub of the recipient, or null when the split tag carried + * only an LN address with no pubkey. */ + val recipientNpub: String?, + /** Hex pubkey of the recipient, or null when only an LN address was given. */ + val recipientPubkeyHex: String?, + /** Best-effort display name from the cache, when the recipient is known. */ + val recipientDisplayName: String?, + /** LN address the invoice was fetched from. */ + val lnAddress: String, + /** Relative weight in the zap split — 1.0 for unweighted recipients. */ + val weight: Double, + /** This recipient's share of the total in whole sats. */ + val amountSats: Long, + /** BOLT11 invoice, or null when the Lightning provider failed + * (see [invoiceError] for the reason). */ + val invoice: String?, + /** Failure reason from the Lightning provider when [invoice] is null. */ + val invoiceError: String?, + /** Hex event id of this recipient's kind:9734 zap request. */ + val zapRequestId: String, + /** True when NWC was configured and we tried to auto-pay this + * invoice. False when no NWC was set up or the invoice itself + * couldn't be fetched. */ + val nwcAttempted: Boolean, + /** True only when an NWC wallet confirmed the payment for this split. */ + val nwcPaid: Boolean, + /** Payment preimage from the wallet on success, otherwise null. */ + val nwcPreimage: String?, + /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */ + val nwcError: String?, +) + +/** + * Result of [AmethystAppFunctions.zapEvent]. Total billed sats may + * differ from requested by a few sats due to whole-sat rounding in the + * splits — same drift the foreground UI has. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapEventResult( + /** Hex event id of the note being zapped. */ + val zappedEventId: String, + /** Total sats the caller asked for (capped, post-validation). */ + val requestedSats: Long, + /** Sum of per-recipient sats actually billed across all invoices. */ + val billedSats: Long, + /** Comment attached to every zap request. */ + val comment: String, + /** One invoice per recipient — multiple entries when the note has + * NIP-57 zap-split tags. Pay each one in a Lightning wallet to + * complete the zap; invoices with non-null [ZapInvoice.invoiceError] + * couldn't be fetched and won't go through. */ + val invoices: List, +) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt new file mode 100644 index 0000000000..0f17e6ca28 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +@Composable +fun LegalSettingsSection() { + val uriHandler = LocalUriHandler.current + + SettingsSection(R.string.about_legal) { + SettingsItem( + title = R.string.privacy_policy, + icon = MaterialSymbols.Lock, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", + ) + } + }, + ) + SettingsDivider() + SettingsItem( + title = R.string.child_safety_standards, + icon = MaterialSymbols.Shield, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", + ) + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt rename to amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt index 12cccee79c..fc6edc8f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedOff +package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal import androidx.compose.foundation.layout.Row import androidx.compose.material3.Checkbox @@ -33,9 +33,26 @@ import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun AcceptTerms( +fun TermsGate( checked: Boolean, - onCheckedChange: ((Boolean) -> Unit)?, + onCheckedChange: (Boolean) -> Unit, + showError: Boolean, +) { + AcceptTerms(checked = checked, onCheckedChange = onCheckedChange) + + if (showError) { + Text( + text = stringRes(R.string.acceptance_of_terms_is_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun AcceptTerms( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml new file mode 100644 index 0000000000..ccd8928677 --- /dev/null +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -0,0 +1,16 @@ + + + diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt new file mode 100644 index 0000000000..df35673a61 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt @@ -0,0 +1,489 @@ +/* + * 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.tor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +/** + * Tier-3 integration tests that drive the real Arti JNI shim on JVM, against the + * Linux x86_64 host build of the same wrapper crate that powers Android. The .so + * is checked in at `amethyst/src/test/native-libs/x86_64-linux/libarti_android.so` + * and the Gradle test task sets `java.library.path` to point at it. + * + * **Layered safety net for our Tor stack:** + * - [TorManagerTest] — fast unit tests, no Arti, virtual time. Covers Kotlin + * self-heal logic. + * - This file (smoke) — JNI bridge loads, version JNI call works. Always runs + * on Linux x86_64 hosts. ~10ms. Catches build/link regressions in the .so. + * - This file (integration) — opt-in via `-Pamethyst.arti.integration=true`. + * Real bootstrap + SOCKS round trips. Needs outbound TCP egress to arbitrary + * IPs/ports — works on most dev machines and Docker hosts with default + * networking; *will hang* on CI runners with restrictive egress lists. + * - `androidTest/.../tor/TorBootstrapInstrumentedTest` — same shape but against + * the Android .so on a connected device/emulator. + * + * **What the integration suite verifies that the unit tests cannot:** + * + * The original "Tor stops working until data-wipe" bug had four root causes that + * unit tests with a fake `TorBackend` can't exercise — they need the real Arti + * client, real circuits, real OS sockets: + * + * 1. The native `TorClient` getting stuck with bad guards / dead circuits / + * expired consensus, with no way to drop it in-process. Pre-fix there was + * no JNI `destroy()`. Verified by `destroy then re-initialize releases the + * state file lock cleanly`. + * + * 2. In-flight per-connection handlers each holding an `Arc` clone, + * pinning the state file lock past `destroy()`. Pre-fix the handler + * tracking was racy. Verified by `destroy aborts an in-flight SOCKS handler`. + * + * 3. `stopSocksProxy` deliberately not destroying the client (by design — for + * the legitimate stop/start reuse path), so a stuck client survived + * toggle-off-then-on. Verified by `stopSocksProxy then startSocksProxy + * reuses the running TorClient`. + * + * 4. State / fd / memory leaks accumulating across many destroy/init cycles + * (which the self-heal watchdog can drive at up to one per 5 minutes + * indefinitely). Verified by `survives multiple destroy then initialize + * cycles`. + * + * **Run the slow tests:** + * ``` + * ./gradlew :amethyst:testPlayDebugUnitTest \ + * --tests "com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest" \ + * -Pamethyst.arti.integration=true + * ``` + */ +class TorArtiNativeIntegrationTest { + private var dataDir: File? = null + + @After + fun tearDown() { + // Drop the in-process client between tests so the state file lock + // doesn't bleed across (and our tests stay independent). Idempotent — + // no-op if initialize never ran. + try { + ArtiNative.destroy() + } catch (_: Throwable) { + // Library may not have loaded if assumeArchAvailable skipped us. + } + dataDir?.deleteRecursively() + dataDir = null + } + + // --------------------------------------------------------------------- + // Smoke — runs without -P. Catches build/link regressions. + // --------------------------------------------------------------------- + + /** + * The host `.so` loads via `System.loadLibrary("arti_android")` and a trivial + * JNI function returns. If this fails, every other Tor test is moot — typical + * causes are a stale `.so` after an arti version bump, a missing rebuild on + * the test native-libs path, or a build that didn't export the expected JNI + * symbol. Always runs (no `-P` gate). + */ + @Test + fun `library loads and reports a version`() { + assumeArchAvailable() + val version = ArtiNative.getVersion() + assertTrue("Version string was: $version", version.startsWith("Arti ")) + println("[arti] $version") + } + + // --------------------------------------------------------------------- + // Bootstrap + round trip — the basic data plane. + // --------------------------------------------------------------------- + + /** + * Real bootstrap + SOCKS round trip. Regression net for: rustls + * `CryptoProvider` install after the v2.3.0 bump, `fs-mistrust` host-trust + * override on JVM, and every `Java_..._ArtiNative_*` JNI export. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `bootstraps and proxies an HTTPS request through Tor`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + val exitIp = fetchExitIp(port) + println("[test] First-bootstrap exit IP: $exitIp") + assertNotNull(exitIp) + } + + // --------------------------------------------------------------------- + // destroy + re-init releases state file lock — root cause #1. + // --------------------------------------------------------------------- + + /** + * After `destroy`, the *same* on-disk data dir must be re-acquirable by a + * fresh `initialize` — no "state file already locked" error, no need to + * `clearAllArtiData`. This is the direct mirror of the self-heal recovery + * path that the watchdog drives on stuck-Connecting. + */ + @Test(timeout = (BOOTSTRAP_TIMEOUT_MS * 2) + 60_000L) + fun `destroy then re-initialize releases the state file lock cleanly`() { + assumeFullIntegration() + val firstPort = bootstrapAndStartSocks() + val firstIp = fetchExitIp(firstPort) + println("[test] Pre-destroy exit IP: $firstIp") + + val destroyResult = ArtiNative.destroy() + assertEquals("destroy returned non-zero", 0, destroyResult) + + // Re-init against the SAME data dir. Must NOT see "state file already locked". + assertEquals( + "re-initialize after destroy should succeed without clearing data", + 0, + ArtiNative.initialize(dataDir!!.absolutePath), + ) + val secondPort = pickPort() + assertEquals("post-destroy startSocksProxy", 0, ArtiNative.startSocksProxy(secondPort)) + + val secondIp = fetchExitIp(secondPort) + println("[test] Post-destroy exit IP: $secondIp (changed=${firstIp != secondIp})") + assertNotNull(secondIp) + } + + // --------------------------------------------------------------------- + // In-flight handler abort — root cause #2. + // --------------------------------------------------------------------- + + /** + * If a SOCKS connection is open at the moment we call [ArtiNative.destroy], + * the handler's `Arc` clone must be released — otherwise the + * `TorClient` stays alive past `destroy()`, the state file lock isn't + * released, and the next `initialize()` fails with "already locked". + * + * Pre-fix the handler-task tracking was racy: a connection accepted between + * `SOCKS_TASK.abort()` and the `HANDLER_TASKS` drain pinned an Arc. This + * test exercises that race window directly. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `destroy aborts an in-flight SOCKS handler quickly`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + // Open a long-lived SOCKS connection. We don't actually read the body — + // we just want a handler to be alive in tokio-land when destroy hits. + val socksClient = socksOkHttp(port, readTimeoutSeconds = 300L) + val inFlight = + Thread { + try { + // Hit a deliberately slow path. The fact that it never returns + // is fine — we're going to destroy() out from under it. + socksClient + .newCall( + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build(), + ).execute() + .use { resp -> + @Suppress("UNUSED_VARIABLE") + val ignored = resp.body.string() + } + } catch (e: Throwable) { + println("[test] In-flight request aborted with: ${e.javaClass.simpleName}: ${e.message}") + } + } + inFlight.name = "in-flight-socks-request" + inFlight.start() + + // Let the handler get into client.connect() or io::copy. + Thread.sleep(1_500) + + // destroy() must return in roughly its budgeted time even with traffic + // in flight: ~1s wait for SOCKS_TASK termination + 500ms sleep for + // handler cleanup, plus some slack. + val destroyMs = + measureTimeMillis { + assertEquals(0, ArtiNative.destroy()) + } + println("[test] destroy() with in-flight handler returned in ${destroyMs}ms") + assertTrue("destroy took ${destroyMs}ms, expected < 3000ms", destroyMs < 3_000) + + // The in-flight thread should die promptly once its socket gets aborted. + inFlight.join(5_000) + assertTrue("In-flight request thread still alive after destroy", !inFlight.isAlive) + + // The critical assertion: the state file lock was released, so a fresh + // initialize against the SAME data dir works. Pre-fix this would fail + // because the orphaned handler still held an Arc. + val reinitMs = + measureTimeMillis { + assertEquals( + "re-initialize after destroy-with-in-flight should succeed", + 0, + ArtiNative.initialize(dataDir!!.absolutePath), + ) + } + println("[test] re-initialize after in-flight-destroy completed in ${reinitMs}ms") + } + + // --------------------------------------------------------------------- + // stop/start reuse — root cause #3 (negative test). + // --------------------------------------------------------------------- + + /** + * The legitimate stop/start reuse path: stopSocksProxy releases the SOCKS + * port but keeps the TorClient alive, and startSocksProxy on a fresh port + * binds against the same client without re-bootstrapping. This is the + * pattern the user-facing toggle uses; we just verify it still works after + * our self-heal changes. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `stopSocksProxy then startSocksProxy reuses the running TorClient`() { + assumeFullIntegration() + val firstPort = bootstrapAndStartSocks() + val firstIp = fetchExitIp(firstPort) + println("[test] Pre-stop exit IP: $firstIp") + + assertEquals(0, ArtiNative.stopSocksProxy()) + + // No new bootstrap should happen — the second startSocksProxy reuses + // the client. So this round trip should be fast (no consensus download). + val secondPort = pickPort() + val secondStartMs = + measureTimeMillis { + assertEquals(0, ArtiNative.startSocksProxy(secondPort)) + } + assertTrue( + "startSocksProxy on existing client took ${secondStartMs}ms — should be fast (no re-bootstrap)", + secondStartMs < 5_000, + ) + + val secondIp = fetchExitIp(secondPort) + println("[test] Post-stop/start exit IP: $secondIp (${secondStartMs}ms to re-bind)") + assertNotNull(secondIp) + } + + // --------------------------------------------------------------------- + // Many cycles — root cause #4 (gradual degradation). + // --------------------------------------------------------------------- + + /** + * The self-heal watchdog can drive `destroy → initialize` cycles up to once + * per 5 minutes for the entire app lifetime. Even at one per hour that's + * thousands of cycles before a user might restart the process. Verify we + * don't accumulate state corruption, file-descriptor leaks, or memory + * leaks across a handful of cycles. + * + * Bumped from 1 to [CYCLE_COUNT] cycles because 2 cycles (`destroy then + * re-initialize releases the state file lock cleanly`) is enough to catch + * the basic regression, but only 5+ catches gradual drift. + */ + @Test(timeout = (BOOTSTRAP_TIMEOUT_MS * CYCLE_COUNT) + 90_000L) + fun `survives multiple destroy then initialize cycles`() { + assumeFullIntegration() + dataDir = Files.createTempDirectory("arti-integ-cycles-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + + val ips = mutableListOf() + for (i in 1..CYCLE_COUNT) { + var ip: String? = null + val cycleMs = + measureTimeMillis { + assertEquals("cycle $i initialize", 0, ArtiNative.initialize(dataDir!!.absolutePath)) + val port = pickPort() + assertEquals("cycle $i startSocksProxy", 0, ArtiNative.startSocksProxy(port)) + ip = fetchExitIp(port) + ips += ip ?: "?" + assertEquals("cycle $i destroy", 0, ArtiNative.destroy()) + } + println("[test] Cycle $i/$CYCLE_COUNT exit=$ip in ${cycleMs}ms") + } + // No hard assertion on IPs being different — Tor's exit selection isn't + // deterministic and small cycles can repeat exits. We just log them so + // the developer can see circuit variety. + println("[test] All ${CYCLE_COUNT} cycles completed. Exit IPs: $ips") + } + + // --------------------------------------------------------------------- + // Concurrent SOCKS — accept loop + handler tracking under load. + // --------------------------------------------------------------------- + + /** + * Verify that multiple in-flight SOCKS connections can coexist. This + * exercises: + * - the Rust accept loop pushing to `HANDLER_TASKS` (incl. its retain-on-push + * dedup of finished handles), + * - per-handler `Arc` clones being independent, + * - the listener handling several `accept().await` rounds back-to-back. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 120_000L) + fun `proxies concurrent SOCKS requests in parallel`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + val concurrency = 5 + val ips = + runBlocking { + (1..concurrency) + .map { + async(Dispatchers.IO) { + fetchExitIp(port) + } + }.awaitAll() + } + ips.forEachIndexed { i, ip -> + println("[test] Concurrent request ${i + 1}/$concurrency exit: $ip") + } + assertTrue("All concurrent requests should return an IP", ips.all { it != null }) + } + + // --------------------------------------------------------------------- + // destroy idempotency. + // --------------------------------------------------------------------- + + /** + * Calling `destroy()` when the client is already destroyed (or was never + * initialized) should be a safe no-op. Pre-fix, a double-destroy could + * panic on the Rust side because of unwrap on an already-`None` Option. + * Now it's idempotent. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `destroy is idempotent`() { + assumeFullIntegration() + // Destroy before any initialize — should be a no-op. + assertEquals("destroy on uninitialized client", 0, ArtiNative.destroy()) + + // Initialize, then destroy twice. + dataDir = Files.createTempDirectory("arti-integ-idem-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + assertEquals(0, ArtiNative.initialize(dataDir!!.absolutePath)) + assertEquals("first destroy", 0, ArtiNative.destroy()) + assertEquals("second destroy is a no-op", 0, ArtiNative.destroy()) + + // After two destroys, a fresh initialize still works. + assertEquals("initialize after double-destroy", 0, ArtiNative.initialize(dataDir!!.absolutePath)) + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private fun bootstrapAndStartSocks(): Int { + dataDir = Files.createTempDirectory("arti-integ-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + val bootstrapMs = + measureTimeMillis { + val initResult = ArtiNative.initialize(dataDir!!.absolutePath) + assertEquals("initialize returned $initResult", 0, initResult) + } + val port = pickPort() + val socksResult = ArtiNative.startSocksProxy(port) + assertEquals("startSocksProxy returned $socksResult", 0, socksResult) + println("[test] Bootstrap+startSocks complete in ${bootstrapMs}ms on port $port") + return port + } + + /** + * Fetches `https://check.torproject.org/api/ip` through the given SOCKS port + * and returns the reported exit IP, or `null` if the response is malformed. + * Asserts the request succeeded — callers can `assertNotNull` if they care + * about the IP itself. + */ + private fun fetchExitIp(port: Int): String? { + val client = socksOkHttp(port) + val body = + client + .newCall( + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build(), + ).execute() + .use { resp -> + assertEquals("HTTP 200 from check.torproject.org", 200, resp.code) + resp.body.string() + } + assertTrue( + "Response should report IsTor:true — body was: $body", + body.contains("\"IsTor\":true"), + ) + return EXIT_IP_REGEX.find(body)?.groupValues?.getOrNull(1) + } + + private fun socksOkHttp( + port: Int, + readTimeoutSeconds: Long = 60L, + ): OkHttpClient = + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) + .build() + + private fun pickPort(): Int = (40_000..49_999).random() + + /** Composite of `assumeArchAvailable` + `assumeIntegrationEnabled`. */ + private fun assumeFullIntegration() { + assumeArchAvailable() + assumeIntegrationEnabled() + } + + /** + * The checked-in test .so is built for Linux x86_64 only. Skip on other + * hosts rather than failing — a developer on macOS or aarch64 shouldn't see + * a build break just because they ran the full test suite. + */ + private fun assumeArchAvailable() { + val arch = System.getProperty("os.arch")?.lowercase().orEmpty() + val os = System.getProperty("os.name")?.lowercase().orEmpty() + assumeTrue( + "Test .so is provided only for Linux x86_64 (was: $os $arch). " + + "To run elsewhere, rebuild with `./tools/arti-build/build-arti-host.sh`.", + os.contains("linux") && (arch == "amd64" || arch == "x86_64"), + ) + } + + private fun assumeIntegrationEnabled() { + assumeTrue( + "Set -Pamethyst.arti.integration=true to enable. Needs network egress " + + "to arbitrary IPs/ports (Tor directory authorities + guards) — restrictive " + + "CI runners will hang in initialize().", + System.getProperty("amethyst.arti.integration") == "true", + ) + } + + companion object { + private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L + private const val CYCLE_COUNT: Int = 5 + private val EXIT_IP_REGEX = """"IP"\s*:\s*"([^"]+)"""".toRegex() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt new file mode 100644 index 0000000000..8afa86072e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -0,0 +1,447 @@ +/* + * 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.tor + +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [TorManager]'s self-heal logic. Drives the manager with in-memory + * [TorBackend] + [TorPreferencesPort] fakes and a virtual clock so the 45s watchdog + * delay and 5-min cooldown can be exercised in milliseconds. + * + * Companion integration test in `amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt` + * covers the real-Arti bootstrap path on-device (currently @Ignore'd; see file for enable steps). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TorManagerTest { + // ------------------------------------------------------------------ + // construction + persisted state + // ------------------------------------------------------------------ + + @Test + fun `init loads persisted bypass approval`() = + runTest(UnconfinedTestDispatcher()) { + val recent = 1_000_000_000_000L + val prefs = FakeTorPreferences(initialApprovalMs = recent) + val manager = buildManager(prefs = prefs, clock = { recent + 1_000L }) + + advanceUntilIdle() + + assertTrue(manager.rememberedApprovalActive()) + } + + @Test + fun `init does not flag approval when none persisted`() = + runTest(UnconfinedTestDispatcher()) { + val manager = buildManager() + advanceUntilIdle() + + assertFalse(manager.rememberedApprovalActive()) + } + + @Test + fun `rememberedApprovalActive is false once outside the 1h window`() = + runTest(UnconfinedTestDispatcher()) { + val now = 1_000_000_000_000L + val tooOld = now - TorManager.APPROVAL_REMEMBER_MS - 1L + val prefs = FakeTorPreferences(initialApprovalMs = tooOld) + val manager = buildManager(prefs = prefs, clock = { now }) + + advanceUntilIdle() + + assertFalse(manager.rememberedApprovalActive()) + } + + // ------------------------------------------------------------------ + // torType change clears the bypass loop + // ------------------------------------------------------------------ + + @Test + fun `torType change clears in-memory bypass and persisted approval`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialApprovalMs = 999L) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + manager.sessionBypass.value = true + + prefs.setTorType(TorType.OFF) + advanceUntilIdle() + + assertFalse(manager.sessionBypass.value) + assertEquals(0L, prefs.lastBypassApprovalMs) + } + + @Test + fun `approveBypassForOneHour sets sessionBypass and persists timestamp`() = + runTest(UnconfinedTestDispatcher()) { + val now = 1_000_000_000_000L + val prefs = FakeTorPreferences() + val manager = buildManager(prefs = prefs, clock = { now }) + advanceUntilIdle() + + manager.approveBypassForOneHour() + advanceUntilIdle() + + assertTrue(manager.sessionBypass.value) + assertEquals(now, prefs.lastBypassApprovalMs) + assertTrue(manager.rememberedApprovalActive()) + } + + // ------------------------------------------------------------------ + // onNetworkChange — drops client + clears bypass + primes cooldown + // ------------------------------------------------------------------ + + @Test + fun `onNetworkChange clears bypass and persisted approval and resets backend`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialApprovalMs = 12345L) + val backend = FakeTorBackend() + val manager = buildManager(prefs = prefs, backend = backend) + advanceUntilIdle() + manager.sessionBypass.value = true + + manager.onNetworkChange() + advanceUntilIdle() + + assertFalse(manager.sessionBypass.value) + assertEquals(0L, prefs.lastBypassApprovalMs) + assertTrue("onNetworkChange should reset backend at least once", backend.resetCount >= 1) + } + + @Test + fun `onNetworkChange primes cooldown so the watchdog does not double-reset`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + // Constant clock — the only way self-heal would fire is if onNetworkChange + // failed to prime lastSelfHealAtMs. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + val resetCountBefore = backend.resetCount + + manager.onNetworkChange() + advanceUntilIdle() + + // Status is back at Connecting after the network-change reset cycle. + // Advance past the 45s watchdog; cooldown must suppress a second reset. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + // Exactly one extra reset from onNetworkChange itself, none from the watchdog. + assertEquals(resetCountBefore + 1, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + // ------------------------------------------------------------------ + // stuck-Connecting watchdog + // ------------------------------------------------------------------ + + @Test + fun `watchdog uses gentle reset before first Active`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + // Big constant clock so (now - lastSelfHealAtMs=0) is well past cooldown. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + assertEquals(0, backend.resetCount) + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("gentle reset only — no state wipe before first Active", 1, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog uses full reset after first Active`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + // Drive backend to Active so hasEverBootstrapped flips. + backend.setActive(9050) + advanceUntilIdle() + assertTrue(manager.status.value is TorServiceStatus.Active) + + // Back to Connecting — watchdog timer (re-)starts. + backend.setConnecting() + advanceUntilIdle() + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals(0, backend.resetCount) + assertEquals("after Active, watchdog wipes state too", 1, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog cancels its delay when status leaves Connecting`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + // Halfway through the watchdog delay, the bootstrap succeeds. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS / 2) + backend.setActive(9050) + advanceUntilIdle() + + // Past the original deadline — must NOT fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS) + runCurrent() + + assertEquals(0, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog cooldown blocks a second fire within the window`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + var clockNow = 1_000_000_000_000L + val manager = buildManager(backend = backend, clock = { clockNow }) + advanceUntilIdle() + + // First fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals(1, backend.resetCount) + + // Status returns to Connecting via the reset → re-start cycle. Advance another + // 45s of virtual time — clock has barely moved, so cooldown must block. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals("cooldown should suppress the second fire", 1, backend.resetCount) + } + + @Test + fun `watchdog can fire again once the cooldown elapses`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + var clockNow = 1_000_000_000_000L + val manager = buildManager(backend = backend, clock = { clockNow }) + advanceUntilIdle() + + // First fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals(1, backend.resetCount) + + // Move wall-clock past the cooldown window. + clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("after cooldown elapses, watchdog fires again", 2, backend.resetCount) + } + + // ------------------------------------------------------------------ + // top-level status routing + // ------------------------------------------------------------------ + + @Test + fun `status emits Off when torType is OFF`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.OFF) + val backend = FakeTorBackend() + val manager = buildManager(prefs = prefs, backend = backend) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + assertEquals(0, backend.startCount) + } + + @Test + fun `status emits Active(port) for EXTERNAL with valid port`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 9150) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + val status = manager.status.value + assertTrue(status is TorServiceStatus.Active) + assertEquals(9150, (status as TorServiceStatus.Active).port) + } + + @Test + fun `status emits Off for EXTERNAL when port is invalid`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 0) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + } + + @Test + fun `status follows backend status under INTERNAL`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + + backend.setActive(17392) + advanceUntilIdle() + assertEquals(TorServiceStatus.Active(17392), manager.status.value) + } + + @Test + fun `activePortOrNull mirrors the Active port`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + // activePortOrNull is WhileSubscribed — give it a subscriber for the test. + val portJob = backgroundScope.launch { manager.activePortOrNull.collect {} } + advanceUntilIdle() + + backend.setActive(17392) + advanceUntilIdle() + + assertEquals(17392, manager.activePortOrNull.value) + portJob.cancel() + } + + @Test + fun `sessionBypass forces Off even with torType INTERNAL`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + advanceUntilIdle() + backend.setActive(17392) + advanceUntilIdle() + assertNotEquals(TorServiceStatus.Off, manager.status.value) + + manager.sessionBypass.value = true + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + assertTrue(backend.stopCount >= 1) + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private fun TestScope.buildManager( + prefs: FakeTorPreferences = FakeTorPreferences(), + backend: FakeTorBackend = FakeTorBackend(), + clock: () -> Long = { 1_000_000_000_000L }, + ): TorManager = + TorManager( + torPrefs = prefs, + service = backend, + scope = backgroundScope, + // Unconfined so `MutableStateFlow.value = …` propagates through `flowOn` + // synchronously — otherwise advanceUntilIdle never settles the cross-dispatcher + // channel and `manager.status.value` is observed as the stateIn initial (Off). + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + nowMs = clock, + ) +} + +/** In-memory [TorBackend] driven by tests. */ +private class FakeTorBackend : TorBackend { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + var startCount = 0 + private set + var stopCount = 0 + private set + var resetCount = 0 + private set + var resetWithCleanStateCount = 0 + private set + + override suspend fun start() { + startCount++ + _status.value = TorServiceStatus.Connecting + } + + override suspend fun stop() { + stopCount++ + _status.value = TorServiceStatus.Off + } + + override suspend fun reset() { + resetCount++ + _status.value = TorServiceStatus.Off + } + + override suspend fun resetWithCleanState() { + resetWithCleanStateCount++ + _status.value = TorServiceStatus.Off + } + + fun setActive(port: Int) { + _status.value = TorServiceStatus.Active(port) + } + + fun setConnecting() { + _status.value = TorServiceStatus.Connecting + } +} + +/** In-memory [TorPreferencesPort] driven by tests. */ +private class FakeTorPreferences( + initialTorType: TorType = TorType.INTERNAL, + initialPort: Int = 9050, + initialApprovalMs: Long = 0L, +) : TorPreferencesPort { + private val _torType = MutableStateFlow(initialTorType) + private val _externalSocksPort = MutableStateFlow(initialPort) + + override val torType: StateFlow = _torType.asStateFlow() + override val externalSocksPort: StateFlow = _externalSocksPort.asStateFlow() + + var lastBypassApprovalMs: Long = initialApprovalMs + + override suspend fun loadLastBypassApprovalMs(): Long = lastBypassApprovalMs + + override suspend fun saveLastBypassApprovalMs(value: Long) { + lastBypassApprovalMs = value + } + + fun setTorType(value: TorType) { + _torType.value = value + } +} diff --git a/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so new file mode 100755 index 0000000000..ef6d500311 Binary files /dev/null and b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so differ diff --git a/build.gradle.kts b/build.gradle.kts index 8d1b957095..4ffcd6c263 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) + alias(libs.plugins.googleKsp) apply false } // Shared app version for all subprojects — read from gradle/libs.versions.toml. 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 db8818f3d7..6d9c0d8fec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -159,6 +159,22 @@ private suspend fun dispatch(argv: Array): Int { Commands.store(dataDir, tail) } + "follow" -> { + Commands.follow(dataDir, tail) + } + + "unfollow" -> { + Commands.unfollow(dataDir, tail) + } + + "search" -> { + Commands.search(dataDir, tail) + } + + "zap" -> { + Commands.zap(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -327,6 +343,28 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Contacts (NIP-02 kind:3): + | follow USER [--timeout SECS] add USER to your contact list + | unfollow USER [--timeout SECS] remove USER from your contact list + | (USER: npub|nprofile|hex|name@domain) + | + |Zaps (NIP-57): + | zap user USER SATS build a profile zap-request, fetch a BOLT11 + | [--comment X] [--anon|--private] invoice from the recipient's LN service + | [--timeout SECS] (no auto-payment — paste invoice into a wallet) + | zap event EVENT-ID SATS same, but attribute the zap to a specific + | [--comment X] [--anon|--private] event (must be in local store) + | [--timeout SECS] + | + |Search (NIP-50): + | search user QUERY [--limit N] search kind:0 profiles + | [--timeout SECS] + | search note QUERY [--limit N] search event content + | [--kinds K[,K…]] (default kind:1; e.g. 1,30023) + | [--timeout SECS] + | uses your kind:10007 search-relay + | list, falls back to Amethyst defaults + | |Direct messages (NIP-17): | dm send RECIPIENT TEXT send a gift-wrapped DM | [--allow-fallback] (default: only deliver to recipient's kind:10050) 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 83a111bd24..446b3acf1c 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 @@ -95,4 +95,24 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = StoreCommands.dispatch(dataDir, tail) + + suspend fun follow( + dataDir: DataDir, + tail: Array, + ): Int = FollowCommand.follow(dataDir, tail) + + suspend fun unfollow( + dataDir: DataDir, + tail: Array, + ): Int = FollowCommand.unfollow(dataDir, tail) + + suspend fun search( + dataDir: DataDir, + tail: Array, + ): Int = SearchCommand.dispatch(dataDir, tail) + + suspend fun zap( + dataDir: DataDir, + tail: Array, + ): Int = ZapCommand.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 9fccae5dcc..9cd003ae30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.DmActions import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator @@ -34,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -86,8 +86,7 @@ object DmCommands { try { ctx.prepare() val recipient = ctx.requireUserHex(rest[0]) - val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) - val result = NIP17Factory().createMessageNIP17(template, ctx.signer) + val result = DmActions.buildTextDm(ctx.signer, recipient, text) return publishWraps(ctx, result, allowFallback) } finally { ctx.close() @@ -124,27 +123,34 @@ object DmCommands { ctx.prepare() val recipient = ctx.requireUserHex(recipientInput) - val (template, summary) = + val (result, summary) = if (args.flag("file") != null) { - buildUploadModeTemplate(ctx, recipient, args) + buildUploadedFileDm(ctx, recipient, args) ?: return 1 } else { - buildReferenceModeTemplate(args, recipient) + buildReferencedFileDm(ctx, recipient, args) ?: return 1 } - val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer) return publishWraps(ctx, result, allowFallback, extra = summary) } finally { ctx.close() } } - private suspend fun buildUploadModeTemplate( + /** + * Upload mode: read the local file, encrypt with a fresh AESGCM key, + * push the ciphertext to a Blossom server, then call into + * [DmActions.buildFileDmReference] with the resulting URL + metadata. + * Returns the gift-wrap result plus an `extra` map that surfaces the + * upload's cipher material on stdout so callers can re-share or + * republish the same blob without re-uploading. + */ + private suspend fun buildUploadedFileDm( ctx: Context, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, + recipient: HexKey, args: Args, - ): Pair, Map>? { + ): Pair>? { val file = java.io.File(args.requireFlag("file")) if (!file.exists()) { Output.error("bad_args", "file does not exist: ${file.absolutePath}") @@ -169,9 +175,10 @@ object DmCommands { .DimensionTag(w, h) } } - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = uploadedUrl, cipher = cipher, mimeType = mimeType, @@ -181,8 +188,6 @@ object DmCommands { blurhash = uploaded.metadata.blurhash, originalHash = uploaded.metadata.sha256, ) - // Surface the cipher material on stdout so callers can re-share - // or republish the same encrypted blob without re-uploading. val summary = mapOf( "url" to uploadedUrl, @@ -193,13 +198,19 @@ object DmCommands { "original_hash" to uploaded.metadata.sha256, "mime_type" to mimeType, ) - return template to summary + return result to summary } - private fun buildReferenceModeTemplate( + /** + * Reference mode: the file is already uploaded somewhere; the user + * hands us the URL + cipher key/nonce + whatever metadata they want + * stamped onto the kind:15. + */ + private suspend fun buildReferencedFileDm( + ctx: Context, + recipient: HexKey, args: Args, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, - ): Pair, Map>? { + ): Pair>? { val url = args.positionalOrNull(0) ?: run { Output.error("bad_args", USAGE_SEND_FILE) @@ -236,9 +247,10 @@ object DmCommands { val cipher = com.vitorpamplona.quartz.utils.ciphers .AESGCM(keyBytes, nonceBytes) - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = url, cipher = cipher, mimeType = mimeType, @@ -248,7 +260,7 @@ object DmCommands { blurhash = blurhash, originalHash = originalHash, ) - return template to emptyMap() + return result to emptyMap() } private const val USAGE_SEND_FILE: String = @@ -278,7 +290,7 @@ object DmCommands { "wrap_id" to wrap.id, "published_to" to ack.filterValues { it }.keys.map { it.url }, "relays_tried" to resolution.relays.map { it.url }, - "relay_source" to resolution.source, + "relay_source" to resolution.source.name.lowercase(), ), ) } @@ -402,39 +414,29 @@ object DmCommands { } /** - * Per NIP-17: kind:1059 should only be delivered to relays the recipient - * has advertised in their kind:10050. When that list is empty: - * - strict (default): refuse with no_dm_relays — caller must fix or - * explicitly opt into a fallback. - * - allowFallback=true: fall through to the NIP-65 read marker and then - * to our bootstrap pool. + * Cache-first relay lookup. If amy has previously seen the recipient's + * kind:10050 / 10051 / 10002 events, use the local copy and skip the + * network drain entirely. Otherwise drain `seedRelays` for them. Then + * hands the resulting [RecipientRelayFetcher.Lists] off to + * [DmActions.resolveDmRelays] which applies the strict-kind:10050 / + * fallback policy. */ private suspend fun resolveDmRelays( ctx: Context, recipient: HexKey, allowFallback: Boolean, - ): RelaySet { + ): DmActions.DmRelaySet { val seed = ctx.bootstrapRelays() - // Cache-first: if Amy has previously seen the recipient's - // kind:10050 / 10051 / 10002 events, use the local copy and - // skip the network drain entirely. Falls back to the live - // fetcher only if the local store has nothing. val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) - val dmInbox = lists.dmInbox.toSet() - if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050") - if (!allowFallback) return RelaySet(emptySet(), "kind_10050") - val nip65Read = lists.nip65Read().toSet() - if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read") - return RelaySet(seed, "bootstrap") + return DmActions.resolveDmRelays( + recipientLists = lists, + bootstrap = seed, + allowFallback = allowFallback, + ) } - private data class RelaySet( - val relays: Set, - val source: String, - ) - private sealed interface DecryptedDm { val id: HexKey val wrapId: HexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt new file mode 100644 index 0000000000..c96234c8f4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.FollowActions +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.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +/** + * `amy follow ` and `amy unfollow ` — update the active + * account's NIP-02 kind:3 contact list. + * + * Both commands fetch the user's latest kind:3 from their outbox relays + * before mutating, so concurrent follows from another client are preserved + * (the new event is built on top of the freshest known list). + * + * Identifier formats accepted by ``: npub / nprofile / 64-hex / + * `name@domain.tld` — same set [Context.requireUserHex] handles. + */ +object FollowCommand { + suspend fun follow( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest, FollowOp.FOLLOW) + + suspend fun unfollow( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest, FollowOp.UNFOLLOW) + + private enum class FollowOp { FOLLOW, UNFOLLOW } + + private suspend fun run( + dataDir: DataDir, + rest: Array, + op: FollowOp, + ): Int { + if (rest.isEmpty()) { + val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow" + return Output.error("bad_args", "$verb [--timeout SECS]") + } + val userArg = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 8L) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val target = ctx.requireUserHex(userArg) + val self = ctx.identity.pubKeyHex + if (target == self) { + return Output.error("bad_args", "cannot follow/unfollow yourself") + } + + val outbox = ctx.outboxRelays() + if (outbox.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; run `amy relay add` or `amy create`") + } + + val latest = fetchLatestContactList(ctx, self, outbox, timeoutSecs * 1000) + val previouslyFollowed = latest?.isTaggedUser(target) ?: false + + // Relay hint embedded in the `p` tag for new follows — points + // readers at a relay where they'll find the target's events. + // Best-effort: first write relay from the target's cached + // kind:10002 advertised relay list, null if we've never seen + // one. Mirrors User.bestRelayHint() in the Android UI. + val targetRelayHint = + if (op == FollowOp.FOLLOW) { + ctx + .relaysOf(target) + ?.writeRelaysNorm() + ?.firstOrNull() + } else { + null + } + + val newEvent: ContactListEvent? = + when (op) { + FollowOp.FOLLOW -> + FollowActions.buildFollow( + signer = ctx.signer, + pubkeyToFollow = target, + currentContactList = latest, + relayHint = targetRelayHint, + ) + FollowOp.UNFOLLOW -> + FollowActions.buildUnfollow( + signer = ctx.signer, + pubkeyToUnfollow = target, + currentContactList = latest, + ) + } + + // No-op cases: already following / not following. + if (newEvent == null || newEvent.id == latest?.id) { + Output.emit( + mapOf( + "target" to target, + "op" to op.name.lowercase(), + "changed" to false, + "previously_followed" to previouslyFollowed, + "based_on" to latest?.id, + "follow_count" to (latest?.verifiedFollowKeySet()?.size ?: 0), + ), + ) + return 0 + } + + val ack = ctx.publish(newEvent, outbox) + Output.emit( + mapOf( + "target" to target, + "op" to op.name.lowercase(), + "changed" to true, + "previously_followed" to previouslyFollowed, + "event_id" to newEvent.id, + "created_at" to newEvent.createdAt, + "based_on" to latest?.id, + "follow_count" to newEvent.verifiedFollowKeySet().size, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Fetch the freshest kind:3 for [pubKey] from [relays]. Returns null when + * no relay surfaces one within the timeout. We never trust the local + * store alone for the base event — a stale read here would silently drop + * follows the user made from another client. + */ + private suspend fun fetchLatestContactList( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): ContactListEvent? { + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(ContactListEvent.KIND), authors = listOf(pubKey), limit = 1) + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return received + .mapNotNull { (_, ev) -> ev as? ContactListEvent } + .filter { it.pubKey == pubKey } + .maxByOrNull { it.createdAt } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt new file mode 100644 index 0000000000..0c52732a96 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.SearchActions +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent + +/** + * `amy search ` — NIP-50 full-text search across the + * caller's configured search relays (kind:10007) or, when none is set, + * Amethyst's curated default search-relay list. + * + * Two subcommands: + * * `search user ` drains kind:0 metadata events whose content + * matches [query] — useful for resolving a partial display name to + * an npub before a follow / DM. + * * `search note ` drains kind:1 short text notes matching + * [query]. Use `--kinds 1,30023` to widen to long-form articles. + * + * Output is the raw relay-side hit set deduped by event id and sorted + * by `created_at` descending. Client-side pseudo-kind filters + * (`reply` / `media`) live in + * [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] and + * are not exposed here yet. + */ +object SearchCommand { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "search [--limit N] [--timeout SECS]") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "user" -> searchUsers(dataDir, rest) + "note" -> searchNotes(dataDir, rest) + else -> Output.error("bad_args", "search ${tail[0]} — expected user|note") + } + } + + private suspend fun searchUsers( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "search user [--limit N] [--timeout SECS]") + val query = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.longFlag("limit", 20L).toInt() + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val filter = + SearchActions.searchProfilesFilter(query, limit) + ?: return Output.error("bad_args", "query must not be blank") + + return runSearch(dataDir, query, filter, timeoutMs) { events -> + events + .mapNotNull { it as? MetadataEvent } + // Dedup by pubkey, not event id — multiple relays may return + // different kind:0 revisions for the same author; keep only + // the freshest. Matches the App Functions adapter so amy + // and Gemini surface the same profile count for a query. + .sortedByDescending { it.createdAt } + .distinctBy { it.pubKey } + .map { ev -> + val parsed = + try { + Output.mapper.readTree(ev.content) + } catch (_: Exception) { + null + } + mapOf( + "event_id" to ev.id, + "pubkey" to ev.pubKey, + "created_at" to ev.createdAt, + "metadata" to (parsed ?: emptyMap()), + ) + } + } + } + + private suspend fun searchNotes( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "search note [--limit N] [--timeout SECS] [--kinds K[,K…]]") + val query = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.longFlag("limit", 50L).toInt() + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val kindList = + args.flags["kinds"] + ?.split(',') + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + ?: SearchActions.DEFAULT_NOTE_KINDS + + val filter = + SearchActions.searchNotesFilter(query, kinds = kindList, limit = limit) + ?: return Output.error("bad_args", "query must not be blank") + + return runSearch(dataDir, query, filter, timeoutMs) { events -> + events + .filter { it.kind in kindList } + .map { ev -> + mapOf( + "event_id" to ev.id, + "pubkey" to ev.pubKey, + "kind" to ev.kind, + "created_at" to ev.createdAt, + "content" to ev.content, + ) + } + } + } + + private suspend fun runSearch( + dataDir: DataDir, + query: String, + filter: Filter, + timeoutMs: Long, + render: (List) -> List>, + ): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = + SearchActions.resolveSearchRelays( + signer = ctx.signer, + currentList = loadOwnSearchList(ctx), + ) + if (relays.isEmpty()) { + return Output.error("no_relays", "no search relays available (no kind:10007 and DefaultSearchRelayList is empty?)") + } + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val deduped = + received + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + + Output.emit( + mapOf( + "query" to query, + "queried_relays" to relays.map { it.url }, + "match_count" to deduped.size, + "results" to render(deduped), + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Pull the caller's own kind:10007 from the local store. Returns null + * when amy has never observed one — caller falls back to + * [com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList] + * via [SearchActions.resolveSearchRelays]. + */ + private suspend fun loadOwnSearchList(ctx: Context): SearchRelayListEvent? = + ctx.store + .query( + Filter( + authors = listOf(ctx.identity.pubKeyHex), + kinds = listOf(SearchRelayListEvent.KIND), + limit = 1, + ), + ).firstOrNull() as? SearchRelayListEvent +} 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 new file mode 100644 index 0000000000..9d864840a6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.ZapActions +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import okhttp3.OkHttpClient + +/** + * `amy zap ` — build a NIP-57 zap request and + * fetch a BOLT11 invoice from the recipient's Lightning service. + * + * Two subcommands: + * * `zap user ` — profile zap (no event reference) + * * `zap event ` — event zap (must be in local store) + * + * The flow is: + * 1. Resolve recipient identifier → pubkey + kind:0 metadata. + * 2. Extract LN address (`lud16` preferred, then `lud06` LNURL). + * 3. Build + sign the NIP-57 kind:9734 zap-request event via + * [ZapActions]. + * 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. + */ +object ZapCommand { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "zap [--comment X] [--anon] [--timeout SECS]") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "user" -> zapUser(dataDir, rest) + "event" -> zapEvent(dataDir, rest) + else -> Output.error("bad_args", "zap ${tail[0]} — expected user|event") + } + } + + private suspend fun zapUser( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + val userArg = rest[0] + val sats = + rest[1].toLongOrNull()?.takeIf { it > 0 } + ?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')") + val args = Args(rest.drop(2).toTypedArray()) + val comment = args.flag("comment") ?: "" + val zapType = parseZapType(args) + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val recipient = ctx.requireUserHex(userArg) + val metadata = + fetchLatestMetadata(ctx, recipient, ctx.bootstrapRelays(), timeoutMs) + ?: return Output.error("not_found", "no kind:0 metadata found for $recipient") + val lnAddress = + ZapActions.extractLnAddress(metadata) + ?: return Output.error("no_lightning", "recipient has no lud16 or lud06 in their profile") + + val request = + ZapActions.buildUserZapRequest( + signer = ctx.signer, + recipientPubkey = recipient, + amountMillisats = ZapActions.satsToMillisats(sats), + inboxRelays = ctx.outboxRelays(), + comment = comment, + zapType = zapType, + ) + + emitZapResult(ctx, sats, lnAddress, comment, request, zapType) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun zapEvent( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--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 = + rest[1].toLongOrNull()?.takeIf { it > 0 } + ?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')") + val args = Args(rest.drop(2).toTypedArray()) + val comment = args.flag("comment") ?: "" + val zapType = parseZapType(args) + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val zappedEvent = + ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() + ?: return Output.error("not_found", "event $eventId not in local store; sync first or fetch by id") + + val bootstrap = ctx.bootstrapRelays() + + // Resolves a pubkey to an LN address by reading the latest + // kind:0 from the local store, falling back to a relay drain + // when never seen. Mirrors what the Amethyst foreground UI + // pulls out of User.lnAddress(). + val lookupLnAddress: suspend (HexKey) -> String? = { pk -> + fetchLatestMetadata(ctx, pk, bootstrap, timeoutMs) + ?.let(ZapActions::extractLnAddress) + } + + // Recipient's NIP-65 read ("inbox") relays — read-side flag on + // their advertised kind:10002. These get unioned into each + // zap request's `relays` tag so the kind:9735 receipt routes + // to the recipient's clients. Matches `User.inboxRelays()` in + // the Android Account. + val lookupInboxRelays: suspend (HexKey) -> Set = { pk -> + ctx + .relaysOf(pk) + ?.readRelaysNorm() + ?.toSet() + .orEmpty() + } + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = ctx.signer, + zappedEvent = zappedEvent, + totalAmountMillisats = ZapActions.satsToMillisats(sats), + senderInboxRelays = ctx.outboxRelays(), + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, + comment = comment, + zapType = zapType, + ) + + if (requests.isEmpty()) { + return Output.error( + "no_lightning", + "no payable recipients — neither the author nor any zap-split recipient has a usable LN address", + ) + } + + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun emitZapResult( + ctx: Context, + sats: Long, + lnAddress: String, + comment: String, + request: LnZapRequestEvent, + zapType: LnZapEvent.ZapType, + zappedEventId: HexKey? = null, + ) { + // Reuse the same OkHttp instance the Context uses for nip-05 / WS; + // this respects any proxy/timeout config wired in there. + val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) + + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = ZapActions.satsToMillisats(sats), + message = comment, + zapRequest = request, + ) + + when (result) { + is LightningAddressResolver.Result.Success -> { + Output.emit( + buildMap { + put("ln_address", lnAddress) + put("amount_sats", sats) + put("zap_type", zapType.name.lowercase()) + put("comment", comment) + put("zap_request_id", request.id) + if (zappedEventId != null) put("zapped_event_id", zappedEventId) + put("invoice", result.invoice) + }, + ) + } + is LightningAddressResolver.Result.Error -> { + Output.error("invoice_failed", result.message) + } + } + } + + /** + * Multi-recipient (split-aware) event-zap result emitter. Fetches one + * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a + * single JSON object enumerating each recipient + its invoice (or + * per-recipient `invoice_error` when the LNURL fetch fails). Total + * sat sum may be a few millisats below the requested amount due to + * whole-sat rounding in the split shares. + */ + private suspend fun emitSplitZapResult( + ctx: Context, + sats: Long, + comment: String, + zappedEventId: HexKey, + zapType: LnZapEvent.ZapType, + requests: List, + ) { + val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) + + val recipientEntries = + requests.map { req -> + val shareSats = req.amountMillisats / 1000 + val result = + resolver.fetchInvoice( + lnAddress = req.recipient.lnAddress, + milliSats = req.amountMillisats, + message = comment, + zapRequest = req.request, + ) + val entry = + mutableMapOf( + "ln_address" to req.recipient.lnAddress, + "pubkey" to req.recipient.pubkey, + "weight" to req.recipient.weight, + "amount_sats" to shareSats, + "zap_request_id" to req.request.id, + ) + when (result) { + is LightningAddressResolver.Result.Success -> + entry["invoice"] = result.invoice + + is LightningAddressResolver.Result.Error -> + entry["invoice_error"] = result.message + } + entry + } + + Output.emit( + mapOf( + "zapped_event_id" to zappedEventId, + "zap_type" to zapType.name.lowercase(), + "comment" to comment, + "requested_sats" to sats, + "billed_sats" to recipientEntries.sumOf { (it["amount_sats"] as? Long) ?: 0L }, + "recipient_count" to recipientEntries.size, + "recipients" to recipientEntries, + ), + ) + } + + private fun parseZapType(args: Args): LnZapEvent.ZapType = + when { + args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS + args.bool("private") -> LnZapEvent.ZapType.PRIVATE + else -> LnZapEvent.ZapType.PUBLIC + } + + private suspend fun fetchLatestMetadata( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): MetadataEvent? { + // Cache-first: try the local store before going to the network. + ctx.profileOf(pubKey)?.let { return it } + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1) + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return received + .mapNotNull { (_, ev) -> ev as? MetadataEvent } + .filter { it.pubKey == pubKey } + .maxByOrNull { it.createdAt } + } + + /** + * Per-invocation OkHttpClient. Amy's [Context] also has its own OkHttp + * (for WS + NIP-05); we keep this separate because [Context.okhttp] is + * private — exposing it just to reuse here would widen the API more + * than is warranted for a single LNURL fetch. + */ + private fun sharedOkHttp( + @Suppress("UNUSED_PARAMETER") ctx: Context, + ): OkHttpClient = OkHttpClient.Builder().build() +} diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 2dc92ff7a3..cb8d7dc1a8 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -44,15 +44,16 @@ kotlin { } } + // iOS targets — Phase 2 spike. Compile-only for now (no framework binary + // configured yet). Reveals which transitive deps need iOS variants and + // which commonMain files still reach for platform-only APIs. + iosArm64() + iosSimulatorArm64() + sourceSets { commonMain { dependencies { implementation(project(":quartz")) - // Audio-rooms ViewModel needs the listener orchestration + audio - // pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer - // interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals - // stay in :nestsClient's platform source sets. - implementation(project(":nestsClient")) // Compose Multiplatform implementation(libs.jetbrains.compose.ui) @@ -61,13 +62,17 @@ kotlin { implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.ui.tooling.preview) - // Lifecycle ViewModel (KMP since 2.8.0) - implementation(libs.androidx.lifecycle.viewmodel.compose) + // Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and + // lifecycle-runtime-compose ship iOS variants; + // lifecycle-viewmodel-compose (the viewModel() Composable + // helper) is Android-only and lives in jvmAndroid below. + implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) - // Image loading (Coil 3 - KMP) + // Image loading (Coil 3 - KMP). The okhttp network fetcher is + // JVM-only and lives in jvmAndroid; iOS will pull coil-ktor + // when that target wires its actual. implementation(libs.coil.compose) - implementation(libs.coil.okhttp) // LruCache (KMP-ready) implementation(libs.androidx.collection) @@ -75,13 +80,12 @@ kotlin { // Immutable collections api(libs.kotlinx.collections.immutable) + // JSON for custom-feed definitions (KMP — replaces Jackson + // for the one commonMain serializer that was blocking iOS). + implementation(libs.kotlinx.serialization.json) + // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) - - // Markdown rendering (richtext-commonmark) - implementation(libs.markdown.commonmark) - implementation(libs.markdown.ui) - implementation(libs.markdown.ui.material3) } } @@ -97,6 +101,32 @@ kotlin { create("jvmAndroid") { dependsOn(commonMain.get()) dependencies { + // Audio-rooms ViewModel needs the listener orchestration + + // audio pipeline types (NestsListener, AudioRoomPlayer, + // AudioPlayer interface). The :nestsClient module is + // jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track + // stacks are JVM-bound), so the dep lives here, not in + // commonMain. iOS will need an audio-rooms reroute when + // Phase 5 lands. + implementation(project(":nestsClient")) + + // Coil's OkHttp network fetcher (JVM-only). iOS will use + // coil-ktor when the iOS Compose UI ships. + implementation(libs.coil.okhttp) + + // Markdown rendering (richtext-commonmark). The single + // consumer (RenderMarkdown.kt) already lives in jvmAndroid. + // iOS support pending Phase 3 markdown decision. + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) + + // viewModel() Compose helper. AndroidX publishes this + // artifact for android/jvmStubs/linuxx64Stubs but not iOS, + // so it stays in jvmAndroid until we either swap to the + // org.jetbrains.androidx.lifecycle variant or accept a + // platform-specific ViewModel access pattern on iOS. + implementation(libs.androidx.lifecycle.viewmodel.compose) } } @@ -127,6 +157,14 @@ kotlin { } } + // iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code. + val iosMain = + create("iosMain") { + dependsOn(commonMain.get()) + } + getByName("iosArm64Main").dependsOn(iosMain) + getByName("iosSimulatorArm64Main").dependsOn(iosMain) + getByName("androidHostTest") { dependencies { implementation(libs.junit) @@ -150,3 +188,68 @@ compose.resources { packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" generateResClass = always } + +// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale +// there. Commons gains this gate once FeedDefinitionSerializer.kt has been +// migrated off Jackson; future commonMain code must not reintroduce JVM-only +// JSON / HTTP deps. +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) + val offenders = + checkedDirs.flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + val trimmed = line.trimStart() + // Skip KDoc / line-comment lines — those legitimately + // mention forbidden names (migration notes, doc refs). + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt new file mode 100644 index 0000000000..bb820f08a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.ciphers.AESGCM + +/** + * NIP-17 direct-message verbs — relay resolution policy + gift-wrap builders. + * + * Like [FollowActions] / [SearchActions] / [ZapActions], this is pure logic + * usable from amy CLI, the Android App Functions adapter for Gemini, and any + * other non-UI consumer. The send builders return signed gift wraps but do + * NOT publish; the read side (decrypting incoming gift wraps) stays at the + * caller because the `unwrapAndUnsealOrNull` extension in + * `commons/.../relayClient/nip17Dm/` is already a one-liner. + * + * **Caller responsibilities** that this object leaves to the consumer: + * + * * **Publish.** Each wrap goes to its own recipient's DM-relay set — + * resolve via [resolveDmRelays] and hand each wrap to your relay client. + * * **Recipient resolution.** Translate npub / NIP-05 / hex to [HexKey] + * before calling — the Android UI uses `User.pubkeyHex`, amy uses + * `Context.requireUserHex`, the Gemini adapter would resolve through + * its own NIP-05 path. + * * **File upload (kind:15).** [buildFileDmReference] assumes the file is + * already at a URL. For "upload-then-DM", use + * `commons/.../service/upload/UploadOrchestrator` (jvmAndroid only, has + * an OkHttp dep) before calling here. + * * **Receipt of incoming DMs.** The kind:1059 gift-wrap drain, NIP-44 + * unseal, and decrypt-to-inner-event step is a 3-line caller-side loop + * over [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull] + * — too small to bother extracting. + */ +object DmActions { + /** + * Source bucket from which [DmRelaySet.relays] was drawn. Useful for + * surfacing "where did we deliver?" telemetry to the caller — amy + * emits this on stdout, Gemini could mention it in the assistant + * response. + */ + enum class RelaySource { + /** Recipient's NIP-17 inbox (kind:10050). The strict NIP-17 path. */ + KIND_10050, + + /** NIP-65 read marker (kind:10002 read relays). Fallback bucket. */ + NIP65_READ, + + /** Caller-provided bootstrap pool. Last-resort fallback. */ + BOOTSTRAP, + + /** No relays available — caller should refuse to send. */ + NONE, + } + + /** Outcome of [resolveDmRelays]: the relays to publish to, plus which bucket they came from. */ + data class DmRelaySet( + val relays: Set, + val source: RelaySource, + ) + + /** + * Apply Amethyst's NIP-17 relay-resolution policy to a recipient. + * + * NIP-17 says clients "shouldn't try" to deliver a gift wrap unless the + * recipient has published a kind:10050. In strict mode (the default), an + * empty kind:10050 returns [RelaySource.NONE] so the caller refuses to + * send. Permissive mode walks the fallback chain instead — NIP-65 read + * relays, then the bootstrap pool — for cases like interop tests and + * brand-new accounts where strict mode is too strict. + * + * @param recipientLists the recipient's relay-list snapshot from + * [RecipientRelayFetcher.fetchRelayLists] (or a local cache). + * Pass null when the recipient is unknown — same effect as empty lists. + * @param bootstrap the caller's bootstrap relay pool, used as the + * last-resort fallback when [allowFallback] is true. + * @param allowFallback opt into the NIP-65-read → bootstrap chain when + * kind:10050 is empty. Default false (strict mode). + */ + fun resolveDmRelays( + recipientLists: RecipientRelayFetcher.Lists?, + bootstrap: Set, + allowFallback: Boolean = false, + ): DmRelaySet { + val dmInbox = recipientLists?.dmInbox?.toSet().orEmpty() + if (dmInbox.isNotEmpty()) return DmRelaySet(dmInbox, RelaySource.KIND_10050) + if (!allowFallback) return DmRelaySet(emptySet(), RelaySource.NONE) + val nip65Read = recipientLists?.nip65Read()?.toSet().orEmpty() + if (nip65Read.isNotEmpty()) return DmRelaySet(nip65Read, RelaySource.NIP65_READ) + return DmRelaySet(bootstrap, RelaySource.BOOTSTRAP) + } + + /** + * Build a NIP-17 text DM (kind:14) wrapped in a NIP-59 gift wrap per + * recipient. The returned [NIP17Factory.Result] carries the inner + * event for local caching and one gift wrap per recipient (just one + * here — the recipient + the sender's own copy). Caller publishes each + * wrap to that recipient's DM-relay set. + */ + suspend fun buildTextDm( + signer: NostrSigner, + recipient: HexKey, + text: String, + ): NIP17Factory.Result { + val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) + return NIP17Factory().createMessageNIP17(template, signer) + } + + /** + * Build a NIP-17 encrypted-file DM (kind:15) for a file that has + * already been uploaded to [url]. The [cipher]'s key + nonce travel + * inside the gift-wrapped inner event so only the recipient — and the + * sender, who keeps their own copy — can decrypt the bytes at [url]. + * + * Pre-uploaded URL only: the upload step is jvmAndroid-only (needs + * OkHttp). For "upload then DM" use `UploadOrchestrator` first and + * pass its returned URL + the cipher you generated here. + */ + suspend fun buildFileDmReference( + signer: NostrSigner, + recipient: HexKey, + url: String, + cipher: AESGCM, + mimeType: String? = null, + hash: String? = null, + originalHash: String? = null, + size: Int? = null, + dimension: DimensionTag? = null, + blurhash: String? = null, + ): NIP17Factory.Result { + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = listOf(PTag(recipient)), + url = url, + cipher = cipher, + mimeType = mimeType, + hash = hash, + size = size, + dimension = dimension, + blurhash = blurhash, + originalHash = originalHash, + ) + return NIP17Factory().createEncryptedFileNIP17(template, signer) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt new file mode 100644 index 0000000000..76ca908515 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -0,0 +1,124 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag + +/** + * Pure event-building "verbs" for the NIP-02 kind:3 contact list. + * + * Builds a signed [ContactListEvent] but does NOT publish it. The Amethyst + * Android UI flow does more than these builders — non-UI callers are + * responsible for the rest: + * + * * **Publish.** Hand the returned event to your relay client. Android + * uses `Account.sendMyPublicAndPrivateOutbox`, amy uses `Context.publish`. + * * **Writeable check.** Skip the call when the active signer is read-only + * (e.g. an npub-only login). Building will fail at the sign step + * otherwise. + * * **Relay hint.** Pass [relayHint] pointing at one of the target's + * advertised kind:10002 write relays so readers can find the followed + * user. The in-app flow does this via `User.bestRelayHint()`. + * * **No-op detection.** When the user already follows the target, the + * underlying builder short-circuits to the same [currentContactList]. + * Compare `result.id == currentContactList?.id` to detect this. + * * **Local cache update.** If your caller has a local event cache, feed + * the new event back in so the UI / next read sees the update without + * a relay round-trip. + * + * Canonical entry point for non-UI callers (CLI commands, Android App + * Functions adapters, automation scripts): takes pubkeys as [HexKey] rather + * than the UI-model `User`, so it has no cache or scope dependency and is + * trivially testable. + */ +object FollowActions { + /** + * Build a kind:3 contact list update that follows [pubkeyToFollow]. + * + * If [currentContactList] is non-null, the new event is derived from it + * (preserving the existing follow set and content). If it is null, a fresh + * kind:3 is created containing only this pubkey. + * + * Returns the (already signed) event ready to be published to outbox + * relays. When the user already follows [pubkeyToFollow] the underlying + * builder returns [currentContactList] unchanged — callers that want to + * detect "no-op" can compare event ids. + */ + suspend fun buildFollow( + signer: NostrSigner, + pubkeyToFollow: HexKey, + currentContactList: ContactListEvent?, + relayHint: NormalizedRelayUrl? = null, + ): ContactListEvent = + if (currentContactList != null) { + ContactListEvent.followUser(currentContactList, pubkeyToFollow, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(pubkeyToFollow, relayHint, null)), + relayUse = emptyMap(), + signer = signer, + ) + } + + /** + * Batch-follow variant — adds every pubkey in [pubkeysWithHints] to the + * follow set in a single kind:3 update. Pubkeys already present in + * [currentContactList] are skipped by the underlying builder. + */ + suspend fun buildFollowBatch( + signer: NostrSigner, + pubkeysWithHints: List>, + currentContactList: ContactListEvent?, + ): ContactListEvent { + val contacts = pubkeysWithHints.map { (pk, hint) -> ContactTag(pk, hint, null) } + return if (currentContactList != null) { + ContactListEvent.followUsers(currentContactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + + /** + * Build a kind:3 contact list update that removes [pubkeyToUnfollow]. + * + * Returns `null` when [currentContactList] is `null` or has no tags — + * there is nothing to unfollow, and callers should treat this as a no-op + * rather than publishing an empty replacement event. + */ + suspend fun buildUnfollow( + signer: NostrSigner, + pubkeyToUnfollow: HexKey, + currentContactList: ContactListEvent?, + ): ContactListEvent? = + if (currentContactList != null && currentContactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser(currentContactList, pubkeyToUnfollow, signer) + } else { + null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt new file mode 100644 index 0000000000..2fb16fd2be --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt @@ -0,0 +1,123 @@ +/* + * 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.actions + +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +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.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent + +/** + * Pure NIP-50 search-filter assembly + search-relay resolution. + * + * Builds [Filter]s and picks relays — caller drives subscription / drain. + * Non-UI callers should layer the following on top to match Amethyst's + * in-app search behavior: + * + * * **Drain / subscribe.** A function-call API (amy `search`, Gemini App + * Functions) usually wants `client.subscribe(...)` until every relay + * sends EOSE or a short timeout elapses, then unsubscribe. The Amethyst + * foreground UI uses a live subscription instead — it stays open as the + * user types. + * * **Dedup.** Profile search dedups by `pubKey` (multiple kind:0 events + * per author); note search dedups by event id. Both pick the freshest + * revision via `sortedByDescending { createdAt }.distinctBy { … }`. + * * **Pseudo-kind filtering.** When you let callers ask for `reply` / + * `media` / exclusion terms, apply + * [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] after + * the drain. This filter is NOT exposed in the filter API itself. + * * **Debounce.** Interactive callers should debounce input. The + * Amethyst UI uses 300 ms before issuing a new subscription; one-shot + * callers (amy, App Functions) skip this. + */ +object SearchActions { + /** Default kinds for "search notes" — kind:1 short text notes. */ + val DEFAULT_NOTE_KINDS: List = listOf(TextNoteEvent.KIND) + + /** + * Build a NIP-50 filter for searching kind:0 profile metadata. + * + * Returns null for a blank [query] — callers should treat as "no + * results" rather than issuing an unconstrained search that most + * relays would reject anyway. + */ + fun searchProfilesFilter( + query: String, + limit: Int = 20, + ): Filter? { + val q = query.trim() + if (q.isEmpty()) return null + return Filter( + kinds = listOf(MetadataEvent.KIND), + search = q, + limit = limit, + ) + } + + /** + * Build a NIP-50 filter for searching event content. Defaults to + * kind:1 short text notes; pass [kinds] to widen (e.g. include + * kind:30023 long-form or kind:9802 highlights). + */ + fun searchNotesFilter( + query: String, + kinds: List = DEFAULT_NOTE_KINDS, + limit: Int = 50, + since: Long? = null, + until: Long? = null, + ): Filter? { + val q = query.trim() + if (q.isEmpty()) return null + return Filter( + kinds = kinds, + search = q, + limit = limit, + since = since, + until = until, + ) + } + + /** + * Pick the relay set to query for NIP-50 search. + * + * Strategy: when [currentList] (the user's kind:10007 search-relay + * list) is present, use its public + decrypted-private relays. + * Otherwise fall back to [fallback] (defaults to Amethyst's curated + * [DefaultSearchRelayList] — the same set the Android UI uses when + * the user hasn't configured their own). + * + * [signer] is only consulted when [currentList] is non-null and has + * private (NIP-44 encrypted) relay entries; an internal/local signer + * is fine, a NIP-46/NIP-55 signer will cost a round-trip. + */ + suspend fun resolveSearchRelays( + signer: NostrSigner, + currentList: SearchRelayListEvent?, + fallback: Collection = DefaultSearchRelayList, + ): Set { + if (currentList == null) return fallback.toSet() + val combined = currentList.relays(signer) + return if (combined.isEmpty()) fallback.toSet() else combined.toSet() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt new file mode 100644 index 0000000000..5c6bd6ecc9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -0,0 +1,206 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * NIP-57 zap-request building + LN address extraction. + * + * Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller + * hands to a LNURL-pay callback to receive a BOLT11 invoice. + * + * **Caller responsibilities** that the Amethyst Android flow handles but + * these builders do not: + * + * * **Use [buildEventZapRequestsForSplits] for events.** A naive call to + * [buildEventZapRequest] on a note carrying NIP-57 zap-split tags, NIP-53 + * live-activity host tags, or NIP-89 app metadata silently misroutes + * funds to a single recipient. The splits variant is what the + * foreground UI uses and what amy `zap event` calls. + * * **Lightning round-trip.** LNURL endpoint fetch, BOLT11 invoice + * retrieval, and optional NIP-47 NWC payment all live outside these + * builders. `LightningAddressResolver` (in commons/jvmAndroid) covers + * the LNURL + invoice steps. + * * **Receipt verification.** When the kind:9735 receipt arrives, validate + * it against the LNURL provider's `nostrPubkey` (NIP-57 Appendix F) — + * primed via `LnurlEndpointCache` on Android. + * * **Onchain zaps** (NIP-BC) are a separate flow — see `OnchainZapSender` + * in commons. These builders only cover Lightning. + * + * Pattern matches [FollowActions] and [SearchActions]: shared, pure logic + * usable from amy CLI, the Android App Functions adapter for Gemini, and + * any other non-UI consumer. + */ +object ZapActions { + /** Convert sats to millisats — LN-side amount unit. */ + fun satsToMillisats(sats: Long): Long = sats * 1000L + + /** + * Extract the LN address (Lightning Address or LNURL) from a kind:0 + * metadata event. Prefers `lud16` (Lightning Address, `user@domain`) + * over `lud06` (raw LNURL). Returns null when the user has no LN + * details published. + */ + fun extractLnAddress(metadata: MetadataEvent): String? = metadata.contactMetaData()?.lnAddress() + + /** + * Build a NIP-57 profile zap request — pays [recipientPubkey] directly, + * not attached to any specific event. + * + * [inboxRelays] becomes the `["relays", ...]` tag of the zap request: + * the LN provider publishes the kind:9735 zap *receipt* to these + * relays. These should be the sender's read-side (NIP-65 inbox) + * relays so the sender's clients see the receipt land. + * + * Pass [lnurl] when known to stamp it as a tag on the request — some + * receipt validators key off it. + */ + suspend fun buildUserZapRequest( + signer: NostrSigner, + recipientPubkey: HexKey, + amountMillisats: Long, + inboxRelays: Set, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + lnurl: String? = null, + ): LnZapRequestEvent = + LnZapRequestEvent.create( + userHex = recipientPubkey, + relays = inboxRelays, + signer = signer, + message = comment, + zapType = zapType, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) + + /** + * Build a NIP-57 event-zap request — pays the author of + * [zappedEvent] in the context of that specific event. Override + * [toUserPubkey] when the payment should go to a co-author or + * delegated recipient (zap splits); when null the zap targets + * `zappedEvent.pubKey`. + * + * **Caller beware:** This builds a single zap request to a single + * recipient. Notes carrying NIP-57 zap-split tags, NIP-53 + * live-activity hosts, or NIP-89 app metadata expect the payment to + * be divided across multiple parties. Use [buildEventZapRequestsForSplits] + * for the split-aware path; that's what the Amethyst foreground UI does. + */ + suspend fun buildEventZapRequest( + signer: NostrSigner, + zappedEvent: Event, + amountMillisats: Long, + inboxRelays: Set, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + toUserPubkey: HexKey? = null, + pollOption: Int? = null, + lnurl: String? = null, + ): LnZapRequestEvent = + LnZapRequestEvent.create( + zappedEvent = zappedEvent, + relays = inboxRelays, + signer = signer, + pollOption = pollOption, + message = comment, + zapType = zapType, + toUserPubHex = toUserPubkey, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) + + /** + * One signed zap request for one split recipient, with the share of + * the total payment already computed. + */ + data class ZapRequestForSplit( + val recipient: ZapSplitResolver.Recipient, + val amountMillisats: Long, + val request: LnZapRequestEvent, + ) + + /** + * Split-aware version of [buildEventZapRequest]: resolves the recipient + * list via [ZapSplitResolver], computes per-recipient shares with + * [ZapSplitResolver.shareMillisats] (rounded to whole sats — matches the + * Amethyst UI), and signs one zap request per recipient. + * + * Each request's relay-list tag includes [senderInboxRelays] union the + * recipient's own inbox relays (resolved via [lookupInboxRelays]), so + * the eventual kind:9735 zap receipt is published to both parties' + * read-side relays. This matches `ZapPaymentHandler.signAllZapRequests`. + * + * Sum of returned `amountMillisats` may differ from [totalAmountMillisats] + * by a few hundred millisats due to whole-sat rounding — same drift the + * in-app flow has. + * + * Recipients with no resolvable LN address are dropped at the resolver + * step; callers that want to surface "missing LN" warnings should call + * [ZapSplitResolver.resolve] separately first. + */ + suspend fun buildEventZapRequestsForSplits( + signer: NostrSigner, + zappedEvent: Event, + totalAmountMillisats: Long, + senderInboxRelays: Set, + lookupLnAddress: suspend (HexKey) -> String?, + lookupInboxRelays: suspend (HexKey) -> Set = { emptySet() }, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + pollOption: Int? = null, + ): List { + val recipients = ZapSplitResolver.resolve(zappedEvent, lookupLnAddress) + if (recipients.isEmpty()) return emptyList() + val totalWeight = recipients.sumOf { it.weight } + + // Author inbox always travels with the zap so the author's clients + // see the receipt even when paying a split recipient. Mirrors the + // `authorRelayList + userRelayList` union in ZapPaymentHandler. + val authorInbox = lookupInboxRelays(zappedEvent.pubKey) + + return recipients.map { recipient -> + val share = ZapSplitResolver.shareMillisats(totalAmountMillisats, recipient.weight, totalWeight) + val recipientInbox = recipient.pubkey?.let { lookupInboxRelays(it) }.orEmpty() + val allRelays = senderInboxRelays + recipientInbox + authorInbox + val request = + LnZapRequestEvent.create( + zappedEvent = zappedEvent, + relays = allRelays, + signer = signer, + pollOption = pollOption, + message = comment, + zapType = zapType, + toUserPubHex = recipient.pubkey, + amountMillisats = share, + lnurl = null, + ) + ZapRequestForSplit(recipient = recipient, amountMillisats = share, request = request) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt new file mode 100644 index 0000000000..06f9484e19 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt @@ -0,0 +1,182 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlin.math.round + +/** + * Resolves the set of recipients for a NIP-57 zap on a given event. + * + * Mirrors the split-resolution logic in Amethyst's + * `service/ZapPaymentHandler.kt` (Android) so non-UI callers — amy CLI, + * Gemini App Functions adapter, automation scripts — pay the same + * recipients the in-app flow would. Without this resolver, a naive + * "zap the event author" path silently misroutes funds on any note that + * carries `zap` tags, live-activity host tags, or app-definition metadata. + * + * The resolution order matches Amethyst: + * 1. NIP-57 zap-split tags on the event (`["zap", ...]`). + * 2. NIP-53 live-activity hosts (kind:30311 only). + * 3. NIP-89 app definition's own LN address (kind:31990 only). + * 4. The event author as the sole recipient. + * + * Recipients without a resolvable LN address are dropped silently — the + * caller is responsible for surfacing that to the user. This matches the + * `mapNotNull` shape of the in-app flow. + */ +object ZapSplitResolver { + /** + * One zap recipient. The total payment is divided among recipients + * proportional to [weight] / sum(weights); [shareMillisats] applies + * the same rounding the in-app flow does. + */ + data class Recipient( + /** LN address ready to hand to [shareMillisats] + an LNURL-pay flow. */ + val lnAddress: String, + /** Pubkey of the recipient, or null when the split tag carried only an LN address. */ + val pubkey: HexKey?, + /** Relative weight in the split. 1.0 when not otherwise specified. */ + val weight: Double, + /** Relay hint the split tag carried, if any — for receipt routing. */ + val relay: NormalizedRelayUrl?, + ) + + /** + * Rounds a per-split share to whole sats (millisats granularity of 1_000). + * Matches `ZapPaymentHandler.calculateZapValue` so sums line up exactly + * with what an Amethyst user would see on-screen. + */ + fun shareMillisats( + totalMillisats: Long, + weight: Double, + totalWeight: Double, + ): Long { + if (totalWeight <= 0.0) return 0L + val shareValue = totalMillisats * (weight / totalWeight) + return round(shareValue / 1000f).toLong() * 1000 + } + + /** + * Resolve the list of zap recipients for [zappedEvent]. + * + * @param lookupLnAddress called to resolve a pubkey to an LN address. For + * amy this reads kind:0 metadata from the local store; for the Android + * adapter it reads `User.lnAddress()` from the live cache. Return null + * when no LN address is known — the recipient is dropped. + * + * @return ordered list of recipients with LN addresses resolved. Empty + * list when no recipient has a usable LN address. + */ + suspend fun resolve( + zappedEvent: Event, + lookupLnAddress: suspend (HexKey) -> String?, + ): List { + val splits = zappedEvent.zapSplitSetup() + + val raw: List = + when { + splits.isNotEmpty() -> + splits.map { setup -> + when (setup) { + is ZapSplitSetupLnAddress -> + Recipient( + lnAddress = setup.lnAddress, + pubkey = null, + weight = setup.weight, + relay = null, + ) + is ZapSplitSetup -> { + val ln = lookupLnAddress(setup.pubKeyHex) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = setup.pubKeyHex, + weight = setup.weight, + relay = setup.relay, + ) + } else { + null + } + } + } + } + + zappedEvent is LiveActivitiesEvent && zappedEvent.hasHost() -> + zappedEvent.hosts().map { host -> + val ln = lookupLnAddress(host.pubKey) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = host.pubKey, + weight = 1.0, + relay = host.relayHint, + ) + } else { + null + } + } + + zappedEvent is AppDefinitionEvent -> { + val appLn = zappedEvent.appMetaData()?.lnAddress() + val ln = appLn ?: lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + // appMetaData has no pubkey association; only attribute when we fell back to the author. + pubkey = if (appLn == null) zappedEvent.pubKey else null, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + + else -> { + val ln = lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + pubkey = zappedEvent.pubKey, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + } + + return raw.filterNotNull() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt index efa37a1408..ec75e7f01f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt @@ -37,7 +37,7 @@ object Base83 { ): String { val buffer = CharArray(length) encode(value, length, buffer, 0) - return String(buffer) + return buffer.concatToString() } fun encode( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt index 529ab2fdac..e1c299749a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt @@ -27,6 +27,7 @@ import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import kotlin.math.roundToLong import kotlin.math.withSign class BlurHashEncoder { @@ -42,7 +43,7 @@ class BlurHashEncoder { val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5)))) val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5)))) val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5)))) - return Math.round(quantR * 19 * 19 + quantG * 19 + quantB) + return (quantR * 19 * 19 + quantG * 19 + quantB).roundToLong() } private fun encodeDC(value: DoubleArray): Long { @@ -126,7 +127,7 @@ class BlurHashEncoder { val actualMaximumValue = max(factors, 1, factors.size) val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5)))) maximumValue = (quantisedMaximumValue + 1) / 166 - encode(Math.round(quantisedMaximumValue), 1, hash, 1) + encode(quantisedMaximumValue.roundToLong(), 1, hash, 1) } else { maximumValue = 1.0 encode(0, 1, hash, 1) @@ -138,6 +139,6 @@ class BlurHashEncoder { for (i in 1 until factors.size) { encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1)) } - return String(hash) + return hash.concatToString() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt index 3758f90c6b..75b56ae8fb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.call +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey /** @@ -46,7 +48,7 @@ class PeerSessionManager( val pendingIceCandidates: MutableList = mutableListOf(), ) - private val lock = Any() + private val lock = KmpLock() private val sessions = mutableMapOf() /** Candidates received before a session exists for the sender. */ @@ -58,7 +60,7 @@ class PeerSessionManager( peerPubKey: HexKey, session: PeerSession, ): SessionEntry = - synchronized(lock) { + lock.withLock { val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList() val entry = SessionEntry(session) entry.pendingIceCandidates.addAll(globalPending) @@ -66,17 +68,17 @@ class PeerSessionManager( entry } - fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] } + fun getSession(peerPubKey: HexKey): SessionEntry? = lock.withLock { sessions[peerPubKey] } - fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) } + fun hasSession(peerPubKey: HexKey): Boolean = lock.withLock { sessions.containsKey(peerPubKey) } fun removeSession(peerPubKey: HexKey): SessionEntry? = - synchronized(lock) { + lock.withLock { globalPendingIce.remove(peerPubKey) sessions.remove(peerPubKey) } - fun allSessionKeys(): Set = synchronized(lock) { sessions.keys.toSet() } + fun allSessionKeys(): Set = lock.withLock { sessions.keys.toSet() } // ---- ICE candidate routing (two-layer buffering) ---- @@ -92,7 +94,7 @@ class PeerSessionManager( senderPubKey: HexKey, candidate: IceCandidateData, ): IceRouteAction = - synchronized(lock) { + lock.withLock { val entry = sessions[senderPubKey] when { entry != null && entry.remoteDescriptionSet -> { @@ -117,21 +119,21 @@ class PeerSessionManager( * Called after setRemoteDescription succeeds. */ fun flushPendingIceCandidates(peerPubKey: HexKey): Int { - val candidates: List - val entry: SessionEntry - synchronized(lock) { - entry = sessions[peerPubKey] ?: return 0 - entry.remoteDescriptionSet = true - candidates = entry.pendingIceCandidates.toList() - entry.pendingIceCandidates.clear() - } + val (entry, candidates) = + lock.withLock { + val entry = sessions[peerPubKey] ?: return@withLock null + entry.remoteDescriptionSet = true + val candidates = entry.pendingIceCandidates.toList() + entry.pendingIceCandidates.clear() + entry to candidates + } ?: return 0 candidates.forEach { entry.session.addIceCandidate(it) } return candidates.size } - fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 } + fun globalPendingCount(peerPubKey: HexKey): Int = lock.withLock { globalPendingIce[peerPubKey]?.size ?: 0 } - fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 } + fun sessionPendingCount(peerPubKey: HexKey): Int = lock.withLock { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 } // ---- Renegotiation glare handling ---- @@ -147,7 +149,7 @@ class PeerSessionManager( remoteSdpOffer: String, onAcceptRemote: (SessionEntry) -> Unit, ): GlareResolution { - val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION + val entry = lock.withLock { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION val signalingState = entry.session.getSignalingState() if (signalingState != SignalingState.HAVE_LOCAL_OFFER) { @@ -184,7 +186,7 @@ class PeerSessionManager( peerPubKey: HexKey, sdpAnswer: String, ): AnswerRouteAction { - val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION + val entry = lock.withLock { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION val signalingState = entry.session.getSignalingState() if (signalingState != SignalingState.HAVE_LOCAL_OFFER) { @@ -199,12 +201,13 @@ class PeerSessionManager( // ---- Cleanup ---- fun disposeAll() { - val entries: List - synchronized(lock) { - entries = sessions.values.toList() - sessions.clear() - globalPendingIce.clear() - } + val entries = + lock.withLock { + val snapshot = sessions.values.toList() + sessions.clear() + globalPendingIce.clear() + snapshot + } for (entry in entries) { entry.session.dispose() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt index eb7b528af7..e1a6521121 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + /** * Global registry of accepted game IDs. * @@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess */ object AcceptedGamesRegistry { private val acceptedGameIds = mutableSetOf() - private val lock = Any() + private val lock = KmpLock() fun markAsAccepted(gameId: String) { - synchronized(lock) { + lock.withLock { acceptedGameIds.add(gameId) } } fun wasAccepted(gameId: String): Boolean = - synchronized(lock) { + lock.withLock { acceptedGameIds.contains(gameId) } fun clear() { - synchronized(lock) { + lock.withLock { acceptedGameIds.clear() } } /** Remove old entries - call periodically to prevent memory leak */ fun clearOldEntries(keepGameIds: Set) { - synchronized(lock) { + lock.withLock { acceptedGameIds.retainAll(keepGameIds) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 9c9b0e6761..da643df359 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -20,16 +20,18 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.ConcurrentHashMap /** * Collects and aggregates Jester chess events for a game from any source. @@ -67,11 +69,15 @@ class ChessEventCollector( private val _startEvent = MutableStateFlow(null) val startEvent: StateFlow = _startEvent.asStateFlow() - // Move events (deduplicated by event ID) - private val moves = ConcurrentHashMap() + // Move events (deduplicated by event ID). String keys are Comparable, so + // LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works. + private val moves = LargeCache() - // Track all processed event IDs for fast deduplication - private val processedEventIds = ConcurrentHashMap.newKeySet() + // Track all processed event IDs for fast deduplication. A plain HashSet + // guarded by KmpLock — simpler than a LargeCache for set-shaped + // membership. + private val processedEventIdsLock = KmpLock() + private val processedEventIds = mutableSetOf() // Flow that emits when any event is added (for reactive updates) private val _eventCount = MutableStateFlow(0) @@ -80,7 +86,11 @@ class ChessEventCollector( /** * Check if an event has already been processed. */ - fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId) + fun hasEvent(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.contains(eventId) } + + private fun markProcessed(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.add(eventId) } + + private fun processedEventCount(): Int = processedEventIdsLock.withLock { processedEventIds.size } /** * Add a Jester event for this game. @@ -89,7 +99,7 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ fun addEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) { + if (hasEvent(event.id)) { Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" } return false } @@ -129,12 +139,12 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ private fun addStartEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) return false + if (hasEvent(event.id)) return false if (!event.isStartEvent()) return false if (event.id != startEventId) return false if (_startEvent.compareAndSet(null, event)) { - processedEventIds.add(event.id) + markProcessed(event.id) incrementEventCount() Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" } return true @@ -149,14 +159,15 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ private fun addMoveEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) return false + if (hasEvent(event.id)) return false if (!event.isMoveEvent()) return false if (event.startEventId() != startEventId) return false - if (moves.putIfAbsent(event.id, event) == null) { - processedEventIds.add(event.id) + // createIfAbsent returns true iff the entry was added (no prior entry). + if (moves.createIfAbsent(event.id) { event }) { + markProcessed(event.id) incrementEventCount() - Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" } + Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size()}" } return true } return false @@ -168,7 +179,7 @@ class ChessEventCollector( fun getEvents(): JesterGameEvents = JesterGameEvents( startEvent = _startEvent.value, - moves = moves.values.toList(), + moves = moves.values().toList(), ) /** @@ -179,22 +190,22 @@ class ChessEventCollector( /** * Check if the game has any moves (indicates game is active). */ - fun hasMoves(): Boolean = moves.isNotEmpty() + fun hasMoves(): Boolean = !moves.isEmpty() /** * Check if the game has ended (has a move with result). */ - fun hasEnded(): Boolean = moves.values.any { it.result() != null } + fun hasEnded(): Boolean = moves.values().any { it.result() != null } /** * Get the number of moves collected. */ - fun moveCount(): Int = moves.size + fun moveCount(): Int = moves.size() /** * Get the latest move (with longest history). */ - fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size } + fun latestMove(): JesterEvent? = moves.values().maxByOrNull { it.history().size } /** * Clear all collected events. @@ -202,12 +213,12 @@ class ChessEventCollector( fun clear() { _startEvent.value = null moves.clear() - processedEventIds.clear() + processedEventIdsLock.withLock { processedEventIds.clear() } _eventCount.value = 0 } private fun incrementEventCount() { - _eventCount.value = processedEventIds.size + _eventCount.value = processedEventCount() } } @@ -218,21 +229,21 @@ class ChessEventCollector( * such as in a chess lobby or when spectating multiple games. */ class ChessEventCollectorManager { - private val collectors = ConcurrentHashMap() + private val collectors = LargeCache() /** * Get or create a collector for a game. * * @param startEventId The start event ID (game identifier) */ - fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) } + fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrCreate(startEventId) { ChessEventCollector(it) } /** * Get a collector if it exists. * * @param startEventId The start event ID (game identifier) */ - fun get(startEventId: String): ChessEventCollector? = collectors[startEventId] + fun get(startEventId: String): ChessEventCollector? = collectors.get(startEventId) /** * Remove a collector for a game. @@ -244,13 +255,13 @@ class ChessEventCollectorManager { /** * Get all active game IDs (start event IDs). */ - fun activeGameIds(): Set = collectors.keys.toSet() + fun activeGameIds(): Set = collectors.keys() /** * Clear all collectors. */ fun clear() { - collectors.values.forEach { it.clear() } + collectors.values().forEach { it.clear() } collectors.clear() } @@ -275,8 +286,8 @@ class ChessEventCollectorManager { return false } val collector = - collectors[startId] ?: run { - Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" } + collectors.get(startId) ?: run { + Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" } return false } return collector.addEvent(event) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 8735c8ec09..8d8b2791ac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -129,18 +132,21 @@ class ChessLobbyLogic( ) { val state = ChessLobbyState(userPubkey, scope) + private val dismissedGameIdsLock = KmpLock() private val dismissedGameIds: MutableSet = - java.util.Collections.synchronizedSet( - dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(), - ) + (dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf()) // Track when games were last loaded to prevent duplicate fetches - // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) - private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap() + // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it). + // String keys are Comparable, so LargeCache works. + private val recentlyLoadedGames = LargeCache() - // Dedup incoming events (same event delivered by multiple relays) - // Bounded LRU: evict oldest when exceeding capacity - private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet()) + // Dedup incoming events (same event delivered by multiple relays). + // Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns + // LinkedHashSet on every KMP target, preserving insertion-order iteration + // required for LRU eviction below. + private val seenEventIdsLock = KmpLock() + private val seenEventIds = mutableSetOf() private val seenEventIdsMax = 500 private val pollingDelegate = @@ -207,15 +213,21 @@ class ChessLobbyLogic( if (!event.isStartEvent() && !event.isMoveEvent()) return // Dedup: skip if we already processed this event ID (multiple relays deliver same event) - synchronized(seenEventIds) { - if (!seenEventIds.add(event.id)) return - if (seenEventIds.size > seenEventIdsMax) { - seenEventIds.iterator().let { - it.next() - it.remove() + val isNew = + seenEventIdsLock.withLock { + if (!seenEventIds.add(event.id)) { + false + } else { + if (seenEventIds.size > seenEventIdsMax) { + seenEventIds.iterator().let { + it.next() + it.remove() + } + } + true } } - } + if (!isNew) return Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" } when { @@ -438,13 +450,13 @@ class ChessLobbyLogic( */ fun handleGameAccepted(startEventId: String) { // Skip if already loaded or in-flight (multiple move events from same game trigger this) - val lastLoaded = recentlyLoadedGames[startEventId] + val lastLoaded = recentlyLoadedGames.get(startEventId) if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent launches - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" } scope.launch(Dispatchers.Default) { @@ -456,7 +468,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" } state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) @@ -608,7 +620,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) state.addSpectatingGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) @@ -641,7 +653,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) if (result.liveState.isSpectator) { state.addSpectatingGame(startEventId, result.liveState) } else { @@ -678,13 +690,13 @@ class ChessLobbyLogic( private suspend fun refreshGame(startEventId: String) { // Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames) - val lastLoaded = recentlyLoadedGames[startEventId] + val lastLoaded = recentlyLoadedGames.get(startEventId) if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent fetches for the same game - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" } val events = fetcher.fetchGameEvents(startEventId) @@ -873,16 +885,17 @@ class ChessLobbyLogic( .map { it.gameId } .toSet() + val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() } for (startEventId in newGameIds) { if (startEventId in completedGameIds) continue - if (startEventId in dismissedGameIds) continue + if (startEventId in dismissedSnapshot) continue val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) // If the discovered game is already finished, send it straight to completed val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { @@ -951,15 +964,23 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) - dismissedGameIds.add(gameId) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + val snapshot = + dismissedGameIdsLock.withLock { + dismissedGameIds.add(gameId) + dismissedGameIds.toSet() + } + dismissedStorage?.save(userPubkey, snapshot) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() - dismissedGameIds.addAll(allIds) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + val snapshot = + dismissedGameIdsLock.withLock { + dismissedGameIds.addAll(allIds) + dismissedGameIds.toSet() + } + dismissedStorage?.save(userPubkey, snapshot) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index f60b777d32..6323e99b99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import java.util.concurrent.atomic.AtomicLong /** * Challenge expiry: 24 hours @@ -245,9 +244,9 @@ class ChessLobbyState( private val _selectedGameId = MutableStateFlow(null) val selectedGameId: StateFlow = _selectedGameId.asStateFlow() - // State version counter - increments on every game state update - // UI can observe this to force recomposition when internal state changes - private val stateVersionCounter = AtomicLong(0) + // State version counter — bumped on every game state update so the UI + // can force recomposition when internal state changes. MutableStateFlow.update + // is itself atomic, so a separate counter is not needed. private val _stateVersion = MutableStateFlow(0L) val stateVersion: StateFlow = _stateVersion.asStateFlow() @@ -383,13 +382,11 @@ class ChessLobbyState( if (inActiveGames) { _activeGames.update { it + (gameId to stateToUse) } - // Increment version to force UI recomposition even if map equals() returns true - val newVersion = stateVersionCounter.incrementAndGet() - _stateVersion.value = newVersion + // Bump version to force UI recomposition even if map equals() returns true + _stateVersion.update { it + 1 } } else if (inSpectatingGames) { _spectatingGames.update { it + (gameId to stateToUse) } - val newVersion = stateVersionCounter.incrementAndGet() - _stateVersion.value = newVersion + _stateVersion.update { it + 1 } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 703ab3c985..7a1a945712 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -20,15 +20,19 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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.utils.cache.LargeCache import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Progress callback for relay fetch operations @@ -67,6 +71,7 @@ class ChessRelayFetchHelper( * @param onProgress Optional callback for progress updates per relay * @return Deduplicated list of events received before timeout/EOSE */ + @OptIn(ExperimentalAtomicApi::class) suspend fun fetchEvents( filters: Map>, timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS, @@ -74,16 +79,22 @@ class ChessRelayFetchHelper( ): List { if (filters.isEmpty()) return emptyList() - val events = ConcurrentHashMap() + val events = LargeCache() val relayCount = filters.keys.size - val eoseReceived = ConcurrentHashMap.newKeySet() - val relayEventCounts = ConcurrentHashMap() + // Per-relay event counters. Each counter is independently atomic, so + // increments don't need to lock the whole map. + val relayEventCounts = LargeCache() + // Small bounded set; KmpLock-guarded plain set is fine for the size we + // expect (one entry per relay in the filter map). + val eoseLock = KmpLock() + val eoseReceived = mutableSetOf() val allEose = CompletableDeferred() val subId = newSubId() - // Initialize all relays as WAITING + // Initialize all relays as WAITING. Eagerly create AtomicInt counters + // so onEose / timeout paths can read load() without a put race. filters.keys.forEach { relay -> - relayEventCounts[relay] = 0 + relayEventCounts.getOrCreate(relay) { AtomicInt(0) } onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0)) } @@ -95,8 +106,8 @@ class ChessRelayFetchHelper( relay: NormalizedRelayUrl, forFilters: List?, ) { - events[event.id] = event - val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + events.put(event.id, event) + val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1) onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) } @@ -104,11 +115,15 @@ class ChessRelayFetchHelper( relay: NormalizedRelayUrl, forFilters: List?, ) { - eoseReceived.add(relay) - val count = relayEventCounts[relay] ?: 0 + val newEoseSize = + eoseLock.withLock { + eoseReceived.add(relay) + eoseReceived.size + } + val count = relayEventCounts.get(relay)?.load() ?: 0 onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count)) // Complete when all relays respond - if (eoseReceived.size >= relayCount) { + if (newEoseSize >= relayCount) { allEose.complete(Unit) } } @@ -119,9 +134,10 @@ class ChessRelayFetchHelper( // Mark timed-out relays if (eoseResult == null) { + val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() } filters.keys.forEach { relay -> - if (relay !in eoseReceived) { - val count = relayEventCounts[relay] ?: 0 + if (relay !in eoseSnapshot) { + val count = relayEventCounts.get(relay)?.load() ?: 0 onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count)) } } @@ -129,6 +145,6 @@ class ChessRelayFetchHelper( client.unsubscribe(subId) - return events.values.toList() + return events.values().toList() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt index 4513306aca..8704c46398 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.emojicoder +import com.vitorpamplona.amethyst.commons.util.codePointAtKmp +import com.vitorpamplona.amethyst.commons.util.codePointCharCount +import com.vitorpamplona.amethyst.commons.util.codePointToChars + object EmojiCoder { // Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html // VS1..=VS16 @@ -35,8 +39,8 @@ object EmojiCoder { Array(256) { // converts to UTF-16. Always char[2] back when (it) { - in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it) - in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) + in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it) + in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) else -> throw RuntimeException("This should never happen") } } @@ -55,11 +59,11 @@ object EmojiCoder { fun isCoded(text: String): Boolean { if (text.length <= 3) return false - if (!isVariationChar(text.codePointAt(text.length - 2))) { + if (!isVariationChar(text.codePointAtKmp(text.length - 2))) { return false } - if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) { + if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) { return false } @@ -70,7 +74,7 @@ object EmojiCoder { emoji: String, text: String, ): String { - val input = text.toByteArray(Charsets.UTF_8) + val input = text.encodeToByteArray() val out = CharArray(input.size * 2) var outIdx = 0 for (i in 0 until input.size) { @@ -78,51 +82,34 @@ object EmojiCoder { out[outIdx++] = chars[0] out[outIdx++] = chars[1] } - return emoji + String(out) + return emoji + out.concatToString() + } + + /** + * Scans [text] collecting variation-selector bytes until a non-selector is found + * after at least one byte has been collected. + * Returns the end index (exclusive) in [text] and the collected byte values. + */ + private fun scanVariationBytes(text: String): Pair> { + val bytes = mutableListOf() + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAtKmp(i) + val byte = fromVariationSelector(codePoint) + if (byte == null && bytes.isNotEmpty()) break + i += codePointCharCount(codePoint) // Advance by correct char count + if (byte != null) bytes.add(byte) + } + return i to bytes } fun decode(text: String): String { - val decoded = mutableListOf() - - var i = 0 - while (i < text.length) { - val codePoint = text.codePointAt(i) - val byte = fromVariationSelector(codePoint) - - if (byte == null && decoded.isNotEmpty()) { - break - } else if (byte == null) { - i += Character.charCount(codePoint) // Advance index by correct number of chars - continue - } - - decoded.add(byte) - i += Character.charCount(codePoint) // Advance index by correct number of chars - } - - val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } - return String(decodedArray, Charsets.UTF_8) + val (_, bytes) = scanVariationBytes(text) + return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString() } fun cropToFirstMessage(text: String): String { - val decoded = mutableListOf() - - var i = 0 - while (i < text.length) { - val codePoint = text.codePointAt(i) - val byte = fromVariationSelector(codePoint) - - if (byte == null && decoded.isNotEmpty()) { - break - } else if (byte == null) { - i += Character.charCount(codePoint) // Advance index by correct number of chars - continue - } - - decoded.add(byte) - i += Character.charCount(codePoint) // Advance index by correct number of chars - } - - return text.substring(0, i) + val (endIndex, _) = scanVariationBytes(text) + return text.substring(0, endIndex) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt index 9417caffc7..285ffc2b66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt @@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Stable class FeedBuilderState( @@ -66,6 +69,7 @@ class FeedBuilderState( private val editId: String? = initial?.id + @OptIn(ExperimentalUuidApi::class) fun toDefinition(): FeedDefinition { val source = FeedSource.Filter( @@ -77,17 +81,14 @@ class FeedBuilderState( kinds = kinds.toImmutableList(), ) return FeedDefinition( - id = - editId ?: java.util.UUID - .randomUUID() - .toString(), + id = editId ?: Uuid.random().toString(), name = name, emoji = emoji, pinned = false, pinOrder = Int.MAX_VALUE, source = source, refreshMode = refreshMode, - createdAt = System.currentTimeMillis() / 1000, + createdAt = TimeUtils.now(), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt index 58d72f665b..71a6dab603 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.commons.feeds.custom import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid class FeedDefinitionBuilder { var name: String = "" @@ -70,13 +73,11 @@ class FeedDefinitionBuilder { pinOrder = Int.MAX_VALUE, source = source ?: error("FeedDefinition requires a source"), refreshMode = refreshMode, - createdAt = System.currentTimeMillis() / 1000, + createdAt = TimeUtils.now(), ) - private fun generateId(): String = - java.util.UUID - .randomUUID() - .toString() + @OptIn(ExperimentalUuidApi::class) + private fun generateId(): String = Uuid.random().toString() } class FilterBuilder { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt index 397491563b..918cab2458 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.commons.feeds.custom -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object FeedDefinitionSerializer { - private val mapper = ObjectMapper() + private val json = Json { ignoreUnknownKeys = true } fun serializeList(feeds: List): String { - val array = mapper.createArrayNode() - feeds.forEach { feed -> array.add(serializeFeed(feed)) } - return mapper.writeValueAsString(array) + val array = + buildJsonArray { + feeds.forEach { add(serializeFeed(it)) } + } + return json.encodeToString(JsonArray.serializer(), array) } - fun deserializeList(json: String): List { - if (json.isBlank()) return emptyList() - val array = mapper.readTree(json) as? ArrayNode ?: return emptyList() - return array.mapNotNull { node -> deserializeFeed(node) } + fun deserializeList(jsonString: String): List { + if (jsonString.isBlank()) return emptyList() + val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList() + return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } } } - private fun serializeFeed(feed: FeedDefinition): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeFeed(feed: FeedDefinition): JsonObject = + buildJsonObject { put("id", feed.id) put("name", feed.name) put("emoji", feed.emoji) @@ -50,25 +58,25 @@ object FeedDefinitionSerializer { put("pinOrder", feed.pinOrder) put("refreshMode", feed.refreshMode.name) put("createdAt", feed.createdAt) - set("source", serializeSource(feed.source)) + put("source", serializeSource(feed.source)) } - private fun deserializeFeed(node: JsonNode): FeedDefinition? { - val id = node.get("id")?.asText() ?: return null - val name = node.get("name")?.asText() ?: return null - val emoji = node.get("emoji")?.asText() ?: "" - val pinned = node.get("pinned")?.asBoolean() ?: false - val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE + private fun deserializeFeed(node: JsonObject): FeedDefinition? { + val id = node.string("id") ?: return null + val name = node.string("name") ?: return null + val emoji = node.string("emoji") ?: "" + val pinned = node.bool("pinned") ?: false + val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE val refreshMode = - node.get("refreshMode")?.asText()?.let { + node.string("refreshMode")?.let { try { RefreshMode.valueOf(it) } catch (_: Exception) { RefreshMode.LIVE_STREAM } } ?: RefreshMode.LIVE_STREAM - val createdAt = node.get("createdAt")?.asLong() ?: 0L - val source = node.get("source")?.let { deserializeSource(it) } ?: return null + val createdAt = node.long("createdAt") ?: 0L + val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null return FeedDefinition( id = id, @@ -82,17 +90,20 @@ object FeedDefinitionSerializer { ) } - private fun serializeSource(source: FeedSource): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeSource(source: FeedSource): JsonObject = + buildJsonObject { when (source) { is FeedSource.Filter -> { put("type", "filter") - set("hashtags", mapper.valueToTree(source.hashtags.toList())) - set("authors", mapper.valueToTree(source.authors.toList())) - set("relays", mapper.valueToTree(source.relays.toList())) - set("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList())) - set("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList())) - set("kinds", mapper.valueToTree(source.kinds.toList())) + put("hashtags", stringArray(source.hashtags)) + put("authors", stringArray(source.authors)) + put("relays", stringArray(source.relays)) + put("excludeAuthors", stringArray(source.excludeAuthors)) + put("excludeKeywords", stringArray(source.excludeKeywords)) + put( + "kinds", + buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } }, + ) } is FeedSource.PeopleList -> { @@ -131,61 +142,73 @@ object FeedDefinitionSerializer { } } - private fun deserializeSource(node: JsonNode): FeedSource? { - val type = node.get("type")?.asText() ?: return null + private fun deserializeSource(node: JsonObject): FeedSource? { + val type = node.string("type") ?: return null return when (type) { "filter" -> { FeedSource.Filter( - hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null, - authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null, - relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null, - kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null, + hashtags = node.stringList("hashtags") ?: return null, + authors = node.stringList("authors") ?: return null, + relays = node.stringList("relays") ?: return null, + excludeAuthors = node.stringList("excludeAuthors") ?: return null, + excludeKeywords = node.stringList("excludeKeywords") ?: return null, + kinds = node.intList("kinds") ?: return null, ) } "people_list" -> { FeedSource.PeopleList( - kind = node.get("kind")?.asInt() ?: 30000, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30000, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "interest_set" -> { FeedSource.InterestSet( - kind = node.get("kind")?.asInt() ?: 30015, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30015, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "dvm" -> { FeedSource.DVM( - kind = node.get("kind")?.asInt() ?: 31990, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 31990, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "single_relay" -> { FeedSource.SingleRelay( - url = node.get("url")?.asText() ?: return null, + url = node.string("url") ?: return null, ) } - "global" -> { - FeedSource.Global - } - - "following" -> { - FeedSource.Following - } - - else -> { - null - } + "global" -> FeedSource.Global + "following" -> FeedSource.Following + else -> null } } + + private fun stringArray(values: Iterable): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } } + + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + + private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.stringList(key: String) = + (this[key] as? JsonArray) + ?.map { (it as? JsonPrimitive)?.content.orEmpty() } + ?.toImmutableList() + + private fun JsonObject.intList(key: String) = + (this[key] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + ?.toImmutableList() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index eac20cda18..960d123b87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -21,13 +21,15 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference @Stable abstract class Channel : NotesGatherer { @@ -41,10 +43,14 @@ abstract class Channel : NotesGatherer { private var relays = mapOf() - private var changesFlow: WeakReference>> = WeakReference(null) + // Single per-instance lock that previously @Synchronized methods share. + // Replaces JVM-only @Synchronized so this class compiles on iOS. + private val syncLock = KmpLock() + + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer { abstract fun toBestDisplayName(): String open fun relays(): Set = - relays.keys - .toSortedSet { o1, o2 -> - val o1Count = relays[o1]?.number ?: 0 - val o2Count = relays[o2]?.number ?: 0 - o2Count.compareTo(o1Count) // descending - } + relays.entries + .sortedByDescending { it.value.number } + .mapTo(LinkedHashSet(relays.size)) { it.key } fun updateChannelInfo() { flowSet?.metadata?.invalidateData() } - @Synchronized - fun addRelaySync(briefInfo: NormalizedRelayUrl) { - if (briefInfo !in relays) { - relays = relays + Pair(briefInfo, Counter(1)) + fun addRelaySync(briefInfo: NormalizedRelayUrl) = + syncLock.withLock { + if (briefInfo !in relays) { + relays = relays + Pair(briefInfo, Counter(1)) + } } - } fun addRelay(relay: NormalizedRelayUrl) { val counter = relays[relay] @@ -107,7 +110,7 @@ abstract class Channel : NotesGatherer { addRelay(relay) } - changesFlow.get()?.tryEmit(ListChange.Addition(note)) + changesFlow?.get()?.tryEmit(ListChange.Addition(note)) flowSet?.notes?.invalidateData() } @@ -122,7 +125,7 @@ abstract class Channel : NotesGatherer { lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull() } - changesFlow.get()?.tryEmit(ListChange.Deletion(note)) + changesFlow?.get()?.tryEmit(ListChange.Deletion(note)) flowSet?.notes?.invalidateData() } @@ -140,7 +143,7 @@ abstract class Channel : NotesGatherer { toBeRemoved.forEach { notes.remove(it.idHex) } - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet())) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet())) flowSet?.notes?.invalidateData() @@ -156,7 +159,7 @@ abstract class Channel : NotesGatherer { hidden.forEach { notes.remove(it.idHex) } - changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden)) flowSet?.notes?.invalidateData() @@ -165,18 +168,18 @@ abstract class Channel : NotesGatherer { var flowSet: ChannelFlowSet? = null - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = ChannelFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null + fun createOrDestroyFlowSync(create: Boolean) = + syncLock.withLock { + if (create) { + if (flowSet == null) { + flowSet = ChannelFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } } } - } fun flow(): ChannelFlowSet { if (flowSet == null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index d250ae7d2a..88b5505385 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -62,17 +64,19 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.BigDecimal import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.launchAndWaitAll +import com.vitorpamplona.quartz.utils.plus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import java.math.BigDecimal +import kotlin.concurrent.Volatile interface NotesGatherer { fun removeNote(note: Note) @@ -105,6 +109,11 @@ class AddressableNote( open class Note( val idHex: HexKey, ) : NotesGatherer { + // Per-instance lock shared by the previously @Synchronized methods (zap / + // onchain-zap / zap-payment / relay-add / flowSet lifecycle). Replaces the + // JVM-only @Synchronized annotation so this class compiles on iOS. + private val syncLock = KmpLock() + // These fields are only available after the Text Note event is received. // They are immutable after that. var event: Event? = null @@ -154,7 +163,7 @@ open class Note( var zaps = mapOf() private set - var zapsAmount: BigDecimal = BigDecimal.ZERO + var zapsAmount: BigDecimal = BigDecimal(0) /** * NIP-BC onchain zaps targeting this note. @@ -165,7 +174,7 @@ open class Note( * only counts CONFIRMED amounts. * * `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope` - * (inside the @Synchronized inner methods) and reads happen on the Compose Main + * (inside the syncLock-guarded inner methods) and reads happen on the Compose Main * thread (gallery recomposition + the reverification driver's `any { … }` check). */ @Volatile @@ -349,7 +358,7 @@ open class Note( onchainZaps = mapOf() onchainZapResolved = false zapPayments = mapOf() - zapsAmount = BigDecimal.ZERO + zapsAmount = BigDecimal(0) relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() @@ -421,19 +430,18 @@ open class Note( } } - @Synchronized private fun innerAddZap( zapRequest: Note, zap: Note?, - ): Boolean { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - return true + ): Boolean = + syncLock.withLock { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + return@withLock true + } + return@withLock false } - return false - } - fun addZap( zapRequest: Note, zap: Note?, @@ -447,48 +455,48 @@ open class Note( } } - @Synchronized private fun innerAddOnchainZap( txid: String, entry: OnchainZapEntry, - ): Boolean { - val existing = onchainZaps[txid] - if (existing != null) { - // Exact structural duplicate (same source Note + same fields) — typical - // relay echo of the same event. Skip the rewrite to avoid spurious - // flowSet invalidation. - if (entry == existing) return false - // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal) - // so the upgrade contract survives future enum reordering or insertions. - if (entry.status.level < existing.status.level) return false - // Same level: accept only when verifiedSats grows OR the source differs - // (legitimate alternate signer republishing a split-zap receipt). A strictly - // smaller verifiedSats is a backend downgrade and we ignore it. - if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false + ): Boolean = + syncLock.withLock { + val existing = onchainZaps[txid] + if (existing != null) { + // Exact structural duplicate (same source Note + same fields) — typical + // relay echo of the same event. Skip the rewrite to avoid spurious + // flowSet invalidation. + if (entry == existing) return@withLock false + // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal) + // so the upgrade contract survives future enum reordering or insertions. + if (entry.status.level < existing.status.level) return@withLock false + // Same level: accept only when verifiedSats grows OR the source differs + // (legitimate alternate signer republishing a split-zap receipt). A strictly + // smaller verifiedSats is a backend downgrade and we ignore it. + if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return@withLock false + } + onchainZaps = onchainZaps + Pair(txid, entry) + return@withLock true } - onchainZaps = onchainZaps + Pair(txid, entry) - return true - } - @Synchronized private fun innerRemoveOnchainZapForSource( txid: String, sourceAuthorPubKey: HexKey, - ): Boolean { - val existing = onchainZaps[txid] ?: return false - // Anti-spoof: only remove the entry if its source matches the rejecting event. - // Otherwise a malicious third party could erase a legitimate CONFIRMED entry - // by publishing a spoofed kind:8333 with the same txid but a bystander recipient. - // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh - // CONFIRMED replacement should change it; a transient backend hiccup must not - // wipe a previously-CONFIRMED entry just because some other target on the same - // event is still UNVERIFIED. - if (existing.status == OnchainZapStatus.CONFIRMED) return false - if (existing.source.author?.pubkeyHex == null) return false - if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false - onchainZaps = onchainZaps - txid - return true - } + ): Boolean = + syncLock.withLock { + val existing = onchainZaps[txid] ?: return@withLock false + // Anti-spoof: only remove the entry if its source matches the rejecting event. + // Otherwise a malicious third party could erase a legitimate CONFIRMED entry + // by publishing a spoofed kind:8333 with the same txid but a bystander recipient. + // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh + // CONFIRMED replacement should change it; a transient backend hiccup must not + // wipe a previously-CONFIRMED entry just because some other target on the same + // event is still UNVERIFIED. + if (existing.status == OnchainZapStatus.CONFIRMED) return@withLock false + if (existing.source.author?.pubkeyHex == null) return@withLock false + if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return@withLock false + onchainZaps = onchainZaps - txid + return@withLock true + } /** * Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own @@ -535,19 +543,18 @@ open class Note( } } - @Synchronized private fun innerAddZapPayment( zapPaymentRequest: Note, zapPayment: Note?, - ): Boolean { - if (zapPayments[zapPaymentRequest] == null) { - zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) - return true + ): Boolean = + syncLock.withLock { + if (zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + return@withLock true + } + return@withLock false } - return false - } - fun addZapPayment( zapPaymentRequest: Note, zapPayment: Note?, @@ -589,12 +596,12 @@ open class Note( } } - @Synchronized - fun addRelaySync(relay: NormalizedRelayUrl) { - if (relay !in relays) { - relays = relays + relay + fun addRelaySync(relay: NormalizedRelayUrl) = + syncLock.withLock { + if (relay !in relays) { + relays = relays + relay + } } - } fun hasRelay(relay: NormalizedRelayUrl) = relay in relays @@ -737,13 +744,13 @@ open class Note( }.flatten() private fun updateZapTotal() { - var sumOfAmounts = BigDecimal.ZERO + var sumOfAmounts = BigDecimal(0) // Regular Zap Receipts zaps.forEach { val noteEvent = it.value?.event if (noteEvent is LnZapEvent) { - sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO + sumOfAmounts += noteEvent.amount ?: BigDecimal(0) } } @@ -751,7 +758,7 @@ open class Note( // Unverified/pending entries are tracked but excluded from the total per spec. onchainZaps.values.forEach { entry -> if (entry.status == OnchainZapStatus.CONFIRMED) { - sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats) + sumOfAmounts += BigDecimal(entry.verifiedSats) } } @@ -828,7 +835,7 @@ open class Note( val amount = try { LnInvoiceUtil.getAmountInSats(invoice) - } catch (e: java.lang.Exception) { + } catch (e: Exception) { if (e is CancellationException) throw e null } @@ -879,7 +886,7 @@ open class Note( .any { val pledgeValue = try { - BigDecimal(it.event?.content) + it.event?.content?.let { content -> BigDecimal(content) } } catch (e: Exception) { if (e is CancellationException) throw e null @@ -889,7 +896,13 @@ open class Note( pledgeValue != null && it.author == user } - fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO } + // Manual fold rather than Iterable.sumOf { -> BigDecimal } because that + // overload is JVM-only; the common stdlib only ships sumOf for the + // primitive numeric types. + fun pledgedAmountByOthers(): BigDecimal = + replies.fold(BigDecimal(0)) { acc, note -> + acc + (note.event?.addedRewardValue() ?: BigDecimal(0)) + } fun hasAnyReports(): Boolean { val dayAgo = TimeUtils.oneDayAgo() @@ -978,7 +991,7 @@ open class Note( boosts = emptyList() reports = emptyMap() zaps = emptyMap() - zapsAmount = BigDecimal.ZERO + zapsAmount = BigDecimal(0) } fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean { @@ -1035,18 +1048,18 @@ open class Note( var flowSet: NoteFlowSet? = null - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = NoteFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null + fun createOrDestroyFlowSync(create: Boolean) = + syncLock.withLock { + if (create) { + if (flowSet == null) { + flowSet = NoteFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } } } - } fun flow(): NoteFlowSet { if (flowSet == null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt index e8cccecac0..ce28f72ffa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 1404783958..92b63c5b89 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -26,13 +26,15 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import java.lang.ref.WeakReference /** * Represents a Marmot MLS group chat room. @@ -84,31 +86,34 @@ class MarmotGroupChatroom( return adminPubkeys.value.any { it in followingKeySet } } + // Per-instance lock shared by previously @Synchronized methods. + private val syncLock = KmpLock() + // Synthetic note used by list views to represent the group when no // messages have been received yet. Lazily created and kept stable so // equality-based feed diffing treats it as the same row across refreshes. private var cachedPlaceholder: Note? = null - @Synchronized - fun placeholderNote(): Note { - val existing = cachedPlaceholder - if (existing != null) return existing - val created = - Note(placeholderIdHex(nostrGroupId)).apply { - addGatherer(this@MarmotGroupChatroom) - } - cachedPlaceholder = created - return created - } + fun placeholderNote(): Note = + syncLock.withLock { + val existing = cachedPlaceholder + if (existing != null) return@withLock existing + val created = + Note(placeholderIdHex(nostrGroupId)).apply { + addGatherer(this@MarmotGroupChatroom) + } + cachedPlaceholder = created + created + } companion object { fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId" } - private var changesFlow: WeakReference>> = WeakReference(null) + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -119,23 +124,23 @@ class MarmotGroupChatroom( removeMessageSync(note) } - @Synchronized - fun addMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun addMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + unreadCount.value += 1 + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + return@withLock true } - - unreadCount.value += 1 - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) - return true + return@withLock false } - return false - } /** * Add a message that is being restored from persistent storage on app @@ -143,38 +148,38 @@ class MarmotGroupChatroom( * count — restored messages were already seen by the user in a previous * session. */ - @Synchronized - fun restoreMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun restoreMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + return@withLock true } - - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) - return true + return@withLock false } - return false - } - @Synchronized - fun removeMessageSync(msg: Note): Boolean { - if (msg in messages) { - messages = messages - msg - msg.removeGatherer(this) + fun removeMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) - if (msg == newestMessage) { - newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + if (msg == newestMessage) { + newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + } + + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) + return@withLock true } - - changesFlow.get()?.tryEmit(ListChange.Deletion(msg)) - return true + return@withLock false } - return false - } fun markAsRead() { unreadCount.value = 0 @@ -199,7 +204,7 @@ class MarmotGroupChatroom( val toRemove = messages.minus(toKeep) messages = toKeep - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) return toRemove } @@ -209,14 +214,14 @@ class MarmotGroupChatroom( * decrypted inner notes become eligible for GC out of LocalCache (which * holds them weakly). */ - @Synchronized - fun clearAllMessagesSync(): Set { - val toRemove = messages - if (toRemove.isEmpty()) return toRemove - messages = emptySet() - newestMessage = null - unreadCount.value = 0 - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) - return toRemove - } + fun clearAllMessagesSync(): Set = + syncLock.withLock { + val toRemove = messages + if (toRemove.isEmpty()) return@withLock toRemove + messages = emptySet() + newestMessage = null + unreadCount.value = 0 + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + toRemove + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt index 6717126d48..8622d1b5f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference +import kotlin.concurrent.Volatile @Stable data class RelayInfo( @@ -52,10 +55,16 @@ val DefaultOrder = @Stable class UserRelaysCache { var data: Map = mapOf() + + // @Volatile is required for the double-checked locking in flow() to be + // safe on weak-memory platforms (K/N + ARM). Without it, the outer + // fast-path read could observe a partially-published WeakReference. + @Volatile private var flow: WeakReference>? = null + private val flowLock = KmpLock() fun flow() = - flow?.get() ?: synchronized(this) { + flow?.get() ?: flowLock.withLock { flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt index 509e2e1c3e..d15c13f040 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -21,18 +21,19 @@ package com.vitorpamplona.amethyst.commons.model.nip02FollowList import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -112,52 +113,27 @@ class Kind3FollowListState( ) } - suspend fun follow(users: List): ContactListEvent { - val contactList = getFollowListEvent() + suspend fun follow(users: List): ContactListEvent = + FollowActions.buildFollowBatch( + signer = signer, + pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() }, + currentContactList = getFollowListEvent(), + ) - val contacts = - users.map { - ContactTag(it.pubkeyHex, it.bestRelayHint(), null) - } + suspend fun follow(user: User): ContactListEvent = + FollowActions.buildFollow( + signer = signer, + pubkeyToFollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + relayHint = user.bestRelayHint(), + ) - return if (contactList != null) { - ContactListEvent.followUsers(contactList, contacts, signer) - } else { - ContactListEvent.createFromScratch( - followUsers = contacts, - relayUse = emptyMap(), - signer = signer, - ) - } - } - - suspend fun follow(user: User): ContactListEvent { - val contactList = getFollowListEvent() - - return if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - relayUse = emptyMap(), - signer = signer, - ) - } - } - - suspend fun unfollow(user: User): ContactListEvent? { - val contactList = getFollowListEvent() - - return if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - ) - } else { - null - } - } + suspend fun unfollow(user: User): ContactListEvent? = + FollowActions.buildUnfollow( + signer = signer, + pubkeyToUnfollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + ) init { settings.backupContactList?.let { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt index d9c151b018..f34b9a0792 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -58,7 +58,7 @@ object LongFormPublishAction { throw IllegalStateException("Cannot publish: signer is not writeable") } - if (content.toByteArray().size > MAX_CONTENT_BYTES) { + if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) { throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes") } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt index 3103bc76a7..2c59434441 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index 2fc9bfbf34..f8f77d36c9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt index 8b56b6e2ba..726c677e88 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt index 67f499deba..5da941a712 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt index f21bf18a7f..557e3fd0f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt index b5af6e94e6..947fa61ab8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt @@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.commons.model.observables import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event val CreatedAtIdHexComparator: Comparator = Comparator { note1, note2 -> // Sort by created_at and idhex, but define uniqueness by reference if (note1 === note2) return@Comparator 0 - if (note1 == null) return@Comparator 1 // null is greater, moves to last - if (note2 == null) return@Comparator -1 // null is greater, moves to last - val createdAt1 = note1.createdAt() val createdAt2 = note2.createdAt() @@ -49,42 +47,31 @@ val CreatedAtIdHexComparator: Comparator = } } +private inline fun compareByCreatedAt( + a: T, + b: T, + eventOf: (T) -> Event?, +): Int { + val firstEvent = eventOf(a) + val secondEvent = eventOf(b) + return when { + firstEvent == null && secondEvent == null -> 0 + firstEvent == null -> 1 + secondEvent == null -> -1 + else -> firstEvent.createdAt.compareTo(secondEvent.createdAt) + } +} + object CreatedAtComparator : Comparator { override fun compare( - first: Note?, - second: Note?, - ): Int { - val firstEvent = first?.event - val secondEvent = second?.event - - return if (firstEvent == null && secondEvent == null) { - 0 - } else if (firstEvent == null) { - 1 - } else if (secondEvent == null) { - -1 - } else { - firstEvent.createdAt.compareTo(secondEvent.createdAt) - } - } + a: Note, + b: Note, + ): Int = compareByCreatedAt(a, b) { it.event } } object CreatedAtComparatorAddresses : Comparator { override fun compare( - first: AddressableNote?, - second: AddressableNote?, - ): Int { - val firstEvent = first?.event - val secondEvent = second?.event - - return if (firstEvent == null && secondEvent == null) { - 0 - } else if (firstEvent == null) { - 1 - } else if (secondEvent == null) { - -1 - } else { - firstEvent.createdAt.compareTo(secondEvent.createdAt) - } - } + a: AddressableNote, + b: AddressableNote, + ): Int = compareByCreatedAt(a, b) { it.event } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 0b732db7cf..43678ca38a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject @@ -33,7 +36,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference @Stable class Chatroom : NotesGatherer { @@ -44,10 +46,13 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null - private var changesFlow: WeakReference>> = WeakReference(null) + // Per-instance lock shared by previously @Synchronized methods. + private val syncLock = KmpLock() + + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -58,68 +63,68 @@ class Chatroom : NotesGatherer { removeMessageSync(note) } - @Synchronized - fun addMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun addMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - msg.author?.let { author -> - if (author !in activeSenders) { - activeSenders + author - } - } - - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg - } - - val newSubject = msg.event?.subject() - - if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) { - subject.tryEmit(newSubject) - subjectCreatedAt = msg.createdAt() - } - - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) - - return true - } - return false - } - - @Synchronized - fun removeMessageSync(msg: Note): Boolean { - if (msg in messages) { - messages = messages - msg - msg.removeGatherer(this) - - if (msg == newestMessage) { - newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } - } - - if (msg.event?.subject() == subject.value) { - messages - .maxByOrNull { - val noteEvent = it.event - if (noteEvent?.subject() != null) { - noteEvent.createdAt - } else { - 0 - } - }?.let { - subject.tryEmit(it.event?.subject()) - subjectCreatedAt = it.createdAt() + msg.author?.let { author -> + if (author !in activeSenders) { + activeSenders + author } + } + + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + val newSubject = msg.event?.subject() + + if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) { + subject.tryEmit(newSubject) + subjectCreatedAt = msg.createdAt() + } + + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + + return@withLock true } - - changesFlow.get()?.tryEmit(ListChange.Deletion(msg)) - - return true + return@withLock false + } + + fun removeMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) + + if (msg == newestMessage) { + newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + } + + if (msg.event?.subject() == subject.value) { + messages + .maxByOrNull { + val noteEvent = it.event + if (noteEvent?.subject() != null) { + noteEvent.createdAt + } else { + 0 + } + }?.let { + subject.tryEmit(it.event?.subject()) + subjectCreatedAt = it.createdAt() + } + } + + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) + + return@withLock true + } + return@withLock false } - return false - } fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet } @@ -138,7 +143,7 @@ class Chatroom : NotesGatherer { val toRemove = messages.minus(toKeep) messages = toKeep - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) return toRemove } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt index c8970683e7..295f945397 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.EOSERelayList import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 06851efead..22ffa50d4e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable -import java.util.concurrent.ConcurrentHashMap +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock /** * This allows composables to directly register their queries @@ -32,13 +33,16 @@ import java.util.concurrent.ConcurrentHashMap abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + // T has no Comparable bound — see the note in MutableComposeSubscriptionManager + // for why LargeCache isn't used here. + private val lock = KmpLock() + private val composeSubscriptions = mutableMapOf() // This is called by main. Keep it really fast. override fun subscribe(query: T?) { if (query == null) return - composeSubscriptions.put(query, query) + lock.withLock { composeSubscriptions[query] = query } invalidateKeys() } @@ -47,7 +51,7 @@ abstract class ComposeSubscriptionManager : override fun unsubscribe(query: T?) { if (query == null) return - composeSubscriptions.remove(query) + lock.withLock { composeSubscriptions.remove(query) } invalidateKeys() } @@ -55,8 +59,8 @@ abstract class ComposeSubscriptionManager : override fun subscribe(query: List) { if (query.isEmpty()) return - query.forEach { - composeSubscriptions.put(it, it) + lock.withLock { + query.forEach { composeSubscriptions[it] = it } } invalidateKeys() @@ -66,14 +70,14 @@ abstract class ComposeSubscriptionManager : override fun unsubscribe(query: List) { if (query.isEmpty()) return - query.forEach { - composeSubscriptions.remove(it) + lock.withLock { + query.forEach { composeSubscriptions.remove(it) } } invalidateKeys() } - fun allKeys() = composeSubscriptions.keys + fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() } } interface Subscribable { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index da8e82276a..97eabca106 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -21,12 +21,13 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap /** * This allows composables to directly register their queries @@ -41,19 +42,26 @@ import java.util.concurrent.ConcurrentHashMap abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + // T is generic and not required to be Comparable, so this can't use + // LargeCache (ConcurrentSkipListMap-backed on JVM). A plain map guarded by + // KmpLock gives the same atomicity guarantees ConcurrentHashMap did + // previously, with no Comparable requirement. + private val lock = KmpLock() + private val composeSubscriptions = mutableMapOf() // This is called by main. Keep it really fast. fun subscribe(query: T?) { if (query == null) return - composeSubscriptions[query]?.cancel() - composeSubscriptions[query] = - scope.launch { - query.flow().collectLatest { - invalidateKeys() + lock.withLock { + composeSubscriptions[query]?.cancel() + composeSubscriptions[query] = + scope.launch { + query.flow().collectLatest { + invalidateKeys() + } } - } + } invalidateKeys() } @@ -62,16 +70,19 @@ abstract class MutableComposeSubscriptionManager( fun unsubscribe(query: T?) { if (query == null) return - composeSubscriptions[query]?.cancel() - composeSubscriptions.remove(query) + lock.withLock { + composeSubscriptions[query]?.cancel() + composeSubscriptions.remove(query) + } invalidateKeys() } - fun allKeys() = composeSubscriptions.keys + fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() } fun forEachSubscriber(action: (T) -> Unit) { - composeSubscriptions.keys.forEach(action) + val snapshot = lock.withLock { composeSubscriptions.keys.toList() } + snapshot.forEach(action) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 427d341953..a16d3734e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO abstract class BaseEoseManager( val client: INostrClient, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt index 7cf60cb2dd..71a9288804 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.relays +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -33,14 +35,14 @@ open class EOSECache( private val maxSize: Int = 200, ) { private val cache = linkedMapOf() - private val lock = Any() + private val lock = KmpLock() fun addOrUpdate( key: K, relayUrl: NormalizedRelayUrl, time: Long, ) { - synchronized(lock) { + lock.withLock { val relayList = cache[key] if (relayList == null) { // Evict oldest if at capacity @@ -57,7 +59,7 @@ open class EOSECache( } fun since(key: K): SincePerRelayMap? = - synchronized(lock) { + lock.withLock { cache[key]?.relayList?.toMutableMap() } @@ -68,18 +70,18 @@ open class EOSECache( ) = addOrUpdate(key, relayUrl, time) fun remove(key: K) { - synchronized(lock) { + lock.withLock { cache.remove(key) } } fun clear() { - synchronized(lock) { + lock.withLock { cache.clear() } } - fun size(): Int = synchronized(lock) { cache.size } + fun size(): Int = lock.withLock { cache.size } } /** @@ -91,7 +93,7 @@ open class EOSETwoLevelCache( private val innerMaxSize: Int = 200, ) { private val cache = linkedMapOf>() - private val lock = Any() + private val lock = KmpLock() fun addOrUpdate( outerKey: K1, @@ -99,7 +101,7 @@ open class EOSETwoLevelCache( relayUrl: NormalizedRelayUrl, time: Long, ) { - synchronized(lock) { + lock.withLock { val innerCache = cache[outerKey] if (innerCache == null) { // Evict oldest if at capacity @@ -119,7 +121,7 @@ open class EOSETwoLevelCache( outerKey: K1, innerKey: K2, ): SincePerRelayMap? = - synchronized(lock) { + lock.withLock { cache[outerKey]?.since(innerKey) } @@ -131,13 +133,13 @@ open class EOSETwoLevelCache( ) = addOrUpdate(outerKey, innerKey, relayUrl, time) fun removeOuter(key: K1) { - synchronized(lock) { + lock.withLock { cache.remove(key) } } fun clear() { - synchronized(lock) { + lock.withLock { cache.clear() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index 342f0b6205..09a67302dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -20,21 +20,20 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import java.util.Base64 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi object Base64Image { val pattern = Patterns.BASE64_IMAGE fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content) + @OptIn(ExperimentalEncodingApi::class) fun parse(content: String): ByteArray { - val matcher = pattern.find(content) - if (matcher != null) { - val base64String = matcher.groups[2]?.value - val byteArray = Base64.getDecoder().decode(base64String) - return byteArray - } - - throw Exception("Unable to convert base64 to image $content") + val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content") + val base64String = + match.groups[2]?.value + ?: throw Exception("Unable to convert base64 to image $content") + return Base64.decode(base64String) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 1d2a534741..be472293f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import java.io.File + +// URL-based media content models (KMP). The locally-cached variants +// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in +// MediaLocalContent.kt under the jvmAndroid source set because they depend +// on java.io.File. @Immutable abstract class BaseMediaContent( @@ -123,44 +127,3 @@ class EncryptedMediaUrlVideo( thumbhash: String? = null, authorPubKey: String? = null, ) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey) - -@Immutable -abstract class MediaPreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - val uri: String, - val id: String? = null, - thumbhash: String? = null, -) : BaseMediaContent(description, dim, blurhash, thumbhash) { - fun localFileExists() = localFile != null && localFile.exists() -} - -@Immutable -class MediaLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - isVerified: Boolean? = null, - uri: String, - thumbhash: String? = null, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) - -@Immutable -class MediaLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null, - thumbhash: String? = null, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) 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 05399c493e..b8f8736716 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 @@ -22,6 +22,7 @@ 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.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -40,9 +41,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList -import java.net.MalformedURLException -import java.net.URI -import java.net.URISyntaxException import kotlin.coroutines.cancellation.CancellationException class RichTextParser { @@ -489,21 +487,7 @@ class RichTextParser { return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } - fun isValidURL(url: String?): Boolean = - try { - if (url != null) { - URI(url).toURL() - true - } else { - false - } - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } catch (e: IllegalArgumentException) { - false - } + fun isValidURL(url: String?): Boolean = isValidUrl(url) fun parseImageOrVideo(fullUrl: String): BaseMediaContent { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 66edbae1e3..97621650b2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -168,7 +168,7 @@ class RobohashAssembler { Hex.decode(msg) } else { Log.w("Robohash") { "$msg is not a hex" } - sha256(msg.toByteArray()) + sha256(msg.encodeToByteArray()) } val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt index 8a843955ae..24cc954eb5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt @@ -20,15 +20,18 @@ */ package com.vitorpamplona.amethyst.commons.search +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + class EventDeduplicator { - private val lock = Any() + private val lock = KmpLock() private val seenIds = mutableSetOf() - fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) } + fun tryAdd(id: String): Boolean = lock.withLock { seenIds.add(id) } - fun contains(id: String): Boolean = synchronized(lock) { id in seenIds } + fun contains(id: String): Boolean = lock.withLock { id in seenIds } - fun clear() = synchronized(lock) { seenIds.clear() } + fun clear() = lock.withLock { seenIds.clear() } - val size: Int get() = synchronized(lock) { seenIds.size } + val size: Int get() = lock.withLock { seenIds.size } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt index cad35cbc4c..79ff14f824 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt index 30bc6fd3b0..a73db81302 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.thumbhash import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.PI @@ -50,14 +52,14 @@ object ThumbHashDecoder { // (size, components) pairs across the entire app lifetime, each table is // a few KB, so the memory ceiling is tiny. private val cosineCache = HashMap() - private val cosineCacheLock = Any() + private val cosineCacheLock = KmpLock() /** * Clear the cosine table cache. Tables are tiny but callers under memory * pressure can release them; they will be recomputed on demand. */ fun clearCache() { - synchronized(cosineCacheLock) { cosineCache.clear() } + cosineCacheLock.withLock { cosineCache.clear() } } private fun cosTable( @@ -65,9 +67,8 @@ object ThumbHashDecoder { components: Int, ): DoubleArray { val key = (size.toLong() shl 32) or components.toLong() - synchronized(cosineCacheLock) { - cosineCache[key]?.let { return it } - } + val cached = cosineCacheLock.withLock { cosineCache[key] } + if (cached != null) return cached val table = DoubleArray(size * components) val piOverSize = PI / size for (i in 0 until size) { @@ -77,10 +78,7 @@ object ThumbHashDecoder { table[rowOffset + c] = cos(phase * c) } } - synchronized(cosineCacheLock) { - cosineCache.getOrPut(key) { table } - } - return table + return cosineCacheLock.withLock { cosineCache.getOrPut(key) { table } } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt index 61d08af462..0ca8bb4748 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -175,7 +175,7 @@ fun FeedErrorState( modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, ) { - val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage) + val formattedMessage = stringResource(Res.string.error_loading_feed, errorMessage) ErrorState( message = formattedMessage, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt index 996e8d052f..2071bbd55c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt @@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt index 743cc70398..d76efe5b99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt @@ -26,8 +26,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.cancellation.CancellationException sealed class SigningOpState { @@ -44,26 +46,27 @@ sealed class SigningOpState { * Global signing status — any [SigningState] instance updates this when signing starts/ends. * Observe [globalState] from a screen-level composable to show a persistent status bar. */ +@OptIn(ExperimentalAtomicApi::class) object GlobalSigningStatus { var globalState by mutableStateOf(SigningOpState.Idle) private set - private val activeCount = AtomicInteger(0) + private val activeCount = AtomicInt(0) fun onPending() { - activeCount.incrementAndGet() + activeCount.addAndFetch(1) globalState = SigningOpState.Pending } fun onIdle() { - if (activeCount.decrementAndGet() <= 0) { - activeCount.set(0) + if (activeCount.addAndFetch(-1) <= 0) { + activeCount.store(0) globalState = SigningOpState.Idle } } fun onError(message: String) { - activeCount.decrementAndGet() + activeCount.addAndFetch(-1) globalState = SigningOpState.Error(message) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt new file mode 100644 index 0000000000..41df19f0d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt @@ -0,0 +1,76 @@ +/* + * 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.util + +// KMP code-point helpers — replacements for `java.lang.Character.*` and the +// JVM-only `String.codePointAt(Int)` / `String.offsetByCodePoints(Int, Int)` +// extensions. UTF-16 surrogate-pair aware so emoji and other supplementary +// code points are handled correctly on Native targets. + +/** Number of UTF-16 code units (1 or 2) needed for [codePoint]. */ +fun codePointCharCount(codePoint: Int): Int = if (codePoint >= 0x10000) 2 else 1 + +/** + * Encode [codePoint] as UTF-16. Returns a 1-char array for BMP code points and + * a high+low surrogate pair for supplementary code points (≥ U+10000). + */ +fun codePointToChars(codePoint: Int): CharArray = + if (codePoint < 0x10000) { + charArrayOf(codePoint.toChar()) + } else { + val offset = codePoint - 0x10000 + charArrayOf( + (0xD800 + (offset ushr 10)).toChar(), + (0xDC00 + (offset and 0x3FF)).toChar(), + ) + } + +/** + * Decode the Unicode code point that starts at [index]. Mirrors + * `java.lang.Character.codePointAt(CharSequence, int)`: returns the + * supplementary code point when [index] points at a well-formed high surrogate + * followed by a low surrogate; otherwise returns the raw `Char.code`. + */ +fun String.codePointAtKmp(index: Int): Int { + val high = this[index] + if (high.isHighSurrogate() && index + 1 < length) { + val low = this[index + 1] + if (low.isLowSurrogate()) { + return 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00) + } + } + return high.code +} + +/** + * Return the index that is [codePointOffset] code points away from [index]. + * Negative offsets are not supported (matches the only call site usage today). + */ +fun String.offsetByCodePointsKmp( + index: Int, + codePointOffset: Int, +): Int { + var i = index + repeat(codePointOffset) { + i += codePointCharCount(codePointAtKmp(i)) + } + return i +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt index e383b1fe45..d225d89c02 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.util import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists -fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 +fun String.isUTF16Char(pos: Int): Boolean = codePointCharCount(this.codePointAtKmp(pos)) == 2 fun String.firstFullCharOld(): String { return when (this.length) { @@ -64,11 +64,11 @@ fun String.firstFullChar(): String { var i = 0 while (i < this.length) { - codePoint = codePointAt(i) + codePoint = codePointAtKmp(i) // Skips if it starts with the join char 0x200D if (codePoint == 0x200D && previousCharLength == 0) { - next = offsetByCodePoints(i, 1) + next = offsetByCodePointsKmp(i, 1) start = next } else { // If join, searches for the next char @@ -78,7 +78,7 @@ fun String.firstFullChar(): String { } else { // stops when two chars are not joined together if (previousCharLength > 0 && !isInJoin) { - if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { + if (codePointCharCount(codePoint) == 1 || hasHadSecondChance) { break } else { hasHadSecondChance = true @@ -91,7 +91,7 @@ fun String.firstFullChar(): String { } // next char to evaluate - next = offsetByCodePoints(i, 1) + next = offsetByCodePointsKmp(i, 1) previousCharLength += (next - i) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt new file mode 100644 index 0000000000..ad2622de74 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.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.amethyst.commons.util + +/** + * KMP-friendly reentrant lock. JVM/Android map to `java.util.concurrent.locks.ReentrantLock`; + * iOS maps to `NSRecursiveLock`. + * + * Use [withLock] in preference to manual lock/unlock — it guarantees release on + * exception. The class is reentrant on every platform, so a thread that already + * holds the lock can re-enter without deadlocking. + */ +expect class KmpLock() { + fun lock() + + fun unlock() +} + +inline fun KmpLock.withLock(block: () -> T): T { + lock() + try { + return block() + } finally { + unlock() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt new file mode 100644 index 0000000000..9a63884678 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt @@ -0,0 +1,31 @@ +/* + * 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.util + +/** + * True iff [url] is a syntactically valid absolute URL with a recognized + * scheme. JVM/Android delegate to `java.net.URI(url).toURL()`; the iOS actual + * will use `NSURL(string:)` when that target is added. + * + * Called per-URL during feed render — actuals should keep this fast and + * allocation-light. + */ +expect fun isValidUrl(url: String?): Boolean diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt new file mode 100644 index 0000000000..1521301189 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt @@ -0,0 +1,34 @@ +/* + * 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.util + +/** + * KMP-friendly weak reference. JVM/Android map to `java.lang.ref.WeakReference`; + * iOS will map to `kotlin.native.ref.WeakReference` when that target is added. + * + * The referent may be reclaimed by GC at any time after this reference is + * constructed — callers must always null-check the result of [get]. + */ +expect class WeakReference( + referent: T, +) { + fun get(): T? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt index 47ff0d18f7..172614a573 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt index 7e8f5ec8ca..d4dc7e0a78 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 032c53a754..66a790c31a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index a3b0b2c32c..1f3f0bd1ed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt new file mode 100644 index 0000000000..55b0550a6e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.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.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DmActionsTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000019" + private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val recipientPub = + Secp256k1Instance + .compressedPubKeyFor(recipientPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val dmInbox = relay("wss://dm-inbox.example") + private val nip65ReadRelay = relay("wss://nip65-read.example") + private val nip65WriteRelay = relay("wss://nip65-write.example") + private val bootstrap = setOf(relay("wss://bootstrap.example")) + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + /** Build a kind:10002 with one read + one write relay so [nip65Read] returns + * the expected single URL. */ + private suspend fun nip65WithReadAndWrite(): AdvertisedRelayListEvent = + signer.sign( + createdAt = 1_700_000_000L, + kind = AdvertisedRelayListEvent.KIND, + tags = + arrayOf( + AdvertisedRelayInfo.assemble(nip65ReadRelay, AdvertisedRelayType.READ), + AdvertisedRelayInfo.assemble(nip65WriteRelay, AdvertisedRelayType.WRITE), + ), + content = "", + ) + + // ------------------------------------------------------------------ + // resolveDmRelays — strict (default) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_strictReturnsKind10050WhenPresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneWhenKind10050Empty() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // NIP-17 strict mode: no kind:10050 → refuse to deliver. Caller + // surfaces a no_dm_relays error rather than guessing. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneEvenWhenNip65Present() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // Strict mode does not fall through to NIP-65 even if it's present. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + // ------------------------------------------------------------------ + // resolveDmRelays — permissive (allowFallback=true) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_fallbackPrefersKind10050OverNip65() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // kind:10050 wins even with fallback enabled — it's still the strict path. + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_fallbackUsesNip65ReadWhenKind10050Empty() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // Falls through to NIP-65 read relays — not write — matching User.inboxRelays(). + assertEquals(setOf(nip65ReadRelay), result.relays) + assertEquals(DmActions.RelaySource.NIP65_READ, result.source) + } + + @Test + fun resolveDmRelays_fallbackReachesBootstrapWhenNothingElsePresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + assertEquals(bootstrap, result.relays) + assertEquals(DmActions.RelaySource.BOOTSTRAP, result.source) + } + + @Test + fun resolveDmRelays_nullListsTreatedAsEmpty() { + val resultStrict = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = false) + assertEquals(DmActions.RelaySource.NONE, resultStrict.source) + + val resultPermissive = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = true) + // Null Lists → no kind:10050, no NIP-65 → bootstrap. + assertEquals(DmActions.RelaySource.BOOTSTRAP, resultPermissive.source) + assertEquals(bootstrap, resultPermissive.relays) + } + + // ------------------------------------------------------------------ + // buildTextDm — smoke test that we get back a kind:14 and the right + // wrap count. NIP17Factory internals are exercised more deeply in + // quartz's own tests. + // ------------------------------------------------------------------ + + @Test + fun buildTextDm_producesKind14InnerAndOneWrapPerSide() = + runTest { + val result = DmActions.buildTextDm(signer, recipientPub, "hi from a test") + + assertEquals(ChatMessageEvent.KIND, result.msg.kind) + assertEquals(signer.pubKey, result.msg.pubKey) + assertEquals("hi from a test", result.msg.content) + // NIP17Factory wraps once per recipient — and the sender keeps + // their own copy, so a 1-recipient DM produces 2 wraps. + assertEquals(2, result.wraps.size) + val recipientsCovered = result.wraps.mapNotNull { it.recipientPubKey() }.toSet() + assertTrue(signer.pubKey in recipientsCovered, "sender's own copy missing") + assertTrue(recipientPub in recipientsCovered, "recipient's wrap missing") + } + + @Test + fun relaySourceEnumNamesAreStable() { + // amy emits these as lowercase strings in JSON output; if these + // names change, the public CLI contract breaks. + assertEquals( + setOf("KIND_10050", "NIP65_READ", "BOOTSTRAP", "NONE"), + DmActions.RelaySource.entries + .map { it.name } + .toSet(), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt new file mode 100644 index 0000000000..144900c27d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt @@ -0,0 +1,143 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class FollowActionsTest { + private val myPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val mySigner = NostrSignerInternal(KeyPair(myPriv.hexToByteArray())) + + // Pre-computed 32-byte (x-only pubkey) hexes — content doesn't matter, only + // length + uniqueness. NIP-02 verification is lenient about these being + // real curve points. + private val alice = "1111111111111111111111111111111111111111111111111111111111111111" + private val bob = "2222222222222222222222222222222222222222222222222222222222222222" + private val carol = "3333333333333333333333333333333333333333333333333333333333333333" + + @Test + fun followFromScratch_createsKind3WithSinglePubkey() = + runTest { + val event = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + assertEquals(ContactListEvent.KIND, event.kind) + assertEquals(mySigner.pubKey, event.pubKey) + assertEquals(setOf(alice), event.verifiedFollowKeySet()) + } + + @Test + fun followFromExistingList_appendsWithoutLosingPriorFollows() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val updated = FollowActions.buildFollow(mySigner, bob, currentContactList = initial) + + assertEquals(setOf(alice, bob), updated.verifiedFollowKeySet()) + // New event must replace the old one (different id), not no-op back. + assertTrue(updated.id != initial.id) + } + + @Test + fun followAlreadyFollowed_isNoOp() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val redundant = FollowActions.buildFollow(mySigner, alice, currentContactList = initial) + + // Underlying builder short-circuits to the same event. + assertSame(initial, redundant) + } + + @Test + fun unfollowFromExistingList_removesOnlyTargetTag() = + runTest { + val twoFollows = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(alice to null, bob to null), + currentContactList = null, + ) + assertEquals(setOf(alice, bob), twoFollows.verifiedFollowKeySet()) + + val removed = FollowActions.buildUnfollow(mySigner, alice, currentContactList = twoFollows) + + assertNotNull(removed) + assertEquals(setOf(bob), removed.verifiedFollowKeySet()) + } + + @Test + fun unfollowWithNullCurrent_returnsNull() = + runTest { + val result = FollowActions.buildUnfollow(mySigner, alice, currentContactList = null) + assertNull(result, "no prior list means nothing to unfollow — caller should treat as no-op") + } + + @Test + fun unfollowNonMember_returnsEventWithSameId() = + runTest { + val onlyAlice = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + // Builder short-circuits when the pubkey isn't tagged — we get the + // same event back, so callers can detect no-op by id equality. + val result = FollowActions.buildUnfollow(mySigner, bob, currentContactList = onlyAlice) + + assertNotNull(result) + assertEquals(onlyAlice.id, result.id) + } + + @Test + fun followBatchFromScratch_createsKind3WithAllPubkeys() = + runTest { + val event = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(alice to null, bob to null, carol to null), + currentContactList = null, + ) + + assertEquals(setOf(alice, bob, carol), event.verifiedFollowKeySet()) + } + + @Test + fun followBatchOnExistingList_unionsWithoutLosingPriorFollows() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val updated = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(bob to null, carol to null), + currentContactList = initial, + ) + + assertEquals(setOf(alice, bob, carol), updated.verifiedFollowKeySet()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt new file mode 100644 index 0000000000..d6ad1c0649 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt @@ -0,0 +1,143 @@ +/* + * 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.actions + +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SearchActionsTest { + private val priv = "0000000000000000000000000000000000000000000000000000000000000007" + private val signer = NostrSignerInternal(KeyPair(priv.hexToByteArray())) + + @Test + fun searchProfilesFilter_buildsKind0FilterWithSearchField() { + val filter = SearchActions.searchProfilesFilter("alice", limit = 10) + + assertNotNull(filter) + assertEquals(listOf(MetadataEvent.KIND), filter.kinds) + assertEquals("alice", filter.search) + assertEquals(10, filter.limit) + assertNull(filter.authors, "must not constrain authors — search is global") + } + + @Test + fun searchProfilesFilter_trimsWhitespace() { + val filter = SearchActions.searchProfilesFilter(" alice ") + assertNotNull(filter) + assertEquals("alice", filter.search) + } + + @Test + fun searchProfilesFilter_returnsNullForBlankQuery() { + assertNull(SearchActions.searchProfilesFilter("")) + assertNull(SearchActions.searchProfilesFilter(" ")) + } + + @Test + fun searchNotesFilter_defaultsToKind1() { + val filter = SearchActions.searchNotesFilter("hello") + assertNotNull(filter) + assertEquals(listOf(TextNoteEvent.KIND), filter.kinds) + assertEquals("hello", filter.search) + } + + @Test + fun searchNotesFilter_acceptsCustomKindsAndTimeWindow() { + val filter = + SearchActions.searchNotesFilter( + query = "music", + kinds = listOf(1, 30023), + limit = 100, + since = 1_700_000_000, + until = 1_800_000_000, + ) + assertNotNull(filter) + assertEquals(listOf(1, 30023), filter.kinds) + assertEquals(100, filter.limit) + assertEquals(1_700_000_000, filter.since) + assertEquals(1_800_000_000, filter.until) + } + + @Test + fun searchNotesFilter_returnsNullForBlankQuery() { + assertNull(SearchActions.searchNotesFilter("")) + assertNull(SearchActions.searchNotesFilter("\t\n")) + } + + @Test + fun resolveSearchRelays_fallsBackToDefaultsWhenNoListConfigured() = + runTest { + val relays = SearchActions.resolveSearchRelays(signer, currentList = null) + assertEquals(DefaultSearchRelayList, relays) + } + + @Test + fun resolveSearchRelays_usesConfiguredPublicRelaysWhenAvailable() = + runTest { + val customRelay = RelayUrlNormalizer.normalizeOrNull("wss://search.example.com") + assertNotNull(customRelay) + + val list = SearchRelayListEvent.create(relays = listOf(customRelay), signer = signer) + val relays = SearchActions.resolveSearchRelays(signer, currentList = list) + + assertEquals(setOf(customRelay), relays) + } + + @Test + fun resolveSearchRelays_respectsCustomFallback() = + runTest { + val custom = + listOfNotNull( + RelayUrlNormalizer.normalizeOrNull("wss://only-fallback.example"), + ) + val relays = + SearchActions.resolveSearchRelays( + signer = signer, + currentList = null, + fallback = custom, + ) + assertEquals(custom.toSet(), relays) + } + + @Test + fun resolveSearchRelays_emptyConfiguredListFallsBack() = + runTest { + val emptyList = SearchRelayListEvent.create(relays = emptyList(), signer = signer) + val relays = SearchActions.resolveSearchRelays(signer, currentList = emptyList) + + // An author who published a kind:10007 with no relays is treated + // the same as no list at all — we don't want to query nothing. + assertTrue(relays.isNotEmpty()) + assertEquals(DefaultSearchRelayList, relays) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt new file mode 100644 index 0000000000..eaa21809b1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -0,0 +1,379 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ZapActionsTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val authorPriv = "000000000000000000000000000000000000000000000000000000000000000d" + private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000011" + private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray())) + + // Use a real curve-point pubkey — PRIVATE / ANONYMOUS zaps internally do + // NIP-04-style ECDH with the recipient, which rejects garbage pubkeys. + private val recipientPubkey = xOnly(recipientPriv) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://inbox.example")!! + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + @Test + fun satsToMillisats_multipliesByThousand() { + assertEquals(0L, ZapActions.satsToMillisats(0)) + assertEquals(1_000L, ZapActions.satsToMillisats(1)) + assertEquals(21_000_000L, ZapActions.satsToMillisats(21_000)) + } + + @Test + fun extractLnAddress_prefersLud16OverLud06() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew( + name = "alice", + lnAddress = "alice@walletofsatoshi.com", + lnURL = "lnurl1somelongstring", + ), + ) + assertEquals("alice@walletofsatoshi.com", ZapActions.extractLnAddress(metadata)) + } + + @Test + fun extractLnAddress_fallsBackToLud06WhenNoLud16() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew( + name = "bob", + lnURL = "lnurl1bobsLightning", + ), + ) + assertEquals("lnurl1bobsLightning", ZapActions.extractLnAddress(metadata)) + } + + @Test + fun extractLnAddress_returnsNullWhenNoLnDetails() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew(name = "noln"), + ) + assertNull(ZapActions.extractLnAddress(metadata)) + } + + @Test + fun buildUserZapRequest_publicTypeStampsAllFields() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 21_000L, + inboxRelays = setOf(relay), + comment = "thanks!", + zapType = LnZapEvent.ZapType.PUBLIC, + lnurl = "lnurl1example", + ) + + assertEquals(9734, request.kind) + assertEquals(signer.pubKey, request.pubKey, "PUBLIC zap is signed by the sender") + assertEquals("thanks!", request.content) + + val tagMap = request.tags.groupBy { it[0] } + assertEquals(recipientPubkey, tagMap["p"]?.first()?.get(1)) + assertEquals("21000", tagMap["amount"]?.first()?.get(1)) + assertEquals("lnurl1example", tagMap["lnurl"]?.first()?.get(1)) + assertTrue(tagMap["relays"]?.first()?.contains(relay.url) == true) + assertNull(tagMap["anon"], "PUBLIC zap must not carry an anon tag") + } + + @Test + fun buildUserZapRequest_anonymousTypeUsesEphemeralKeyAndAnonTag() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 1_000L, + inboxRelays = setOf(relay), + zapType = LnZapEvent.ZapType.ANONYMOUS, + ) + + assertTrue( + request.pubKey != signer.pubKey, + "ANONYMOUS zaps are signed with a freshly-generated keypair, not the sender's", + ) + assertNotNull(request.tags.firstOrNull { it[0] == "anon" }) + } + + @Test + fun buildUserZapRequest_privateTypeCarriesAnonTagWithEncryptedPayload() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 1_000L, + inboxRelays = setOf(relay), + zapType = LnZapEvent.ZapType.PRIVATE, + ) + + // NIP-57 PRIVATE zaps use an ephemeral key derived from + // (sender, recipient, zappedEvent) so the recipient can re-derive + // and verify origin via NIP-04 decryption of the anon tag value. + // The outer event is therefore NOT signed by the sender. + val anon = request.tags.firstOrNull { it[0] == "anon" } + assertNotNull(anon, "PRIVATE zap must carry an anon tag") + assertTrue( + (anon.getOrNull(1) ?: "").isNotEmpty(), + "PRIVATE zap's anon tag carries the NIP-04-encrypted private payload", + ) + } + + @Test + fun buildEventZapRequest_carriesEventTagAndAuthorPTag() = + runTest { + val note = authorSigner.sign(TextNoteEvent.build("hello world")) + + val request = + ZapActions.buildEventZapRequest( + signer = signer, + zappedEvent = note, + amountMillisats = 5_000L, + inboxRelays = setOf(relay), + comment = "great post", + ) + + val tagMap = request.tags.groupBy { it[0] } + assertEquals(note.id, tagMap["e"]?.first()?.get(1)) + assertEquals(note.pubKey, tagMap["p"]?.first()?.get(1)) + assertEquals("5000", tagMap["amount"]?.first()?.get(1)) + assertEquals("great post", request.content) + } + + @Test + fun buildEventZapRequest_toUserPubkeyOverridesAuthorTag() = + runTest { + val note = authorSigner.sign(TextNoteEvent.build("split me")) + val splitTo = "2222222222222222222222222222222222222222222222222222222222222222" + + val request = + ZapActions.buildEventZapRequest( + signer = signer, + zappedEvent = note, + amountMillisats = 5_000L, + inboxRelays = setOf(relay), + toUserPubkey = splitTo, + ) + + val pTag = request.tags.firstOrNull { it[0] == "p" } + assertEquals(splitTo, pTag?.getOrNull(1), "explicit toUserPubkey wins over event.pubKey") + } + + // ------------------------------------------------------------------ + // buildEventZapRequestsForSplits — covers the correctness bug the + // single-recipient buildEventZapRequest has for split notes. + // ------------------------------------------------------------------ + + @Test + fun buildEventZapRequestsForSplits_lnAddressSplitTagsProduceOneRequestPerRecipient() = + runTest { + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", "alice@wallet.example"), + arrayOf("zap", "bob@wallet.example"), + ), + content = "split me 50/50", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertEquals(2, requests.size) + assertEquals(setOf("alice@wallet.example", "bob@wallet.example"), requests.map { it.recipient.lnAddress }.toSet()) + // LnAddress-style splits are always weight 1.0 (per quartz parser), + // so 10000 msats / 2 = 5000 msats each. + assertEquals(setOf(5_000L), requests.map { it.amountMillisats }.toSet()) + } + + @Test + fun buildEventZapRequestsForSplits_pubkeySplitsRespectWeights() = + runTest { + val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitAPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitAPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + val splitBPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitBPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "4.0"), + ), + content = "20/80 split", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 100_000L, // 100 sats + senderInboxRelays = setOf(relay), + lookupLnAddress = { pk -> + when (pk) { + splitAPub -> "a@wallet" + splitBPub -> "b@wallet" + else -> null + } + }, + ) + + val byPub = requests.associateBy { it.recipient.pubkey } + assertEquals(20_000L, byPub[splitAPub]?.amountMillisats, "1/5 of 100 sats") + assertEquals(80_000L, byPub[splitBPub]?.amountMillisats, "4/5 of 100 sats") + // Sum matches input within rounding. + assertEquals(100_000L, requests.sumOf { it.amountMillisats }) + } + + @Test + fun buildEventZapRequestsForSplits_unionsAuthorAndRecipientInboxRelays() = + runTest { + // Use a key distinct from authorPriv/senderPriv so the split + // recipient and the note author are different pubkeys — otherwise + // their inbox-relay lookups collide and we can't tell which one + // ended up in the relays tag. + val splitPriv = "0000000000000000000000000000000000000000000000000000000000000019" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "test inbox unioning", + ) + + val senderRelay = relay + val authorRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://author-inbox.example")!! + val recipientRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://recipient-inbox.example")!! + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 1_000L, + senderInboxRelays = setOf(senderRelay), + lookupLnAddress = { _ -> "x@wallet" }, + lookupInboxRelays = { pk -> + when (pk) { + authorSigner.pubKey -> setOf(authorRelay) + splitPub -> setOf(recipientRelay) + else -> emptySet() + } + }, + ) + + assertEquals(1, requests.size) + val relaysTag = requests[0].request.tags.firstOrNull { it[0] == "relays" } + assertNotNull(relaysTag) + val relayUrls = relaysTag.drop(1).toSet() + // All three sources end up in the kind:9734 `relays` tag. + assertTrue(senderRelay.url in relayUrls, "sender inbox missing") + assertTrue(authorRelay.url in relayUrls, "author inbox missing") + assertTrue(recipientRelay.url in relayUrls, "recipient inbox missing") + } + + @Test + fun buildEventZapRequestsForSplits_emptyWhenNoRecipientHasLnAddress() = + runTest { + val splitPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "no recipient ln", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertTrue(requests.isEmpty()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt new file mode 100644 index 0000000000..7fdca3f94d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt @@ -0,0 +1,236 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ZapSplitResolverTest { + private val authorPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + private val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + + private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray())) + private val authorPub = xOnly(authorPriv) + private val splitAPub = xOnly(splitAPriv) + private val splitBPub = xOnly(splitBPriv) + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + /** Build a kind:1 note with the given extra tags, signed by the author. */ + private suspend fun noteWithTags(vararg tags: Array): Event = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = TextNoteEvent.KIND, + tags = arrayOf(*tags), + content = "hello world", + ) + + // ------------------------------------------------------------------ + // shareMillisats + // ------------------------------------------------------------------ + + @Test + fun shareMillisats_distributesProportionallyAndRoundsToSats() { + val total = 10_000L // 10 sats — millisats + val a = ZapSplitResolver.shareMillisats(total, weight = 1.0, totalWeight = 4.0) + val b = ZapSplitResolver.shareMillisats(total, weight = 3.0, totalWeight = 4.0) + + // Always a multiple of 1000 (whole sats). + assertEquals(0L, a % 1000) + assertEquals(0L, b % 1000) + // 1/4 + 3/4 = full sat total (within rounding). + assertTrue(a + b in (total - 1000)..(total + 1000)) + } + + @Test + fun shareMillisats_zeroTotalWeightReturnsZero() { + assertEquals(0L, ZapSplitResolver.shareMillisats(1_000L, 1.0, 0.0)) + } + + @Test + fun shareMillisats_roundsHalfUpToWholeSat() { + // 1234 msats with weight 1/1 → 1234 msats, rounds to 1000 msats (1 sat). + val r = ZapSplitResolver.shareMillisats(1_234L, 1.0, 1.0) + assertEquals(1_000L, r) + } + + // ------------------------------------------------------------------ + // resolve — author fallback + // ------------------------------------------------------------------ + + @Test + fun resolve_authorFallbackWhenNoSplitsAndNoSpecialEventKind() = + runTest { + val note = noteWithTags() + val lookup: suspend (HexKey) -> String? = { pk -> + if (pk == authorPub) "author@wallet.example" else null + } + + val recipients = ZapSplitResolver.resolve(note, lookup) + + assertEquals(1, recipients.size) + assertEquals("author@wallet.example", recipients[0].lnAddress) + assertEquals(authorPub, recipients[0].pubkey) + assertEquals(1.0, recipients[0].weight) + } + + @Test + fun resolve_authorWithoutLnAddressReturnsEmpty() = + runTest { + val note = noteWithTags() + val recipients = ZapSplitResolver.resolve(note) { null } + assertTrue(recipients.isEmpty(), "author has no LN address → no recipients") + } + + // ------------------------------------------------------------------ + // resolve — LN-address split tags (legacy variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_lnAddressSplitTagsUsedDirectlyWithoutLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", "carol@damus.io"), + arrayOf("zap", "dave@wallet.io"), + ) + // Lookup should never be consulted for LnAddress-style splits. + var lookupCalls = 0 + val recipients = + ZapSplitResolver.resolve(note) { _ -> + lookupCalls++ + null + } + + assertEquals(0, lookupCalls) + assertEquals(2, recipients.size) + assertEquals(setOf("carol@damus.io", "dave@wallet.io"), recipients.map { it.lnAddress }.toSet()) + // LnAddress splits never carry a pubkey. + assertTrue(recipients.all { it.pubkey == null }) + // The legacy LnAddress format is always weight 1.0 per ZapSplitSetupParser. + assertTrue(recipients.all { it.weight == 1.0 }) + } + + // ------------------------------------------------------------------ + // resolve — pubkey split tags (current variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_pubkeySplitTagsResolvedViaLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val knownAddresses = + mapOf( + splitAPub to "split-a@wallet.example", + splitBPub to "split-b@wallet.example", + ) + + val recipients = ZapSplitResolver.resolve(note) { pk -> knownAddresses[pk] } + + assertEquals(2, recipients.size) + val byPub = recipients.associateBy { it.pubkey } + assertEquals("split-a@wallet.example", byPub[splitAPub]?.lnAddress) + assertEquals(2.0, byPub[splitAPub]?.weight) + assertEquals("split-b@wallet.example", byPub[splitBPub]?.lnAddress) + assertEquals(3.0, byPub[splitBPub]?.weight) + } + + @Test + fun resolve_pubkeySplitWithoutLnAddressIsDroppedSilently() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "1.0"), + ) + // Only split A has an LN address; B is silently dropped — same + // behavior as the in-app `mapNotNull` after error display. + val recipients = + ZapSplitResolver.resolve(note) { pk -> + if (pk == splitAPub) "split-a@wallet.example" else null + } + + assertEquals(1, recipients.size) + assertEquals(splitAPub, recipients[0].pubkey) + } + + @Test + fun resolve_pubkeySplitsDoNotFallBackToAuthor() = + runTest { + // When split tags are present but none resolve to an LN address, + // we get empty — we do NOT silently bill the author. + val note = noteWithTags(arrayOf("zap", splitAPub, "", "1.0")) + + val recipients = ZapSplitResolver.resolve(note) { null } + + assertTrue(recipients.isEmpty()) + } + + // ------------------------------------------------------------------ + // shareMillisats integration: weighted distribution sums correctly + // ------------------------------------------------------------------ + + @Test + fun resolveAndShare_weighted2to3SplitMatchesUiBehavior() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val recipients = + ZapSplitResolver.resolve(note) { pk -> + when (pk) { + splitAPub -> "a@x" + splitBPub -> "b@x" + else -> null + } + } + val totalWeight = recipients.sumOf { it.weight } + val totalMsats = 100_000L // 100 sats + + val shares = recipients.map { ZapSplitResolver.shareMillisats(totalMsats, it.weight, totalWeight) } + + // 2/5 of 100 sats = 40 sats; 3/5 = 60 sats. + assertEquals(40_000L, shares[0]) + assertEquals(60_000L, shares[1]) + assertEquals(totalMsats, shares.sum()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt index 0badc90af2..afbbfb5379 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -212,6 +212,71 @@ class FeedDefinitionSerializerTest { assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) } + @Test + fun serializesToExpectedWireFormat() { + // Pins the byte-exact JSON output so any future change to the + // serializer (field order, null handling, number formatting) is + // caught here. Users have feeds saved on disk in this exact format + // and may share JSON between app versions; downstream consumers + // depend on the field order being stable. + val feed = + FeedDefinition( + id = "test-1", + name = "Bitcoin", + emoji = "₿", + pinned = true, + pinOrder = 0, + source = + FeedSource.Filter( + hashtags = persistentListOf("bitcoin", "btc"), + authors = persistentListOf("abc123"), + relays = persistentListOf("wss://relay.damus.io"), + excludeAuthors = persistentListOf("spammer"), + excludeKeywords = persistentListOf("scam"), + kinds = persistentListOf(1, 6), + ), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 1000L, + ) + + val expected = + """[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" + + """"pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],""" + + """"authors":["abc123"],"relays":["wss://relay.damus.io"],""" + + """"excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" + + """"kinds":[1,6]}}]""" + + assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed))) + } + + @Test + fun parsesLegacyJacksonOutput() { + // Wire format previously emitted by ObjectMapper. Users have feed + // definitions stored on disk in exactly this shape — the new + // kotlinx.serialization-backed parser must keep accepting it. + val legacy = + """[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" + + """"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" + + """"relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]""" + + val feeds = FeedDefinitionSerializer.deserializeList(legacy) + assertEquals(1, feeds.size) + val feed = feeds[0] + assertEquals("x", feed.id) + assertEquals("Bitcoin", feed.name) + assertEquals("₿", feed.emoji) + assertTrue(feed.pinned) + assertEquals(0, feed.pinOrder) + assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode) + assertEquals(1000L, feed.createdAt) + val source = feed.source as FeedSource.Filter + assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags) + assertEquals(persistentListOf("abc"), source.authors) + assertEquals(persistentListOf(1, 6), source.kinds) + } + @Test fun defaultFeedsAreValid() { val defaults = defaultFeeds() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt new file mode 100644 index 0000000000..316c8b3743 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt @@ -0,0 +1,92 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Lock in the descending-by-usage ordering and equal-count preservation of + * [Channel.relays]. Phase 2 KMP migration replaced the JVM `toSortedSet` + * (TreeSet) implementation — which silently dropped duplicates whose + * comparator returned 0 — with a LinkedHashSet built from a pre-sorted list. + * That keeps every distinct relay even when usage counts tie. + */ +class ChannelRelaysTest { + /** Minimal concrete subclass — the base [Channel] is abstract. */ + private class TestChannel : Channel() { + override fun toBestDisplayName(): String = "test" + } + + private val relayA = NormalizedRelayUrl("wss://a.example/") + private val relayB = NormalizedRelayUrl("wss://b.example/") + private val relayC = NormalizedRelayUrl("wss://c.example/") + + @Test + fun emptyWhenNoRelaysAdded() { + assertEquals(emptySet(), TestChannel().relays()) + } + + @Test + fun singleRelayIsReturned() { + val channel = TestChannel() + channel.addRelay(relayA) + assertEquals(setOf(relayA), channel.relays()) + } + + @Test + fun relaysAreOrderedDescendingByUsageCount() { + val channel = TestChannel() + // C will end up with the highest count. + channel.addRelay(relayA) // A: 1 + channel.addRelay(relayB) // B: 1 + channel.addRelay(relayB) // B: 2 + channel.addRelay(relayC) // C: 1 + channel.addRelay(relayC) // C: 2 + channel.addRelay(relayC) // C: 3 + + // LinkedHashSet preserves insertion order, which is the + // descending-by-count order produced by sortedByDescending. + assertEquals(listOf(relayC, relayB, relayA), channel.relays().toList()) + } + + /** + * Behavioural change from the JVM-only implementation: the previous + * `toSortedSet` returned a [java.util.TreeSet] whose membership uses the + * comparator for equality. Two relays with equal counts would be treated + * as duplicates by the TreeSet and one would be silently dropped. The + * KMP-friendly replacement uses [LinkedHashSet], whose membership uses + * `equals`, so every distinct relay is preserved regardless of count. + */ + @Test + fun relaysWithEqualUsageCountsAreAllPreserved() { + val channel = TestChannel() + channel.addRelay(relayA) // A: 1 + channel.addRelay(relayB) // B: 1 + channel.addRelay(relayC) // C: 1 + + // All three relays share count 1. The old behaviour would return + // exactly one of them; the new behaviour returns all three. + assertEquals(setOf(relayA, relayB, relayC), channel.relays()) + assertEquals(3, channel.relays().size) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt index 59064d5512..5bd761e324 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.quartz.nip01Core.core.HexKey -import java.math.BigDecimal +import com.vitorpamplona.quartz.utils.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -67,7 +67,7 @@ class NoteOnchainZapTest { assertEquals(1000L, entry?.claimedSats) assertEquals(0L, entry?.verifiedSats) assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -82,7 +82,7 @@ class NoteOnchainZapTest { assertSame(src, entry?.source) assertEquals(1000L, entry?.verifiedSats) assertEquals(OnchainZapStatus.PENDING, entry?.status) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -93,7 +93,7 @@ class NoteOnchainZapTest { target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED) assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status) - assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount) + assertEquals(BigDecimal(5000L), target.zapsAmount) } @Test @@ -109,7 +109,7 @@ class NoteOnchainZapTest { assertSame(secondSrc, entry?.source) assertEquals(OnchainZapStatus.PENDING, entry?.status) assertEquals(2400L, entry?.verifiedSats) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -124,7 +124,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(secondSrc, entry?.source) assertEquals(OnchainZapStatus.CONFIRMED, entry?.status) - assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount) + assertEquals(BigDecimal(2500L), target.zapsAmount) } @Test @@ -139,7 +139,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(firstSrc, entry?.source) assertEquals(OnchainZapStatus.CONFIRMED, entry?.status) - assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount) + assertEquals(BigDecimal(7500L), target.zapsAmount) } @Test @@ -188,7 +188,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(firstSrc, entry?.source) assertEquals(999L, entry?.verifiedSats) - assertEquals(BigDecimal.valueOf(999L), target.zapsAmount) + assertEquals(BigDecimal(999L), target.zapsAmount) } @Test @@ -206,7 +206,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(secondSrc, entry?.source) - assertEquals(BigDecimal.valueOf(1000L), target.zapsAmount) + assertEquals(BigDecimal(1000L), target.zapsAmount) } @Test @@ -224,7 +224,7 @@ class NoteOnchainZapTest { val secondEntry = target.onchainZaps["tx1"] assertSame(firstEntry, secondEntry) - assertEquals(BigDecimal.valueOf(100L), target.zapsAmount) + assertEquals(BigDecimal(100L), target.zapsAmount) } @Test @@ -238,13 +238,13 @@ class NoteOnchainZapTest { target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING) // PENDING entries don't contribute to total per spec. - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) assertNotNull(target.onchainZaps["tx1"]) target.removeOnchainZapForSource("tx1", srcKey) assertNull(target.onchainZaps["tx1"]) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -277,13 +277,13 @@ class NoteOnchainZapTest { val src = sourceNote(srcKey) target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED) - assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount) + assertEquals(BigDecimal(4200L), target.zapsAmount) target.removeOnchainZapForSource("tx1", srcKey) assertNotNull(target.onchainZaps["tx1"]) assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status) - assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount) + assertEquals(BigDecimal(4200L), target.zapsAmount) } @Test @@ -342,7 +342,7 @@ class NoteOnchainZapTest { target.addOnchainZap(sourceNote("82".repeat(32)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING) target.addOnchainZap(sourceNote("93".repeat(32)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED) - assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount) + assertEquals(BigDecimal(3000L), target.zapsAmount) assertEquals(4, target.onchainZaps.size) assertTrue(target.onchainZaps.containsKey("tx3")) assertTrue(target.onchainZaps.containsKey("tx4")) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt new file mode 100644 index 0000000000..ee7ea2c932 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.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.commons.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class CodePointsTest { + // ---- codePointCharCount ---- + + @Test + fun charCountIsOneForBmp() { + assertEquals(1, codePointCharCount(0x0041)) // 'A' + assertEquals(1, codePointCharCount(0xFFFF)) // last BMP code point + } + + @Test + fun charCountIsTwoForSupplementary() { + assertEquals(2, codePointCharCount(0x10000)) // first supplementary code point + assertEquals(2, codePointCharCount(0x1F600)) // 😀 grinning face + assertEquals(2, codePointCharCount(0x10FFFF)) // last valid code point + } + + // ---- codePointToChars ---- + + @Test + fun toCharsRoundTripsAscii() { + assertArrayEquals(charArrayOf('A'), codePointToChars(0x0041)) + } + + @Test + fun toCharsRoundTripsLastBmp() { + assertArrayEquals(charArrayOf('￿'), codePointToChars(0xFFFF)) + } + + @Test + fun toCharsProducesSurrogatePairForGrinningFace() { + // U+1F600 (😀) is encoded as the surrogate pair (0xD83D, 0xDE00). + assertArrayEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600)) + } + + @Test + fun toCharsProducesSurrogatePairForFirstSupplementary() { + // U+10000 -> (0xD800, 0xDC00). + assertArrayEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000)) + } + + @Test + fun toCharsProducesSurrogatePairForLastCodePoint() { + // U+10FFFF -> (0xDBFF, 0xDFFF). + assertArrayEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF)) + } + + // ---- String.codePointAtKmp ---- + + @Test + fun codePointAtReturnsAsciiCodeForBmp() { + assertEquals(0x0041, "Aa".codePointAtKmp(0)) + assertEquals(0x0061, "Aa".codePointAtKmp(1)) + } + + @Test + fun codePointAtDecodesSurrogatePair() { + // "😀X" — surrogate pair followed by 'X'. + val s = "😀X" + assertEquals(0x1F600, s.codePointAtKmp(0)) + } + + @Test + fun codePointAtReturnsLowSurrogateWhenIndexIsOnLowHalf() { + // Indexing into the middle of a surrogate pair returns the low surrogate's raw code unit. + val s = "😀" + assertEquals(0xDE00, s.codePointAtKmp(1)) + } + + @Test + fun codePointAtReturnsHighSurrogateWhenStringEndsOnLoneHighSurrogate() { + // Lone high surrogate at end of string: `index + 1 < length` is false, so we + // fall back to returning the surrogate's raw code unit. This matches + // java.lang.Character.codePointAt(CharSequence, int). + val s = "\uD83D" + assertEquals(0xD83D, s.codePointAtKmp(0)) + } + + @Test + fun codePointAtReturnsHighSurrogateWhenFollowedByNonSurrogate() { + // High surrogate followed by a non-low-surrogate char: return the high surrogate raw. + val s = "\uD83DA" + assertEquals(0xD83D, s.codePointAtKmp(0)) + assertEquals(0x0041, s.codePointAtKmp(1)) + } + + // ---- String.offsetByCodePointsKmp ---- + + @Test + fun offsetByOneStepsOverBmp() { + // Two ASCII chars; offset 1 from index 0 lands on index 1. + assertEquals(1, "Aa".offsetByCodePointsKmp(0, 1)) + } + + @Test + fun offsetByOneStepsOverSupplementaryPair() { + // 😀 is 2 UTF-16 code units; offset 1 from index 0 lands on index 2. + val s = "😀X" + assertEquals(2, s.offsetByCodePointsKmp(0, 1)) + // Then a single step lands on the end of the string. + assertEquals(3, s.offsetByCodePointsKmp(2, 1)) + } + + @Test + fun offsetByZeroIsIdentity() { + assertEquals(0, "Aa".offsetByCodePointsKmp(0, 0)) + assertEquals(2, "Aa".offsetByCodePointsKmp(2, 0)) + } + + // ---- Round-trip ---- + + @Test + fun encodeDecodeRoundTripCoversFullCodePointRange() { + // Spot-check a handful of code points across BMP and supplementary planes. + val samples = intArrayOf(0x0001, 0x0041, 0x00FF, 0x4F60, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF) + for (cp in samples) { + val chars = codePointToChars(cp) + val asString = chars.concatToString() + assertEquals("round-trip code point U+${cp.toString(16).uppercase()}", cp, asString.codePointAtKmp(0)) + assertEquals( + "char count for U+${cp.toString(16).uppercase()}", + chars.size, + codePointCharCount(cp), + ) + } + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt new file mode 100644 index 0000000000..54bfed7b5c --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt @@ -0,0 +1,76 @@ +/* + * 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.blurhash + +// Phase 2 compile-only iOS actual. Holds an ARGB pixel buffer + size. Phase 3 +// will swap to a UIImage/CGImage-backed implementation when the iosApp module +// lands and BlurHash/ThumbHash output needs to render to UIKit. +actual class PlatformImage internal constructor( + private val pixels: IntArray, + actual val width: Int, + actual val height: Int, +) { + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + for (row in 0 until height) { + val srcStart = (y + row) * this.width + x + val dstStart = offset + row * stride + this.pixels.copyInto( + destination = pixels, + destinationOffset = dstStart, + startIndex = srcStart, + endIndex = srcStart + width, + ) + } + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage { + // Nearest-neighbour resample. Adequate for the Phase 2 compile-only target; + // Phase 3 should swap to CoreGraphics scaling for production quality. + val out = IntArray(width * height) + for (j in 0 until height) { + val srcY = (j.toLong() * this.height / height).toInt().coerceAtMost(this.height - 1) + for (i in 0 until width) { + val srcX = (i.toLong() * this.width / width).toInt().coerceAtMost(this.width - 1) + out[j * width + i] = pixels[srcY * this.width + srcX] + } + } + return PlatformImage(out, width, height) + } + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage = PlatformImage(pixels.copyOf(), width, height) + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt new file mode 100644 index 0000000000..268b92d6a7 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.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.amethyst.commons.chess + +// Phase 2 compile-only iOS actual. In-memory only; persistence via +// NSUserDefaults arrives with the iosApp module in Phase 3. +actual class ChessDismissedGamesStorage private actual constructor() { + private val dismissed = mutableMapOf>() + + actual companion object { + actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() + } + + actual fun load(userPubkey: String): Set = dismissed[userPubkey] ?: emptySet() + + actual fun save( + userPubkey: String, + ids: Set, + ) { + if (ids.isEmpty()) { + dismissed.remove(userPubkey) + } else { + dismissed[userPubkey] = ids + } + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt new file mode 100644 index 0000000000..d67627da12 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt @@ -0,0 +1,40 @@ +/* + * 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.keystorage + +// Phase 2 compile-only iOS actual. Keychain Services wiring lands in Phase 4 +// (write paths: signing, posting, settings) per amethyst/plans/2026-05-24-ios-support.md. +actual class SecureKeyStorage private actual constructor() { + actual companion object { + actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage() + } + + actual suspend fun savePrivateKey( + npub: String, + privKeyHex: String, + ): Unit = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt new file mode 100644 index 0000000000..960eb5282b --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.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. + */ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.localTimeZone + +// Format pattern matches the JVM actual ("uuuu-MM-dd-HH:mm:ss"). For the +// post-1970 timestamps Nostr events use, `yyyy` and `uuuu` are equivalent +// (proleptic Gregorian, year >= 1), so NSDateFormatter's `yyyy` is fine. +private val levelFormatter: NSDateFormatter = + NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd-HH:mm:ss" + timeZone = NSTimeZone.localTimeZone + locale = NSLocale("en_US_POSIX") + } + +// NSDateFormatter is not thread-safe for concurrent stringFromDate calls +// (Apple docs). ThreadLevelCalculator.replyLevelSignature runs under +// Dispatchers.IO via LevelFeedViewModel, so multiple threads can hit +// formattedDateTime simultaneously. Guard the shared formatter rather than +// allocating one per call (cheaper for the O(notes-per-thread-sort) call rate). +private val levelFormatterLock = KmpLock() + +actual fun formattedDateTime(timestamp: Long): String = + levelFormatterLock.withLock { + levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble())) + } diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt new file mode 100644 index 0000000000..7eff5bb65b --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt @@ -0,0 +1,28 @@ +/* + * 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.threading + +// Mirrors the jvmMain no-op. The iosApp module (Phase 3) can swap this for an +// `NSThread.isMainThread` check if iOS-specific main-thread restrictions need +// enforcement; today there are none. +actual fun checkNotInMainThread() { + // No-op +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt new file mode 100644 index 0000000000..71cfb217fe --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt @@ -0,0 +1,33 @@ +/* + * 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.util + +import platform.Foundation.NSRecursiveLock + +actual class KmpLock { + // NSRecursiveLock mirrors ReentrantLock semantics on JVM/Android — a + // thread that already holds the lock can re-enter without deadlock. + private val delegate = NSRecursiveLock() + + actual fun lock() = delegate.lock() + + actual fun unlock() = delegate.unlock() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt new file mode 100644 index 0000000000..6179b77777 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSNumber +import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.numberWithLongLong + +actual class PlatformNumberFormatter { + private val formatter: NSNumberFormatter = + NSNumberFormatter().apply { + numberStyle = NSNumberFormatterDecimalStyle + } + + actual fun format(value: Long): String = formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt new file mode 100644 index 0000000000..c72e2da008 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt @@ -0,0 +1,42 @@ +/* + * 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.util + +import platform.Foundation.NSURL + +// Schemes that JVM's URI.toURL() requires a host for (network schemes). +// "http:" without a host throws MalformedURLException on JVM; NSURL accepts +// it. Explicitly reject to keep behavior aligned. +private val HOST_REQUIRED_SCHEMES = setOf("http", "https", "ws", "wss", "ftp") + +actual fun isValidUrl(url: String?): Boolean { + if (url == null) return false + // NSURL.URLWithString returns null for syntactically invalid URLs. It is + // more permissive than JVM's URI(url).toURL() — it accepts scheme-less + // relative URLs ("foo", "/bar") and scheme-only inputs ("http:"). Match + // the JVM contract (absolute URL with a known scheme and, for network + // schemes, a host). + val nsUrl = NSURL.URLWithString(url) ?: return false + val scheme = nsUrl.scheme?.lowercase() ?: return false + if (scheme.isEmpty()) return false + if (scheme in HOST_REQUIRED_SCHEMES && nsUrl.host.isNullOrEmpty()) return false + return true +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt new file mode 100644 index 0000000000..cc52116fef --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt @@ -0,0 +1,31 @@ +/* + * 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. + */ +@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class) + +package com.vitorpamplona.amethyst.commons.util + +actual class WeakReference actual constructor( + referent: T, +) { + private val ref = kotlin.native.ref.WeakReference(referent) + + actual fun get(): T? = ref.get() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt new file mode 100644 index 0000000000..169879b250 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt @@ -0,0 +1,25 @@ +/* + * 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.utils + +// iosApp (Phase 3) can flip this from a build flag (`DEBUG` Swift compile +// condition surfaced through the framework). Compile-only target ships false. +actual val isDebug: Boolean = false diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt new file mode 100644 index 0000000000..30c0de6441 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.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.amethyst.commons.richtext + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import java.io.File + +// Locally-cached media content. Lives in jvmAndroid because java.io.File is +// JVM-only; iOS support will arrive when we either swap to okio.Path or pull +// these classes back into commonMain behind an expect/actual File abstraction. +// URL-based media classes (MediaUrlImage, MediaUrlVideo, …) remain in +// commonMain in MediaContentModels.kt. + +@Immutable +abstract class MediaPreloadedContent( + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + val uri: String, + val id: String? = null, + thumbhash: String? = null, +) : BaseMediaContent(description, dim, blurhash, thumbhash) { + fun localFileExists() = localFile != null && localFile.exists() +} + +@Immutable +class MediaLocalImage( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) + +@Immutable +class MediaLocalVideo( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt new file mode 100644 index 0000000000..db64401eb5 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt @@ -0,0 +1,31 @@ +/* + * 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.util + +import java.util.concurrent.locks.ReentrantLock + +actual class KmpLock { + private val delegate = ReentrantLock() + + actual fun lock() = delegate.lock() + + actual fun unlock() = delegate.unlock() +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt new file mode 100644 index 0000000000..70905418b4 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt @@ -0,0 +1,41 @@ +/* + * 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.util + +import java.net.MalformedURLException +import java.net.URI +import java.net.URISyntaxException + +actual fun isValidUrl(url: String?): Boolean = + try { + if (url != null) { + URI(url).toURL() + true + } else { + false + } + } catch (_: MalformedURLException) { + false + } catch (_: URISyntaxException) { + false + } catch (_: IllegalArgumentException) { + false + } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt new file mode 100644 index 0000000000..15b46abcaf --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt @@ -0,0 +1,23 @@ +/* + * 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.util + +actual typealias WeakReference = java.lang.ref.WeakReference diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt 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 2797a26071..a314a3449a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1043,6 +1043,7 @@ fun App( onDismiss = onDismissComposeDialog, relayManager = relayManager, account = account, + localCache = localCache, replyTo = replyToNote, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt new file mode 100644 index 0000000000..c478f7f6ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service + +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState + +object DesktopCachedRichTextParser { + private const val MAX_CACHE_SIZE = 50 + + private val cache = + java.util.Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_CACHE_SIZE + }, + ) + + fun parseText( + content: String, + tags: ImmutableListOfLists, + callbackUri: String? = null, + ): RichTextViewerState { + cache[content]?.let { return it } + val state = RichTextParser().parseText(content, tags, callbackUri) + cache[content] = state + return state + } + + fun isMarkdown(content: String): Boolean = + content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("**") || + content.contains("```") || + content.contains("](") +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index ab5a6c3ef7..9d8f5d3b65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -21,24 +21,32 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -46,14 +54,19 @@ 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -98,6 +111,7 @@ fun ComposeNoteDialog( onDismiss: () -> Unit, relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, replyTo: Event? = null, quoteOf: Event? = null, ) { @@ -111,8 +125,43 @@ fun ComposeNoteDialog( "" } } - var content by remember { mutableStateOf(initialContent) } + var contentField by remember { + mutableStateOf( + TextFieldValue( + text = initialContent, + selection = TextRange(initialContent.length), + ), + ) + } + val content = contentField.text var isPosting by remember { mutableStateOf(false) } + + // Mention autocomplete state + var mentionSuggestions by remember { mutableStateOf>(emptyList()) } + var mentionQuery by remember { mutableStateOf(null) } + var mentionWordStart by remember { mutableStateOf(0) } + + LaunchedEffect(contentField) { + val cursor = contentField.selection.end + val text = contentField.text + if (cursor > 0 && localCache != null) { + // Find the word at cursor + val wordStart = text.lastIndexOf(' ', cursor - 1) + 1 + val wordAtCursor = text.substring(wordStart, cursor) + if (wordAtCursor.startsWith("@") && wordAtCursor.length > 1) { + val query = wordAtCursor.removePrefix("@") + mentionQuery = query + mentionWordStart = wordStart + mentionSuggestions = localCache.findUsersStartingWith(query, 5) + } else { + mentionQuery = null + mentionSuggestions = emptyList() + } + } else { + mentionQuery = null + mentionSuggestions = emptyList() + } + } var errorMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val attachedFiles = remember { mutableStateListOf() } @@ -208,22 +257,55 @@ fun ComposeNoteDialog( Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = if (postAsPicture) "" else content, - onValueChange = { - content = it - errorMessage = null - }, - modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), - label = { - Text( - if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", - ) - }, - placeholder = { Text(if (postAsPicture) "" else "Write your note...") }, - enabled = !isPosting && !postAsPicture, - maxLines = if (postAsPicture) 1 else 10, - ) + Box { + OutlinedTextField( + value = if (postAsPicture) TextFieldValue("") else contentField, + onValueChange = { + contentField = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), + label = { + Text( + if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", + ) + }, + placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") }, + enabled = !isPosting && !postAsPicture, + maxLines = if (postAsPicture) 1 else 10, + ) + + // Mention autocomplete dropdown + if (mentionSuggestions.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { + items(mentionSuggestions, key = { it.pubkeyHex }) { user -> + MentionSuggestionRow( + user = user, + onClick = { + val npub = user.pubkeyNpub() + val replacement = "nostr:$npub " + val cursorEnd = contentField.selection.end + val newText = + contentField.text.replaceRange( + mentionWordStart, + cursorEnd, + replacement, + ) + val newCursor = mentionWordStart + replacement.length + contentField = TextFieldValue(newText, TextRange(newCursor)) + mentionSuggestions = emptyList() + mentionQuery = null + }, + ) + } + } + } + } + } Spacer(Modifier.height(8.dp)) @@ -590,3 +672,39 @@ private suspend fun publishNote( relayManager.publish(signedEvent, relays) } } + +@Composable +private fun MentionSuggestionRow( + user: User, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 28.dp, + contentDescription = null, + ) + Column { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + Text( + text = user.pubkeyNpub().take(24) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 26650f5140..0367228efa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event @@ -49,5 +50,6 @@ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, + tags = ImmutableListOfLists(tags), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 8c666e145c..4bc55e7cee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -126,6 +126,7 @@ fun FeedNoteCard( onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, ) { val event = note.event ?: return val isRepost = event is RepostEvent || event is GenericRepostEvent @@ -195,6 +196,7 @@ fun FeedNoteCard( onClick = { onNavigateToThread(originalEvent.id) }, onAuthorClick = onNavigateToProfile, onMentionClick = onNavigateToProfile, + onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, ) @@ -247,6 +249,7 @@ fun FeedNoteCard( onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, onMentionClick = onNavigateToProfile, + onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, ) @@ -686,6 +689,7 @@ fun FeedScreen( onDismiss = { replyToEvent = null }, relayManager = relayManager, account = account, + localCache = localCache, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 8f90bfdf00..a515977d43 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1354,6 +1354,13 @@ fun NoteActionsRow( showOverflowMenu = false }, ) + DropdownMenuItem( + text = { Text("Copy Raw JSON") }, + onClick = { + copyToClipboard(event.toJson()) + showOverflowMenu = false + }, + ) } } } @@ -1400,6 +1407,7 @@ fun NoteActionsRow( onDismiss = { quoteEvent = null }, relayManager = relayManager, account = account, + localCache = localCache, quoteOf = quoteEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 5f8661a258..27060880ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,6 +44,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -63,7 +66,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -93,6 +99,10 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers @@ -138,6 +148,30 @@ fun UserProfileScreen( ) } var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) } + var nip05 by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.nip05, + ) + } + var website by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.website, + ) + } + var lnAddress by remember { mutableStateOf(cachedMetadata?.lnAddress()) } + var identities by remember { + mutableStateOf( + cachedMetadata?.flow?.value?.identities ?: emptyList(), + ) + } var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) } var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) } @@ -258,6 +292,10 @@ fun UserProfileScreen( displayName = metadata.displayName ?: metadata.name about = metadata.about picture = metadata.picture + nip05 = metadata.nip05 + website = metadata.website + lnAddress = metadata.lnAddress() + identities = event.identityClaims() } // Store MetadataEvent for editing (only for own profile) @@ -678,6 +716,68 @@ fun UserProfileScreen( ) } + // Profile metadata fields + nip05?.takeIf { it.isNotBlank() }?.let { addr -> + Spacer(Modifier.height(8.dp)) + ProfileMetadataField( + text = addr, + icon = MaterialSymbols.CheckCircle, + onClick = { + runCatching { + java.awt.Desktop.getDesktop().browse( + java.net.URI("https://${addr.substringAfter("@")}"), + ) + } + }, + ) + } + + website?.takeIf { it.isNotBlank() }?.let { site -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = site.removePrefix("https://").removePrefix("http://").removeSuffix("/"), + copyValue = site, + icon = MaterialSymbols.Language, + onClick = { + runCatching { + val url = if (site.contains("://")) site else "https://$site" + java.awt.Desktop + .getDesktop() + .browse(java.net.URI(url)) + } + }, + ) + } + + lnAddress?.takeIf { it.isNotBlank() }?.let { addr -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = addr, + icon = MaterialSymbols.Bolt, + ) + } + + identities.forEach { identity -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = + when (identity) { + is TwitterIdentity -> "@${identity.identity}" + is GitHubIdentity -> identity.identity + is MastodonIdentity -> identity.identity + else -> identity.identity + }, + icon = MaterialSymbols.Language, + onClick = { + runCatching { + java.awt.Desktop.getDesktop().browse( + java.net.URI(identity.toProofUrl()), + ) + } + }, + ) + } + Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -1173,3 +1273,64 @@ private fun PublishedHighlightCard( } } } + +@Composable +private fun ProfileMetadataField( + text: String, + copyValue: String = text, + icon: com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol? = null, + onClick: () -> Unit = {}, +) { + var showContextMenu by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if ( + event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + showContextMenu = true + } + } + } + }.clickable { onClick() }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp), + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(copyValue), null) + showContextMenu = false + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt new file mode 100644 index 0000000000..9039978f28 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -0,0 +1,700 @@ +/* + * 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.ui.note + +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.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +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.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +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.EmailSegment +import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment +import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment +import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment +import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment +import com.vitorpamplona.amethyst.commons.richtext.PdfSegment +import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment +import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.commons.richtext.VideoSegment +import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +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 +import kotlinx.collections.immutable.ImmutableMap +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.net.URI + +data class RichTextCallbacks( + val onMentionClick: ((String) -> Unit)? = null, + val onHashtagClick: ((String) -> Unit)? = null, + val onNavigateToThread: ((String) -> Unit)? = null, + val onImageClick: ((List, Int) -> Unit)? = null, + val onPayInvoice: ((String) -> Unit)? = null, +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DesktopRichTextViewer( + content: String, + state: RichTextViewerState, + localCache: DesktopLocalCache? = null, + callbacks: RichTextCallbacks = RichTextCallbacks(), + modifier: Modifier = Modifier, +) { + if (DesktopCachedRichTextParser.isMarkdown(content)) { + RenderMarkdown( + content = content, + onLinkClick = { url -> + when { + url.startsWith("nostr:") -> { + val parsed = Nip19Parser.uriToRoute(url) + when (val entity = parsed?.entity) { + is NPub -> { + callbacks.onMentionClick?.invoke(entity.hex) + } + + is NProfile -> { + callbacks.onMentionClick?.invoke(entity.hex) + } + + is NNote -> { + callbacks.onNavigateToThread?.invoke(entity.hex) + } + + is NEvent -> { + callbacks.onNavigateToThread?.invoke(entity.hex) + } + + else -> {} + } + } + + else -> { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + } + } + }, + modifier = modifier, + ) + return + } + + Column(modifier = modifier) { + for (paragraph in state.paragraphs) { + when (paragraph) { + is ImageGalleryParagraph -> { + val urls = paragraph.words.map { it.segmentText } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + for ((index, segment) in paragraph.words.withIndex()) { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .weight(1f) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .then( + if (callbacks.onImageClick != null) { + Modifier.clickable { callbacks.onImageClick.invoke(urls, index) } + } else { + Modifier + }, + ), + contentScale = ContentScale.Crop, + ) + } + } + } + + else -> { + val hasOnlyText = paragraph.words.all { it is RegularTextSegment } + if (hasOnlyText) { + Text( + text = paragraph.words.joinToString("") { it.segmentText }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } else { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start, + ) { + for (word in paragraph.words) { + RenderSegment(word, state, localCache, callbacks) + } + } + } + } + } + } + } +} + +@Composable +private fun RenderSegment( + segment: Segment, + state: RichTextViewerState, + localCache: DesktopLocalCache?, + callbacks: RichTextCallbacks, +) { + when (segment) { + is RegularTextSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + is LinkSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is SchemelessUrlSegment -> { + ClickableLink("https://${segment.segmentText}", segment.segmentText) + } + + is BechSegment -> { + RenderBechSegment(segment, localCache, callbacks) + } + + is HashTagSegment -> { + val display = "#${segment.hashtag}" + (segment.extras ?: "") + Text( + text = display, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onHashtagClick?.invoke(segment.hashtag) }, + ) + } + + is HashIndexUserSegment -> { + val user = localCache?.getUserIfExists(segment.hex) + val display = "@${user?.toBestDisplayName() ?: segment.hex.take(8) + "..."}" + Text( + text = display + (segment.extras ?: ""), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onMentionClick?.invoke(segment.hex) }, + ) + } + + is HashIndexEventSegment -> { + QuotedNoteEmbed( + noteId = segment.hex, + localCache = localCache, + onMentionClick = callbacks.onMentionClick, + onNavigateToThread = callbacks.onNavigateToThread, + ) + } + + is EmailSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI("mailto:${segment.segmentText}")) + } + }, + ) + } + + is PhoneSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + is RelayUrlSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { copyToClipboard(segment.segmentText) }, + ) + } + + is EmojiSegment -> { + RenderCustomEmojiSegment(segment.segmentText, state.customEmoji) + } + + is NowhereLinkSegment -> { + RenderNowhereLinkCard(segment) + } + + is InvoiceSegment -> { + RenderInvoiceCard(segment.segmentText, callbacks) + } + + is CashuSegment -> { + RenderCashuCard(segment.segmentText) + } + + is WithdrawSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is BlossomUriSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is PdfSegment -> { + RenderPdfCard(segment.segmentText) + } + + is Base64Segment -> { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + is ImageSegment -> { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + is VideoSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(segment.segmentText)) + } + }, + ) + } + + is SecretEmoji -> { + RenderSecretEmoji(segment.segmentText) + } + + else -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun ClickableLink( + url: String, + displayText: String, +) { + Text( + text = displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + }, + ) +} + +@Composable +private fun RenderBechSegment( + segment: BechSegment, + localCache: DesktopLocalCache?, + callbacks: RichTextCallbacks, +) { + val resolved = + remember(segment.segmentText, localCache) { + resolveBech32(segment.segmentText, localCache) + } + when { + resolved.noteIdHex != null -> { + QuotedNoteEmbed( + noteId = resolved.noteIdHex, + localCache = localCache, + onMentionClick = callbacks.onMentionClick, + onNavigateToThread = callbacks.onNavigateToThread, + ) + } + + resolved.pubKeyHex != null -> { + Text( + text = resolved.displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onMentionClick?.invoke(resolved.pubKeyHex) }, + ) + } + + else -> { + Text( + text = resolved.displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun RenderInvoiceCard( + invoice: String, + callbacks: RichTextCallbacks, +) { + val amount = + remember(invoice) { + runCatching { LnInvoiceUtil.getAmountInSats(invoice) }.getOrNull() + } + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = "Lightning Invoice", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (amount != null) "$amount sats" else "Lightning Invoice", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { copyToClipboard(invoice) }) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Copy") + } + if (callbacks.onPayInvoice != null) { + TextButton(onClick = { callbacks.onPayInvoice.invoke(invoice) }) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = "Pay", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Pay") + } + } + } + } + } +} + +@Composable +private fun RenderCashuCard(token: String) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Cashu Token", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = token.take(40) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { copyToClipboard(token) }) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Copy") + } + } + } +} + +@Composable +private fun RenderPdfCard(url: String) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.PictureAsPdf, + contentDescription = "PDF", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RenderSecretEmoji(text: String) { + var expanded by remember { mutableStateOf(false) } + if (expanded) { + val decoded = remember(text) { runCatching { EmojiCoder.decode(text) }.getOrDefault(text) } + Text( + text = decoded, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.clickable { expanded = false }, + ) + } else { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { expanded = true }, + ) + } +} + +@Composable +private fun RenderCustomEmojiSegment( + word: String, + customEmoji: ImmutableMap, +) { + val matchedEmoji = remember(word, customEmoji) { customEmoji.entries.firstOrNull { word.contains(it.key) } } + if (matchedEmoji != null) { + val parts = word.split(matchedEmoji.key, limit = 2) + Row(verticalAlignment = Alignment.CenterVertically) { + if (parts[0].isNotEmpty()) { + Text(parts[0], style = MaterialTheme.typography.bodyMedium) + } + AsyncImage( + model = matchedEmoji.value, + contentDescription = matchedEmoji.key, + modifier = Modifier.size(20.dp), + contentScale = ContentScale.Fit, + ) + if (parts.size > 1 && parts[1].isNotEmpty()) { + Text(parts[1], style = MaterialTheme.typography.bodyMedium) + } + } + } else { + Text(word, style = MaterialTheme.typography.bodyMedium) + } +} + +private val nowhereToolLabels = + mapOf( + "e" to "Nowhere Event", + "f" to "Nowhere Fundraiser", + "s" to "Nowhere Store", + "p" to "Nowhere Petition", + "m" to "Nowhere Message", + "d" to "Nowhere Drop", + "a" to "Nowhere Art", + "fo" to "Nowhere Forum", + ) + +@Composable +private fun RenderNowhereLinkCard(segment: NowhereLinkSegment) { + val label = nowhereToolLabels[segment.tool] ?: "Nowhere Site" + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(segment.segmentText)) + } + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = segment.host, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun copyToClipboard(text: String) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 1b0208f63a..7b152971ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -46,20 +46,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer @@ -85,6 +80,7 @@ data class NoteDisplayData( val profilePictureUrl: String? = null, val content: String, val createdAt: Long, + val tags: ImmutableListOfLists = EmptyTagList, ) /** @@ -99,8 +95,10 @@ fun NoteCard( onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, onMentionClick: ((String) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, + onPayInvoice: ((String) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -135,18 +133,6 @@ fun NoteCard( } text } - val strippedUrls = - remember(urls, mediaUrls) { - Urls( - withScheme = urls.withScheme - mediaUrls, - withoutScheme = urls.withoutScheme, - emails = urls.emails, - bech32s = urls.bech32s, - relayUrls = urls.relayUrls, - blossomUris = urls.blossomUris, - ) - } - // Cap media height to half the window so text is never pushed off-screen val windowState = LocalWindowState.current val maxMediaHeight = @@ -212,11 +198,22 @@ fun NoteCard( Spacer(Modifier.height(8.dp)) if (strippedContent.isNotBlank()) { - RichTextContent( + val richState = + remember(strippedContent, note.tags) { + DesktopCachedRichTextParser.parseText(strippedContent, note.tags) + } + DesktopRichTextViewer( content = strippedContent, - urls = strippedUrls, + state = richState, localCache = localCache, - onMentionClick = onMentionClick, + callbacks = + RichTextCallbacks( + onMentionClick = onMentionClick, + onHashtagClick = onHashtagClick, + onNavigateToThread = null, + onImageClick = onImageClick, + onPayInvoice = onPayInvoice, + ), modifier = Modifier.fillMaxWidth(), ) } @@ -325,7 +322,7 @@ fun NoteCard( /** * Resolved bech32 mention with display text and optional pubkey for click navigation. */ -private data class ResolvedMention( +internal data class ResolvedMention( val displayText: String, val pubKeyHex: String? = null, val noteIdHex: String? = null, @@ -336,7 +333,7 @@ private data class ResolvedMention( * For npub/nprofile → @displayName + pubkey hex for navigation. * For note/nevent → truncated note ID. */ -private fun resolveBech32( +internal fun resolveBech32( bech32: String, localCache: DesktopLocalCache?, ): ResolvedMention { @@ -386,99 +383,6 @@ fun extractMentionedPubkeys(bech32s: Set): List = } } -/** - * Renders text content with highlighted URLs and clickable nostr: bech32 mentions. - * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color - * and navigate to profile on click. - */ -private data class ContentSegment( - val start: Int, - val raw: String, - val isUrl: Boolean, -) - -private fun buildSegments( - content: String, - schemeUrls: Collection, - bech32s: Collection, -): List { - val segments = mutableListOf() - for (url in schemeUrls) { - val idx = content.indexOf(url) - if (idx != -1) segments.add(ContentSegment(idx, url, true)) - } - for (bech32 in bech32s) { - val idx = content.indexOf(bech32) - if (idx != -1) segments.add(ContentSegment(idx, bech32, false)) - } - segments.sortBy { it.start } - return segments -} - -@Composable -fun RichTextContent( - content: String, - urls: Urls, - localCache: DesktopLocalCache? = null, - onMentionClick: ((String) -> Unit)? = null, - onNavigateToThread: ((String) -> Unit)? = null, - modifier: Modifier = Modifier, -) { - val defaultColor = MaterialTheme.colorScheme.onSurface - val primaryColor = MaterialTheme.colorScheme.primary - - if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) { - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) - return - } - - // Resolve bech32s to find quoted notes vs inline mentions - val resolvedBech32s = - remember(urls.bech32s, localCache) { - urls.bech32s.associateWith { resolveBech32(it, localCache) } - } - - // Collect quoted note IDs (nevent/note references) - val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys } - val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() } - - if (quotedNoteIds.isEmpty()) { - // No quoted notes — render everything as annotated text - val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) } - RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier) - } else { - // Has quoted notes — render text + embedded note cards - Column(modifier = modifier) { - // Strip quoted note bech32 references from text - val strippedText = - remember(content, quotedBech32s) { - var text = content - for (bech32 in quotedBech32s) { - text = text.replace(bech32, "").trim() - } - text - } - - if (strippedText.isNotBlank()) { - val inlineBech32s = urls.bech32s - quotedBech32s - val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) } - RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick) - } - - // Render quoted notes as embedded cards - for (noteId in quotedNoteIds) { - Spacer(Modifier.height(8.dp)) - QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread) - } - } - } -} - /** * Renders a quoted note by ID. Observes the note's metadata flow so it * recomposes when the event arrives asynchronously from a relay fetch. @@ -533,60 +437,3 @@ fun QuotedNoteEmbed( } } } - -@Composable -private fun RichAnnotatedText( - content: String, - segments: List, - resolvedBech32s: Map, - defaultColor: androidx.compose.ui.graphics.Color, - primaryColor: androidx.compose.ui.graphics.Color, - onMentionClick: ((String) -> Unit)?, - modifier: Modifier = Modifier, -) { - val annotatedText = - buildAnnotatedString { - var lastIndex = 0 - for (seg in segments) { - if (seg.start < lastIndex) continue - if (seg.start > lastIndex) { - append(content.substring(lastIndex, seg.start)) - } - if (seg.isUrl) { - withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) { - append(seg.raw) - } - } else { - val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw) - if (resolved.pubKeyHex != null && onMentionClick != null) { - val pubKey = resolved.pubKeyHex - withLink( - LinkAnnotation.Clickable( - tag = "mention", - styles = TextLinkStyles(SpanStyle(color = primaryColor)), - ) { - onMentionClick(pubKey) - }, - ) { - append(resolved.displayText) - } - } else { - withStyle(SpanStyle(color = primaryColor)) { - append(resolved.displayText) - } - } - } - lastIndex = seg.start + seg.raw.length - } - if (lastIndex < content.length) { - append(content.substring(lastIndex)) - } - } - - Text( - text = annotatedText, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d3f898c4a..52c161cda9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,12 +83,21 @@ sqlite = "2.6.2" ktor = "3.5.0" fourkoma = "1.2.0" +# Phase 2 (Gemini App Functions) — both still pre-stable as of May 2026. +# Scoped to the play flavor only (see amethyst/build.gradle.kts) so the +# fdroid channel doesn't pull in Google alpha dependencies. +appfunctions = "1.0.0-alpha09" +ksp = "2.3.8" + [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } +androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" } +androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" } androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } @@ -109,6 +118,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" } @@ -213,3 +223,4 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +googleKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a654fcbebb..9652b8df67 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -358,6 +358,74 @@ dependencies { "jvmAndroidTestImplementation"(testFixtures(project(":geode"))) } +// iOS purity gate. Jackson and OkHttp are JVM-only and must never appear +// in source sets that compile for iOS / native / common targets. The KMP +// build would eventually fail with an "unresolved reference" if these +// leaked in, but that requires a macOS runner. This task is a fast Linux +// linter that fails with a clear message before iOS CI ever spins up. +// +// Scope: source sets whose code is compiled for at least one non-JVM +// target. Excludes jvmAndroid, jvmMain, androidMain (and their tests), +// where Jackson and OkHttp are legitimately used. +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use a KMP lock primitive", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) + val offenders = + checkedDirs.flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + val trimmed = line.trimStart() + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } + mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt new file mode 100644 index 0000000000..571f812db1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt @@ -0,0 +1,25 @@ +/* + * 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 + +operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other) + +operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other) diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION index c0095959b7..5c804c82cf 100644 --- a/tools/arti-build/ARTI_VERSION +++ b/tools/arti-build/ARTI_VERSION @@ -1 +1 @@ -arti-v2.2.0 +arti-v2.3.0 \ No newline at end of file diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml index dccb35ba13..b7d8c377e8 100644 --- a/tools/arti-build/Cargo.toml +++ b/tools/arti-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "arti-android" -version = "2.2.0" +version = "2.3.0" edition = "2021" [lib] @@ -9,14 +9,19 @@ crate-type = ["cdylib"] [workspace] [dependencies] -arti-client = { version = "0.41", default-features = false, features = [ +arti-client = { version = "0.42", default-features = false, features = [ "tokio", "rustls", "compression", "onion-service-client", "static-sqlite", ] } -tor-rtcompat = { version = "0.41", default-features = false, features = ["tokio", "rustls"] } +tor-rtcompat = { version = "0.42", default-features = false, features = ["tokio", "rustls"] } +# Direct dep on rustls so we can install the `ring` crypto provider ourselves — +# arti-v2.3.0's tor-rtcompat no longer installs one implicitly. `ring` matches +# what arti-v2.2.0 effectively used and avoids the Android build pain of +# aws-lc-rs (which became Arti's default in 2.3.0). +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } jni = "0.21" tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] } anyhow = "1" diff --git a/tools/arti-build/build-arti-host.sh b/tools/arti-build/build-arti-host.sh new file mode 100755 index 0000000000..219adeab2d --- /dev/null +++ b/tools/arti-build/build-arti-host.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Build the Arti JNI shim for the *host* (typically Linux x86_64) and stage it +# under amethyst/src/test/native-libs//libarti_android.so so the JVM +# unit tests in TorArtiNativeIntegrationTest can `System.loadLibrary` it. +# +# Companion to build-arti.sh, which builds the *Android* targets for shipping +# in the APK. Same wrapper crate, same lib.rs — only the cargo target differs. +# +# Prerequisites: +# - Rust toolchain with the host target installed (default after `rustup install stable`). +# - The Arti source must already be cloned at .arti-source/ — run build-arti.sh +# once first if this is a fresh checkout. +# +# Usage: +# ./build-arti-host.sh +# +# Why this exists: +# Tier-3 JVM integration tests in amethyst/src/test/.../tor/TorArtiNativeIntegrationTest +# call the real Arti library. The checked-in .so under src/test/native-libs/x86_64-linux/ +# covers the most common dev/CI host. If you bump ARTI_VERSION or touch +# tools/arti-build/src/lib.rs, regenerate the host .so with this script before +# running the integration tests; otherwise you'll be testing the previous shim. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WRAPPER_DIR="$SCRIPT_DIR/.arti-source/arti-android-wrapper" + +if [ ! -d "$WRAPPER_DIR" ]; then + echo "Arti source / wrapper not found at $WRAPPER_DIR." + echo "Run ./build-arti.sh first (clones .arti-source and sets up the wrapper)." + exit 1 +fi + +# Sync the latest wrapper sources into the .arti-source clone — build-arti.sh +# normally does this, but if you've only edited lib.rs the host build needs it too. +cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/lib.rs" + +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +case "$HOST_TARGET" in + x86_64-unknown-linux-gnu) DEST_TAG="x86_64-linux" ;; + aarch64-unknown-linux-gnu) DEST_TAG="aarch64-linux" ;; + x86_64-apple-darwin) DEST_TAG="x86_64-macos" ;; + aarch64-apple-darwin) DEST_TAG="aarch64-macos" ;; + *) + echo "Unmapped host target $HOST_TARGET — add it to build-arti-host.sh." + exit 1 + ;; +esac + +OUT_DIR="$PROJECT_ROOT/amethyst/src/test/native-libs/$DEST_TAG" +mkdir -p "$OUT_DIR" + +echo "Building Arti shim for $HOST_TARGET → $OUT_DIR/libarti_android.so" +cargo build --release \ + --manifest-path "$WRAPPER_DIR/Cargo.toml" \ + --target "$HOST_TARGET" + +# macOS Rust toolchains produce .dylib, not .so. Rename so the existing +# System.loadLibrary("arti_android") path keeps working. +case "$HOST_TARGET" in + *-apple-darwin) + src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.dylib" + ;; + *) + src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.so" + ;; +esac + +cp "$src" "$OUT_DIR/libarti_android.so" +size=$(du -h "$OUT_DIR/libarti_android.so" | cut -f1) +echo "Built $OUT_DIR/libarti_android.so ($size)" +echo "" +echo "Run the smoke test:" +echo " ./gradlew :amethyst:testPlayDebugUnitTest \\" +echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest" +echo "" +echo "Run the full bootstrap tests (needs Tor network egress):" +echo " ./gradlew :amethyst:testPlayDebugUnitTest \\" +echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest \\" +echo " -Pamethyst.arti.integration=true" diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index 71c3136194..7e403eb7c3 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -195,6 +195,7 @@ verify_jni_symbols() { "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize" "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy" "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy" ) for arch_dir in "$OUTPUT_DIR"/*/; do diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs index 20a7a43d11..81883a31da 100644 --- a/tools/arti-build/src/lib.rs +++ b/tools/arti-build/src/lib.rs @@ -20,6 +20,10 @@ static TOKIO_RUNTIME: Mutex> = Mutex::new(None); static JAVA_VM: Mutex> = Mutex::new(None); static LOG_CALLBACK: Mutex> = Mutex::new(None); static SOCKS_TASK: Mutex>> = Mutex::new(None); +// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers +// — otherwise their Arc clones keep the client alive and the +// state file lock would not be released for the next initialize(). +static HANDLER_TASKS: Mutex>> = Mutex::new(Vec::new()); static INIT_ONCE: Once = Once::new(); // ============================================================================ @@ -127,6 +131,12 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( log_info!("Initializing Arti with data directory: {}", data_dir_str); INIT_ONCE.call_once(|| { + // arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider + // implicitly — without this, TorClient::create_bootstrapped panics on + // first TLS handshake. install_default() returns Err if a provider is + // already installed, which is fine; we just want at-least-one. + let _ = rustls::crypto::ring::default_provider().install_default(); + match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -160,8 +170,21 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( let result: Result<()> = runtime.block_on(async { log_info!("Creating Arti client..."); - let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) - .build()?; + let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir); + + // Arti's fs-mistrust walks every parent of the state dir and rejects any + // that has an "unsafe" owner. On Android the app's private filesDir is + // sandboxed by the OS, so the default strict check is correct. On JVM + // host runs (TorArtiNativeIntegrationTest) the data dir lives under + // /tmp and the check trips on container-style ownership of `/` (UID 999 + // etc.). Disable it for non-Android targets — these are the test/dev + // surface, not a user-facing binary. + #[cfg(not(target_os = "android"))] + { + builder.storage().permissions().dangerously_trust_everyone(); + } + + let config = builder.build()?; let client = TorClient::create_bootstrapped(config).await?; @@ -243,11 +266,14 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksPr match listener.accept().await { Ok((stream, _peer_addr)) => { let client_clone = Arc::clone(&client); - tokio::spawn(async move { + let h = tokio::spawn(async move { if let Err(e) = handle_socks_connection(stream, client_clone).await { log_error!("SOCKS connection error: {:?}", e); } }); + let mut handlers = HANDLER_TASKS.lock().unwrap(); + handlers.retain(|h| !h.is_finished()); + handlers.push(h); } Err(e) => { log_error!("Failed to accept SOCKS connection: {:?}", e); @@ -373,8 +399,13 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro handle.abort(); } - if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { - rt.block_on(async { + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .as_ref() + .map(|rt| rt.handle().clone()); + if let Some(rh) = rt_handle { + rh.block_on(async { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; }); } @@ -383,4 +414,66 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro log_info!("SOCKS proxy stopped"); 0 +} + +/// Destroy the TorClient — used by self-heal paths in Kotlin when Tor is +/// stuck and the in-memory state (guards, circuits) needs to be rebuilt +/// from scratch. Aborts the SOCKS listener and all in-flight per-connection +/// handlers, then drops the static Arc so Arti's state file lock can be +/// released. The next call to [initialize] will create a fresh TorClient +/// (and re-bootstrap). +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("Destroying Arti client"); + + // Clone the runtime handle and release the TOKIO_RUNTIME mutex immediately — + // we will hold it for ~500ms below, and other JNI calls that need the runtime + // (e.g. a Kotlin start() racing with us) would otherwise block on this mutex. + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .as_ref() + .map(|rt| rt.handle().clone()); + + // Abort the listener and wait for it to actually terminate before draining + // HANDLER_TASKS. The accept loop has no .await between `accept` and + // `HANDLER_TASKS.push(h)`, so abort() alone is racy: a handler can be spawned + // and pushed AFTER our drain. Awaiting the JoinHandle (with timeout) closes + // that window — no new handlers can be pushed once the listener task is gone. + let socks_handle = SOCKS_TASK.lock().unwrap().take(); + if let (Some(h), Some(rh)) = (socks_handle, rt_handle.as_ref()) { + h.abort(); + rh.block_on(async { + let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), h).await; + }); + } + + // Abort all in-flight handlers — each holds an Arc clone, and + // the client cannot drop (state file lock cannot release) while any clone + // is alive. + let handlers = std::mem::take(&mut *HANDLER_TASKS.lock().unwrap()); + for h in &handlers { + h.abort(); + } + drop(handlers); + + // Give tokio a moment to actually cancel and drop the task frames so the + // handler Arcs are released before we drop our static one. + if let Some(rh) = rt_handle { + rh.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + }); + } + + // Drop the static Arc. If any handler is still holding a clone, the + // TorClient stays alive until that handler finishes — in which case the + // next initialize() will fail and Kotlin's clearAllArtiData retry path + // will handle it. + let _ = ARTI_CLIENT.lock().unwrap().take(); + + log_info!("Arti client destroyed"); + 0 } \ No newline at end of file