Merge remote-tracking branch 'upstream/main' into feat/desktop-profile-editing

# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt
This commit is contained in:
nrobi144
2026-05-27 06:38:47 +03:00
194 changed files with 14303 additions and 1333 deletions
@@ -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 '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
done
```
**Why this beats using the `l10n_crowdin_translations` branch tip:**
- The branch is often deleted after merge — the branch tip then doesn't exist or points to a stale ref.
- The branch tip may include Crowdin commits that haven't been merged to main yet. Those changes aren't in our working tree, so they don't affect what's on disk for us. The "reachable from HEAD" cutoff matches what we can actually observe in `values-*/strings.xml`.
**Only the listed (post-sync) keys are actionable.** Anything older is either:
- A deliberate "use English" choice in Crowdin (brand terms like `Nowhere X`, loanwords like `Apps` / `Feed` / `Issues`, version prefixes like `v%1$s`), or
- A pending translation the translator hasn't filled in yet — still Crowdin's job, not ours.
In both cases, Android's resource resolution falls back to `values/strings.xml` at runtime, so there is no user-visible bug. Adding source-identical fallbacks locally is noise that the next sync will strip again.
Report the pre-sync skipped count as a one-liner ("N keys predate the last Crowdin sync, skipped — Crowdin owns them"). Do not list them or propose translations.
If no Crowdin export commit can be found in history (`sync_ts=0` fallback), warn the user and fall back to treating all missing keys as actionable — but flag that the workflow is degraded.
### 3. Get English values for missing keys
For each missing key, extract its English value:
@@ -198,7 +263,10 @@ When adding translated strings to locale files:
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` 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 `<plurals>` `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`)
+60 -1
View File
@@ -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
+86 -26
View File
@@ -1,54 +1,114 @@
# Amethyst Privacy Policy and Terms of Use
## Privacy Policy
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**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.
+37
View File
@@ -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)
+390
View File
@@ -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<T>` 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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
}
}
@@ -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() {
}
@@ -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,
) {
}
@@ -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() }
}
}
@@ -323,8 +323,16 @@ object LocalPreferences {
}
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
// Save the per-npub file before emitting onto the savedAccounts flow.
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
// written, get null back, and poison `cachedAccounts[npub] = null` for the
// rest of the session — making every later switch to this account land on
// LoggedOff instead of LoggedIn.
saveToEncryptedStorage(accountSettings)
val npub = accountSettings.keyPair.pubKey.toNpub()
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
setCurrentAccount(accountSettings)
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
@@ -489,19 +497,20 @@ object LocalPreferences {
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
}
cachedAccounts[npub]?.let { return it }
return withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
cachedAccounts[npub]?.let { return@withContext it }
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, accountSettings)
// Only cache successful loads. Caching null would leave the account
// permanently unreachable for the rest of the session if a reader
// raced in before the per-npub file finished being written.
if (accountSettings != null) {
cachedAccounts.put(npub, accountSettings)
}
return@withContext accountSettings
}
@@ -735,11 +735,24 @@ class Account(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
) {
): HexKey {
val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -26,6 +26,7 @@ import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
@@ -250,8 +252,6 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
@@ -308,6 +308,15 @@ object LocalCache : ILocalCache, ICacheProvider {
@Volatile
var onchainBackend: OnchainBackend? = null
/**
* NIP-BC on-chain zap verification coordinator. Owns the chain-tip poller flow,
* the in-flight de-duplication of verifier calls across consume/reverify paths,
* and the parallelism cap. `consume(OnchainZapEvent)` delegates the async
* verification side here; the gallery's reverification driver also calls in here
* directly.
*/
val onchainZapResolver = OnchainZapResolver(this)
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
@@ -1848,52 +1857,57 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val alreadyLoaded = note.event != null
if (!(wasVerified || justVerify(event))) return false
// `relay == null` means this event was generated locally by the signed-in user
// and routed through `justConsumeMyOwnEvent` — see `Account.sendOnchainZap` →
// `LocalCache.justConsumeMyOwnEvent`. Only these get the optimistic gallery
// attachment; for incoming zaps from others, the sender-claimed `amount` tag
// is untrusted and could mislead the viewer until the chain verifier responds.
val isOwnEvent = relay == null
var repliesTo: List<Note>? = null
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
if (!alreadyLoaded) {
if (!(wasVerified || justVerify(event))) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
refreshNewNoteObservers(note)
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
// Verification needs a chain backend. Without one (e.g. before Account
// wires its EsploraBackend) the event is still cached so subscriptions
// and profile zap views see it, but it can't contribute to Note totals.
val backend = onchainBackend ?: return true
val verifier = OnchainZapVerifier(backend)
val author = getOrCreateUser(event.pubKey)
val resolvedRepliesTo = computeReplyTo(event)
repliesTo = resolvedRepliesTo
note.loadEvent(event, author, resolvedRepliesTo)
Amethyst.instance.applicationIOScope.launch {
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = false)
}
}
is VerifiedOnchainZap.Rejected -> {
Log.d("OnchainZap") {
"rejected ${result.txid}: ${result.reason}"
}
if (isOwnEvent) {
// Optimistic attachment for the sender's own zap: surface it on the
// thread immediately, before the chain backend has indexed the tx.
// Crucial because `OnchainZapSender.send` consumes the kind:8333
// milliseconds after broadcasting the tx — the verifier would
// otherwise return TX_NOT_FOUND and the entry would never appear
// until a later re-verification pass.
val txid = event.txid()
if (txid != null) {
// Clamp claimedSats to a non-negative value. `amount` tag parses
// via toLongOrNull() with no sign check, so a malicious sender
// could otherwise put "-1" in the gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
resolvedRepliesTo.forEach {
it.addOnchainZap(note, txid, claimedSats, verifiedSats = 0L, OnchainZapStatus.UNVERIFIED)
}
}
} catch (t: Throwable) {
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
refreshNewNoteObservers(note)
}
return true
// Async chain verification is delegated to OnchainZapResolver, which owns the
// in-flight gates (so two relay echoes don't double-fetch) and the chain-tip
// polling flow used by the gallery driver. Reusing the repliesTo already
// computed above avoids the second computeReplyTo pass on the new-event path.
onchainZapResolver.launchVerification(event, note, repliesTo ?: computeReplyTo(event))
return !alreadyLoaded
}
private fun attachZapToLiveActivityChannel(
@@ -2075,6 +2089,12 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event != null) return false
if (wasVerified || justVerify(event)) {
val expectedServicePubkey =
event.walletServicePubKey() ?: run {
Log.w("LocalCache", "NWC request ${event.id} has no `p` tag; cannot register for response.")
return false
}
note.loadEvent(event, author, emptyList())
relay?.let {
@@ -2083,7 +2103,7 @@ object LocalCache : ILocalCache, ICacheProvider {
zappedNote?.addZapPayment(note, null)
paymentTracker.registerRequest(event.id, zappedNote, onResponse)
paymentTracker.registerRequest(event.id, expectedServicePubkey, zappedNote, onResponse)
refreshNewNoteObservers(note)
@@ -2100,13 +2120,28 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val requestId = event.requestId()
val pending =
paymentTracker.onResponseReceived(requestId) ?: run {
Log.w(
"LocalCache",
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.",
)
return false
when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) {
is NwcPaymentTracker.MatchResult.Matched -> match.pending
is NwcPaymentTracker.MatchResult.WrongAuthor -> {
// Possible spoof: a kind-23195 event from someone other than
// the wallet service we sent the request to. The pending
// entry is left in place so the real response can still
// resolve it; we silently drop this one.
Log.w(
"LocalCache",
"Rejecting NWC response ${event.id}: expected author ${match.expected} but event was signed by ${match.actual}. " +
"This may be a spoofed reply — keeping the request pending for the legitimate wallet response.",
)
return false
}
NwcPaymentTracker.MatchResult.NoMatch -> {
Log.w(
"LocalCache",
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.",
)
return false
}
}
val zappedNote = pending.zappedNote
@@ -144,7 +144,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
@@ -185,7 +184,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nipBCOnchainZaps
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
/**
* Coordinates NIP-BC on-chain zap verification on top of the chain backend.
*
* `LocalCache.consume(OnchainZapEvent)` owns the event dispatch and optimistic
* gallery attachment; this class owns the asynchronous chain-verification side:
*
* - Launching a verifier coroutine when a new event arrives (with per-event
* in-flight de-duplication so simultaneous relay echoes don't double-fetch).
* - Re-running verification for visible notes when the chain tip advances or
* when a screen first comes into view.
* - Owning the shared chain-tip poller flow that UI subscribes to.
*
* Stateless from the caller's perspective — the per-event resolution state lives
* on the source [Note] itself (`onchainZapResolved`), so it travels with the Note
* across cache eviction and doesn't accumulate here.
*/
class OnchainZapResolver(
private val cache: LocalCache,
) {
/**
* Caps the parallelism of [reverifyOnchainZapsForNote] so a thread with many
* pending entries doesn't blast public Esplora endpoints with a burst of
* simultaneous requests.
*/
private val reverifySemaphore = Semaphore(permits = 8)
/**
* In-flight set of event ids currently being verified. Prevents two `consume()`
* calls (or a `consume` plus a reverify) from issuing parallel Esplora fetches
* for the same event. `ConcurrentHashMap.newKeySet` gives lock-free atomic `add`
* returning `true` only for the inserting caller.
*/
private val verifyingEventIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* In-flight set of note id strings currently being reverified. Lets the on-chain
* zap gallery driver be called from many visible composables without the same
* note's reverify pass running concurrently. The per-event gate above is the
* backstop; this one short-circuits earlier and avoids creating async coroutines
* that would just no-op.
*/
private val reverifyingNoteIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* Shared poller for the current bitcoin chain tip height. Each gallery that
* holds non-CONFIRMED on-chain zaps subscribes to this flow and re-verifies its
* entries whenever the tip advances. Lazy + `WhileSubscribed` so the HTTP call
* only fires when at least one UI surface needs it.
*
* Lazy initialization is required because [Amethyst.instance] may not exist
* when [OnchainZapResolver] is first constructed. Falls back to a constant
* null-emitting [StateFlow] if the application scope isn't available yet
* (e.g. unit tests, ContentProvider invocations), so the lazy field doesn't
* permanently fail with `UninitializedPropertyAccessException`.
*/
val onchainTipHeightFlow: StateFlow<Long?> by lazy {
val scope =
runCatching { Amethyst.instance.applicationIOScope }.getOrNull()
?: return@lazy MutableStateFlow<Long?>(null).asStateFlow()
flow {
while (true) {
val tip =
cache.onchainBackend?.let { backend ->
// Explicit try/catch instead of `runCatching` because the latter
// would swallow CancellationException too — when the upstream
// scope cancels, we must let it propagate so the flow tears down
// promptly instead of looping through one more delay().
try {
backend.tipHeight()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
null
}
}
emit(tip)
delay(TIP_POLL_INTERVAL_MS)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = null,
)
}
/**
* Launches an asynchronous chain verification for [event] unless one is already
* in flight or [source] has already reached a terminal verdict. Returns
* immediately. Apply-to-Note updates happen on the application IO scope.
*
* [repliesTo] is the pre-computed list of notes the event references so consume()
* doesn't pay the cost of resolving it twice.
*/
fun launchVerification(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
) {
val backend = cache.onchainBackend ?: return
if (source.onchainZapResolved) return
if (!verifyingEventIds.add(event.id)) return
val verifier = OnchainZapVerifier(backend)
Amethyst.instance.applicationIOScope.launch {
try {
verifyAndUpgradeOnchainZap(event, source, repliesTo, verifier)
} finally {
verifyingEventIds.remove(event.id)
}
}
}
/**
* Re-run on-chain zap verification for every non-CONFIRMED entry attached to
* [note]. Safe to call from a screen-visibility hook or a chain-tip change
* observer; each verifier call is bounded by the chain backend's cache TTLs.
*
* - Per-note in-flight gate ([reverifyingNoteIds]) — if another caller is
* already reverifying this note (e.g. multiple visible galleries fired in
* the same tip tick) we skip the dup.
* - Per-event in-flight gate ([verifyingEventIds]) — coordinates with
* [launchVerification] so the same event isn't verified twice concurrently.
* - [supervisorScope] — a single verifier failure won't cancel sibling
* verifiers for other entries on the same note.
* - Bounded parallelism via [reverifySemaphore] so a thread with many pending
* entries doesn't blast Esplora.
*/
suspend fun reverifyOnchainZapsForNote(note: Note) {
val backend = cache.onchainBackend ?: return
if (!reverifyingNoteIds.add(note.idHex)) return
try {
val pendingEntries =
note.onchainZaps.values.filter { it.status != OnchainZapStatus.CONFIRMED }
if (pendingEntries.isEmpty()) return
val verifier = OnchainZapVerifier(backend)
supervisorScope {
pendingEntries
.mapNotNull { entry ->
val sourceEvent = entry.source.event as? OnchainZapEvent ?: return@mapNotNull null
val source = entry.source
if (source.onchainZapResolved) return@mapNotNull null
if (!verifyingEventIds.add(sourceEvent.id)) return@mapNotNull null
async {
try {
reverifySemaphore.withPermit {
val repliesTo = cache.computeReplyTo(sourceEvent)
verifyAndUpgradeOnchainZap(sourceEvent, source, repliesTo, verifier)
}
} finally {
verifyingEventIds.remove(sourceEvent.id)
}
}
}.awaitAll()
}
} finally {
reverifyingNoteIds.remove(note.idHex)
}
}
/**
* Run the chain verifier for a single on-chain zap event and apply the result to
* every target note. Designed to be safe to call repeatedly — the [Note] entries
* upgrade monotonically (UNVERIFIED → PENDING → CONFIRMED) and won't move
* backwards, and source-scoped removal prevents one event's rejection from
* erasing another sender's legitimate entry that happens to share a txid.
*
* Callers must wrap the call site with the [verifyingEventIds] gate; this
* function does not itself protect against duplicate concurrent runs.
*/
private suspend fun verifyAndUpgradeOnchainZap(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
verifier: OnchainZapVerifier,
) {
// Clamp claimedSats to non-negative — `amount` tag parses via toLongOrNull()
// with no sign check, so a malicious sender could otherwise put "-1" in the
// gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.CONFIRMED)
}
// Terminal — the chain has confirmed. Future relay echoes of this
// event id skip the verifier entirely via the `onchainZapResolved`
// gate in `launchVerification`.
source.onchainZapResolved = true
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.PENDING)
}
// Not terminal — the tx is in the mempool. A future tip-poll or
// reverify call can upgrade it to CONFIRMED.
}
is VerifiedOnchainZap.Rejected -> {
if (result.reason == VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) {
// Transient — the tx may not have propagated to the backend's
// indexer yet. Leave the entry as UNVERIFIED so a later
// reverifyOnchainZapsForNote() call (e.g. on chain tip change)
// can promote it.
Log.d("OnchainZap") { "tx not yet indexed for ${event.id} (${result.txid}); will retry" }
} else if (result.txid.isNotEmpty()) {
// Hard rejection — e.g. ZERO_VERIFIED_AMOUNT means this sender's
// tx did not pay the recipient. Drop only entries whose source
// matches THIS event AND that aren't CONFIRMED (the per-target
// CONFIRMED check inside `removeOnchainZapForSource` prevents a
// transient backend hiccup from wiping a previously-verified
// entry on a sibling target).
Log.d("OnchainZap") { "rejected ${result.txid}: ${result.reason}" }
repliesTo.forEach { it.removeOnchainZapForSource(result.txid, event.pubKey) }
// Terminal — don't re-verify on future relay echoes of this
// event id. (Different event ids with the same txid still go
// through their own verifier pass.)
source.onchainZapResolved = true
} else {
// MISSING_TXID etc. — log so we have observability when a
// malformed event reaches the backend. Mark terminal so we
// don't re-verify the same broken event on every echo.
Log.d("OnchainZap") { "rejected ${event.id}: ${result.reason} (no txid)" }
source.onchainZapResolved = true
}
}
}
} catch (t: Throwable) {
// Never swallow cancellation — it must propagate so screen-scoped callers
// (the gallery's reverification driver) can tear down cleanly.
if (t is CancellationException) throw t
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
}
companion object {
/**
* Polling interval for the shared on-chain chain-tip flow. Aligned with
* `CachingOnchainBackend.tipHeightTtlSeconds` (60s) — polling faster would
* just hit the cache and do no useful work.
*/
private const val TIP_POLL_INTERVAL_MS = 60_000L
}
}
@@ -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<TorType> get() = value.torType
override val externalSocksPort: StateFlow<Int> 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
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
@@ -122,6 +123,7 @@ class RelaySubscriptionsCoordinator(
val nestRoomLiveness = NestRoomLivenessAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
@@ -154,6 +156,7 @@ class RelaySubscriptionsCoordinator(
nestRoomLiveness,
longs,
articles,
softwareApps,
badges,
profileBadges,
browseEmojiSets,
@@ -24,14 +24,26 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
// The request event id (#e) is a unique 32-byte identifier — sufficient on
// its own to match a spec-compliant kind-23195 response. We deliberately keep
// `authors` out of the filter because some wallet services route through a
// signer pubkey that differs from the one advertised in the connection URI,
// which made the previous `authors`-strict filter time out.
//
// We DO keep `#p: [client pubkey]` because purpose-built NWC relays
// (e.g. relay.getalby.com/v1) use the `#p` tag as the routing key — without
// it the relay never delivers the response to our subscription, even though
// the event is well-formed. Spec-compliant responses always set `p` to the
// client pubkey, so including `#p` does not exclude any conforming wallet.
// The wallet's identity is still authenticated end-to-end by NIP-04
// decryption against the per-connection shared secret AND by a client-side
// `event.pubKey == request.p` check in NwcPaymentTracker.
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
paymentRequests: Set<HexKey>,
fromUsers: Set<HexKey>,
): Filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = serviceKeys.sorted(),
tags =
mapOf(
"e" to paymentRequests.sorted(),
@@ -49,10 +49,9 @@ fun NWCFinderFilterAssemblerSubscription(
remember(note) {
val zapPaymentRequestNote = note
(zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent ->
noteEvent.walletServicePubKey()?.let { serviceId ->
noteEvent.walletServicePubKey()?.let {
zapPaymentRequestNote.relays.map {
NWCPaymentQueryState(
fromServiceHex = serviceId,
toUserHex = noteEvent.pubKey,
replyingToHex = noteEvent.id,
relay = it,
@@ -26,10 +26,14 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
// This allows multiple screen to be listening to tags, even the same tag
// This allows multiple screen to be listening to tags, even the same tag.
// The subscription filter carries `#e: [request id]` plus `#p: [client pubkey]`.
// The `#p` field is the routing key for purpose-built NWC relays (e.g.
// relay.getalby.com/v1) — dropping it caused those relays to silently never
// deliver the response. `authors` is intentionally left out because some
// wallets sign responses with a key different from the URI-advertised one.
@Stable
class NWCPaymentQueryState(
val fromServiceHex: HexKey,
val toUserHex: HexKey,
val replyingToHex: HexKey,
val relay: NormalizedRelayUrl,
@@ -32,15 +32,14 @@ class NWCPaymentWatcherSubAssembler(
if (keys.isEmpty()) return null
return keys.groupBy { it.relay }.map { relayGroup ->
val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex }
val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex }
val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex }
if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null
if (replyingToPayments.isEmpty()) return null
RelayBasedFilter(
relay = relayGroup.key,
filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers),
filter = filterNWCPaymentsFromRequests(replyingToPayments, aboutUsers),
)
}
}
@@ -56,9 +56,20 @@ fun filterUserMetadataForKey(
val perRelayUsers =
mapOfSet {
authors.forEach { key ->
val outbox = key.outboxRelays()
val relays =
key.outboxRelays()
?: (key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays)
when {
outbox == null ->
key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays
// Outbox is published but exhausted (every relay either EOSE'd
// or is known-unreachable) and metadata is still missing —
// widen to indexers so a misconfigured outbox doesn't strand
// the user's profile.
key.metadataOrNull()?.flow?.value == null &&
outbox.all { it in cannotConnectRelays || since.since(key)?.get(it) != null } ->
outbox + indexRelays
else -> outbox
}
(relays - cannotConnectRelays).forEach {
add(it, key)
@@ -59,6 +59,9 @@ class UserWatcherSubAssembler(
}
}
}
// Re-evaluate filters: filterUserMetadataForKey may widen to indexer
// relays once a user's outbox is exhausted without yielding metadata.
invalidateFilters()
}
val sub =
@@ -182,6 +182,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
@@ -259,6 +260,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
@@ -283,9 +285,9 @@ fun BuildNavigation(
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletSend> { WalletSendScreen(it.walletId, accountViewModel, nav) }
composableFromEndArgs<Route.WalletReceive> { WalletReceiveScreen(it.walletId, accountViewModel, nav) }
composableFromEndArgs<Route.WalletTransactions> { WalletTransactionsScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
@@ -50,6 +50,7 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
SOFTWARE_APPS,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
@@ -201,6 +202,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
labelRes = R.string.software_apps,
icon = MaterialSymbols.Apps,
resolveRoute = { Route.SoftwareApps },
),
NavBarItem.CALENDARS to
NavBarItemDef(
id = NavBarItem.CALENDARS,
@@ -334,6 +342,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SOFTWARE_APPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
@@ -81,6 +81,8 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object SoftwareApps : Route()
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@@ -144,11 +146,20 @@ sealed class Route {
@Serializable object Wallet : Route()
@Serializable object WalletSend : Route()
@Serializable
data class WalletSend(
val walletId: String,
) : Route()
@Serializable object WalletReceive : Route()
@Serializable
data class WalletReceive(
val walletId: String,
) : Route()
@Serializable object WalletTransactions : Route()
@Serializable
data class WalletTransactions(
val walletId: String,
) : Route()
@Serializable object OnchainTransactions : Route()
@@ -167,6 +167,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
import com.vitorpamplona.amethyst.ui.note.types.RenderReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
@@ -212,6 +215,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -256,6 +262,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -870,6 +877,23 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is SoftwareApplicationEvent -> {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
}
is SoftwareAssetEvent -> {
RenderSoftwareAsset(baseNote, accountViewModel, nav)
}
is ReleaseArtifactSetEvent -> {
// kind 30063 is used by both NIP-51 (ReleaseArtifactSet) and NIP-82 (SoftwareRelease).
// The EventFactory always materializes the NIP-51 class; dispatch to NIP-82 when the
// tag signature matches.
if (noteEvent.isNip82SoftwareRelease()) {
RenderSoftwareRelease(baseNote, accountViewModel, nav)
}
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
@@ -29,13 +29,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -65,23 +69,70 @@ internal fun WatchOnchainZapsAndRenderGallery(
) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. The flow also fires for lightning
// zap arrivals on the same note, so memoize the list snapshot.
// arrive or upgrade pending → confirmed. The flow ALSO fires for lightning
// zap arrivals on the same note — memoize on the onchainZaps map reference
// (a fresh immutable map per onchain mutation, stable across lightning-only
// updates) so a busy lightning thread doesn't churn this gallery's state.
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val onchainZapsMap = zapsState?.note?.onchainZaps
val entries =
remember(zapsState) {
zapsState
?.note
?.onchainZaps
?.values
?.toImmutableList() ?: persistentListOf()
remember(onchainZapsMap) {
onchainZapsMap?.values?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
// Drive re-verification of any non-CONFIRMED entries while this gallery
// is on screen — covers home feed, profile, notifications, channels,
// single-note view, threads. Per-note in-flight gating inside the cache
// dedupes the work when multiple gallery instances for the same note
// (lazy-list off/on screen flicker, split feed) all fire together.
DriveOnchainZapReverification(baseNote, entries)
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
/**
* Drives on-chain zap re-verification for [note] while the gallery is composed.
*
* Three triggers fire reverify:
* 1. First view of this note (independent of tip availability — covers the cold-
* start case where the chain backend isn't wired yet, or `tipHeight()` is slow).
* 2. A new pending entry arrives (`entries.size` changes).
* 3. The chain tip advances (the shared StateFlow emits a new value).
*
* The cache's per-note + per-event gates dedupe concurrent calls; this composable
* doesn't need its own throttling.
*/
@Composable
private fun DriveOnchainZapReverification(
note: Note,
entries: ImmutableList<OnchainZapEntry>,
) {
val pendingCount = remember(entries) { entries.count { it.status != OnchainZapStatus.CONFIRMED } }
if (pendingCount == 0) return
val resolver = LocalCache.onchainZapResolver
// First-view kick — unconditional, doesn't wait for the tip flow. Keyed on
// (idHex, pendingCount) so a brand-new pending entry arriving while the
// gallery is still on screen also kicks an immediate reverify instead of
// waiting up to a full tip-poll interval.
LaunchedEffect(note.idHex, pendingCount) {
resolver.reverifyOnchainZapsForNote(note)
}
// Tip-change kick — subscribes to the shared poller (lazy, WhileSubscribed
// so only one HTTP poller runs across the whole UI no matter how many
// galleries are visible). Skips the first emission (null) to avoid
// duplicating the first-view kick above.
val tip by resolver.onchainTipHeightFlow.collectAsStateWithLifecycle()
LaunchedEffect(note.idHex, tip) {
if (tip != null) {
resolver.reverifyOnchainZapsForNote(note)
}
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
@@ -122,18 +173,30 @@ private fun OnchainZapEntryRow(
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val amountText =
remember(entry.verifiedSats) {
showAmount(BigDecimal.valueOf(entry.verifiedSats))
val isConfirmed = entry.status == OnchainZapStatus.CONFIRMED
val avatarAlpha = if (isConfirmed) 1f else 0.6f
// Anti-spoof: `claimedSats` comes from the kind:8333 `amount` tag, which is
// attacker-controlled for incoming zaps. Only show the claimed amount for the
// signed-in user's own outgoing zap (where the user knows what they sent) —
// otherwise show the on-chain verified amount, or nothing while still unverified.
val displaySats =
when {
isConfirmed || entry.status == OnchainZapStatus.PENDING -> entry.verifiedSats
user != null && accountViewModel.isLoggedUser(user.pubkeyHex) -> entry.claimedSats
else -> 0L
}
val amountText =
remember(displaySats) {
if (displaySats > 0L) showAmount(BigDecimal.valueOf(displaySats)) else ""
}
val avatarAlpha = if (entry.confirmed) 1f else 0.6f
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
// Only the avatar dims for pending entries. The amount overlay and clock
// badge stay at full opacity so they remain readable.
// Only the avatar dims for unverified/pending entries. The amount overlay
// and clock badge stay at full opacity so they remain readable.
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
@@ -141,9 +204,11 @@ private fun OnchainZapEntryRow(
)
}
CrossfadeToDisplayAmount(amountText)
if (amountText.isNotEmpty()) {
CrossfadeToDisplayAmount(amountText)
}
if (!entry.confirmed) {
if (!isConfirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users.
@@ -404,9 +404,14 @@ private fun GenericInnerReactionRow(
}
enabledReactions.forEachIndexed { index, item ->
val isLast = index == lastIndex
// Skip the weighted slice for the rightmost item only when it has no counter
// (e.g. Share / Pay) — those are icon-only, so letting them collapse to natural
// width pins them flush against the right padding. If the rightmost item carries
// a counter (Zap / Like / etc.), keep it weighted so its icon stays at the left
// of an equal-width slice and the counter doesn't get pushed to the edge.
val isLastIconOnly = index == lastIndex && !item.showCounter
val itemWeight = if (item.action == ReactionRowAction.Reply) weightTwo else 1f
val mod = if (isLast) Modifier else Modifier.weight(itemWeight)
val mod = if (isLastIconOnly) Modifier else Modifier.weight(itemWeight)
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = RowColSpacing,
@@ -0,0 +1,539 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LinkIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
/**
* NIP-82 kind 32267 — Software Application card. Renders icon, name, summary,
* a horizontal screenshot strip, hashtag/platform chips, and quick links.
*/
@Composable
fun RenderSoftwareApplication(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? SoftwareApplicationEvent ?: return
val uri = LocalUriHandler.current
val icon = remember(event) { event.icon() }
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
val summary = remember(event) { event.summary() }
val images = remember(event) { event.images() }
val topics = remember(event) { event.topics() }
val platforms = remember(event) { event.platforms() }
val website = remember(event) { event.url() }
val repo = remember(event) { event.repository() }
val license = remember(event) { event.license() }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(12.dp)),
) {
icon?.let {
AsyncImage(
model = it,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
if (name.isNotBlank()) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (images.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(180.dp),
) {
items(images) { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.height(180.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)),
)
}
}
}
if (platforms.isNotEmpty() || topics.isNotEmpty() || license != null) {
Spacer(StdVertSpacer)
ChipFlowRow(platforms = platforms, topics = topics, license = license)
}
if (website != null || repo != null) {
Spacer(StdVertSpacer)
Column {
website?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = it.removePrefix("https://").removePrefix("http://"),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
repo?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
}
}
}
}
@Composable
private fun ChipFlowRow(
platforms: List<String>,
topics: List<String>,
license: String?,
) {
// FlowRow is in compose foundation but we use a simple LazyRow to keep this compatible.
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(platforms) { Chip(it) }
items(topics) { Chip("#$it") }
license?.let { item { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) } }
}
}
@Composable
private fun Chip(
text: String,
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
) {
Box(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(tint)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = text, fontSize = 11.sp, style = MaterialTheme.typography.labelSmall)
}
}
/**
* NIP-82 kind 30063 — Software Release card. Renders the version and channel as
* a header, the aggregated platforms as chips, the release notes (markdown), and
* a count of bundled assets.
*/
@Composable
fun RenderSoftwareRelease(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
// kind 30063 is shared between NIP-51 ReleaseArtifactSetEvent and NIP-82 SoftwareReleaseEvent;
// EventFactory always returns the NIP-51 class, so we re-parse the same tag array as the
// NIP-82 form when the tag signature matches.
val rawEvent = note.event ?: return
val event: SoftwareReleaseEvent =
if (rawEvent is SoftwareReleaseEvent) {
rawEvent
} else if (rawEvent.isNip82SoftwareRelease()) {
rawEvent.asSoftwareRelease()
} else {
return
}
val appId = remember(event) { event.appId() }
val version = remember(event) { event.version() }
val channel = remember(event) { event.channel() }
val assets = remember(event) { event.assets() }
val notes = remember(event) { event.content }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
channel?.let {
Chip(it.uppercase(), tint = MaterialTheme.colorScheme.tertiaryContainer)
}
}
if (notes.isNotBlank()) {
Spacer(StdVertSpacer)
Text(
text = notes,
style = MaterialTheme.typography.bodyMedium,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
)
}
if (assets.isNotEmpty()) {
Spacer(StdVertSpacer)
val n = assets.size
Text(
text = pluralStringResource(R.plurals.nip82_assets_count, n, n),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(6.dp))
BundledAssetsList(
assetIds = assets.map { it.eventId },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun BundledAssetsList(
assetIds: List<String>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
assetIds.forEach { id ->
key(id) {
LoadAssetNote(id, accountViewModel) { assetNote ->
if (assetNote != null) {
SoftwareAssetRow(assetNote, accountViewModel)
}
}
}
}
}
}
@Composable
private fun LoadAssetNote(
eventId: String,
accountViewModel: AccountViewModel,
content: @Composable (Note?) -> Unit,
) {
val note by produceState<Note?>(initialValue = accountViewModel.getNoteIfExists(eventId), eventId) {
if (value == null) {
value = accountViewModel.checkGetOrCreateNote(eventId)
}
}
content(note)
}
@Composable
private fun SoftwareAssetRow(
note: Note,
accountViewModel: AccountViewModel,
) {
// Subscribe so a missing asset event is fetched from the relay and we recompose when it arrives.
val eventState = observeNoteEvent<SoftwareAssetEvent>(note, accountViewModel)
val event = eventState.value ?: return
val uri = LocalUriHandler.current
val version = remember(event) { event.version() }
val mimeType = remember(event) { event.mimeType() }
val sizeBytes = remember(event) { event.sizeInBytes() }
val platforms = remember(event) { event.platforms() }
val downloadUrl = remember(event) { event.url() }
val variant = remember(event) { event.variant() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
mimeType?.let {
Text(
text = prettyMime(it),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(6.dp))
}
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
variant?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· $it",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
sizeBytes?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· ${formatBytes(it.toLong())}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
if (platforms.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(platforms) { Chip(it) }
}
}
}
downloadUrl?.let {
Spacer(Modifier.width(8.dp))
ClickableTextPrimary(
text = stringRes(R.string.nip82_download),
onClick = { runCatching { uri.openUri(it) } },
)
}
}
}
/**
* NIP-82 kind 3063 — Software Asset card. A compact descriptor of a single
* install artifact: MIME type, version, size, platforms, and a download link.
*/
@Composable
fun RenderSoftwareAsset(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? SoftwareAssetEvent ?: return
val uri = LocalUriHandler.current
val appId = remember(event) { event.appId() }
val version = remember(event) { event.version() }
val mimeType = remember(event) { event.mimeType() }
val sizeBytes = remember(event) { event.sizeInBytes() }
val platforms = remember(event) { event.platforms() }
val downloadUrl = remember(event) { event.url() }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
sizeBytes?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· ${formatBytes(it.toLong())}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
downloadUrl?.let {
ClickableTextPrimary(
text = stringRes(R.string.nip82_download),
onClick = { runCatching { uri.openUri(it) } },
)
}
}
if (mimeType != null || platforms.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
mimeType?.let { item { Chip(prettyMime(it)) } }
items(platforms) { Chip(it) }
}
}
}
}
private fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
private fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
if (kb < 1024) return "%.1f KB".format(kb)
val mb = kb / 1024
if (mb < 1024) return "%.1f MB".format(mb)
val gb = mb / 1024
return "%.2f GB".format(gb)
}
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.dal.ProductsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.dal.PublicChatsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAppsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -111,6 +112,7 @@ class AccountFeedContentStates(
val nestsFeed = FeedContentState(NestsFeedFilter(account), scope, LocalCache)
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsFollowing = CardFeedContentState(NotificationFeedFilter(account, TopFilter.AllFollows), scope)
@@ -199,6 +201,7 @@ class AccountFeedContentStates(
nestsFeed.updateFeedWith(newNotes)
longsFeed.updateFeedWith(newNotes)
articlesFeed.updateFeedWith(newNotes)
softwareAppsFeed.updateFeedWith(newNotes)
calendarAppointmentsFeed.updateFeedWith(newNotes)
calendarCollectionsFeed.updateFeedWith(newNotes)
@@ -254,6 +257,7 @@ class AccountFeedContentStates(
nestsFeed.deleteFromFeed(newNotes)
longsFeed.deleteFromFeed(newNotes)
articlesFeed.deleteFromFeed(newNotes)
softwareAppsFeed.deleteFromFeed(newNotes)
calendarAppointmentsFeed.deleteFromFeed(newNotes)
calendarCollectionsFeed.deleteFromFeed(newNotes)
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilte
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
/**
@@ -86,6 +87,8 @@ private fun PreloadFor(
NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel)
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
-> CalendarsFilterAssemblerSubscription(accountViewModel)
@@ -261,6 +261,8 @@ fun AllSettingsScreen(
)
}
LegalSettingsSection()
SettingsSection(R.string.danger_zone, isDanger = true) {
if (hasPrivateKey) {
SettingsItem(
@@ -0,0 +1,142 @@
/*
* 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.softwareapps
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
@Composable
fun SoftwareAppsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
SoftwareAppsScreen(
feedContentState = accountViewModel.feedStates.softwareAppsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun SoftwareAppsScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
SoftwareAppsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
UserDrawerSearchTopBar(accountViewModel, nav) {}
},
bottomBar = {
AppBottomBar(Route.SoftwareApps, nav, accountViewModel) { route ->
if (route == Route.SoftwareApps) {
feedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "SoftwareAppsFeed",
onLoaded = { loaded ->
SoftwareAppsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun SoftwareAppsFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is SoftwareApplicationEvent) {
RenderSoftwareApplication(
note = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = DividerThickness)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
class SoftwareAppsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun limit() = 200
override fun showHiddenKey(): Boolean = false
override fun feed(): List<Note> {
val notes =
LocalCache.addressables.filterIntoSet(SoftwareApplicationEvent.KIND) { _, it ->
val ev = it.event
ev is SoftwareApplicationEvent && account.isAcceptable(it)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
val ev = it.event
ev is SoftwareApplicationEvent && account.isAcceptable(it)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
class SoftwareAppsQueryState(
val account: Account,
val scope: CoroutineScope,
)
@Stable
class SoftwareAppsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<SoftwareAppsQueryState>() {
val sub = SoftwareAppsSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
class SoftwareAppsSubAssembler(
client: INostrClient,
allKeys: () -> Set<SoftwareAppsQueryState>,
) : PerUserEoseManager<SoftwareAppsQueryState>(client, allKeys) {
override fun user(key: SoftwareAppsQueryState) = key.account.userProfile()
override fun updateFilter(
key: SoftwareAppsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
if (relays.isEmpty()) return emptyList()
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND),
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SoftwareAppsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
SoftwareAppsFilterAssemblerSubscription(
accountViewModel.dataSources().softwareApps,
accountViewModel,
)
}
@Composable
fun SoftwareAppsFilterAssemblerSubscription(
dataSource: SoftwareAppsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
SoftwareAppsQueryState(accountViewModel.account, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -182,6 +182,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
@@ -225,6 +228,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -264,6 +270,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -725,6 +732,12 @@ private fun FullBleedNoteCompose(
RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareApplicationEvent) {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareAssetEvent) {
RenderSoftwareAsset(baseNote, accountViewModel, nav)
} else if (noteEvent is ReleaseArtifactSetEvent && noteEvent.isNip82SoftwareRelease()) {
RenderSoftwareRelease(baseNote, accountViewModel, nav)
} else if (noteEvent is DraftWrapEvent) {
RenderDraft(baseNote, 3, ReplyRenderType.FULL, backgroundColor, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
@@ -149,7 +149,7 @@ fun WalletDetailScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Button(
onClick = { nav.nav(Route.WalletReceive) },
onClick = { nav.nav(Route.WalletReceive(walletId)) },
modifier =
Modifier
.weight(1f)
@@ -171,7 +171,7 @@ fun WalletDetailScreen(
}
Button(
onClick = { nav.nav(Route.WalletSend) },
onClick = { nav.nav(Route.WalletSend(walletId)) },
modifier =
Modifier
.weight(1f)
@@ -192,7 +192,7 @@ fun WalletDetailScreen(
// Transactions button
OutlinedButton(
onClick = { nav.nav(Route.WalletTransactions) },
onClick = { nav.nav(Route.WalletTransactions(walletId)) },
modifier =
Modifier
.fillMaxWidth()
@@ -74,13 +74,15 @@ import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletReceiveScreen(
walletId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
LaunchedEffect(accountViewModel, walletId) {
walletViewModel.init(accountViewModel)
walletViewModel.selectWallet(walletId)
}
DisposableEffect(Unit) {
@@ -73,6 +73,10 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
import java.text.NumberFormat
@@ -197,6 +201,12 @@ private fun MultiWalletHomeContent(
}
}
val dragState =
rememberRelayDragState(
onMove = { from, to -> walletViewModel.moveWallet(from, to) },
itemCount = { walletInfoList.size },
)
LazyColumn(
state = listState,
modifier =
@@ -204,6 +214,7 @@ private fun MultiWalletHomeContent(
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = !dragState.isDragging,
) {
item {
Spacer(modifier = Modifier.height(8.dp))
@@ -218,7 +229,7 @@ private fun MultiWalletHomeContent(
WalletCard(
walletInfo = walletInfo,
index = index,
totalCount = walletInfoList.size,
dragState = if (walletInfoList.size > 1) dragState else null,
onSelect = {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
@@ -229,12 +240,6 @@ private fun MultiWalletHomeContent(
onRename = { newName ->
walletViewModel.renameWallet(walletInfo.walletId, newName)
},
onMoveUp = {
walletViewModel.moveWallet(index, index - 1)
},
onMoveDown = {
walletViewModel.moveWallet(index, index + 1)
},
onRemove = {
walletViewModel.removeWallet(walletInfo.walletId)
},
@@ -264,12 +269,10 @@ private fun MultiWalletHomeContent(
private fun WalletCard(
walletInfo: WalletInfo,
index: Int,
totalCount: Int,
dragState: RelayDragState?,
onSelect: () -> Unit,
onSetDefault: () -> Unit,
onRename: (String) -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) {
var showRemoveDialog by remember { mutableStateOf(false) }
@@ -311,6 +314,7 @@ private fun WalletCard(
modifier =
Modifier
.fillMaxWidth()
.let { if (dragState != null) it.draggableRelayItem(index, dragState) else it }
.clickable(onClick = onSelect),
shape = RoundedCornerShape(16.dp),
border =
@@ -363,32 +367,18 @@ private fun WalletCard(
}
}
// Reorder buttons
if (totalCount > 1) {
Column {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(28.dp),
) {
Icon(
MaterialSymbols.KeyboardArrowUp,
contentDescription = stringRes(R.string.wallet_move_up),
modifier = Modifier.size(20.dp),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(28.dp),
) {
Icon(
MaterialSymbols.KeyboardArrowDown,
contentDescription = stringRes(R.string.wallet_move_down),
modifier = Modifier.size(20.dp),
)
}
}
// Drag handle for reordering
if (dragState != null) {
Icon(
MaterialSymbols.DragIndicator,
contentDescription = stringRes(R.string.wallet_reorder),
modifier =
Modifier
.size(24.dp)
.relayDragHandle(index, dragState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
}
// Balance
@@ -64,13 +64,15 @@ import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletSendScreen(
walletId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
LaunchedEffect(accountViewModel, walletId) {
walletViewModel.init(accountViewModel)
walletViewModel.selectWallet(walletId)
}
DisposableEffect(Unit) {
@@ -73,13 +73,15 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletTransactionsScreen(
walletId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
LaunchedEffect(accountViewModel, walletId) {
walletViewModel.init(accountViewModel)
walletViewModel.selectWallet(walletId)
walletViewModel.fetchTransactions()
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
@@ -186,10 +187,22 @@ class WalletViewModel : ViewModel() {
"Wallet returned an unrecognized reply for ${response.resultType}"
}
private fun launchTimeout(onTimeout: () -> Unit): Job =
private fun launchTimeout(
requestIdProvider: () -> HexKey?,
onTimeout: () -> Unit,
): Job =
viewModelScope.launch(Dispatchers.IO) {
delay(NWC_TIMEOUT_MS)
_error.value = "Wallet request timed out"
val requestId = requestIdProvider()
val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0
_error.value =
if (spoofs > 0) {
"Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " +
"${if (spoofs == 1) "it was" else "they were"} signed by an unexpected key. Your relay may be untrusted."
} else {
"Wallet request timed out"
}
requestId?.let { account?.cleanupNwcRequest(it) }
onTimeout()
}
@@ -384,27 +397,29 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
val timeoutJob = launchTimeout { _isLoading.value = false }
var requestId: HexKey? = null
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) {
is GetBalanceSuccessResponse -> {
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L
updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) }
_error.value = null
}
requestId =
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) {
is GetBalanceSuccessResponse -> {
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L
updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) }
_error.value = null
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Balance request failed"
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Balance request failed"
}
else -> {
_error.value = unreadableResponseError(response)
else -> {
_error.value = unreadableResponseError(response)
}
}
_isLoading.value = false
}
_isLoading.value = false
}
} catch (e: Exception) {
timeoutJob.cancel()
_error.value = e.message
@@ -441,41 +456,43 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_hasMoreTransactions.value = true
val timeoutJob = launchTimeout { _isLoading.value = false }
var requestId: HexKey? = null
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
offset = 0,
unpaid = false,
),
) { response ->
timeoutJob.cancel()
when (response) {
is ListTransactionsSuccessResponse -> {
val txs = response.result?.transactions ?: emptyList()
allTransactions.value = txs
val totalCount = response.result?.total_count
_hasMoreTransactions.value =
if (totalCount != null) {
txs.size < totalCount
} else {
txs.size >= pageSize
}
_error.value = null
}
requestId =
acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
offset = 0,
unpaid = false,
),
) { response ->
timeoutJob.cancel()
when (response) {
is ListTransactionsSuccessResponse -> {
val txs = response.result?.transactions ?: emptyList()
allTransactions.value = txs
val totalCount = response.result?.total_count
_hasMoreTransactions.value =
if (totalCount != null) {
txs.size < totalCount
} else {
txs.size >= pageSize
}
_error.value = null
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load transactions"
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load transactions"
}
else -> {
_error.value = unreadableResponseError(response)
else -> {
_error.value = unreadableResponseError(response)
}
}
_isLoading.value = false
}
_isLoading.value = false
}
} catch (e: Exception) {
timeoutJob.cancel()
_error.value = e.message
@@ -492,40 +509,42 @@ class WalletViewModel : ViewModel() {
val currentOffset = allTransactions.value.size
viewModelScope.launch(Dispatchers.IO) {
_isLoadingMore.value = true
val timeoutJob = launchTimeout { _isLoadingMore.value = false }
var requestId: HexKey? = null
val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false }
try {
acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
offset = currentOffset,
unpaid = false,
),
) { response ->
timeoutJob.cancel()
when (response) {
is ListTransactionsSuccessResponse -> {
val newTxs = response.result?.transactions ?: emptyList()
allTransactions.value += newTxs
val totalCount = response.result?.total_count
_hasMoreTransactions.value =
if (totalCount != null) {
allTransactions.value.size < totalCount
} else {
newTxs.size >= pageSize
}
}
requestId =
acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
offset = currentOffset,
unpaid = false,
),
) { response ->
timeoutJob.cancel()
when (response) {
is ListTransactionsSuccessResponse -> {
val newTxs = response.result?.transactions ?: emptyList()
allTransactions.value += newTxs
val totalCount = response.result?.total_count
_hasMoreTransactions.value =
if (totalCount != null) {
allTransactions.value.size < totalCount
} else {
newTxs.size >= pageSize
}
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load more transactions"
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load more transactions"
}
else -> {
_error.value = unreadableResponseError(response)
else -> {
_error.value = unreadableResponseError(response)
}
}
_isLoadingMore.value = false
}
_isLoadingMore.value = false
}
} catch (e: Exception) {
timeoutJob.cancel()
_error.value = e.message
@@ -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))
@@ -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
@@ -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)) {
@@ -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) {
@@ -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
}
/**
@@ -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<TorServiceStatus>
suspend fun start()
suspend fun stop()
suspend fun reset()
suspend fun resetWithCleanState()
}
@@ -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
}
}
@@ -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<TorType>
val externalSocksPort: StateFlow<Int>
suspend fun loadLastBypassApprovalMs(): Long
suspend fun saveLastBypassApprovalMs(value: Long)
}
@@ -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>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val status: StateFlow<TorServiceStatus> = _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()
}
}
}
Binary file not shown.
@@ -14,6 +14,12 @@
<item quantity="many">Tento příspěvek má více než %1$d hashtagů</item>
<item quantity="other">Tento příspěvek má více než %1$d hashtagů</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d majetek je zbalený</item>
<item quantity="few">%1$d majetky jsou zbalený</item>
<item quantity="many">%1$d majetků jsou zbalený</item>
<item quantity="other">%1$d majetků jsou zbalený</item>
</plurals>
<string name="post_not_found">Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Obrázek kanálu</string>
@@ -562,6 +568,11 @@
<string name="profile_badges_description">Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu.</string>
<string name="profile_badges_empty">Zatím jste neobdrželi žádné odznaky.</string>
<string name="pictures">Obrázky</string>
<string name="software_apps">Aplikace</string>
<string name="route_software_apps">Aplikace</string>
<string name="nip82_repository_label">Zdroj: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Stáhnout</string>
<string name="calendars">Kalendáře</string>
<string name="shorts">Krátká videa</string>
<string name="public_chats">Veřejné chaty</string>
@@ -1199,6 +1210,8 @@
<string name="posting_policy">Politika příspěvků</string>
<string name="privacy_policy">Zásady ochrany soukromí</string>
<string name="terms_and_conditions">Podmínky &amp; ujednání</string>
<string name="child_safety_standards">Standardy ochrany dětí</string>
<string name="about_legal">O aplikaci a právní informace</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
<string name="relay_monitor_reports">Zprávy monitoru relé</string>
@@ -1770,6 +1783,7 @@
<string name="calendar_relative_ongoing_with_end">%1$s · končí %2$s</string>
<string name="calendar_export_share_title">Sdílet událost kalendáře</string>
<string name="calendar_export_event">Exportovat do kalendáře (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Připomenutí kalendáře</string>
<string name="calendar_reminder_channel_description">Upozornění, když je událost, které se účastníte, blízko začátku.</string>
<string name="calendar_reminder_default_title">Událost kalendáře</string>
@@ -1797,6 +1811,12 @@
<string name="calendar_reminder_settings_enabled_subtitle">Oznámení se spustí, když je událost, které se účastníte, blízko začátku.</string>
<string name="calendar_reminder_settings_lead_title">Doba předstihu připomenutí</string>
<string name="calendar_reminder_settings_lead_subtitle">Kolik minut před událostí chcete být upozorněni.</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d min</item>
<item quantity="few">%1$d min</item>
<item quantity="many">%1$d min</item>
<item quantity="other">%1$d min</item>
</plurals>
<string name="calendar_share_nostr">Sdílet jako Nostr odkaz</string>
<string name="calendar_share_nostr_title">Sdílet odkaz na kalendář</string>
<string name="calendar_filter_all">Všechny kalendáře</string>
@@ -1828,6 +1848,7 @@
<string name="boost_or_quote_description">Zvýšit nebo citovat</string>
<string name="like_description">Olajkovat</string>
<string name="zap_description">Zap</string>
<string name="onchain_zap_description">On-chain Bitcoin zap</string>
<string name="onchain_zap_pending">Čeká na potvrzení</string>
<string name="change_reaction">Změnit rychlé reakce</string>
<string name="bottom_bar_settings">Spodní navigační lišta</string>
@@ -2788,6 +2809,7 @@
<string name="nowhere_link_card_store">Nowhere Obchod</string>
<string name="nowhere_link_card_petition">Nowhere Petice</string>
<string name="nowhere_link_card_message">Nowhere Zpráva</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Umění</string>
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
</resources>
@@ -12,6 +12,10 @@
<item quantity="one">Dieser Beitrag hat mehr als %1$d Hashtag</item>
<item quantity="other">Dieser Beitrag hat mehr als %1$d Hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d Asset gebündelt</item>
<item quantity="other">%1$d Assets bündelt</item>
</plurals>
<string name="post_not_found">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanalbild</string>
@@ -556,6 +560,11 @@ anz der Bedingungen ist erforderlich</string>
<string name="profile_badges_description">Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.</string>
<string name="profile_badges_empty">Du hast noch keine Abzeichen erhalten.</string>
<string name="pictures">Bilder</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="nip82_repository_label">Quelle: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Herunterladen</string>
<string name="calendars">Kalender</string>
<string name="shorts">Kurzvideos</string>
<string name="public_chats">Öffentliche Chats</string>
@@ -1190,6 +1199,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="privacy_policy">Datenschutzerklärung</string>
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="child_safety_standards">Kinderschutzstandards</string>
<string name="about_legal">Über &amp; Rechtliches</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
<string name="relay_monitor_reports">Relay-Überwachungsberichte</string>
@@ -1681,6 +1692,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="edit_calendar_collection">Kalender bearbeiten</string>
<string name="calendar_collection_events_section">Termine in diesem Kalender (%1$d)</string>
<string name="calendar_collection_no_events_yet">Du hast noch keine Kalendertermine erstellt.</string>
<string name="calendar_view_feed">Feed</string>
<string name="calendar_view_month">Monat</string>
<string name="calendar_view_week">Woche</string>
<string name="calendar_view_day">Tag</string>
@@ -1746,6 +1758,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="calendar_rsvp_going">Komme</string>
<string name="calendar_rsvp_maybe">Vielleicht</string>
<string name="calendar_rsvp_not_going">Komme nicht</string>
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
<string name="calendar_rsvp_none">Noch keine Antworten.</string>
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
<string name="calendar_event_in_calendars">In Kalendern (%1$d)</string>
@@ -1755,6 +1768,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="calendar_relative_ongoing_with_end">%1$s · endet %2$s</string>
<string name="calendar_export_share_title">Kalendertermin teilen</string>
<string name="calendar_export_event">In Kalender exportieren (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Kalendererinnerungen</string>
<string name="calendar_reminder_channel_description">Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.</string>
<string name="calendar_reminder_default_title">Kalendertermin</string>
@@ -1991,8 +2005,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="git_status_closed">Geschlossen</string>
<string name="git_status_draft">Entwurf</string>
<string name="git_repo_tab_overview">Übersicht</string>
<string name="git_repo_tab_issues">Tickets</string>
<string name="git_repo_tab_patches">Patches und PRs</string>
<string name="git_repo_section_about">Über</string>
<string name="git_repo_section_links">Links</string>
<string name="git_repo_section_maintainers">Maintainer</string>
<string name="git_repo_section_topics">Themen</string>
<string name="git_repo_personal_fork">Persönlicher Fork</string>
@@ -2757,8 +2773,12 @@ anz der Bedingungen ist erforderlich</string>
<string name="community_rules_violation_stale_rules">Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt.</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">Nowhere-Seite</string>
<string name="nowhere_link_card_event">Nowhere Ereignis</string>
<string name="nowhere_link_card_fundraiser">Nowhere Spendenaktion</string>
<string name="nowhere_link_card_store">Nowhere Shop</string>
<string name="nowhere_link_card_petition">Nowhere Petition</string>
<string name="nowhere_link_card_message">Nowhere Nachricht</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Kunst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
</resources>
+176 -2
View File
@@ -8,6 +8,14 @@
<string name="show_anyway">फिर भी दिखाएँ</string>
<string name="post_was_hidden">इस प्रकाशित पत्र को छिपाया गया क्योंकि इसमें आपके आच्छाद्य उपयोगकर्ता अथवा शब्द उल्लेखित हैं</string>
<string name="post_was_flagged_as_inappropriate_by">प्रकाशित पत्र को मौन किया गया अथवा सूचित किया गया इनके द्वारा</string>
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="one">इस पत्र मे %1$d से अधिक विषयसूचक हैं</item>
<item quantity="other">इस पत्र मे %1$d से अधिक विषयसूचक हैं</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d संसाधन समाविष्ट</item>
<item quantity="other">%1$d संसाधन समाविष्ट</item>
</plurals>
<string name="post_not_found">घटना उपलब्ध की जा रही है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं</string>
<string name="post_not_found_short">देखें</string>
<string name="channel_image">प्रणाली चित्र</string>
@@ -548,6 +556,12 @@
<string name="profile_badges_description">चयन करें कि आपके प्राप्त पदकों में से कौन से आपके परिचय पर दिखाई देंगे।</string>
<string name="profile_badges_empty">आपको अब तक कोई पदक प्राप्त नहीं।</string>
<string name="pictures">चित्र</string>
<string name="software_apps">क्रमक</string>
<string name="route_software_apps">क्रमक</string>
<string name="nip82_repository_label">स्रोत : %1$s</string>
<string name="nip82_version_label">संस्करण %1$s</string>
<string name="nip82_download">अवरोहण</string>
<string name="calendars">दिनदर्शिकाएँ</string>
<string name="shorts">छोटे</string>
<string name="public_chats">सार्वजनिक चर्चा</string>
<string name="follow_packs">अनुचरण पोटलियाँ</string>
@@ -780,6 +794,10 @@
<string name="wallet_connect_manual_config">उन्नत : संयोजन विवरणों को स्वयम प्रविष्ट करें</string>
<string name="quick_zap_amounts">शीघ्र ज्साप संख्याएँ</string>
<string name="quick_zap_amounts_explainer">ज्साप घुण्डी दबाने पर दिखाया जाता है। एक संख्या दबाएँ उसे हटाने के लिए। उसे रिक्त छोड दें तो प्रत्येक बार संख्या प्रविष्ट करने के लिए पूछेगा।</string>
<string name="quick_zap_amounts_onchain">शीघ्र खण्डश्रृंखला सत्यापित ज्साप मात्राएँ</string>
<string name="quick_zap_amounts_onchain_explainer">खण्डश्रृंखला सत्यापित ज्साप के लिए खननकर्ता शुल्क का भुगतान है इसलिए मात्राएँ साधारणतः लैटनिंग॰ से अधिक हैं। मात्रा दबाएँ हटाने के लिए।</string>
<string name="new_amount_in_sats_onchain">नयी खण्डश्रृंखला सत्यापित मात्रा साट्स में</string>
<string name="send_onchain_instead">खण्डश्रृंखला सत्यापित विधि से भेजें इसके स्थान पर</string>
<string name="zap_privacy_section">ज्साप गोपनीयता</string>
<string name="zap_type_section_explainer">नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर।</string>
<string name="wallet_connect_connect_app">संयोजन धनकोष</string>
@@ -1383,6 +1401,7 @@
<string name="load_image_description">चित्रों का अवरोहण कब करें</string>
<string name="copy_stack_to_clipboard">चिति की अनुकृति करें</string>
<string name="copy_to_clipboard">टाँकाफलक में अनुकृति करें</string>
<string name="copied_to_clipboard">टाँकाफलक में अनुकृत</string>
<string name="copy_nprofile_to_clipboard">टाँकाफलक में एन॰परिचय की अनुकृति करें</string>
<string name="copy_npub_to_clipboard">टाँकाफलक में एनपुब॰ की अनुकृति करें</string>
<string name="share_or_save">बाँटें अथवा अभिलेखन करें</string>
@@ -1486,6 +1505,14 @@
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
<string name="read_only_user">केवल पढनेवाला उपयोगकर्ता</string>
<string name="no_reactions_setup">कोई प्रतिक्रियाएँ स्थापित नहीं</string>
<string name="notification_settings">सूचनाएँ</string>
<string name="notification_settings_section_delivery">वितरण</string>
<string name="notification_settings_section_display">क्रमकाभ्यन्तर प्रदर्शन</string>
<string name="notification_settings_section_categories">श्रेणियाँ</string>
<string name="notification_settings_categories_explainer">श्रेणि दबाएँ उसके आण्ड्रोइड सूचना स्थापना विकल्पों को खोलने के लिए। ध्वनि महत्व पदक तथा विघ्नविराम वहाँ हैं।</string>
<string name="notification_channel_status_on">चालू</string>
<string name="notification_channel_status_silent">मौन</string>
<string name="notification_channel_status_off">निष्क्रिय</string>
<string name="select_push_server">संयुक्तप्रेषण क्रमक का चयन करें</string>
<string name="push_server_title">प्रेषित सूचना</string>
<string name="push_server_explainer">स्थापित संयुक्तप्रेषण क्रमकों से</string>
@@ -1582,8 +1609,10 @@
<string name="route_messages">संदेश</string>
<string name="route_notifications">सूचनाएँ</string>
<string name="route_global">वैश्विक</string>
<string name="route_video">छोटे</string>
<string name="route_video">लघु चलचित्र</string>
<string name="route_pictures">चित्र</string>
<string name="route_calendars">दिनदर्शिकाएँ</string>
<string name="route_calendar_collections">दिनदर्शिका सूचियाँ</string>
<string name="route_chess">चतुरंग</string>
<string name="wallet">धनकोष</string>
<string name="wallet_balance">शेष</string>
@@ -1604,6 +1633,7 @@
<string name="wallet_creating_invoice">चालान बनाया जा रहा है…</string>
<string name="wallet_copy_invoice">चालान अनुकृति</string>
<string name="wallet_no_transactions">अभी कोई लेनदेन नहीं</string>
<string name="wallet_no_transactions_for_filter">इस छलनी के अनुकूल कोई लेनदेन नहीं</string>
<string name="wallet_loading">आवहन चालू…</string>
<string name="wallet_incoming">प्राप्त</string>
<string name="wallet_outgoing">भेजा गया</string>
@@ -1632,10 +1662,19 @@
<string name="wallet_invalid_uri">अमान्य नोस्टर धनकोष संयोजन पता</string>
<string name="wallet_move_up">ऊर्ध्वगमन</string>
<string name="wallet_move_down">अधोगमन</string>
<string name="wallet_onchain_transactions">खण्डश्रृंखला सत्यापित लेनदेन</string>
<string name="wallet_onchain_no_address">इस लेखा के लिए कोई खण्डश्रृंखला पता उपलब्ध नहीं।</string>
<string name="wallet_onchain_no_backend">कोई खणडश्रृंखला पृष्ठभाग समाकृत नहीं।</string>
<string name="wallet_onchain_pending">अपूर्ण</string>
<string name="wallet_onchain_public_chip">सार्वजनिक</string>
<string name="wallet_onchain_public_dialog_title">यह धनकोष सार्वजनिक है</string>
<string name="wallet_onchain_public_dialog_body">आपका टापरूट॰ पता आपकी नोस्टर ख्याप्यकुंचिका से व्युत्पन्न है। अतः जो भी आपका एनपुब॰ जानता है इस धनकोष की शेष राशि तथा लेनदेन इतिहास देख सकता है खण्डश्रृंखला पर।\n\n अपनी गोपनीयता बचा के रखने के लिए इस धनकोष का वित्तपोषण तथा अपवहन करें अनिजी लेखाओं के साथ जैसे विनिमयस्थानों में। कभी भी इस धन को अपने जालरहित धनकोष के साथ मिश्रण ना करें। तथा एसा व्यवहार करें जैसे कि इस धन को खो सकते हैं। क्योंकि आपके एनसेक॰ को जो भी नियन्त्रण करता है इस धन का व्यय कर सकेगा।</string>
<string name="wallet_onchain_public_dialog_confirm">समझ गया</string>
<string name="wallet_onchain_copy_dialog_confirm">पता अनुकृति</string>
<string name="route_security_filters">सुरक्षार्थ छलनियाँ</string>
<string name="route_import_follows">अनुचरित आयात करें</string>
<string name="new_post">नया पत्र प्रकाशन</string>
<string name="new_short">नये छोटे : चित्र अथवा चलचित्र</string>
<string name="new_short">नये लघु : चित्र अथवा चलचित्र</string>
<string name="new_community_note">नया सामुदायिक टीका</string>
<string name="new_product">नया उत्पाद</string>
<string name="new_exclusive_geo_note">नया स्थान विशेष पत्र</string>
@@ -1644,6 +1683,127 @@
<string name="new_zap_poll">नया ज्साप मतदान</string>
<string name="new_regular_poll">नया सामान्य मतदान</string>
<string name="new_picture">नया चित्र</string>
<string name="new_calendar_event">नयी दिनदर्शिका घटना</string>
<string name="edit_calendar_event">दिनदर्शिका घटना सम्पादन</string>
<string name="calendar_event_all_day_locked">अवरोधित सम्पादन करते समय। इसके परिवर्तन से एक नयी घटना बनेगी।</string>
<string name="new_calendar_collection">नयी दिनदर्शिका</string>
<string name="edit_calendar_collection">दिनदर्शिका सम्पादन</string>
<string name="calendar_collection_events_section">इस दिनदर्शिका में घटनाएँ (%1$d)</string>
<string name="calendar_collection_no_events_yet">आपने कोई दिनदर्शिका घटना बनाए नहीं अब तक।</string>
<string name="calendar_view_feed">सूचनावली</string>
<string name="calendar_view_month">मास</string>
<string name="calendar_view_week">सप्ताह</string>
<string name="calendar_view_day">दिन</string>
<string name="calendar_section_upcoming">आगामी</string>
<string name="calendar_section_past">अतीत</string>
<string name="calendar_empty_feed">कोई आगामी अथवा अतीत दिनदर्शिका घटनाएँ नहीं आपके चयनित सूचनावली से अब तक।</string>
<string name="calendar_empty_collections">कोई दिनदर्शिका संग्रह नहीं अब तक।</string>
<string name="calendar_empty_feed_title">आपकी दिनदर्शिका रिक्त है</string>
<string name="calendar_empty_feed_subtitle">आपके द्वारा अनुचरित लोगों के बाँटे हुए घटनाएँ यहाँ दिखेंगे। संकलनचिह्न दबाएँ आपके अपने बनाने के लिए।</string>
<string name="calendar_empty_collections_title">कोई संग्रह नहीं अब तक</string>
<string name="calendar_empty_collections_subtitle">घटनाओं को एकत्र करें। एक मेलन श्रृंखला अथवा एक वर्गविशेष सम्मेलन अथवा आपके दल का मार्गचित्रण। संकलनचिह्न दबाएँ एक बनाने के लिए।</string>
<string name="calendar_empty_day_title">समय निर्धारित कुछ नहीं</string>
<string name="calendar_empty_day_subtitle">इस दिन कोई घटनाएँ नहीं। संकलनचिह्न दबाएँ एक जोडने के लिए।</string>
<string name="calendar_empty_week_title">इस सप्ताह कुछ नहीं</string>
<string name="calendar_empty_week_subtitle">कोई घटनाएँ नहीं इस सप्ताह में।</string>
<string name="calendar_event_title">शीर्षक</string>
<string name="calendar_event_summary">साराम्श</string>
<string name="calendar_event_location">स्थान</string>
<string name="calendar_event_image">चित्र जालपता</string>
<string name="calendar_event_all_day">पूर्ण दिन घटना</string>
<string name="calendar_event_start">आरम्भ</string>
<string name="calendar_event_end">समाप्त</string>
<string name="calendar_event_hashtags">विषयसूचक (अल्पविराम विभाजित)</string>
<string name="calendar_event_pick_date">दिनांक चयन</string>
<string name="calendar_event_pick_time">समय चयन</string>
<string name="calendar_event_invalid">शीर्षक तथा आरम्भ आवश्यक।</string>
<string name="calendar_event_end_before_start">अन्त आरम्भ पश्चात होना चाहिए।</string>
<string name="calendar_nav_previous_month">पूर्व मास</string>
<string name="calendar_nav_next_month">अगला मास</string>
<string name="calendar_nav_previous_week">पूर्व सप्ताह</string>
<string name="calendar_nav_next_week">अगले सप्ताह</string>
<string name="calendar_nav_previous_day">पुर्व दिन</string>
<string name="calendar_nav_next_day">अगले दिन</string>
<string name="calendar_no_events_today">कोई घटनाएँ नहीं इस दिन</string>
<string name="calendar_no_events">कोई घटनाएँ नहीं</string>
<string name="calendar_continues">आगे चलता है</string>
<string name="calendar_day_of_total">%2$d में से %1$d दिन</string>
<string name="calendar_add_to_phone_calendar">संचारयन्त्र दिनदर्शिका में जोडें</string>
<string name="calendar_nav_jump_to_today">आज तक जाएँ</string>
<plurals name="calendar_day_a11y_events">
<item quantity="one">%1$s, %2$d घटना</item>
<item quantity="other">%1$s, %2$d घटनाएँ</item>
</plurals>
<string name="calendar_day_a11y_no_events">%1$s, कोई घटनाएँ नहीं</string>
<string name="calendar_day_a11y_today_suffix">आज</string>
<string name="calendar_day_a11y_selected_suffix">चयनित</string>
<string name="calendar_fab_new_event">एक नयी दिनदर्शिका घटना बनाएँ</string>
<string name="calendar_fab_new_collection">एक नया दिनदर्शिका संग्रह बनाएँ</string>
<string name="calendar_fab_toggle">रचना विकल्प दिखाएँ</string>
<string name="calendar_untitled">(शीर्षकरहित)</string>
<string name="calendar_all_day">पूर्ण दिन</string>
<string name="calendar_rsvp_going_prefixed">जाना निश्चित</string>
<string name="calendar_rsvp_maybe_prefixed">सम्भाव्य</string>
<string name="calendar_rsvp_not_going_prefixed">नहीं जा सकते</string>
<string name="calendar_collection_title">शीर्षक</string>
<string name="calendar_collection_description">विवरण</string>
<string name="calendar_collection_invalid">शीर्षक अनिवार्य।</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d घटना</item>
<item quantity="other">%1$d घटनाएँ</item>
</plurals>
<string name="calendar_collection_empty_members">इस दिनदर्शिका में कोई घटनाएँ नहीं अब तक।</string>
<string name="calendar_rsvp_going">जाना निश्चित</string>
<string name="calendar_rsvp_maybe">सम्भाव्य</string>
<string name="calendar_rsvp_not_going">नहीं जा सकते</string>
<string name="calendar_rsvp_section">शीघ्रप्रत्युत्तर (%1$d)</string>
<string name="calendar_rsvp_none">कोई शीघ्रप्रत्युत्तर नहीं अब तक।</string>
<string name="calendar_participants_section">सहभागी (%1$d)</string>
<string name="calendar_event_in_calendars">दिनदर्शिकाओं में (%1$d)</string>
<string name="calendar_event_in_no_calendars">किसी भी दिनदर्शिका का भाग नहीं अब तक।</string>
<string name="calendar_event_loading">घटना आवहन…</string>
<string name="calendar_relative_ongoing">हो रहा है अब</string>
<string name="calendar_relative_ongoing_with_end">%1$s। %2$s समापन</string>
<string name="calendar_export_share_title">दिनदर्शिका घटना बाँटें</string>
<string name="calendar_export_event">दिनदर्शिका तक निर्यात (.आईसीएस)</string>
<string name="calendar_reminder_channel_id">दिनदर्शिका अनुस्मारक</string>
<string name="calendar_reminder_channel_name">दिनदर्शिका अनुस्मारक</string>
<string name="calendar_reminder_channel_description">सूचना जब एक घटना जिस में आप भाग ले रहे हैं आरम्भ होनेवाला है।</string>
<string name="calendar_reminder_default_title">दिनदर्शिका घटना</string>
<plurals name="calendar_reminder_body">
<item quantity="one">आरम्भ %1$d मिनट में</item>
<item quantity="other">आरम्भ %1$d मिनटों में</item>
</plurals>
<string name="calendar_add_to_calendar_action">आपके एक दिनदर्शिका में जोडें</string>
<string name="calendar_add_to_calendar_title">दिनदर्शिका में जोडें</string>
<string name="calendar_add_to_calendar_none">आपने कोई दिनदर्शिका बनाए नहीं अब तक।</string>
<string name="calendar_collection_delete">दिनदर्शिका मिटाएँ</string>
<string name="calendar_collection_delete_confirm_title">क्या इस दिनदर्शिका को मिटा दिया जाए।</string>
<string name="calendar_collection_delete_confirm_message">दिनदर्शिका सूची हटाया जाएगा। उसके अन्दर घटनाएँ नहीं मिटेंगे।</string>
<string name="calendar_event_pick_image">चित्र चयन</string>
<string name="calendar_event_image_upload_failed">चित्र आरोहण असफल</string>
<string name="calendar_event_image_upload_failed_body">चयनित चित्र का अरोहण असफल। पुनःप्रयास करें अथवा जालपता चिपकाएँ।</string>
<string name="calendar_event_participants_section">सहभागी (%1$d)</string>
<string name="calendar_event_participant_input">नाम अथवा एनपुब॰ अथवा निप॰०५ ढूँढें</string>
<string name="calendar_event_participant_invalid">मान्य एनपुब॰ प्रविष्ट करें… अथवा ६४ अक्षर षोडशांकरूप ख्याप्यकुंचिका।</string>
<string name="calendar_event_participant_remove">सहभागी हटाएँ</string>
<string name="calendar_reminder_settings_title">दिनदर्शिका अनुस्मारक</string>
<string name="calendar_reminder_settings_enabled_title">अनुस्मारक भेजें</string>
<string name="calendar_reminder_settings_enabled_subtitle">सूचना भेजी जाती है जब एक घटना जिस में आप भाग ले रहे हैं आरम्भ होनेवाला है।</string>
<string name="calendar_reminder_settings_lead_title">आनुस्मारक समयावधि</string>
<string name="calendar_reminder_settings_lead_subtitle">घटना के कितने मिनट पूर्व आप सूचित होना चाहते हैं।</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d मि॰</item>
<item quantity="other">%1$d मि॰</item>
</plurals>
<string name="calendar_share_nostr">नोस्टर योजक के रूप में बाँटें</string>
<string name="calendar_share_nostr_title">दिनदर्शिका योजक बाँटें</string>
<string name="calendar_filter_all">सभी दिनदर्शिकाएँ</string>
<string name="calendar_filter_sheet_title">से घटनाएँ दिखाएँ…</string>
<string name="calendar_filter_no_calendars">आपने कोई दिनदर्शिका बनाए नहीं अब तक।</string>
<string name="calendar_open_in_maps">मानचित्र में खोलें</string>
<string name="calendar_open_link">योजक खोलें</string>
<string name="route_calendar_event_detail">घटना विवरण</string>
<string name="new_short_video">नया छोटा चलचित्र</string>
<string name="new_long_video">नया लम्बा चलचित्र</string>
<string name="article_title">शीर्षक</string>
@@ -1667,6 +1827,8 @@
<string name="boost_or_quote_description">उद्धृत करें अथवा टीका लिखें</string>
<string name="like_description">इष्ट</string>
<string name="zap_description">ज्साप</string>
<string name="onchain_zap_description">खण्डश्रृंखला पर द्व्यंकरूप्य ज्साप</string>
<string name="onchain_zap_pending">पुष्टिकरण की प्रतीक्षा</string>
<string name="change_reaction">शीघ्र प्रतिक्रियाओं का परिवर्तन करें</string>
<string name="bottom_bar_settings">निचली मार्गदर्शन पट्टी</string>
<string name="bottom_bar_settings_description">क्रम पुनःव्यवस्थित करने के लिए खींचें। विकल्पान्तरण करें वस्तु को जोडने अथवा हटाने के लिए निचली पट्टी से। शून्य वस्तु हो तो निचली पट्टी अदृश्य रहेगी।</string>
@@ -2018,6 +2180,9 @@
<string name="remove_user_from_the_list">सूची से प्रयोक्ता हटाएँ</string>
<string name="follow_list_item_label">अनुचरण पोटली</string>
<string name="members">सदस्य सूची</string>
<string name="contact_list_containing_label">अनुचरण सूची %1$d प्रयोक्ता युक्त :</string>
<string name="contact_list_screen_title">अनुचरण सूची</string>
<string name="contact_list_screen_title_with_count">अनुचरण सूची (%1$d)</string>
<string name="people_list_title">अनुचरण सूची उपतथ्य</string>
<string name="people_list_explainer">अनुचरण सूची उपतथ्य नोस्टर में सब के द्वारा देखा जा सकता है। केवल आपके निजी सदस्य रहस्यीकृत हैं।</string>
<string name="follow_pack_title">अनुचरण पोटली उपतथ्य</string>
@@ -2605,4 +2770,13 @@
<string name="community_rules_violation_wot_gate_failed">आप इस समुदाय के विश्वासजाल आवश्यकताओं को पार नहीं कर सके।</string>
<string name="community_rules_violation_stale_rules">समुदाय का नवीनतम नियम अभिलेख पुराना होने के कारण अस्वीकृत।</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">नोव्हेर जालस्थान</string>
<string name="nowhere_link_card_event">नोव्हेर घटना</string>
<string name="nowhere_link_card_fundraiser">नोव्हेर धनसंग्रहण</string>
<string name="nowhere_link_card_store">नोव्हेर विपणि</string>
<string name="nowhere_link_card_petition">नोव्हेर याचिका</string>
<string name="nowhere_link_card_message">नोव्हेर सन्देश</string>
<string name="nowhere_link_card_drop">नोव्हेर अभिलेख</string>
<string name="nowhere_link_card_art">नोव्हेर कला</string>
<string name="nowhere_link_card_forum">नोव्हेर मंच</string>
</resources>
@@ -12,6 +12,10 @@
<item quantity="one">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
<item quantity="other">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d asset egybecsomagolva</item>
<item quantity="other">%1$d asset egybecsomagolva</item>
</plurals>
<string name="post_not_found">Az esemény épp betöltődik vagy nem található az átjátszólistában</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Csatorna profilképe</string>
@@ -552,6 +556,11 @@
<string name="profile_badges_description">Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban.</string>
<string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
<string name="pictures">Képek</string>
<string name="software_apps">Alkalmazások</string>
<string name="route_software_apps">Alkalmazások</string>
<string name="nip82_repository_label">Forrás: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Letöltés</string>
<string name="calendars">Naptárak</string>
<string name="shorts">Rövidek</string>
<string name="public_chats">Nyilvános csevegések</string>
@@ -1189,6 +1198,8 @@
<string name="posting_policy">Közzétételi szabályzat</string>
<string name="privacy_policy">Adatvédelmi irányelvek</string>
<string name="terms_and_conditions">Általános szerződési feltételek</string>
<string name="child_safety_standards">Gyermekbiztonsági szabványok</string>
<string name="about_legal">Névjegy &amp; Jogi információk</string>
<string name="not_available_acronym">Nem érhető el</string>
<string name="relay_error_messages">Hibák és megjegyzések ettől az átjátszótól</string>
<string name="relay_monitor_reports">Átjátszófigyelési jelentések</string>
@@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="profile_badges_description">Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.</string>
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
<string name="pictures">Zdjęcia</string>
<string name="software_apps">Aplikacje</string>
<string name="route_software_apps">Aplikacje</string>
<string name="nip82_repository_label">Źródło: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Pobierz</string>
<string name="calendars">Kalendarze</string>
<string name="shorts">Filmiki</string>
<string name="public_chats">Czaty publiczne</string>
@@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="posting_policy">Polityka publikowania</string>
<string name="privacy_policy">Polityka Prywatności</string>
<string name="terms_and_conditions">Zasady i warunki użytkowania</string>
<string name="child_safety_standards">Normy bezpieczeństwa dzieci</string>
<string name="about_legal">O Nas &amp; Informacje Prawne</string>
<string name="not_available_acronym">Nie dotyczy</string>
<string name="relay_error_messages">Błędy i powiadomienia z tego transmitera</string>
<string name="relay_monitor_reports">Raporty z monitoringu transmitera</string>
+232 -39
View File
@@ -8,6 +8,18 @@
<string name="show_anyway">Vseeno prikaži</string>
<string name="post_was_hidden">To sporočilo je skrito, ker je omenjena nekatera od skritih uporabnikov ali besed</string>
<string name="post_was_flagged_as_inappropriate_by">Sporočilo je bilo utišano ali prijavljeno s strani</string>
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="one">Ta objava ima več kot %1$d ključnik</item>
<item quantity="two">Ta objava ima več kot %1$d ključnika</item>
<item quantity="few">Ta objava ima več kot %1$d ključnikov</item>
<item quantity="other">Ta objava ima več kot %1$d ključnikov</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d sredstvo v paketu</item>
<item quantity="two">%1$d sredstva v paketu</item>
<item quantity="few">%1$d sredstva v paketu</item>
<item quantity="other">%1$d sredstev v paketu</item>
</plurals>
<string name="post_not_found">Dogodek se nalaga ali pa ni najden v tvojih relejih</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Slika kanala</string>
@@ -145,11 +157,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="errors">Napake</string>
<string name="connection_success_rate_description">Delež uspešnih povezav z relejem</string>
<string name="errors_description">Število napak pri povezovanju v tej seji</string>
<string name="home_feed">Domače vsebine</string>
<string name="private_message_feed">Vsebine privatnih sporočil</string>
<string name="public_chat_feed">Vsebina javnih novic</string>
<string name="global_feed">Globalne vsebine</string>
<string name="search_feed">Išči vsebine</string>
<string name="home_feed">Domač vir vsebin</string>
<string name="private_message_feed">Vir vsebin privatnih sporočil</string>
<string name="public_chat_feed">Vir vsebin javnih novic</string>
<string name="global_feed">Globalni vir vsebin</string>
<string name="search_feed">Išči vir vsebin</string>
<string name="search_and_add_a_user">Išči in dodaj uporabnika</string>
<string name="add_a_user">Dodaj Uporabnika</string>
<string name="add_a_relay">Dodaj rele</string>
@@ -214,7 +226,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="blocked_users">Blokirani uporabniki</string>
<string name="new_threads">Nove objave</string>
<string name="conversations">Razprave</string>
<string name="feed">Vsebine</string>
<string name="feed">Vir vsebin</string>
<string name="mod_queue">Moderatorska čakalna vrsta</string>
<string name="notes">Zapiski</string>
<string name="replies">Pogovori</string>
@@ -263,13 +275,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="already_have_an_account">Že imam Nostr račun?</string>
<string name="create_a_new_account">Ustvari nov račun</string>
<string name="generate_a_new_key">Ustvari nov ključ</string>
<string name="loading_feed">Nalagam vsebine</string>
<string name="loading_feed">Nalagam vir vsebin</string>
<string name="loading_account">Račun se nalaga</string>
<string name="error_loading_replies">"Napaka pri nalaganju odgovorov: "</string>
<string name="try_again">Poskusi ponovno</string>
<string name="notification_feed_is_empty">Ni še obvestil.</string>
<string name="open_poll">Aktivna anketa</string>
<string name="feed_is_empty">Ni vsebin.</string>
<string name="feed_is_empty">Vir vsebin je prazen.</string>
<string name="refresh">Osveži</string>
<string name="created">ustvarjeno</string>
<string name="with_description_of">z opisom</string>
@@ -358,7 +370,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="drawer_accounts">Računi</string>
<string name="drawer_section_navigate">Krmarjenje</string>
<string name="drawer_section_you">Vi</string>
<string name="drawer_section_feeds">Vsebina</string>
<string name="drawer_section_feeds">Viri vsebin</string>
<string name="drawer_section_create">Ustvari</string>
<string name="drawer_section_system">Sistem</string>
<string name="account_switch_select_account">Izberi račun</string>
@@ -569,6 +581,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="profile_badges_description">Izberite, katere od prejetih značk bodo vidne na vašem profilu.</string>
<string name="profile_badges_empty">Niste še prejeli nobenih značk</string>
<string name="pictures">Fotografije</string>
<string name="software_apps">Aplikacije</string>
<string name="route_software_apps">Aplikacije</string>
<string name="nip82_repository_label">Vir: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Prenos</string>
<string name="calendars">Koledarji</string>
<string name="shorts">Kratki posnetki</string>
<string name="public_chats">Javni klepeti</string>
<string name="follow_packs">Paketi sledenih</string>
@@ -595,7 +613,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="nest_chat_send_failed_title">Sporočila ni bilo mogoče poslati</string>
<string name="nest_no_app_to_open_link">Nimate nameščene aplikacije za odpiranje te povezave.</string>
<string name="nest_close_room_confirm_title">Zaprem to sobo?</string>
<string name="nest_close_room_confirm_body">Vsi udeleženci bodo odklopljeni. Soba bo v vsebini prikazana kot ZAPRTA.</string>
<string name="nest_close_room_confirm_body">Vsi udeleženci bodo odklopljeni. Soba bo v viru vsebin prikazana kot ZAPRTA.</string>
<string name="nest_close_room_confirm_action">Zapri sobo</string>
<string name="nest_create_when_in_past">Izberite čas začetka v prihodnosti.</string>
<string name="nest_audio_failed">Napaka pri zvoku: %1$s</string>
@@ -803,10 +821,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_connect_manual_config">Napredno: ročni vnos podatkov o povezavi</string>
<string name="quick_zap_amounts">Zneski za hitre zape</string>
<string name="quick_zap_amounts_explainer">Prikaže se ob pritisku na gumb za zape. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zapu odprlo okno za vnos poljubnega zneska.</string>
<string name="quick_zap_amounts_onchain">Hitri On-chain zapi</string>
<string name="quick_zap_amounts_onchain_explainer">On-chain zapi vključujejo rudarske provizije, zato so zneski običajno višji kot prek Lightninga. Dotakni se zneska, da ga odstraniš.</string>
<string name="new_amount_in_sats_onchain">Nov on-chain znesek v sat</string>
<string name="send_onchain_instead">Pošlji raje on-chain</string>
<string name="zap_privacy_section">Zasebnost zapov</string>
<string name="zap_type_section_explainer">Določa, kako je prikazana vaša identiteta, ko pošljete zap.</string>
<string name="wallet_connect_connect_app">Poveži denarnico</string>
<string name="see_relay_feed">Pogled v vsebino releja</string>
<string name="see_relay_feed">Pogled rele vira vsebin</string>
<string name="pledge_amount_in_sats">Prispevaj vsoto v sat</string>
<string name="post_poll">Pošlji anketo</string>
<string name="poll_heading_required">Zahtevana polja:</string>
@@ -876,7 +898,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="local_blossom_cache_detected">Na vratih 24242 je bil zaznan lokalni predpomnilnik.</string>
<string name="local_blossom_cache_not_detected">Na vratih 24242 ni zaznati lokalnega predpomnilnika.</string>
<string name="local_blossom_cache_profile_pics_only">V predpomnilnik shrani le slike profilov</string>
<string name="local_blossom_cache_profile_pics_only_caption">Lokalni predpomnilnik omeji le na slike profilov. Slike in videoposnetki iz vsebin se bodo prenašali neposredno z izvornih strežnikov.</string>
<string name="local_blossom_cache_profile_pics_only_caption">Lokalni predpomnilnik omeji le na slike profilov. Slike in videoposnetki iz vira vsebin se bodo prenašali neposredno z izvornih strežnikov.</string>
<string name="no_nip96_server_message">Nimate nastavljenih NIP-96 strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓</string>
<string name="no_blossom_server_message">Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓</string>
<string name="recommended_media_servers">Priporočljivi medijski strežniki</string>
@@ -930,7 +952,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_set_type_public">Javno</string>
<string name="follow_set_type_private">Zasebno</string>
<string name="follow_set_type_mixed">Mešano</string>
<string name="follow_set_empty_feed_msg"> Videti je, da še nimate setov sledenih.
<string name="follow_set_empty_feed_msg"> Videti je, da še nimate nabora sledenih.
\nTapnite spodaj za osvežitev ali dodajte nov seznam.
</string>
<string name="follow_set_create_btn_label">Nov</string>
@@ -1090,6 +1112,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="call_failed_start">Klic ni uspel</string>
<string name="call_failed_accept">Sprejem klica ni uspel</string>
<string name="call_failed_session">Vzpostavitev klicne seje ni uspela</string>
<string name="call_permission_denied_title">Potrebno je dovoljenje</string>
<string name="call_permission_denied_voice">Amethyst potrebuje dostop do mikrofona za začetek glasovnega klica. Omogoči ga v nastavitvah aplikacije.</string>
<string name="call_permission_denied_video">Amethyst potrebuje dostop do kamere in mikrofona za začetek video klica. Omogoči ju v nastavitvah aplikacije.</string>
<string name="call_permission_denied_open_settings">Odri nastavitve</string>
<string name="call_permission_denied_cancel">Prekliči</string>
<string name="call_settings">Klicne nastavitve</string>
<string name="call_settings_enable_calls">Vklop glasovnih in video klicev</string>
<string name="call_settings_enable_calls_description">Če funkcijo izklopite, bodo gumbi za klice skriti, vsi dohodni klici pa se bodo tiho zavrnili.</string>
@@ -1150,7 +1177,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="max_hashtag_limit_title">Največje število ključnikov na objavo</string>
<string name="max_hashtag_limit_explainer">Skrije objave, ki imajo več oznak od te omejitve. Za izklop nastavi na 0</string>
<string name="hide_community_rules_violations_title">Skrij objave, ki kršijo pravila skupnosti</string>
<string name="hide_community_rules_violations_explainer">Iz vsebin skupnosti odstrani objave, ki kršijo pravila NIP-9B, če jih je skupnost objavila. Nima učinka, če skupnost nima strukturiranih pravil.</string>
<string name="hide_community_rules_violations_explainer">Iz vira vsebin skupnosti odstrani objave, ki kršijo pravila NIP-9B, če jih je skupnost objavila. Nima učinka, če skupnost nima strukturiranih pravil.</string>
<string name="security_section_filtering_preferences">Možnosti filtriranja</string>
<string name="security_section_blocked_content">Blokirana vsebina</string>
<string name="security_unlimited">∞</string>
@@ -1279,7 +1306,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="discover_follows">Paketi sledenih</string>
<string name="discover_follows_explainer">To so seznami uporabnikov, ki jih priporočate drugim. Dovoljeni so le javni uporabniki.</string>
<string name="discover_reads">Branje</string>
<string name="discover_content_v2">Algoritmi vsebin</string>
<string name="discover_content_v2">Algoritmi vira vsebin</string>
<string name="discover_marketplace">Tržnica</string>
<string name="discover_live_v2">Prenos v živo</string>
<string name="discover_community_v2">Skupnosti</string>
@@ -1401,6 +1428,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="load_image_description">Kdaj naložiti slike</string>
<string name="copy_stack_to_clipboard">Kopiraj nabor</string>
<string name="copy_to_clipboard">Kopiraj v odložišče</string>
<string name="copied_to_clipboard">Kopirano v odložišče</string>
<string name="copy_nprofile_to_clipboard">Kopiraj nprofil v odložišče</string>
<string name="copy_npub_to_clipboard">Kopiraj npub v odložišče</string>
<string name="share_or_save">Deli ali shrani</string>
@@ -1481,7 +1509,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_connect_pay_invoice_error_error">Vaš \"Wallet Connect\" ponudnik je vrnil naslednjo napako: %1$s</string>
<string name="could_not_connect_to_tor">Ni bilo mogoče povezati s Tor</string>
<string name="tor_connection_failed_title">Tor se ne povezuje</string>
<string name="tor_connection_failed_body">Amethystu ni uspelo zagnati povezave Tor. Želite uporabiti običajno povezavo za nalaganje vsebine? To izbiro bomo ohranili eno uro, nato bomo ponovno poskusili s Torom.</string>
<string name="tor_connection_failed_body">Amethystu ni uspelo zagnati povezave Tor. Želite uporabiti običajno povezavo za nalaganje vira vsebin? To izbiro bomo ohranili eno uro, nato bomo ponovno poskusili s Torom.</string>
<string name="tor_continue_without_for_session">Uporabi običajno povezavo</string>
<string name="tor_keep_waiting">Nadaljuj s čakanjem</string>
<string name="unable_to_download_relay_document">Prenos rele dokumentacije ni na voljo</string>
@@ -1504,6 +1532,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Ni mogoče ustvariti lightning fakture od %1$s: Element \'pr\' ni bil najden v JSON-u.</string>
<string name="read_only_user">Uporabnik samo za branje</string>
<string name="no_reactions_setup">Ni nastavitev za reakcije</string>
<string name="notification_settings">Obvestila</string>
<string name="notification_settings_section_delivery">Dostava</string>
<string name="notification_settings_section_display">Prikaz v aplikaciji</string>
<string name="notification_settings_section_categories">Kategorije</string>
<string name="notification_settings_categories_explainer">Dotakni se kategorije, da odpreš njene nastavitve obvestil v Androidu — tam te čakajo zvok, pomembnost, značke in funkcija Ne moti.</string>
<string name="notification_channel_status_on">Vklop</string>
<string name="notification_channel_status_silent">Tiho</string>
<string name="notification_channel_status_off">Izklop</string>
<string name="select_push_server">Izberi \"UnifiedPush\" aplikacijo</string>
<string name="push_server_title">Potisna obvestila</string>
<string name="push_server_explainer">Od nameščenih \"UnifiedPush\" aplikacij</string>
@@ -1602,6 +1638,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="route_global">Globalno</string>
<string name="route_video">Kratki mediji</string>
<string name="route_pictures">Fotografije</string>
<string name="route_calendars">Koledarji</string>
<string name="route_calendar_collections">Seznami koledarjev</string>
<string name="route_chess">Šah</string>
<string name="wallet">Denarnica</string>
<string name="wallet_balance">Bilanca</string>
@@ -1622,6 +1660,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_creating_invoice">Ustvarjam fakturo…</string>
<string name="wallet_copy_invoice">Kopiraj fakturo</string>
<string name="wallet_no_transactions">Ni transakcij</string>
<string name="wallet_no_transactions_for_filter">Nobena transakcija ne ustreza temu filtru</string>
<string name="wallet_loading">Nalagam…</string>
<string name="wallet_incoming">Prejeto</string>
<string name="wallet_outgoing">Poslano</string>
@@ -1650,6 +1689,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_invalid_uri">Neveljaven NWC URI povezave</string>
<string name="wallet_move_up">Premakni navzgor</string>
<string name="wallet_move_down">Premakni navzdol</string>
<string name="wallet_onchain_transactions">On-chain transakcije</string>
<string name="wallet_onchain_no_address">Za ta račun ni na voljo nobenega on-chain naslova.</string>
<string name="wallet_onchain_no_backend">Zaledni sistem verige ni nastavljen.</string>
<string name="wallet_onchain_pending">V čakanju</string>
<string name="wallet_onchain_public_chip">Javno</string>
<string name="wallet_onchain_public_dialog_title">Ta denarnica je javna</string>
<string name="wallet_onchain_public_dialog_body">Tvoj Taproot naslov je izpeljan iz tvojega javnega Nostr ključa (npub..), kar pomeni, da lahko vsakdo, ki pozna tvoj npub, vidi stanje te denarnice in zgodovino transakcij na bločni verigi.
Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, kot so borze. Teh sredstev nikoli ne mešaj s svojimi hladnimi denarnicami in z njimi ravnaj kot z denarjem, ki ga lahko izgubiš, saj lahko vsakdo, ki ima nadzor nad tvojim zasebnim ključem (nsec...), ta sredstva tudi porabi.</string>
<string name="wallet_onchain_public_dialog_confirm">Razumem</string>
<string name="wallet_onchain_copy_dialog_confirm">Kopiraj naslov</string>
<string name="route_security_filters">Varnostni filtri</string>
<string name="route_import_follows">Uvozi sledilce</string>
<string name="new_post">Nova objava</string>
@@ -1662,6 +1712,135 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="new_zap_poll">Nova anketa z zapi</string>
<string name="new_regular_poll">Nova navadna anketa</string>
<string name="new_picture">Nova slika</string>
<string name="new_calendar_event">Nov koledarski dogodek</string>
<string name="edit_calendar_event">Uredi koledarski dogodek</string>
<string name="calendar_event_all_day_locked">Zaklenjeno med urejanjem — sprememba bi namesto tega ustvarila nov dogodek.</string>
<string name="new_calendar_collection">Nov koledar</string>
<string name="edit_calendar_collection">Uredi koledar</string>
<string name="calendar_collection_events_section">Dogodki v tem koledarju (%1$d)</string>
<string name="calendar_collection_no_events_yet">Nimaš še ustvarjenih koledarskih dogodkov.</string>
<string name="calendar_view_feed">Vir vsebin</string>
<string name="calendar_view_month">Mesec</string>
<string name="calendar_view_week">Teden</string>
<string name="calendar_view_day">Dan</string>
<string name="calendar_section_upcoming">Prihajajoči</string>
<string name="calendar_section_past">Pretekli</string>
<string name="calendar_empty_feed">Ni prihajajočih ali preteklih dogodkov v izbranem viru vsebin.</string>
<string name="calendar_empty_collections">Nimaš še nobenih zbirk koledarjev.</string>
<string name="calendar_empty_feed_title">Vaš koledar je prazen</string>
<string name="calendar_empty_feed_subtitle">Tukaj se prikažejo dogodki, ki jih delijo ljudje, ki jim slediš. Dotakni se gumba +, da ustvariš svojega.</string>
<string name="calendar_empty_collections_title">Nimate še nobenih zbirk</string>
<string name="calendar_empty_collections_subtitle">Združuj dogodke — serijo srečanj, konferenčni sklop ali razvojni načrt svoje ekipe. Dotakni se gumba +, da ustvariš novo.</string>
<string name="calendar_empty_day_title">Urnik je prazen.</string>
<string name="calendar_empty_day_subtitle">Na ta dan ni dogodkov. Dotakni se gumba +, da ga dodaš.</string>
<string name="calendar_empty_week_title">Nič za ta teden</string>
<string name="calendar_empty_week_subtitle">V tem tednu ni nobenih dogodkov.</string>
<string name="calendar_event_title">Naslov</string>
<string name="calendar_event_summary">Povzetek</string>
<string name="calendar_event_location">Lokacija</string>
<string name="calendar_event_image">URL slike</string>
<string name="calendar_event_all_day">Celodnevni dogodek</string>
<string name="calendar_event_start">Začetek</string>
<string name="calendar_event_end">Konec</string>
<string name="calendar_event_hashtags">Ključniki (ločeno z vejicami)</string>
<string name="calendar_event_pick_date">Izberi datum</string>
<string name="calendar_event_pick_time">izberi čas</string>
<string name="calendar_event_invalid">Naslov in začetek sta obvezna.</string>
<string name="calendar_event_end_before_start">Konec mora biti po začetku.</string>
<string name="calendar_nav_previous_month">Prejšnji mesec</string>
<string name="calendar_nav_next_month">Naslednji mesec</string>
<string name="calendar_nav_previous_week">Prejšnji teden</string>
<string name="calendar_nav_next_week">Naslednji teden</string>
<string name="calendar_nav_previous_day">Prejšnji dan</string>
<string name="calendar_nav_next_day">Naslednji dan</string>
<string name="calendar_no_events_today">Ni dogodkov na ta dan</string>
<string name="calendar_no_events">Ni dogodgov</string>
<string name="calendar_continues">Ponavljajoč</string>
<string name="calendar_day_of_total">Dan %1$d od %2$d</string>
<string name="calendar_add_to_phone_calendar">Dodaj v koledar telefona</string>
<string name="calendar_nav_jump_to_today">Nazaj na danes</string>
<plurals name="calendar_day_a11y_events">
<item quantity="one">%1$s, %2$d dogodek</item>
<item quantity="two">%1$s, %2$d dogodka</item>
<item quantity="few">%1$s, %2$d dogodki</item>
<item quantity="other">%1$s, %2$d dogodkov</item>
</plurals>
<string name="calendar_day_a11y_no_events">%1$s, ni dogodkov</string>
<string name="calendar_day_a11y_today_suffix">danes</string>
<string name="calendar_day_a11y_selected_suffix">izbrano</string>
<string name="calendar_fab_new_event">Ustvari nov koledarski dogodek</string>
<string name="calendar_fab_new_collection">Ustvari novo zbirko koledarjev </string>
<string name="calendar_fab_toggle">Ustvari novo zbirko koledarjev</string>
<string name="calendar_untitled">(neimenovano)</string>
<string name="calendar_all_day">Ves dan</string>
<string name="calendar_rsvp_going_prefixed">✓ Pridem</string>
<string name="calendar_rsvp_maybe_prefixed">\? Mogoče</string>
<string name="calendar_rsvp_not_going_prefixed">✗ Ne morem</string>
<string name="calendar_collection_title">Naslov</string>
<string name="calendar_collection_description">Opis</string>
<string name="calendar_collection_invalid">Naslov je zahtevan.</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d dogodek</item>
<item quantity="two">%1$d dogodka</item>
<item quantity="few">%1$d dogodki</item>
<item quantity="other">%1$d dogodkov</item>
</plurals>
<string name="calendar_collection_empty_members">Ta koledar je še brez dogodkov.</string>
<string name="calendar_rsvp_going">Grem</string>
<string name="calendar_rsvp_maybe">Mogoče</string>
<string name="calendar_rsvp_not_going">Ne morem</string>
<string name="calendar_rsvp_section">Odzivi (%1$d)</string>
<string name="calendar_rsvp_none">Še ni odzivov</string>
<string name="calendar_participants_section">Sodelujoči (%1$d)</string>
<string name="calendar_event_in_calendars">V koledarjih (%1$d)</string>
<string name="calendar_event_in_no_calendars">Ni dodeljeno nobenemu koledarju.</string>
<string name="calendar_event_loading">Nalaganje dogodka…</string>
<string name="calendar_relative_ongoing">Pravkar poteka</string>
<string name="calendar_relative_ongoing_with_end">%1$s · konec ob %2$s</string>
<string name="calendar_export_share_title">Deli koledarske dogodke</string>
<string name="calendar_export_event">Izvozi v koledar (.ics)</string>
<string name="calendar_reminder_channel_id">opomniki_koledarja</string>
<string name="calendar_reminder_channel_name">Opomniki koledarja</string>
<string name="calendar_reminder_channel_description">Opozorilo pred začetkom dogodka, ki se ga udeležuješ.</string>
<string name="calendar_reminder_default_title">Koledarski dogodek</string>
<plurals name="calendar_reminder_body">
<item quantity="one">Začne se čez %1$d minuto</item>
<item quantity="two">Začne se čez %1$d minuti</item>
<item quantity="few">Začne se čez %1$d minute</item>
<item quantity="other">Začne se čez %1$d minut</item>
</plurals>
<string name="calendar_add_to_calendar_action">Dodaj med enega od svojih koledarjev</string>
<string name="calendar_add_to_calendar_title">Dodal v koledar</string>
<string name="calendar_add_to_calendar_none">Nimaš še ustvarjenega nobenega koledarja.</string>
<string name="calendar_collection_delete">Izbriši koledar</string>
<string name="calendar_collection_delete_confirm_title">Izbrišem ta koledar?</string>
<string name="calendar_collection_delete_confirm_message">Seznam koledarjev bo odstranjen. Dogodki v njem ne bodo izbrisani.</string>
<string name="calendar_event_pick_image">Izberi sliko</string>
<string name="calendar_event_image_upload_failed">Nalaganje slike ni uspelo</string>
<string name="calendar_event_image_upload_failed_body">Izbrane slike ni bilo mogoče naložiti. Poskusi znova ali prilepi URL.</string>
<string name="calendar_event_participants_section">Udeleženci (%1$d)</string>
<string name="calendar_event_participant_input">Išči ime, npub, nip-05</string>
<string name="calendar_event_participant_invalid">Vnesi veljaven npub… ali 64-znakovni javni ključ hex.</string>
<string name="calendar_event_participant_remove">Odstrani udeleženca</string>
<string name="calendar_reminder_settings_title">Opomniki koledarja</string>
<string name="calendar_reminder_settings_enabled_title">Pošlji opomnike</string>
<string name="calendar_reminder_settings_enabled_subtitle">Prejmi obvestilo tik pred začetkom dogodka, ki se ga udeležuješ.</string>
<string name="calendar_reminder_settings_lead_title">Čas pred opomnikom</string>
<string name="calendar_reminder_settings_lead_subtitle">Koliko minut pred dogodkom želiš prejeti obvestilo.</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d min</item>
<item quantity="two">%1$d min</item>
<item quantity="few">%1$d min</item>
<item quantity="other">%1$d min</item>
</plurals>
<string name="calendar_share_nostr">Deli kot Nostr povezavo</string>
<string name="calendar_share_nostr_title">Deli povezavo do koledarja</string>
<string name="calendar_filter_all">Vsi koledarji</string>
<string name="calendar_filter_sheet_title">Prikaži dogodke od…</string>
<string name="calendar_filter_no_calendars">Niste ustvarili še nobenega koledarja</string>
<string name="calendar_open_in_maps">Odpri z maps</string>
<string name="calendar_open_link">Odpri povezavo</string>
<string name="route_calendar_event_detail">Podrobnosti dogodka</string>
<string name="new_short_video">Novi kratki videoposnetek</string>
<string name="new_long_video">Novi dolg videoposnetek</string>
<string name="article_title">Naslov</string>
@@ -1685,6 +1864,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="boost_or_quote_description">Posreduj ali citiraj</string>
<string name="like_description">Všečkaj</string>
<string name="zap_description">Zap</string>
<string name="onchain_zap_description">On-chain Bitcoin zap</string>
<string name="onchain_zap_pending">V potrjevanju</string>
<string name="change_reaction">Spremeni hitre reakcije</string>
<string name="bottom_bar_settings">Spodnja navigacijska vrstica</string>
<string name="bottom_bar_settings_description">Povlecite za spremembo vrstnega reda. Preklopite za dodajanje ali odstranjevanje elementov s spodnje vrstice. Če ni izbran noben element, je spodnja vrstica skrita.</string>
@@ -1695,11 +1876,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="home_tabs_settings_description">Izberite zavihke, ki se prikažejo na začetnem zaslonu. Če je aktiven le en zavihek, je vrstica z zavihki skrita.</string>
<string name="home_tab_everything">Vse</string>
<string name="profile_ui_settings">UI profila</string>
<string name="profile_ui_settings_description">Izberi, kateri razdelki in vsebine se prikažejo na profilih uporabnikov. Vse možnosti so privzeto omogočene.</string>
<string name="profile_ui_settings_description">Izberi, kateri razdelki in viri vsebin naj se prikažejo na profilih uporabnikov. Vse možnosti so privzeto omogočene.</string>
<string name="profile_ui_setting_badges">profilne zančke</string>
<string name="profile_ui_setting_app_recommendations">Priporočene aplikacije</string>
<string name="profile_ui_setting_zap_received_feed">Vsebina prejetih zapov</string>
<string name="profile_ui_setting_followers_feed">Vsebina sledilcev</string>
<string name="profile_ui_setting_zap_received_feed">Vir vsebin prejetih zapov</string>
<string name="profile_ui_setting_followers_feed">Vir vsebin sledilcev</string>
<string name="reactions_settings">Vrstica odzivnih ikon</string>
<string name="reactions_settings_description">Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev.</string>
<string name="reactions_settings_enabled">Omogočeno</string>
@@ -1823,9 +2004,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="trusted_relays_title">Zaupanja vredni releji</string>
<string name="trusted_section">Zaupanja vredni releji</string>
<string name="trusted_section_explainer">Releji katerim zaupaš in zanje ne rabiš Tor povezave</string>
<string name="favorite_relays_title">Najljubše vsebine releja</string>
<string name="favorite_section">Najljubše vsebine releja</string>
<string name="favorite_section_explainer">Releji katerih pogosto spremljaš globalne vsebine</string>
<string name="favorite_relays_title">Najljubši viri vsebin releja</string>
<string name="favorite_section">Najljubši viri vsebin releja</string>
<string name="favorite_section_explainer">Releji na katerih pogosto spremljaš globalne vire vsebin</string>
<string name="proxy_relays_title">Proxy releji</string>
<string name="proxy_section">Proxy releji</string>
<string name="proxy_section_explainer">Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama.</string>
@@ -1949,31 +2130,31 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="my_lists_and_sets">Moji seznami/seti</string>
<string name="my_lists">Moji seznami</string>
<string name="people_list_label">Uporabniki</string>
<string name="select_list_to_filter">Izberite seznam za filtriranje vsebin</string>
<string name="feed_group_feeds">Vsebina</string>
<string name="select_list_to_filter">Izberite seznam za filtriranje vira vsebin</string>
<string name="feed_group_feeds">Viri vsebin</string>
<string name="feed_group_hashtags">Ključniki</string>
<string name="feed_group_interest_sets">Nabor zanimanj</string>
<string name="feed_group_locations">Lokacije</string>
<string name="feed_group_communities">Skupnosti</string>
<string name="feed_group_lists">Seznami</string>
<string name="feed_group_dvms">Algoritmi vsebin</string>
<string name="follow_list_all_favorite_dvms">Vsi priljubljeni algoritmi vsebin</string>
<string name="feed_group_dvms">Algoritmi vira vsebin</string>
<string name="follow_list_all_favorite_dvms">Vsi priljubljeni algoritmi vira vsebin</string>
<string name="feed_group_relays">Releji</string>
<string name="add_dvm_to_favorites">Dodaj algoritem vsebin med priljubljene</string>
<string name="add_dvm_to_favorites">Dodaj algoritem vira vsebin med priljubljene</string>
<string name="remove_dvm_from_favorites">Odstrani iz priljubljenih</string>
<string name="favorite_dvms_title">Priljubljeni algoritmi vsebin</string>
<string name="favorite_dvms_explainer">Algoritmi vsebin, ki jih tukaj označite z zvezdico, se bodo prikazali kot filtri na začetnem zaslonu. Za nove odprite »Odkrij«.</string>
<string name="favorite_dvms_title">Priljubljeni algoritmi vira vsebin</string>
<string name="favorite_dvms_explainer">Algoritmi vira vsebin, ki jih tukaj označite z zvezdico, se bodo prikazali kot filtri na začetnem zaslonu. Za nove odprite »Odkrij«.</string>
<string name="favorite_dvms_empty_headline">Pripni svoje najljubše algoritme</string>
<string name="favorite_dvms_empty_step1">Tapni \"%1$s\" spodaj za brskanje med algoritmi.</string>
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
<string name="favorite_dvms_empty_step2">Tapni %1$s ob vsebini, da jo ohraniš tukaj.</string>
<string name="favorite_dvms_empty_cta">Dodaj nekaj vsebine</string>
<string name="favorite_dvms_empty_step2">Tapni %1$s ob viru vsebin, daga ohraniš tukaj.</string>
<string name="favorite_dvms_empty_cta">Dodaj nekaj virov vsebin</string>
<string name="favorite_dvms_add_more">Dodaj več…</string>
<string name="dvm_home_status_requesting">Poizvedba pri %1$s za vsebine…</string>
<string name="dvm_home_status_requesting_all">Pridobivanje vsebin iz vaših najljubših algoritmov…</string>
<string name="dvm_home_status_processing">Priprava vaših vsebin…</string>
<string name="dvm_home_status_payment_required">Ta algoritem vsebin zahteva plačilo</string>
<string name="dvm_home_status_error">Napaka algoritma pri osveževanju vsebin</string>
<string name="dvm_home_status_requesting">Poizvedba pri %1$s za vir vsebin…</string>
<string name="dvm_home_status_requesting_all">Pridobivanje vira vsebin iz vaših najljubših algoritmov…</string>
<string name="dvm_home_status_processing">Priprava vašega vira vsebin…</string>
<string name="dvm_home_status_payment_required">Ta algoritem vira vsebin zahteva plačilo</string>
<string name="dvm_home_status_error">Napaka algoritma pri osveževanju vira vsebin</string>
<string name="dvm_home_retry">Poskusi znova</string>
<string name="temporary_account">Odjava ob zaklepu naprave</string>
<string name="private_message">Zasebno sporočilo</string>
@@ -2020,8 +2201,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="play">Predvajaj</string>
<string name="open_dropdown_menu">Odpri spustni meni</string>
<string name="option_of">Možnost %1$s od %2$s</string>
<string name="feed_filter_selected">Filter vsebin, %1$s izbran</string>
<string name="feed_filter_select_an_option">Filter vsebin, %1$s</string>
<string name="feed_filter_selected">Filter vira vsebin, %1$s izbran</string>
<string name="feed_filter_select_an_option">Filter vira vsebin, %1$s</string>
<string name="crashreport_found">Najdeno je poročilo o napaki</string>
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani.</string>
<string name="crashreport_found_send">Pošlji</string>
@@ -2037,6 +2218,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="remove_user_from_the_list">Odstrani uporabnika iz seznama</string>
<string name="follow_list_item_label">Paket sledenih</string>
<string name="members">Člani</string>
<string name="contact_list_containing_label">Seznam sledenja (%1$d uporabnikov):</string>
<string name="contact_list_screen_title">Seznam sledenih</string>
<string name="contact_list_screen_title_with_count">Seznam sledenih (%1$d)</string>
<string name="people_list_title">Sledi metapodatkom seznama</string>
<string name="people_list_explainer">Sledenje metapodatkom seznama je lahko vidno vsem na Nostru. Le tvoji zasebni člani so šifrirani.</string>
<string name="follow_pack_title">Metapodatki paketa sledenih</string>
@@ -2245,7 +2429,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="num_selected">Izbranih %1$d</string>
<string name="resolved_via_namecoin">Razrešeno prek Namecoina</string>
<string name="now_following_accounts">Zdaj sledite %1$d računom</string>
<string name="your_feed_is_ready">Vaš vsebina objav je pripravljen.</string>
<string name="your_feed_is_ready">Vaš vir vsebin je pripravljen.</string>
<string name="skip">Preskoči</string>
<string name="search_another">Poišči še koga</string>
<string name="follow_accounts">Sledi %1$d računom</string>
@@ -2636,4 +2820,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="community_rules_violation_wot_gate_failed">Ne izpolnjuješ pogojev mreže zaupanja (WoT) te skupnosti.</string>
<string name="community_rules_violation_stale_rules">Najnovejša pravila skupnosti so bila zavrnjena, ker so zastarela.</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">Stran Nowhere</string>
<string name="nowhere_link_card_event">Nowhere dogodek</string>
<string name="nowhere_link_card_fundraiser">Zbiranje sredstev Nowhere</string>
<string name="nowhere_link_card_store">Trgovina Nowhere</string>
<string name="nowhere_link_card_petition">Nowhere peticija</string>
<string name="nowhere_link_card_message">Sporočilo Nowhere</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Umetnost Nowhere</string>
<string name="nowhere_link_card_forum">Forum Nowhere</string>
</resources>
@@ -12,6 +12,10 @@
<item quantity="one">Det här inlägget har fler än %1$d hashtagg</item>
<item quantity="other">Det här inlägget har fler än %1$d hashtaggar</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d tillgång paketerad</item>
<item quantity="other">%1$d tillgångar buntade</item>
</plurals>
<string name="post_not_found">Inlägg hittades inte</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanal bild</string>
@@ -550,6 +554,11 @@
<string name="profile_badges_description">Välj vilka av märkena du fått som ska visas på din profil.</string>
<string name="profile_badges_empty">Du har inte fått några märken ännu.</string>
<string name="pictures">Bilder</string>
<string name="software_apps">Appar</string>
<string name="route_software_apps">Appar</string>
<string name="nip82_repository_label">Källa: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Ladda ner</string>
<string name="calendars">Kalendrar</string>
<string name="shorts">Kortfilmer</string>
<string name="public_chats">Offentliga chattar</string>
@@ -1184,6 +1193,8 @@
<string name="posting_policy">Publiceringspolicy</string>
<string name="privacy_policy">Integritetspolicy</string>
<string name="terms_and_conditions">Villkor &amp; bestämmelser</string>
<string name="child_safety_standards">Standarder för barnsäkerhet</string>
<string name="about_legal">Om och juridiskt</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
<string name="relay_monitor_reports">Reläövervakningsrapporter</string>
@@ -1751,6 +1762,7 @@
<string name="calendar_relative_ongoing_with_end">%1$s · slutar %2$s</string>
<string name="calendar_export_share_title">Dela kalenderhändelse</string>
<string name="calendar_export_event">Exportera till kalender (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Kalenderpåminnelser</string>
<string name="calendar_reminder_channel_description">Påminnelse när en händelse du deltar i snart ska börja.</string>
<string name="calendar_reminder_default_title">Kalenderhändelse</string>
@@ -1776,6 +1788,10 @@
<string name="calendar_reminder_settings_enabled_subtitle">En notis visas när en händelse du deltar i snart ska börja.</string>
<string name="calendar_reminder_settings_lead_title">Påminnelsetid</string>
<string name="calendar_reminder_settings_lead_subtitle">Hur många minuter före händelsen du vill bli notifierad.</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d min</item>
<item quantity="other">%1$d min</item>
</plurals>
<string name="calendar_share_nostr">Dela som Nostr-länk</string>
<string name="calendar_share_nostr_title">Dela kalenderlänk</string>
<string name="calendar_filter_all">Alla kalendrar</string>
@@ -2756,5 +2772,7 @@
<string name="nowhere_link_card_store">Nowhere Butik</string>
<string name="nowhere_link_card_petition">Nowhere Namninsamling</string>
<string name="nowhere_link_card_message">Nowhere Meddelande</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Konst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
</resources>
@@ -11,6 +11,9 @@
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="other">此帖有超过 %1$d 个话题标签</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="other">捆绑了 %1$d 个资产</item>
</plurals>
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">频道图片</string>
@@ -546,6 +549,11 @@
<string name="profile_badges_description">选择要在个人资料中显示的已收到的徽章。</string>
<string name="profile_badges_empty">您还没有收到任何徽章。</string>
<string name="pictures">图片</string>
<string name="software_apps">应用</string>
<string name="route_software_apps">应用</string>
<string name="nip82_repository_label">来源: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">下载</string>
<string name="calendars">日历</string>
<string name="shorts">短视频</string>
<string name="public_chats">公共聊天</string>
@@ -1182,6 +1190,8 @@
<string name="posting_policy">发布政策</string>
<string name="privacy_policy">隐私政策</string>
<string name="terms_and_conditions">使用条款</string>
<string name="child_safety_standards">儿童安全标准</string>
<string name="about_legal">关于 &amp; 法律</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">该中继的错误和通知</string>
<string name="relay_monitor_reports">中继监视器报告</string>
+12
View File
@@ -14,6 +14,10 @@
<item quantity="one">This post has more than %1$d hashtag</item>
<item quantity="other">This post has more than %1$d hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d asset bundled</item>
<item quantity="other">%1$d assets bundled</item>
</plurals>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
@@ -583,6 +587,11 @@
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
<string name="calendars">Calendars</string>
<string name="shorts">Shorts</string>
<string name="public_chats">Public Chats</string>
@@ -1309,6 +1318,8 @@
<string name="posting_policy">Posting policy</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_and_conditions">Terms &amp; Conditions</string>
<string name="child_safety_standards">Child Safety Standards</string>
<string name="about_legal">About &amp; Legal</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Errors and Notices from this Relay</string>
<string name="relay_monitor_reports">Relay Monitor Reports</string>
@@ -1859,6 +1870,7 @@
<string name="wallet_invalid_uri">Invalid NWC connection URI</string>
<string name="wallet_move_up">Move Up</string>
<string name="wallet_move_down">Move Down</string>
<string name="wallet_reorder">Reorder wallet</string>
<string name="wallet_onchain_transactions">Onchain Transactions</string>
<string name="wallet_onchain_no_address">No on-chain address available for this account.</string>
<string name="wallet_onchain_no_backend">No chain backend is configured.</string>
+17
View File
@@ -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" />
<!-- Gemini / system-agent bridge via androidx.appfunctions
(pre-stable as of May 2026). The androidx-provided
PlatformAppFunctionService (registered automatically via
manifest merging by the library) dispatches to plain Kotlin
classes annotated with @AppFunction (see
com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions).
Play flavor only — the F-Droid channel ships without the
alpha Google AI dependency.
The `app_metadata` property below gives the system agent a
user-facing summary of what this app exposes. Without it,
system logcat logs "Unable to resolve AppFunctionMetadata"
and our functions never make it into Gemini's tool picker. -->
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
</application>
</manifest>
@@ -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",
)
}
},
)
}
}
@@ -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(
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Natural-language description of the app for the AppFunctions agent
(Gemini and other system assistants). Resolved by the system via the
application-level `<property android:name="android.app.appfunctions.app_metadata">`
pointer in play/AndroidManifest.xml. Without this resource, system
logcat shows "Unable to resolve AppFunctionMetadata" and the agent
can't render a user-facing summary of what our functions do.
Keep descriptions short and Nostr-grounded so a user reading "What
can Amethyst do?" in the agent gets a useful answer. Update when
the @AppFunction surface grows.
-->
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/res-auto"
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users via Lightning or onchain Bitcoin (NIP-BC) — Lightning zaps auto-pay over NIP-47 when NWC is configured; onchain zaps broadcast a real Bitcoin transaction when a chain backend is set up. Specific notes can also be zapped via Lightning with NIP-57 zap splits honored. Sign-in required with a local key or NIP-46 bunker; NIP-55 external signers (Amber) are read-only from the agent for now."
appfn:displayDescription="Read, write, summarize, and zap Nostr (Lightning + onchain) through Amethyst" />
@@ -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<TorClient>` 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<TorClient>` 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<TorClient>.
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<String>()
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<TorClient>` 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()
}
}
@@ -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>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _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> = _torType.asStateFlow()
override val externalSocksPort: StateFlow<Int> = _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
}
}
Binary file not shown.
+1
View File
@@ -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.
@@ -159,6 +159,22 @@ private suspend fun dispatch(argv: Array<String>): 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)
@@ -95,4 +95,24 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = StoreCommands.dispatch(dataDir, tail)
suspend fun follow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.follow(dataDir, tail)
suspend fun unfollow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.unfollow(dataDir, tail)
suspend fun search(
dataDir: DataDir,
tail: Array<String>,
): Int = SearchCommand.dispatch(dataDir, tail)
suspend fun zap(
dataDir: DataDir,
tail: Array<String>,
): Int = ZapCommand.dispatch(dataDir, tail)
}
@@ -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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<NormalizedRelayUrl>,
val source: String,
)
private sealed interface DecryptedDm {
val id: HexKey
val wrapId: HexKey
@@ -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 <user>` and `amy unfollow <user>` — 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 `<user>`: npub / nprofile / 64-hex /
* `name@domain.tld` — same set [Context.requireUserHex] handles.
*/
object FollowCommand {
suspend fun follow(
dataDir: DataDir,
rest: Array<String>,
): Int = run(dataDir, rest, FollowOp.FOLLOW)
suspend fun unfollow(
dataDir: DataDir,
rest: Array<String>,
): Int = run(dataDir, rest, FollowOp.UNFOLLOW)
private enum class FollowOp { FOLLOW, UNFOLLOW }
private suspend fun run(
dataDir: DataDir,
rest: Array<String>,
op: FollowOp,
): Int {
if (rest.isEmpty()) {
val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow"
return Output.error("bad_args", "$verb <user> [--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<NormalizedRelayUrl>,
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 }
}
}
@@ -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 <user|note> <query>` — 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 <query>` 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 <query>` 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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "search <user|note> <query> [--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<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "search user <query> [--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<String, Any?>()),
)
}
}
}
private suspend fun searchNotes(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "search note <query> [--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<Event>) -> List<Map<String, Any?>>,
): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val relays =
SearchActions.resolveSearchRelays(
signer = ctx.signer,
currentList = loadOwnSearchList(ctx),
)
if (relays.isEmpty()) {
return Output.error("no_relays", "no search relays available (no kind:10007 and DefaultSearchRelayList is empty?)")
}
val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
val deduped =
received
.map { it.second }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
Output.emit(
mapOf(
"query" to query,
"queried_relays" to relays.map { it.url },
"match_count" to deduped.size,
"results" to render(deduped),
),
)
return 0
} finally {
ctx.close()
}
}
/**
* Pull the caller's own kind:10007 from the local store. Returns null
* when amy has never observed one — caller falls back to
* [com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList]
* via [SearchActions.resolveSearchRelays].
*/
private suspend fun loadOwnSearchList(ctx: Context): SearchRelayListEvent? =
ctx.store
.query<Event>(
Filter(
authors = listOf(ctx.identity.pubKeyHex),
kinds = listOf(SearchRelayListEvent.KIND),
limit = 1,
),
).firstOrNull() as? SearchRelayListEvent
}
@@ -0,0 +1,318 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.actions.ZapActions
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import okhttp3.OkHttpClient
/**
* `amy zap <user|event> <target> <sats>` — build a NIP-57 zap request and
* fetch a BOLT11 invoice from the recipient's Lightning service.
*
* Two subcommands:
* * `zap user <user> <sats>` — profile zap (no event reference)
* * `zap event <event-id> <sats>` — event zap (must be in local store)
*
* The flow is:
* 1. Resolve recipient identifier → pubkey + kind:0 metadata.
* 2. Extract LN address (`lud16` preferred, then `lud06` LNURL).
* 3. Build + sign the NIP-57 kind:9734 zap-request event via
* [ZapActions].
* 4. POST it to the recipient's LNURL-pay callback via
* [LightningAddressResolver] to receive a BOLT11 invoice.
*
* The invoice is printed but **not** auto-paid — amy has no NWC wallet
* wired up yet. Paste the invoice into any LN wallet to settle.
*/
object ZapCommand {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "zap <user|event> <target> <sats> [--comment X] [--anon] [--timeout SECS]")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"user" -> zapUser(dataDir, rest)
"event" -> zapEvent(dataDir, rest)
else -> Output.error("bad_args", "zap ${tail[0]} — expected user|event")
}
}
private suspend fun zapUser(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--timeout SECS]")
val userArg = rest[0]
val sats =
rest[1].toLongOrNull()?.takeIf { it > 0 }
?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')")
val args = Args(rest.drop(2).toTypedArray())
val comment = args.flag("comment") ?: ""
val zapType = parseZapType(args)
val timeoutMs = args.longFlag("timeout", 8L) * 1000
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val recipient = ctx.requireUserHex(userArg)
val metadata =
fetchLatestMetadata(ctx, recipient, ctx.bootstrapRelays(), timeoutMs)
?: return Output.error("not_found", "no kind:0 metadata found for $recipient")
val lnAddress =
ZapActions.extractLnAddress(metadata)
?: return Output.error("no_lightning", "recipient has no lud16 or lud06 in their profile")
val request =
ZapActions.buildUserZapRequest(
signer = ctx.signer,
recipientPubkey = recipient,
amountMillisats = ZapActions.satsToMillisats(sats),
inboxRelays = ctx.outboxRelays(),
comment = comment,
zapType = zapType,
)
emitZapResult(ctx, sats, lnAddress, comment, request, zapType)
return 0
} finally {
ctx.close()
}
}
private suspend fun zapEvent(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--timeout SECS]")
val eventId = rest[0]
if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)")
val sats =
rest[1].toLongOrNull()?.takeIf { it > 0 }
?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')")
val args = Args(rest.drop(2).toTypedArray())
val comment = args.flag("comment") ?: ""
val zapType = parseZapType(args)
val timeoutMs = args.longFlag("timeout", 8L) * 1000
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val zappedEvent =
ctx.store.query<Event>(Filter(ids = listOf(eventId), limit = 1)).firstOrNull()
?: return Output.error("not_found", "event $eventId not in local store; sync first or fetch by id")
val bootstrap = ctx.bootstrapRelays()
// Resolves a pubkey to an LN address by reading the latest
// kind:0 from the local store, falling back to a relay drain
// when never seen. Mirrors what the Amethyst foreground UI
// pulls out of User.lnAddress().
val lookupLnAddress: suspend (HexKey) -> String? = { pk ->
fetchLatestMetadata(ctx, pk, bootstrap, timeoutMs)
?.let(ZapActions::extractLnAddress)
}
// Recipient's NIP-65 read ("inbox") relays — read-side flag on
// their advertised kind:10002. These get unioned into each
// zap request's `relays` tag so the kind:9735 receipt routes
// to the recipient's clients. Matches `User.inboxRelays()` in
// the Android Account.
val lookupInboxRelays: suspend (HexKey) -> Set<NormalizedRelayUrl> = { pk ->
ctx
.relaysOf(pk)
?.readRelaysNorm()
?.toSet()
.orEmpty()
}
val requests =
ZapActions.buildEventZapRequestsForSplits(
signer = ctx.signer,
zappedEvent = zappedEvent,
totalAmountMillisats = ZapActions.satsToMillisats(sats),
senderInboxRelays = ctx.outboxRelays(),
lookupLnAddress = lookupLnAddress,
lookupInboxRelays = lookupInboxRelays,
comment = comment,
zapType = zapType,
)
if (requests.isEmpty()) {
return Output.error(
"no_lightning",
"no payable recipients — neither the author nor any zap-split recipient has a usable LN address",
)
}
emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests)
return 0
} finally {
ctx.close()
}
}
private suspend fun emitZapResult(
ctx: Context,
sats: Long,
lnAddress: String,
comment: String,
request: LnZapRequestEvent,
zapType: LnZapEvent.ZapType,
zappedEventId: HexKey? = null,
) {
// Reuse the same OkHttp instance the Context uses for nip-05 / WS;
// this respects any proxy/timeout config wired in there.
val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx))
val result =
resolver.fetchInvoice(
lnAddress = lnAddress,
milliSats = ZapActions.satsToMillisats(sats),
message = comment,
zapRequest = request,
)
when (result) {
is LightningAddressResolver.Result.Success -> {
Output.emit(
buildMap {
put("ln_address", lnAddress)
put("amount_sats", sats)
put("zap_type", zapType.name.lowercase())
put("comment", comment)
put("zap_request_id", request.id)
if (zappedEventId != null) put("zapped_event_id", zappedEventId)
put("invoice", result.invoice)
},
)
}
is LightningAddressResolver.Result.Error -> {
Output.error("invoice_failed", result.message)
}
}
}
/**
* Multi-recipient (split-aware) event-zap result emitter. Fetches one
* BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a
* single JSON object enumerating each recipient + its invoice (or
* per-recipient `invoice_error` when the LNURL fetch fails). Total
* sat sum may be a few millisats below the requested amount due to
* whole-sat rounding in the split shares.
*/
private suspend fun emitSplitZapResult(
ctx: Context,
sats: Long,
comment: String,
zappedEventId: HexKey,
zapType: LnZapEvent.ZapType,
requests: List<ZapActions.ZapRequestForSplit>,
) {
val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx))
val recipientEntries =
requests.map { req ->
val shareSats = req.amountMillisats / 1000
val result =
resolver.fetchInvoice(
lnAddress = req.recipient.lnAddress,
milliSats = req.amountMillisats,
message = comment,
zapRequest = req.request,
)
val entry =
mutableMapOf<String, Any?>(
"ln_address" to req.recipient.lnAddress,
"pubkey" to req.recipient.pubkey,
"weight" to req.recipient.weight,
"amount_sats" to shareSats,
"zap_request_id" to req.request.id,
)
when (result) {
is LightningAddressResolver.Result.Success ->
entry["invoice"] = result.invoice
is LightningAddressResolver.Result.Error ->
entry["invoice_error"] = result.message
}
entry
}
Output.emit(
mapOf(
"zapped_event_id" to zappedEventId,
"zap_type" to zapType.name.lowercase(),
"comment" to comment,
"requested_sats" to sats,
"billed_sats" to recipientEntries.sumOf { (it["amount_sats"] as? Long) ?: 0L },
"recipient_count" to recipientEntries.size,
"recipients" to recipientEntries,
),
)
}
private fun parseZapType(args: Args): LnZapEvent.ZapType =
when {
args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS
args.bool("private") -> LnZapEvent.ZapType.PRIVATE
else -> LnZapEvent.ZapType.PUBLIC
}
private suspend fun fetchLatestMetadata(
ctx: Context,
pubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): MetadataEvent? {
// Cache-first: try the local store before going to the network.
ctx.profileOf(pubKey)?.let { return it }
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1)
val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
return received
.mapNotNull { (_, ev) -> ev as? MetadataEvent }
.filter { it.pubKey == pubKey }
.maxByOrNull { it.createdAt }
}
/**
* Per-invocation OkHttpClient. Amy's [Context] also has its own OkHttp
* (for WS + NIP-05); we keep this separate because [Context.okhttp] is
* private — exposing it just to reuse here would widen the API more
* than is warranted for a single LNURL fetch.
*/
private fun sharedOkHttp(
@Suppress("UNUSED_PARAMETER") ctx: Context,
): OkHttpClient = OkHttpClient.Builder().build()
}
+117 -14
View File
@@ -44,15 +44,16 @@ kotlin {
}
}
// iOS targets — Phase 2 spike. Compile-only for now (no framework binary
// configured yet). Reveals which transitive deps need iOS variants and
// which commonMain files still reach for platform-only APIs.
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain {
dependencies {
implementation(project(":quartz"))
// Audio-rooms ViewModel needs the listener orchestration + audio
// pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer
// interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals
// stay in :nestsClient's platform source sets.
implementation(project(":nestsClient"))
// Compose Multiplatform
implementation(libs.jetbrains.compose.ui)
@@ -61,13 +62,17 @@ kotlin {
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui.tooling.preview)
// Lifecycle ViewModel (KMP since 2.8.0)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and
// lifecycle-runtime-compose ship iOS variants;
// lifecycle-viewmodel-compose (the viewModel() Composable
// helper) is Android-only and lives in jvmAndroid below.
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
// Image loading (Coil 3 - KMP)
// Image loading (Coil 3 - KMP). The okhttp network fetcher is
// JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
// when that target wires its actual.
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
// LruCache (KMP-ready)
implementation(libs.androidx.collection)
@@ -75,13 +80,12 @@ kotlin {
// Immutable collections
api(libs.kotlinx.collections.immutable)
// JSON for custom-feed definitions (KMP — replaces Jackson
// for the one commonMain serializer that was blocking iOS).
implementation(libs.kotlinx.serialization.json)
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
// Markdown rendering (richtext-commonmark)
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
}
}
@@ -97,6 +101,32 @@ kotlin {
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Audio-rooms ViewModel needs the listener orchestration +
// audio pipeline types (NestsListener, AudioRoomPlayer,
// AudioPlayer interface). The :nestsClient module is
// jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track
// stacks are JVM-bound), so the dep lives here, not in
// commonMain. iOS will need an audio-rooms reroute when
// Phase 5 lands.
implementation(project(":nestsClient"))
// Coil's OkHttp network fetcher (JVM-only). iOS will use
// coil-ktor when the iOS Compose UI ships.
implementation(libs.coil.okhttp)
// Markdown rendering (richtext-commonmark). The single
// consumer (RenderMarkdown.kt) already lives in jvmAndroid.
// iOS support pending Phase 3 markdown decision.
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
// viewModel() Compose helper. AndroidX publishes this
// artifact for android/jvmStubs/linuxx64Stubs but not iOS,
// so it stays in jvmAndroid until we either swap to the
// org.jetbrains.androidx.lifecycle variant or accept a
// platform-specific ViewModel access pattern on iOS.
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
}
@@ -127,6 +157,14 @@ kotlin {
}
}
// iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code.
val iosMain =
create("iosMain") {
dependsOn(commonMain.get())
}
getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
getByName("androidHostTest") {
dependencies {
implementation(libs.junit)
@@ -150,3 +188,68 @@ compose.resources {
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
// JSON / HTTP deps.
val verifyKmpPurity by tasks.registering {
group = "verification"
description = "Fails if iOS-targeted source sets import JVM-only deps."
val checkedDirs =
listOf(
"src/commonMain", "src/commonTest",
"src/appleMain", "src/appleTest",
"src/nativeMain", "src/nativeTest",
"src/iosMain", "src/iosTest",
"src/iosArm64Main", "src/iosArm64Test",
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
"src/linuxMain", "src/linuxTest",
"src/linuxX64Main", "src/linuxX64Test",
"src/macosMain", "src/macosTest",
"src/macosArm64Main", "src/macosArm64Test",
).map { layout.projectDirectory.dir(it).asFile }
.filter { it.exists() }
inputs.files(checkedDirs)
doLast {
// Each pattern is paired with a short hint so the failure message
// points at the canonical KMP replacement.
val forbidden =
listOf(
"com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
"okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
"System.currentTimeMillis" to "use TimeUtils.now()",
"Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
"java.util.UUID" to "use kotlin.uuid.Uuid",
"kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
"kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
)
val offenders =
checkedDirs.flatMap { dir ->
dir.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex().mapNotNull { (idx, line) ->
val trimmed = line.trimStart()
// Skip KDoc / line-comment lines — those legitimately
// mention forbidden names (migration notes, doc refs).
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
return@mapNotNull null
}
forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
"${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
}
}
}
}
if (offenders.isNotEmpty()) {
throw GradleException(
"iOS-targeted source sets must not reference JVM-only APIs. " +
"Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
offenders.joinToString("\n "),
)
}
}
}
tasks.named("check").configure { dependsOn(verifyKmpPurity) }
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
/**
* NIP-17 direct-message verbs — relay resolution policy + gift-wrap builders.
*
* Like [FollowActions] / [SearchActions] / [ZapActions], this is pure logic
* usable from amy CLI, the Android App Functions adapter for Gemini, and any
* other non-UI consumer. The send builders return signed gift wraps but do
* NOT publish; the read side (decrypting incoming gift wraps) stays at the
* caller because the `unwrapAndUnsealOrNull` extension in
* `commons/.../relayClient/nip17Dm/` is already a one-liner.
*
* **Caller responsibilities** that this object leaves to the consumer:
*
* * **Publish.** Each wrap goes to its own recipient's DM-relay set —
* resolve via [resolveDmRelays] and hand each wrap to your relay client.
* * **Recipient resolution.** Translate npub / NIP-05 / hex to [HexKey]
* before calling — the Android UI uses `User.pubkeyHex`, amy uses
* `Context.requireUserHex`, the Gemini adapter would resolve through
* its own NIP-05 path.
* * **File upload (kind:15).** [buildFileDmReference] assumes the file is
* already at a URL. For "upload-then-DM", use
* `commons/.../service/upload/UploadOrchestrator` (jvmAndroid only, has
* an OkHttp dep) before calling here.
* * **Receipt of incoming DMs.** The kind:1059 gift-wrap drain, NIP-44
* unseal, and decrypt-to-inner-event step is a 3-line caller-side loop
* over [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull]
* — too small to bother extracting.
*/
object DmActions {
/**
* Source bucket from which [DmRelaySet.relays] was drawn. Useful for
* surfacing "where did we deliver?" telemetry to the caller — amy
* emits this on stdout, Gemini could mention it in the assistant
* response.
*/
enum class RelaySource {
/** Recipient's NIP-17 inbox (kind:10050). The strict NIP-17 path. */
KIND_10050,
/** NIP-65 read marker (kind:10002 read relays). Fallback bucket. */
NIP65_READ,
/** Caller-provided bootstrap pool. Last-resort fallback. */
BOOTSTRAP,
/** No relays available — caller should refuse to send. */
NONE,
}
/** Outcome of [resolveDmRelays]: the relays to publish to, plus which bucket they came from. */
data class DmRelaySet(
val relays: Set<NormalizedRelayUrl>,
val source: RelaySource,
)
/**
* Apply Amethyst's NIP-17 relay-resolution policy to a recipient.
*
* NIP-17 says clients "shouldn't try" to deliver a gift wrap unless the
* recipient has published a kind:10050. In strict mode (the default), an
* empty kind:10050 returns [RelaySource.NONE] so the caller refuses to
* send. Permissive mode walks the fallback chain instead — NIP-65 read
* relays, then the bootstrap pool — for cases like interop tests and
* brand-new accounts where strict mode is too strict.
*
* @param recipientLists the recipient's relay-list snapshot from
* [RecipientRelayFetcher.fetchRelayLists] (or a local cache).
* Pass null when the recipient is unknown — same effect as empty lists.
* @param bootstrap the caller's bootstrap relay pool, used as the
* last-resort fallback when [allowFallback] is true.
* @param allowFallback opt into the NIP-65-read → bootstrap chain when
* kind:10050 is empty. Default false (strict mode).
*/
fun resolveDmRelays(
recipientLists: RecipientRelayFetcher.Lists?,
bootstrap: Set<NormalizedRelayUrl>,
allowFallback: Boolean = false,
): DmRelaySet {
val dmInbox = recipientLists?.dmInbox?.toSet().orEmpty()
if (dmInbox.isNotEmpty()) return DmRelaySet(dmInbox, RelaySource.KIND_10050)
if (!allowFallback) return DmRelaySet(emptySet(), RelaySource.NONE)
val nip65Read = recipientLists?.nip65Read()?.toSet().orEmpty()
if (nip65Read.isNotEmpty()) return DmRelaySet(nip65Read, RelaySource.NIP65_READ)
return DmRelaySet(bootstrap, RelaySource.BOOTSTRAP)
}
/**
* Build a NIP-17 text DM (kind:14) wrapped in a NIP-59 gift wrap per
* recipient. The returned [NIP17Factory.Result] carries the inner
* event for local caching and one gift wrap per recipient (just one
* here — the recipient + the sender's own copy). Caller publishes each
* wrap to that recipient's DM-relay set.
*/
suspend fun buildTextDm(
signer: NostrSigner,
recipient: HexKey,
text: String,
): NIP17Factory.Result {
val template = ChatMessageEvent.build(text, listOf(PTag(recipient)))
return NIP17Factory().createMessageNIP17(template, signer)
}
/**
* Build a NIP-17 encrypted-file DM (kind:15) for a file that has
* already been uploaded to [url]. The [cipher]'s key + nonce travel
* inside the gift-wrapped inner event so only the recipient — and the
* sender, who keeps their own copy — can decrypt the bytes at [url].
*
* Pre-uploaded URL only: the upload step is jvmAndroid-only (needs
* OkHttp). For "upload then DM" use `UploadOrchestrator` first and
* pass its returned URL + the cipher you generated here.
*/
suspend fun buildFileDmReference(
signer: NostrSigner,
recipient: HexKey,
url: String,
cipher: AESGCM,
mimeType: String? = null,
hash: String? = null,
originalHash: String? = null,
size: Int? = null,
dimension: DimensionTag? = null,
blurhash: String? = null,
): NIP17Factory.Result {
val template =
ChatMessageEncryptedFileHeaderEvent.build(
to = listOf(PTag(recipient)),
url = url,
cipher = cipher,
mimeType = mimeType,
hash = hash,
size = size,
dimension = dimension,
blurhash = blurhash,
originalHash = originalHash,
)
return NIP17Factory().createEncryptedFileNIP17(template, signer)
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
/**
* Pure event-building "verbs" for the NIP-02 kind:3 contact list.
*
* Builds a signed [ContactListEvent] but does NOT publish it. The Amethyst
* Android UI flow does more than these builders — non-UI callers are
* responsible for the rest:
*
* * **Publish.** Hand the returned event to your relay client. Android
* uses `Account.sendMyPublicAndPrivateOutbox`, amy uses `Context.publish`.
* * **Writeable check.** Skip the call when the active signer is read-only
* (e.g. an npub-only login). Building will fail at the sign step
* otherwise.
* * **Relay hint.** Pass [relayHint] pointing at one of the target's
* advertised kind:10002 write relays so readers can find the followed
* user. The in-app flow does this via `User.bestRelayHint()`.
* * **No-op detection.** When the user already follows the target, the
* underlying builder short-circuits to the same [currentContactList].
* Compare `result.id == currentContactList?.id` to detect this.
* * **Local cache update.** If your caller has a local event cache, feed
* the new event back in so the UI / next read sees the update without
* a relay round-trip.
*
* Canonical entry point for non-UI callers (CLI commands, Android App
* Functions adapters, automation scripts): takes pubkeys as [HexKey] rather
* than the UI-model `User`, so it has no cache or scope dependency and is
* trivially testable.
*/
object FollowActions {
/**
* Build a kind:3 contact list update that follows [pubkeyToFollow].
*
* If [currentContactList] is non-null, the new event is derived from it
* (preserving the existing follow set and content). If it is null, a fresh
* kind:3 is created containing only this pubkey.
*
* Returns the (already signed) event ready to be published to outbox
* relays. When the user already follows [pubkeyToFollow] the underlying
* builder returns [currentContactList] unchanged — callers that want to
* detect "no-op" can compare event ids.
*/
suspend fun buildFollow(
signer: NostrSigner,
pubkeyToFollow: HexKey,
currentContactList: ContactListEvent?,
relayHint: NormalizedRelayUrl? = null,
): ContactListEvent =
if (currentContactList != null) {
ContactListEvent.followUser(currentContactList, pubkeyToFollow, signer)
} else {
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(pubkeyToFollow, relayHint, null)),
relayUse = emptyMap(),
signer = signer,
)
}
/**
* Batch-follow variant — adds every pubkey in [pubkeysWithHints] to the
* follow set in a single kind:3 update. Pubkeys already present in
* [currentContactList] are skipped by the underlying builder.
*/
suspend fun buildFollowBatch(
signer: NostrSigner,
pubkeysWithHints: List<Pair<HexKey, NormalizedRelayUrl?>>,
currentContactList: ContactListEvent?,
): ContactListEvent {
val contacts = pubkeysWithHints.map { (pk, hint) -> ContactTag(pk, hint, null) }
return if (currentContactList != null) {
ContactListEvent.followUsers(currentContactList, contacts, signer)
} else {
ContactListEvent.createFromScratch(
followUsers = contacts,
relayUse = emptyMap(),
signer = signer,
)
}
}
/**
* Build a kind:3 contact list update that removes [pubkeyToUnfollow].
*
* Returns `null` when [currentContactList] is `null` or has no tags —
* there is nothing to unfollow, and callers should treat this as a no-op
* rather than publishing an empty replacement event.
*/
suspend fun buildUnfollow(
signer: NostrSigner,
pubkeyToUnfollow: HexKey,
currentContactList: ContactListEvent?,
): ContactListEvent? =
if (currentContactList != null && currentContactList.tags.isNotEmpty()) {
ContactListEvent.unfollowUser(currentContactList, pubkeyToUnfollow, signer)
} else {
null
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
/**
* Pure NIP-50 search-filter assembly + search-relay resolution.
*
* Builds [Filter]s and picks relays — caller drives subscription / drain.
* Non-UI callers should layer the following on top to match Amethyst's
* in-app search behavior:
*
* * **Drain / subscribe.** A function-call API (amy `search`, Gemini App
* Functions) usually wants `client.subscribe(...)` until every relay
* sends EOSE or a short timeout elapses, then unsubscribe. The Amethyst
* foreground UI uses a live subscription instead — it stays open as the
* user types.
* * **Dedup.** Profile search dedups by `pubKey` (multiple kind:0 events
* per author); note search dedups by event id. Both pick the freshest
* revision via `sortedByDescending { createdAt }.distinctBy { … }`.
* * **Pseudo-kind filtering.** When you let callers ask for `reply` /
* `media` / exclusion terms, apply
* [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] after
* the drain. This filter is NOT exposed in the filter API itself.
* * **Debounce.** Interactive callers should debounce input. The
* Amethyst UI uses 300 ms before issuing a new subscription; one-shot
* callers (amy, App Functions) skip this.
*/
object SearchActions {
/** Default kinds for "search notes" — kind:1 short text notes. */
val DEFAULT_NOTE_KINDS: List<Int> = listOf(TextNoteEvent.KIND)
/**
* Build a NIP-50 filter for searching kind:0 profile metadata.
*
* Returns null for a blank [query] — callers should treat as "no
* results" rather than issuing an unconstrained search that most
* relays would reject anyway.
*/
fun searchProfilesFilter(
query: String,
limit: Int = 20,
): Filter? {
val q = query.trim()
if (q.isEmpty()) return null
return Filter(
kinds = listOf(MetadataEvent.KIND),
search = q,
limit = limit,
)
}
/**
* Build a NIP-50 filter for searching event content. Defaults to
* kind:1 short text notes; pass [kinds] to widen (e.g. include
* kind:30023 long-form or kind:9802 highlights).
*/
fun searchNotesFilter(
query: String,
kinds: List<Int> = DEFAULT_NOTE_KINDS,
limit: Int = 50,
since: Long? = null,
until: Long? = null,
): Filter? {
val q = query.trim()
if (q.isEmpty()) return null
return Filter(
kinds = kinds,
search = q,
limit = limit,
since = since,
until = until,
)
}
/**
* Pick the relay set to query for NIP-50 search.
*
* Strategy: when [currentList] (the user's kind:10007 search-relay
* list) is present, use its public + decrypted-private relays.
* Otherwise fall back to [fallback] (defaults to Amethyst's curated
* [DefaultSearchRelayList] — the same set the Android UI uses when
* the user hasn't configured their own).
*
* [signer] is only consulted when [currentList] is non-null and has
* private (NIP-44 encrypted) relay entries; an internal/local signer
* is fine, a NIP-46/NIP-55 signer will cost a round-trip.
*/
suspend fun resolveSearchRelays(
signer: NostrSigner,
currentList: SearchRelayListEvent?,
fallback: Collection<NormalizedRelayUrl> = DefaultSearchRelayList,
): Set<NormalizedRelayUrl> {
if (currentList == null) return fallback.toSet()
val combined = currentList.relays(signer)
return if (combined.isEmpty()) fallback.toSet() else combined.toSet()
}
}
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
/**
* NIP-57 zap-request building + LN address extraction.
*
* Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller
* hands to a LNURL-pay callback to receive a BOLT11 invoice.
*
* **Caller responsibilities** that the Amethyst Android flow handles but
* these builders do not:
*
* * **Use [buildEventZapRequestsForSplits] for events.** A naive call to
* [buildEventZapRequest] on a note carrying NIP-57 zap-split tags, NIP-53
* live-activity host tags, or NIP-89 app metadata silently misroutes
* funds to a single recipient. The splits variant is what the
* foreground UI uses and what amy `zap event` calls.
* * **Lightning round-trip.** LNURL endpoint fetch, BOLT11 invoice
* retrieval, and optional NIP-47 NWC payment all live outside these
* builders. `LightningAddressResolver` (in commons/jvmAndroid) covers
* the LNURL + invoice steps.
* * **Receipt verification.** When the kind:9735 receipt arrives, validate
* it against the LNURL provider's `nostrPubkey` (NIP-57 Appendix F) —
* primed via `LnurlEndpointCache` on Android.
* * **Onchain zaps** (NIP-BC) are a separate flow — see `OnchainZapSender`
* in commons. These builders only cover Lightning.
*
* Pattern matches [FollowActions] and [SearchActions]: shared, pure logic
* usable from amy CLI, the Android App Functions adapter for Gemini, and
* any other non-UI consumer.
*/
object ZapActions {
/** Convert sats to millisats — LN-side amount unit. */
fun satsToMillisats(sats: Long): Long = sats * 1000L
/**
* Extract the LN address (Lightning Address or LNURL) from a kind:0
* metadata event. Prefers `lud16` (Lightning Address, `user@domain`)
* over `lud06` (raw LNURL). Returns null when the user has no LN
* details published.
*/
fun extractLnAddress(metadata: MetadataEvent): String? = metadata.contactMetaData()?.lnAddress()
/**
* Build a NIP-57 profile zap request — pays [recipientPubkey] directly,
* not attached to any specific event.
*
* [inboxRelays] becomes the `["relays", ...]` tag of the zap request:
* the LN provider publishes the kind:9735 zap *receipt* to these
* relays. These should be the sender's read-side (NIP-65 inbox)
* relays so the sender's clients see the receipt land.
*
* Pass [lnurl] when known to stamp it as a tag on the request — some
* receipt validators key off it.
*/
suspend fun buildUserZapRequest(
signer: NostrSigner,
recipientPubkey: HexKey,
amountMillisats: Long,
inboxRelays: Set<NormalizedRelayUrl>,
comment: String = "",
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
lnurl: String? = null,
): LnZapRequestEvent =
LnZapRequestEvent.create(
userHex = recipientPubkey,
relays = inboxRelays,
signer = signer,
message = comment,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
/**
* Build a NIP-57 event-zap request — pays the author of
* [zappedEvent] in the context of that specific event. Override
* [toUserPubkey] when the payment should go to a co-author or
* delegated recipient (zap splits); when null the zap targets
* `zappedEvent.pubKey`.
*
* **Caller beware:** This builds a single zap request to a single
* recipient. Notes carrying NIP-57 zap-split tags, NIP-53
* live-activity hosts, or NIP-89 app metadata expect the payment to
* be divided across multiple parties. Use [buildEventZapRequestsForSplits]
* for the split-aware path; that's what the Amethyst foreground UI does.
*/
suspend fun buildEventZapRequest(
signer: NostrSigner,
zappedEvent: Event,
amountMillisats: Long,
inboxRelays: Set<NormalizedRelayUrl>,
comment: String = "",
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
toUserPubkey: HexKey? = null,
pollOption: Int? = null,
lnurl: String? = null,
): LnZapRequestEvent =
LnZapRequestEvent.create(
zappedEvent = zappedEvent,
relays = inboxRelays,
signer = signer,
pollOption = pollOption,
message = comment,
zapType = zapType,
toUserPubHex = toUserPubkey,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
/**
* One signed zap request for one split recipient, with the share of
* the total payment already computed.
*/
data class ZapRequestForSplit(
val recipient: ZapSplitResolver.Recipient,
val amountMillisats: Long,
val request: LnZapRequestEvent,
)
/**
* Split-aware version of [buildEventZapRequest]: resolves the recipient
* list via [ZapSplitResolver], computes per-recipient shares with
* [ZapSplitResolver.shareMillisats] (rounded to whole sats — matches the
* Amethyst UI), and signs one zap request per recipient.
*
* Each request's relay-list tag includes [senderInboxRelays] union the
* recipient's own inbox relays (resolved via [lookupInboxRelays]), so
* the eventual kind:9735 zap receipt is published to both parties'
* read-side relays. This matches `ZapPaymentHandler.signAllZapRequests`.
*
* Sum of returned `amountMillisats` may differ from [totalAmountMillisats]
* by a few hundred millisats due to whole-sat rounding — same drift the
* in-app flow has.
*
* Recipients with no resolvable LN address are dropped at the resolver
* step; callers that want to surface "missing LN" warnings should call
* [ZapSplitResolver.resolve] separately first.
*/
suspend fun buildEventZapRequestsForSplits(
signer: NostrSigner,
zappedEvent: Event,
totalAmountMillisats: Long,
senderInboxRelays: Set<NormalizedRelayUrl>,
lookupLnAddress: suspend (HexKey) -> String?,
lookupInboxRelays: suspend (HexKey) -> Set<NormalizedRelayUrl> = { emptySet() },
comment: String = "",
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
pollOption: Int? = null,
): List<ZapRequestForSplit> {
val recipients = ZapSplitResolver.resolve(zappedEvent, lookupLnAddress)
if (recipients.isEmpty()) return emptyList()
val totalWeight = recipients.sumOf { it.weight }
// Author inbox always travels with the zap so the author's clients
// see the receipt even when paying a split recipient. Mirrors the
// `authorRelayList + userRelayList` union in ZapPaymentHandler.
val authorInbox = lookupInboxRelays(zappedEvent.pubKey)
return recipients.map { recipient ->
val share = ZapSplitResolver.shareMillisats(totalAmountMillisats, recipient.weight, totalWeight)
val recipientInbox = recipient.pubkey?.let { lookupInboxRelays(it) }.orEmpty()
val allRelays = senderInboxRelays + recipientInbox + authorInbox
val request =
LnZapRequestEvent.create(
zappedEvent = zappedEvent,
relays = allRelays,
signer = signer,
pollOption = pollOption,
message = comment,
zapType = zapType,
toUserPubHex = recipient.pubkey,
amountMillisats = share,
lnurl = null,
)
ZapRequestForSplit(recipient = recipient, amountMillisats = share, request = request)
}
}
}
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlin.math.round
/**
* Resolves the set of recipients for a NIP-57 zap on a given event.
*
* Mirrors the split-resolution logic in Amethyst's
* `service/ZapPaymentHandler.kt` (Android) so non-UI callers — amy CLI,
* Gemini App Functions adapter, automation scripts — pay the same
* recipients the in-app flow would. Without this resolver, a naive
* "zap the event author" path silently misroutes funds on any note that
* carries `zap` tags, live-activity host tags, or app-definition metadata.
*
* The resolution order matches Amethyst:
* 1. NIP-57 zap-split tags on the event (`["zap", ...]`).
* 2. NIP-53 live-activity hosts (kind:30311 only).
* 3. NIP-89 app definition's own LN address (kind:31990 only).
* 4. The event author as the sole recipient.
*
* Recipients without a resolvable LN address are dropped silently — the
* caller is responsible for surfacing that to the user. This matches the
* `mapNotNull` shape of the in-app flow.
*/
object ZapSplitResolver {
/**
* One zap recipient. The total payment is divided among recipients
* proportional to [weight] / sum(weights); [shareMillisats] applies
* the same rounding the in-app flow does.
*/
data class Recipient(
/** LN address ready to hand to [shareMillisats] + an LNURL-pay flow. */
val lnAddress: String,
/** Pubkey of the recipient, or null when the split tag carried only an LN address. */
val pubkey: HexKey?,
/** Relative weight in the split. 1.0 when not otherwise specified. */
val weight: Double,
/** Relay hint the split tag carried, if any — for receipt routing. */
val relay: NormalizedRelayUrl?,
)
/**
* Rounds a per-split share to whole sats (millisats granularity of 1_000).
* Matches `ZapPaymentHandler.calculateZapValue` so sums line up exactly
* with what an Amethyst user would see on-screen.
*/
fun shareMillisats(
totalMillisats: Long,
weight: Double,
totalWeight: Double,
): Long {
if (totalWeight <= 0.0) return 0L
val shareValue = totalMillisats * (weight / totalWeight)
return round(shareValue / 1000f).toLong() * 1000
}
/**
* Resolve the list of zap recipients for [zappedEvent].
*
* @param lookupLnAddress called to resolve a pubkey to an LN address. For
* amy this reads kind:0 metadata from the local store; for the Android
* adapter it reads `User.lnAddress()` from the live cache. Return null
* when no LN address is known — the recipient is dropped.
*
* @return ordered list of recipients with LN addresses resolved. Empty
* list when no recipient has a usable LN address.
*/
suspend fun resolve(
zappedEvent: Event,
lookupLnAddress: suspend (HexKey) -> String?,
): List<Recipient> {
val splits = zappedEvent.zapSplitSetup()
val raw: List<Recipient?> =
when {
splits.isNotEmpty() ->
splits.map { setup ->
when (setup) {
is ZapSplitSetupLnAddress ->
Recipient(
lnAddress = setup.lnAddress,
pubkey = null,
weight = setup.weight,
relay = null,
)
is ZapSplitSetup -> {
val ln = lookupLnAddress(setup.pubKeyHex)
if (ln != null) {
Recipient(
lnAddress = ln,
pubkey = setup.pubKeyHex,
weight = setup.weight,
relay = setup.relay,
)
} else {
null
}
}
}
}
zappedEvent is LiveActivitiesEvent && zappedEvent.hasHost() ->
zappedEvent.hosts().map { host ->
val ln = lookupLnAddress(host.pubKey)
if (ln != null) {
Recipient(
lnAddress = ln,
pubkey = host.pubKey,
weight = 1.0,
relay = host.relayHint,
)
} else {
null
}
}
zappedEvent is AppDefinitionEvent -> {
val appLn = zappedEvent.appMetaData()?.lnAddress()
val ln = appLn ?: lookupLnAddress(zappedEvent.pubKey)
if (ln != null) {
listOf(
Recipient(
lnAddress = ln,
// appMetaData has no pubkey association; only attribute when we fell back to the author.
pubkey = if (appLn == null) zappedEvent.pubKey else null,
weight = 1.0,
relay = null,
),
)
} else {
listOf(null)
}
}
else -> {
val ln = lookupLnAddress(zappedEvent.pubKey)
if (ln != null) {
listOf(
Recipient(
lnAddress = ln,
pubkey = zappedEvent.pubKey,
weight = 1.0,
relay = null,
),
)
} else {
listOf(null)
}
}
}
return raw.filterNotNull()
}
}
@@ -37,7 +37,7 @@ object Base83 {
): String {
val buffer = CharArray(length)
encode(value, length, buffer, 0)
return String(buffer)
return buffer.concatToString()
}
fun encode(
@@ -27,6 +27,7 @@ import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToLong
import kotlin.math.withSign
class BlurHashEncoder {
@@ -42,7 +43,7 @@ class BlurHashEncoder {
val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))))
val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))))
val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))))
return Math.round(quantR * 19 * 19 + quantG * 19 + quantB)
return (quantR * 19 * 19 + quantG * 19 + quantB).roundToLong()
}
private fun encodeDC(value: DoubleArray): Long {
@@ -126,7 +127,7 @@ class BlurHashEncoder {
val actualMaximumValue = max(factors, 1, factors.size)
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = (quantisedMaximumValue + 1) / 166
encode(Math.round(quantisedMaximumValue), 1, hash, 1)
encode(quantisedMaximumValue.roundToLong(), 1, hash, 1)
} else {
maximumValue = 1.0
encode(0, 1, hash, 1)
@@ -138,6 +139,6 @@ class BlurHashEncoder {
for (i in 1 until factors.size) {
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
}
return String(hash)
return hash.concatToString()
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
@@ -46,7 +48,7 @@ class PeerSessionManager(
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
)
private val lock = Any()
private val lock = KmpLock()
private val sessions = mutableMapOf<HexKey, SessionEntry>()
/** Candidates received before a session exists for the sender. */
@@ -58,7 +60,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry =
synchronized(lock) {
lock.withLock {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
@@ -66,17 +68,17 @@ class PeerSessionManager(
entry
}
fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] }
fun getSession(peerPubKey: HexKey): SessionEntry? = lock.withLock { sessions[peerPubKey] }
fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) }
fun hasSession(peerPubKey: HexKey): Boolean = lock.withLock { sessions.containsKey(peerPubKey) }
fun removeSession(peerPubKey: HexKey): SessionEntry? =
synchronized(lock) {
lock.withLock {
globalPendingIce.remove(peerPubKey)
sessions.remove(peerPubKey)
}
fun allSessionKeys(): Set<HexKey> = synchronized(lock) { sessions.keys.toSet() }
fun allSessionKeys(): Set<HexKey> = lock.withLock { sessions.keys.toSet() }
// ---- ICE candidate routing (two-layer buffering) ----
@@ -92,7 +94,7 @@ class PeerSessionManager(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction =
synchronized(lock) {
lock.withLock {
val entry = sessions[senderPubKey]
when {
entry != null && entry.remoteDescriptionSet -> {
@@ -117,21 +119,21 @@ class PeerSessionManager(
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
val candidates: List<IceCandidateData>
val entry: SessionEntry
synchronized(lock) {
entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
}
val (entry, candidates) =
lock.withLock {
val entry = sessions[peerPubKey] ?: return@withLock null
entry.remoteDescriptionSet = true
val candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
entry to candidates
} ?: return 0
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 }
fun globalPendingCount(peerPubKey: HexKey): Int = lock.withLock { globalPendingIce[peerPubKey]?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = lock.withLock { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
// ---- Renegotiation glare handling ----
@@ -147,7 +149,7 @@ class PeerSessionManager(
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val entry = lock.withLock { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -184,7 +186,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val entry = lock.withLock { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -199,12 +201,13 @@ class PeerSessionManager(
// ---- Cleanup ----
fun disposeAll() {
val entries: List<SessionEntry>
synchronized(lock) {
entries = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
}
val entries =
lock.withLock {
val snapshot = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
snapshot
}
for (entry in entries) {
entry.session.dispose()
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* Global registry of accepted game IDs.
*
@@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess
*/
object AcceptedGamesRegistry {
private val acceptedGameIds = mutableSetOf<String>()
private val lock = Any()
private val lock = KmpLock()
fun markAsAccepted(gameId: String) {
synchronized(lock) {
lock.withLock {
acceptedGameIds.add(gameId)
}
}
fun wasAccepted(gameId: String): Boolean =
synchronized(lock) {
lock.withLock {
acceptedGameIds.contains(gameId)
}
fun clear() {
synchronized(lock) {
lock.withLock {
acceptedGameIds.clear()
}
}
/** Remove old entries - call periodically to prevent memory leak */
fun clearOldEntries(keepGameIds: Set<String>) {
synchronized(lock) {
lock.withLock {
acceptedGameIds.retainAll(keepGameIds)
}
}
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Collects and aggregates Jester chess events for a game from any source.
@@ -67,11 +69,15 @@ class ChessEventCollector(
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
// Move events (deduplicated by event ID). String keys are Comparable, so
// LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works.
private val moves = LargeCache<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
// Track all processed event IDs for fast deduplication. A plain HashSet
// guarded by KmpLock — simpler than a LargeCache<K, Boolean> for set-shaped
// membership.
private val processedEventIdsLock = KmpLock()
private val processedEventIds = mutableSetOf<String>()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
@@ -80,7 +86,11 @@ class ChessEventCollector(
/**
* Check if an event has already been processed.
*/
fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId)
fun hasEvent(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.contains(eventId) }
private fun markProcessed(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.add(eventId) }
private fun processedEventCount(): Int = processedEventIdsLock.withLock { processedEventIds.size }
/**
* Add a Jester event for this game.
@@ -89,7 +99,7 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) {
if (hasEvent(event.id)) {
Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" }
return false
}
@@ -129,12 +139,12 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addStartEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (hasEvent(event.id)) return false
if (!event.isStartEvent()) return false
if (event.id != startEventId) return false
if (_startEvent.compareAndSet(null, event)) {
processedEventIds.add(event.id)
markProcessed(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
return true
@@ -149,14 +159,15 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addMoveEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (hasEvent(event.id)) return false
if (!event.isMoveEvent()) return false
if (event.startEventId() != startEventId) return false
if (moves.putIfAbsent(event.id, event) == null) {
processedEventIds.add(event.id)
// createIfAbsent returns true iff the entry was added (no prior entry).
if (moves.createIfAbsent(event.id) { event }) {
markProcessed(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" }
Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size()}" }
return true
}
return false
@@ -168,7 +179,7 @@ class ChessEventCollector(
fun getEvents(): JesterGameEvents =
JesterGameEvents(
startEvent = _startEvent.value,
moves = moves.values.toList(),
moves = moves.values().toList(),
)
/**
@@ -179,22 +190,22 @@ class ChessEventCollector(
/**
* Check if the game has any moves (indicates game is active).
*/
fun hasMoves(): Boolean = moves.isNotEmpty()
fun hasMoves(): Boolean = !moves.isEmpty()
/**
* Check if the game has ended (has a move with result).
*/
fun hasEnded(): Boolean = moves.values.any { it.result() != null }
fun hasEnded(): Boolean = moves.values().any { it.result() != null }
/**
* Get the number of moves collected.
*/
fun moveCount(): Int = moves.size
fun moveCount(): Int = moves.size()
/**
* Get the latest move (with longest history).
*/
fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size }
fun latestMove(): JesterEvent? = moves.values().maxByOrNull { it.history().size }
/**
* Clear all collected events.
@@ -202,12 +213,12 @@ class ChessEventCollector(
fun clear() {
_startEvent.value = null
moves.clear()
processedEventIds.clear()
processedEventIdsLock.withLock { processedEventIds.clear() }
_eventCount.value = 0
}
private fun incrementEventCount() {
_eventCount.value = processedEventIds.size
_eventCount.value = processedEventCount()
}
}
@@ -218,21 +229,21 @@ class ChessEventCollector(
* such as in a chess lobby or when spectating multiple games.
*/
class ChessEventCollectorManager {
private val collectors = ConcurrentHashMap<String, ChessEventCollector>()
private val collectors = LargeCache<String, ChessEventCollector>()
/**
* Get or create a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) }
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrCreate(startEventId) { ChessEventCollector(it) }
/**
* Get a collector if it exists.
*
* @param startEventId The start event ID (game identifier)
*/
fun get(startEventId: String): ChessEventCollector? = collectors[startEventId]
fun get(startEventId: String): ChessEventCollector? = collectors.get(startEventId)
/**
* Remove a collector for a game.
@@ -244,13 +255,13 @@ class ChessEventCollectorManager {
/**
* Get all active game IDs (start event IDs).
*/
fun activeGameIds(): Set<String> = collectors.keys.toSet()
fun activeGameIds(): Set<String> = collectors.keys()
/**
* Clear all collectors.
*/
fun clear() {
collectors.values.forEach { it.clear() }
collectors.values().forEach { it.clear() }
collectors.clear()
}
@@ -275,8 +286,8 @@ class ChessEventCollectorManager {
return false
}
val collector =
collectors[startId] ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" }
collectors.get(startId) ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" }
return false
}
return collector.addEvent(event)
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -129,18 +132,21 @@ class ChessLobbyLogic(
) {
val state = ChessLobbyState(userPubkey, scope)
private val dismissedGameIdsLock = KmpLock()
private val dismissedGameIds: MutableSet<String> =
java.util.Collections.synchronizedSet(
dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
)
(dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf())
// Track when games were last loaded to prevent duplicate fetches
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it).
// String keys are Comparable, so LargeCache works.
private val recentlyLoadedGames = LargeCache<String, Long>()
// Dedup incoming events (same event delivered by multiple relays)
// Bounded LRU: evict oldest when exceeding capacity
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
// Dedup incoming events (same event delivered by multiple relays).
// Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns
// LinkedHashSet on every KMP target, preserving insertion-order iteration
// required for LRU eviction below.
private val seenEventIdsLock = KmpLock()
private val seenEventIds = mutableSetOf<String>()
private val seenEventIdsMax = 500
private val pollingDelegate =
@@ -207,15 +213,21 @@ class ChessLobbyLogic(
if (!event.isStartEvent() && !event.isMoveEvent()) return
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
synchronized(seenEventIds) {
if (!seenEventIds.add(event.id)) return
if (seenEventIds.size > seenEventIdsMax) {
seenEventIds.iterator().let {
it.next()
it.remove()
val isNew =
seenEventIdsLock.withLock {
if (!seenEventIds.add(event.id)) {
false
} else {
if (seenEventIds.size > seenEventIdsMax) {
seenEventIds.iterator().let {
it.next()
it.remove()
}
}
true
}
}
}
if (!isNew) return
Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" }
when {
@@ -438,13 +450,13 @@ class ChessLobbyLogic(
*/
fun handleGameAccepted(startEventId: String) {
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
val lastLoaded = recentlyLoadedGames[startEventId]
val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent launches
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
scope.launch(Dispatchers.Default) {
@@ -456,7 +468,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
@@ -608,7 +620,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
@@ -641,7 +653,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
if (result.liveState.isSpectator) {
state.addSpectatingGame(startEventId, result.liveState)
} else {
@@ -678,13 +690,13 @@ class ChessLobbyLogic(
private suspend fun refreshGame(startEventId: String) {
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
val lastLoaded = recentlyLoadedGames[startEventId]
val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent fetches for the same game
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
val events = fetcher.fetchGameEvents(startEventId)
@@ -873,16 +885,17 @@ class ChessLobbyLogic(
.map { it.gameId }
.toSet()
val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }
for (startEventId in newGameIds) {
if (startEventId in completedGameIds) continue
if (startEventId in dismissedGameIds) continue
if (startEventId in dismissedSnapshot) continue
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
// If the discovered game is already finished, send it straight to completed
val gameStatus = result.liveState.gameStatus.value
if (gameStatus is GameStatus.Finished) {
@@ -951,15 +964,23 @@ class ChessLobbyLogic(
fun dismissCompletedGame(gameId: String) {
state.removeCompletedGame(gameId)
dismissedGameIds.add(gameId)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
val snapshot =
dismissedGameIdsLock.withLock {
dismissedGameIds.add(gameId)
dismissedGameIds.toSet()
}
dismissedStorage?.save(userPubkey, snapshot)
}
fun dismissAllCompletedGames() {
val allIds = state.completedGames.value.map { it.gameId }
state.clearCompletedGames()
dismissedGameIds.addAll(allIds)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
val snapshot =
dismissedGameIdsLock.withLock {
dismissedGameIds.addAll(allIds)
dismissedGameIds.toSet()
}
dismissedStorage?.save(userPubkey, snapshot)
}
/**
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.atomic.AtomicLong
/**
* Challenge expiry: 24 hours
@@ -245,9 +244,9 @@ class ChessLobbyState(
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// State version counter - increments on every game state update
// UI can observe this to force recomposition when internal state changes
private val stateVersionCounter = AtomicLong(0)
// State version counter — bumped on every game state update so the UI
// can force recomposition when internal state changes. MutableStateFlow.update
// is itself atomic, so a separate counter is not needed.
private val _stateVersion = MutableStateFlow(0L)
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
@@ -383,13 +382,11 @@ class ChessLobbyState(
if (inActiveGames) {
_activeGames.update { it + (gameId to stateToUse) }
// Increment version to force UI recomposition even if map equals() returns true
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
// Bump version to force UI recomposition even if map equals() returns true
_stateVersion.update { it + 1 }
} else if (inSpectatingGames) {
_spectatingGames.update { it + (gameId to stateToUse) }
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
_stateVersion.update { it + 1 }
}
}
@@ -20,15 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Progress callback for relay fetch operations
@@ -67,6 +71,7 @@ class ChessRelayFetchHelper(
* @param onProgress Optional callback for progress updates per relay
* @return Deduplicated list of events received before timeout/EOSE
*/
@OptIn(ExperimentalAtomicApi::class)
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
@@ -74,16 +79,22 @@ class ChessRelayFetchHelper(
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val events = LargeCache<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
// Per-relay event counters. Each counter is independently atomic, so
// increments don't need to lock the whole map.
val relayEventCounts = LargeCache<NormalizedRelayUrl, AtomicInt>()
// Small bounded set; KmpLock-guarded plain set is fine for the size we
// expect (one entry per relay in the filter map).
val eoseLock = KmpLock()
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
// Initialize all relays as WAITING
// Initialize all relays as WAITING. Eagerly create AtomicInt counters
// so onEose / timeout paths can read load() without a put race.
filters.keys.forEach { relay ->
relayEventCounts[relay] = 0
relayEventCounts.getOrCreate(relay) { AtomicInt(0) }
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
}
@@ -95,8 +106,8 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events[event.id] = event
val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
events.put(event.id, event)
val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1)
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
}
@@ -104,11 +115,15 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
val count = relayEventCounts[relay] ?: 0
val newEoseSize =
eoseLock.withLock {
eoseReceived.add(relay)
eoseReceived.size
}
val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
// Complete when all relays respond
if (eoseReceived.size >= relayCount) {
if (newEoseSize >= relayCount) {
allEose.complete(Unit)
}
}
@@ -119,9 +134,10 @@ class ChessRelayFetchHelper(
// Mark timed-out relays
if (eoseResult == null) {
val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() }
filters.keys.forEach { relay ->
if (relay !in eoseReceived) {
val count = relayEventCounts[relay] ?: 0
if (relay !in eoseSnapshot) {
val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
}
}
@@ -129,6 +145,6 @@ class ChessRelayFetchHelper(
client.unsubscribe(subId)
return events.values.toList()
return events.values().toList()
}
}
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.emojicoder
import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
import com.vitorpamplona.amethyst.commons.util.codePointCharCount
import com.vitorpamplona.amethyst.commons.util.codePointToChars
object EmojiCoder {
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
// VS1..=VS16
@@ -35,8 +39,8 @@ object EmojiCoder {
Array<CharArray>(256) {
// converts to UTF-16. Always char[2] back
when (it) {
in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it)
in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it)
in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
else -> throw RuntimeException("This should never happen")
}
}
@@ -55,11 +59,11 @@ object EmojiCoder {
fun isCoded(text: String): Boolean {
if (text.length <= 3) return false
if (!isVariationChar(text.codePointAt(text.length - 2))) {
if (!isVariationChar(text.codePointAtKmp(text.length - 2))) {
return false
}
if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) {
if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) {
return false
}
@@ -70,7 +74,7 @@ object EmojiCoder {
emoji: String,
text: String,
): String {
val input = text.toByteArray(Charsets.UTF_8)
val input = text.encodeToByteArray()
val out = CharArray(input.size * 2)
var outIdx = 0
for (i in 0 until input.size) {
@@ -78,51 +82,34 @@ object EmojiCoder {
out[outIdx++] = chars[0]
out[outIdx++] = chars[1]
}
return emoji + String(out)
return emoji + out.concatToString()
}
/**
* Scans [text] collecting variation-selector bytes until a non-selector is found
* after at least one byte has been collected.
* Returns the end index (exclusive) in [text] and the collected byte values.
*/
private fun scanVariationBytes(text: String): Pair<Int, List<Int>> {
val bytes = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAtKmp(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && bytes.isNotEmpty()) break
i += codePointCharCount(codePoint) // Advance by correct char count
if (byte != null) bytes.add(byte)
}
return i to bytes
}
fun decode(text: String): String {
val decoded = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAt(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && decoded.isNotEmpty()) {
break
} else if (byte == null) {
i += Character.charCount(codePoint) // Advance index by correct number of chars
continue
}
decoded.add(byte)
i += Character.charCount(codePoint) // Advance index by correct number of chars
}
val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() }
return String(decodedArray, Charsets.UTF_8)
val (_, bytes) = scanVariationBytes(text)
return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString()
}
fun cropToFirstMessage(text: String): String {
val decoded = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAt(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && decoded.isNotEmpty()) {
break
} else if (byte == null) {
i += Character.charCount(codePoint) // Advance index by correct number of chars
continue
}
decoded.add(byte)
i += Character.charCount(codePoint) // Advance index by correct number of chars
}
return text.substring(0, i)
val (endIndex, _) = scanVariationBytes(text)
return text.substring(0, endIndex)
}
}
@@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Stable
class FeedBuilderState(
@@ -66,6 +69,7 @@ class FeedBuilderState(
private val editId: String? = initial?.id
@OptIn(ExperimentalUuidApi::class)
fun toDefinition(): FeedDefinition {
val source =
FeedSource.Filter(
@@ -77,17 +81,14 @@ class FeedBuilderState(
kinds = kinds.toImmutableList(),
)
return FeedDefinition(
id =
editId ?: java.util.UUID
.randomUUID()
.toString(),
id = editId ?: Uuid.random().toString(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source,
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
createdAt = TimeUtils.now(),
)
}
}
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class FeedDefinitionBuilder {
var name: String = ""
@@ -70,13 +73,11 @@ class FeedDefinitionBuilder {
pinOrder = Int.MAX_VALUE,
source = source ?: error("FeedDefinition requires a source"),
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
createdAt = TimeUtils.now(),
)
private fun generateId(): String =
java.util.UUID
.randomUUID()
.toString()
@OptIn(ExperimentalUuidApi::class)
private fun generateId(): String = Uuid.random().toString()
}
class FilterBuilder {
@@ -20,29 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
object FeedDefinitionSerializer {
private val mapper = ObjectMapper()
private val json = Json { ignoreUnknownKeys = true }
fun serializeList(feeds: List<FeedDefinition>): String {
val array = mapper.createArrayNode()
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
return mapper.writeValueAsString(array)
val array =
buildJsonArray {
feeds.forEach { add(serializeFeed(it)) }
}
return json.encodeToString(JsonArray.serializer(), array)
}
fun deserializeList(json: String): List<FeedDefinition> {
if (json.isBlank()) return emptyList()
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
return array.mapNotNull { node -> deserializeFeed(node) }
fun deserializeList(jsonString: String): List<FeedDefinition> {
if (jsonString.isBlank()) return emptyList()
val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList()
return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } }
}
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeFeed(feed: FeedDefinition): JsonObject =
buildJsonObject {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
@@ -50,25 +58,25 @@ object FeedDefinitionSerializer {
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
set<ObjectNode>("source", serializeSource(feed.source))
put("source", serializeSource(feed.source))
}
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
val id = node.get("id")?.asText() ?: return null
val name = node.get("name")?.asText() ?: return null
val emoji = node.get("emoji")?.asText() ?: ""
val pinned = node.get("pinned")?.asBoolean() ?: false
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
private fun deserializeFeed(node: JsonObject): FeedDefinition? {
val id = node.string("id") ?: return null
val name = node.string("name") ?: return null
val emoji = node.string("emoji") ?: ""
val pinned = node.bool("pinned") ?: false
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
node.get("refreshMode")?.asText()?.let {
node.string("refreshMode")?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.get("createdAt")?.asLong() ?: 0L
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
val createdAt = node.long("createdAt") ?: 0L
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
@@ -82,17 +90,20 @@ object FeedDefinitionSerializer {
)
}
private fun serializeSource(source: FeedSource): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeSource(source: FeedSource): JsonObject =
buildJsonObject {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
put("hashtags", stringArray(source.hashtags))
put("authors", stringArray(source.authors))
put("relays", stringArray(source.relays))
put("excludeAuthors", stringArray(source.excludeAuthors))
put("excludeKeywords", stringArray(source.excludeKeywords))
put(
"kinds",
buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } },
)
}
is FeedSource.PeopleList -> {
@@ -131,61 +142,73 @@ object FeedDefinitionSerializer {
}
}
private fun deserializeSource(node: JsonNode): FeedSource? {
val type = node.get("type")?.asText() ?: return null
private fun deserializeSource(node: JsonObject): FeedSource? {
val type = node.string("type") ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
hashtags = node.stringList("hashtags") ?: return null,
authors = node.stringList("authors") ?: return null,
relays = node.stringList("relays") ?: return null,
excludeAuthors = node.stringList("excludeAuthors") ?: return null,
excludeKeywords = node.stringList("excludeKeywords") ?: return null,
kinds = node.intList("kinds") ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
kind = node.get("kind")?.asInt() ?: 30000,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30000,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
kind = node.get("kind")?.asInt() ?: 30015,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30015,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
kind = node.get("kind")?.asInt() ?: 31990,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 31990,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
url = node.get("url")?.asText() ?: return null,
url = node.string("url") ?: return null,
)
}
"global" -> {
FeedSource.Global
}
"following" -> {
FeedSource.Following
}
else -> {
null
}
"global" -> FeedSource.Global
"following" -> FeedSource.Following
else -> null
}
}
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
private fun JsonObject.stringList(key: String) =
(this[key] as? JsonArray)
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
?.toImmutableList()
private fun JsonObject.intList(key: String) =
(this[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
?.toImmutableList()
}
@@ -21,13 +21,15 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
@@ -41,10 +43,14 @@ abstract class Channel : NotesGatherer {
private var relays = mapOf<NormalizedRelayUrl, Counter>()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
// Single per-instance lock that previously @Synchronized methods share.
// Replaces JVM-only @Synchronized so this class compiles on iOS.
private val syncLock = KmpLock()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer {
abstract fun toBestDisplayName(): String
open fun relays(): Set<NormalizedRelayUrl> =
relays.keys
.toSortedSet { o1, o2 ->
val o1Count = relays[o1]?.number ?: 0
val o2Count = relays[o2]?.number ?: 0
o2Count.compareTo(o1Count) // descending
}
relays.entries
.sortedByDescending { it.value.number }
.mapTo(LinkedHashSet(relays.size)) { it.key }
fun updateChannelInfo() {
flowSet?.metadata?.invalidateData()
}
@Synchronized
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
fun addRelaySync(briefInfo: NormalizedRelayUrl) =
syncLock.withLock {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
}
}
}
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
@@ -107,7 +110,7 @@ abstract class Channel : NotesGatherer {
addRelay(relay)
}
changesFlow.get()?.tryEmit(ListChange.Addition(note))
changesFlow?.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
@@ -122,7 +125,7 @@ abstract class Channel : NotesGatherer {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
changesFlow?.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
@@ -140,7 +143,7 @@ abstract class Channel : NotesGatherer {
toBeRemoved.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
@@ -156,7 +159,7 @@ abstract class Channel : NotesGatherer {
hidden.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
@@ -165,18 +168,18 @@ abstract class Channel : NotesGatherer {
var flowSet: ChannelFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
fun createOrDestroyFlowSync(create: Boolean) =
syncLock.withLock {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
}
fun flow(): ChannelFlowSet {
if (flowSet == null) {

Some files were not shown because too many files have changed in this diff Show More