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