mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
@@ -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
|
- Preparing a batch of strings for a translator
|
||||||
- Checking translation coverage after adding new features
|
- 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
|
## Target Locales
|
||||||
|
|
||||||
The default set of locales (unless the user specifies otherwise):
|
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.
|
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
|
### 3. Get English values for missing keys
|
||||||
|
|
||||||
For each missing key, extract its English value:
|
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
|
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||||
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
|
- **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
|
- **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
|
- **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`)
|
- **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`)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Linter (gradle)
|
- name: Linter (gradle)
|
||||||
run: ./gradlew spotlessCheck
|
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
|
||||||
|
|
||||||
build-desktop:
|
build-desktop:
|
||||||
needs: lint
|
needs: lint
|
||||||
@@ -186,6 +186,65 @@ jobs:
|
|||||||
name: ${{ matrix.desktop-artifact-name }}
|
name: ${{ matrix.desktop-artifact-name }}
|
||||||
path: ${{ matrix.desktop-artifact-path }}
|
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:
|
test-and-build-android:
|
||||||
needs: lint
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+86
-26
@@ -1,54 +1,114 @@
|
|||||||
# Amethyst Privacy Policy and Terms of Use
|
# 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.
|
- Your IP address (or the Tor exit node when using it).
|
||||||
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 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
|
## 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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ plugins {
|
|||||||
alias(libs.plugins.googleServices)
|
alias(libs.plugins.googleServices)
|
||||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||||
alias(libs.plugins.serialization)
|
alias(libs.plugins.serialization)
|
||||||
|
alias(libs.plugins.googleKsp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentBranch(): String =
|
fun getCurrentBranch(): String =
|
||||||
@@ -268,9 +269,36 @@ android {
|
|||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
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
|
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
|
||||||
configurations.all {
|
configurations.all {
|
||||||
val tink = "com.google.crypto.tink:tink-android:1.17.0"
|
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.
|
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
||||||
"playImplementation"(libs.play.services.cast.framework)
|
"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
|
// Charts
|
||||||
implementation(libs.vico.charts.compose)
|
implementation(libs.vico.charts.compose)
|
||||||
implementation(libs.vico.charts.m3)
|
implementation(libs.vico.charts.m3)
|
||||||
|
|||||||
@@ -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.
|
||||||
+176
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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() {
|
||||||
|
}
|
||||||
+35
@@ -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.AccountState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
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.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
@@ -190,12 +191,12 @@ class AppModules(
|
|||||||
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
|
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
|
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||||
// offline, etc.) we clear any active Tor session bypass so the manager re-attempts
|
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
|
||||||
// bootstrap on the new network. The remembered-approval window is unaffected: if Tor
|
// client + on-disk state/ both need a fresh start. onNetworkChange drops the
|
||||||
// stays stuck we will silently bypass again after the timeout fires.
|
// TorClient, clears the bypass + persisted approval, and triggers a full re-init.
|
||||||
init {
|
init {
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
connManager.status
|
connManager.status
|
||||||
@@ -203,7 +204,7 @@ class AppModules(
|
|||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.collect { torManager.clearSessionBypass() }
|
.collect { torManager.onNetworkChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -29,12 +29,14 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||||
|
import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -48,10 +50,13 @@ class TorSharedPreferences(
|
|||||||
prefs: TorSettings,
|
prefs: TorSettings,
|
||||||
val context: Context,
|
val context: Context,
|
||||||
val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
) {
|
) : TorPreferencesPort {
|
||||||
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
|
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
|
||||||
val value = TorSettingsFlow.build(prefs)
|
val value = TorSettingsFlow.build(prefs)
|
||||||
|
|
||||||
|
override val torType: StateFlow<TorType> get() = value.torType
|
||||||
|
override val externalSocksPort: StateFlow<Int> get() = value.externalSocksPort
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
val saving =
|
val saving =
|
||||||
value.propertyWatchFlow
|
value.propertyWatchFlow
|
||||||
@@ -66,9 +71,9 @@ class TorSharedPreferences(
|
|||||||
value.toSettings(),
|
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 {
|
companion object {
|
||||||
// loads faster when individualized
|
// loads faster when individualized
|
||||||
|
|||||||
+2
@@ -261,6 +261,8 @@ fun AllSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LegalSettingsSection()
|
||||||
|
|
||||||
SettingsSection(R.string.danger_zone, isDanger = true) {
|
SettingsSection(R.string.danger_zone, isDanger = true) {
|
||||||
if (hasPrivateKey) {
|
if (hasPrivateKey) {
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
|
|||||||
+3
-10
@@ -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.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
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.TorSettingsSetup
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||||
@@ -197,18 +197,11 @@ fun LoginPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loginViewModel.isFirstLogin) {
|
if (loginViewModel.isFirstLogin) {
|
||||||
AcceptTerms(
|
TermsGate(
|
||||||
checked = loginViewModel.acceptedTerms,
|
checked = loginViewModel.acceptedTerms,
|
||||||
onCheckedChange = loginViewModel::updateAcceptedTerms,
|
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))
|
Spacer(modifier = Modifier.height(Size10dp))
|
||||||
|
|||||||
+3
-2
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
@@ -68,7 +69,7 @@ class LoginViewModel : ViewModel() {
|
|||||||
) {
|
) {
|
||||||
clear()
|
clear()
|
||||||
this.isFirstLogin = isFirstLogin
|
this.isFirstLogin = isFirstLogin
|
||||||
acceptedTerms = !isFirstLogin
|
acceptedTerms = !isFirstLogin || BuildConfig.FLAVOR != "play"
|
||||||
if (newAccountKey != null) {
|
if (newAccountKey != null) {
|
||||||
key = TextFieldValue(newAccountKey)
|
key = TextFieldValue(newAccountKey)
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ class LoginViewModel : ViewModel() {
|
|||||||
password = TextFieldValue("")
|
password = TextFieldValue("")
|
||||||
|
|
||||||
errorManager.clearErrors()
|
errorManager.clearErrors()
|
||||||
acceptedTerms = false
|
acceptedTerms = BuildConfig.FLAVOR != "play"
|
||||||
processingLogin = false
|
processingLogin = false
|
||||||
isTemporary = false
|
isTemporary = false
|
||||||
offerTemporaryLogin = false
|
offerTemporaryLogin = false
|
||||||
|
|||||||
+3
-10
@@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.R
|
|||||||
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
|
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
|
||||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
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.TorSettingsSetup
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
@@ -187,19 +187,12 @@ fun SignUpPage(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
AcceptTerms(
|
TermsGate(
|
||||||
checked = signUpViewModel.acceptedTerms,
|
checked = signUpViewModel.acceptedTerms,
|
||||||
onCheckedChange = signUpViewModel::updateAcceptedTerms,
|
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))
|
Spacer(modifier = Modifier.height(Size10dp))
|
||||||
|
|
||||||
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
|
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
|
||||||
@@ -40,7 +41,7 @@ class SignUpViewModel : ViewModel() {
|
|||||||
|
|
||||||
var displayName by mutableStateOf(TextFieldValue(""))
|
var displayName by mutableStateOf(TextFieldValue(""))
|
||||||
|
|
||||||
var acceptedTerms by mutableStateOf(false)
|
var acceptedTerms by mutableStateOf(BuildConfig.FLAVOR != "play")
|
||||||
var termsAcceptanceIsRequiredError by mutableStateOf(false)
|
var termsAcceptanceIsRequiredError by mutableStateOf(false)
|
||||||
|
|
||||||
fun init(accountSessionManager: AccountSessionManager) {
|
fun init(accountSessionManager: AccountSessionManager) {
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ object ArtiNative {
|
|||||||
* @return 0 on success.
|
* @return 0 on success.
|
||||||
*/
|
*/
|
||||||
external fun stopSocksProxy(): Int
|
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
|
package com.vitorpamplona.amethyst.ui.tor
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -41,20 +40,26 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There should be only one instance of the Tor binding per app.
|
* There should be only one instance of the Tor binding per app.
|
||||||
*
|
*
|
||||||
* Tor will connect as soon as status is listened to.
|
* 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(
|
class TorManager(
|
||||||
private val torPrefs: TorSharedPreferences,
|
private val torPrefs: TorPreferencesPort,
|
||||||
app: Context,
|
val service: TorBackend,
|
||||||
private val scope: CoroutineScope,
|
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
|
* 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
|
* 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
|
@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 {
|
init {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(ioDispatcher) {
|
||||||
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any user-initiated change to torType clears the in-memory bypass so the
|
// Any user-initiated change to torType clears the in-memory bypass AND the
|
||||||
// explicit user action wins over the implicit override.
|
// remembered-approval window. Otherwise a single past "Use regular connection"
|
||||||
torPrefs.value.torType
|
// 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)
|
.drop(1)
|
||||||
.onEach { sessionBypass.value = false }
|
.onEach {
|
||||||
.launchIn(scope)
|
sessionBypass.value = false
|
||||||
|
lastBypassApprovalMs = 0L
|
||||||
|
torPrefs.saveLastBypassApprovalMs(0L)
|
||||||
|
}.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val status =
|
val status =
|
||||||
combine(
|
combine(
|
||||||
torPrefs.value.torType,
|
torPrefs.torType,
|
||||||
torPrefs.value.externalSocksPort,
|
torPrefs.externalSocksPort,
|
||||||
sessionBypass,
|
sessionBypass,
|
||||||
) { torType, externalSocksPort, bypass ->
|
resetEpoch,
|
||||||
|
) { torType, externalSocksPort, bypass, _ ->
|
||||||
Triple(torType, externalSocksPort, bypass)
|
Triple(torType, externalSocksPort, bypass)
|
||||||
}.transformLatest { (torType, externalSocksPort, bypass) ->
|
}.transformLatest { (torType, externalSocksPort, bypass) ->
|
||||||
if (bypass) {
|
if (bypass) {
|
||||||
@@ -119,7 +154,7 @@ class TorManager(
|
|||||||
}.catch { e ->
|
}.catch { e ->
|
||||||
Log.e("TorManager") { "Tor service error: ${e.message}" }
|
Log.e("TorManager") { "Tor service error: ${e.message}" }
|
||||||
emit(TorServiceStatus.Off)
|
emit(TorServiceStatus.Off)
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(ioDispatcher)
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope,
|
scope,
|
||||||
SharingStarted.WhileSubscribed(30000),
|
SharingStarted.WhileSubscribed(30000),
|
||||||
@@ -164,28 +199,92 @@ class TorManager(
|
|||||||
false,
|
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 {
|
fun rememberedApprovalActive(): Boolean {
|
||||||
val ts = lastBypassApprovalMs
|
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. */
|
/** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */
|
||||||
fun approveBypassForOneHour() {
|
fun approveBypassForOneHour() {
|
||||||
val now = System.currentTimeMillis()
|
val now = nowMs()
|
||||||
lastBypassApprovalMs = now
|
lastBypassApprovalMs = now
|
||||||
sessionBypass.value = true
|
sessionBypass.value = true
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(ioDispatcher) {
|
||||||
torPrefs.saveLastBypassApprovalMs(now)
|
torPrefs.saveLastBypassApprovalMs(now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-attempt Tor on this session — used on network change. Does not clear the
|
* Network identity changed (wifi↔cellular, captive portal cleared, regained from
|
||||||
* remembered-approval window: if Tor stays stuck, we will silently bypass again
|
* offline). The old network's guards and circuits are dead, but Arti's in-memory
|
||||||
* after the timeout fires.
|
* 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
|
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
|
fun isSocksReady() = status.value is TorServiceStatus.Active
|
||||||
@@ -195,5 +294,9 @@ class TorManager(
|
|||||||
companion object {
|
companion object {
|
||||||
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
|
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
|
||||||
const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L
|
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(
|
class TorService(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
) {
|
) : TorBackend {
|
||||||
private var socksPort = DEFAULT_SOCKS_PORT
|
private var socksPort = DEFAULT_SOCKS_PORT
|
||||||
private val initialized = AtomicBoolean(false)
|
private val initialized = AtomicBoolean(false)
|
||||||
private val proxyRunning = AtomicBoolean(false)
|
private val proxyRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
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")
|
private fun artiDataDir() = File(context.filesDir, "arti")
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ class TorService(
|
|||||||
* Initialize the TorClient (once) and start the SOCKS proxy.
|
* Initialize the TorClient (once) and start the SOCKS proxy.
|
||||||
* Must be called from a coroutine on [Dispatchers.IO].
|
* Must be called from a coroutine on [Dispatchers.IO].
|
||||||
*/
|
*/
|
||||||
suspend fun start() {
|
override suspend fun start() {
|
||||||
if (proxyRunning.get()) {
|
if (proxyRunning.get()) {
|
||||||
if (_status.value is TorServiceStatus.Active) return
|
if (_status.value is TorServiceStatus.Active) return
|
||||||
_status.value = TorServiceStatus.Connecting
|
_status.value = TorServiceStatus.Connecting
|
||||||
@@ -167,7 +167,7 @@ class TorService(
|
|||||||
* Stop the SOCKS proxy and release the port.
|
* Stop the SOCKS proxy and release the port.
|
||||||
* The TorClient stays alive — no file lock issues on restart.
|
* The TorClient stays alive — no file lock issues on restart.
|
||||||
*/
|
*/
|
||||||
suspend fun stop() {
|
override suspend fun stop() {
|
||||||
if (!proxyRunning.compareAndSet(true, false)) return
|
if (!proxyRunning.compareAndSet(true, false)) return
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -177,4 +177,36 @@ class TorService(
|
|||||||
|
|
||||||
_status.value = TorServiceStatus.Off
|
_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.
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="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>
|
<item quantity="other">Tento příspěvek má více než %1$d hashtagů</item>
|
||||||
</plurals>
|
</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">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="post_not_found_short">👀</string>
|
||||||
<string name="channel_image">Obrázek kanálu</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_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="profile_badges_empty">Zatím jste neobdrželi žádné odznaky.</string>
|
||||||
<string name="pictures">Obrázky</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="calendars">Kalendáře</string>
|
||||||
<string name="shorts">Krátká videa</string>
|
<string name="shorts">Krátká videa</string>
|
||||||
<string name="public_chats">Veřejné chaty</string>
|
<string name="public_chats">Veřejné chaty</string>
|
||||||
@@ -1199,6 +1210,8 @@
|
|||||||
<string name="posting_policy">Politika příspěvků</string>
|
<string name="posting_policy">Politika příspěvků</string>
|
||||||
<string name="privacy_policy">Zásady ochrany soukromí</string>
|
<string name="privacy_policy">Zásady ochrany soukromí</string>
|
||||||
<string name="terms_and_conditions">Podmínky & ujednání</string>
|
<string name="terms_and_conditions">Podmínky & 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="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
|
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
|
||||||
<string name="relay_monitor_reports">Zprávy monitoru 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_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_share_title">Sdílet událost kalendáře</string>
|
||||||
<string name="calendar_export_event">Exportovat do kalendáře (.ics)</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_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_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>
|
<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_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_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>
|
<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">Sdílet jako Nostr odkaz</string>
|
||||||
<string name="calendar_share_nostr_title">Sdílet odkaz na kalendář</string>
|
<string name="calendar_share_nostr_title">Sdílet odkaz na kalendář</string>
|
||||||
<string name="calendar_filter_all">Všechny kalendáře</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="boost_or_quote_description">Zvýšit nebo citovat</string>
|
||||||
<string name="like_description">Olajkovat</string>
|
<string name="like_description">Olajkovat</string>
|
||||||
<string name="zap_description">Zap</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="onchain_zap_pending">Čeká na potvrzení</string>
|
||||||
<string name="change_reaction">Změnit rychlé reakce</string>
|
<string name="change_reaction">Změnit rychlé reakce</string>
|
||||||
<string name="bottom_bar_settings">Spodní navigační lišta</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_store">Nowhere Obchod</string>
|
||||||
<string name="nowhere_link_card_petition">Nowhere Petice</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_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_art">Nowhere Umění</string>
|
||||||
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
|
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<item quantity="one">Dieser Beitrag hat mehr als %1$d Hashtag</item>
|
<item quantity="one">Dieser Beitrag hat mehr als %1$d Hashtag</item>
|
||||||
<item quantity="other">Dieser Beitrag hat mehr als %1$d Hashtags</item>
|
<item quantity="other">Dieser Beitrag hat mehr als %1$d Hashtags</item>
|
||||||
</plurals>
|
</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">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
|
||||||
<string name="post_not_found_short">👀</string>
|
<string name="post_not_found_short">👀</string>
|
||||||
<string name="channel_image">Kanalbild</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_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="profile_badges_empty">Du hast noch keine Abzeichen erhalten.</string>
|
||||||
<string name="pictures">Bilder</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="calendars">Kalender</string>
|
||||||
<string name="shorts">Kurzvideos</string>
|
<string name="shorts">Kurzvideos</string>
|
||||||
<string name="public_chats">Öffentliche Chats</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="posting_policy">Veröffentlichungsrichtlinie</string>
|
||||||
<string name="privacy_policy">Datenschutzerklärung</string>
|
<string name="privacy_policy">Datenschutzerklärung</string>
|
||||||
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
|
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
|
||||||
|
<string name="child_safety_standards">Kinderschutzstandards</string>
|
||||||
|
<string name="about_legal">Über & Rechtliches</string>
|
||||||
<string name="not_available_acronym">N/A</string>
|
<string name="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
|
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
|
||||||
<string name="relay_monitor_reports">Relay-Überwachungsberichte</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="edit_calendar_collection">Kalender bearbeiten</string>
|
||||||
<string name="calendar_collection_events_section">Termine in diesem Kalender (%1$d)</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_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_month">Monat</string>
|
||||||
<string name="calendar_view_week">Woche</string>
|
<string name="calendar_view_week">Woche</string>
|
||||||
<string name="calendar_view_day">Tag</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_going">Komme</string>
|
||||||
<string name="calendar_rsvp_maybe">Vielleicht</string>
|
<string name="calendar_rsvp_maybe">Vielleicht</string>
|
||||||
<string name="calendar_rsvp_not_going">Komme nicht</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_rsvp_none">Noch keine Antworten.</string>
|
||||||
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
|
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
|
||||||
<string name="calendar_event_in_calendars">In Kalendern (%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_relative_ongoing_with_end">%1$s · endet %2$s</string>
|
||||||
<string name="calendar_export_share_title">Kalendertermin teilen</string>
|
<string name="calendar_export_share_title">Kalendertermin teilen</string>
|
||||||
<string name="calendar_export_event">In Kalender exportieren (.ics)</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_name">Kalendererinnerungen</string>
|
||||||
<string name="calendar_reminder_channel_description">Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.</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>
|
<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_closed">Geschlossen</string>
|
||||||
<string name="git_status_draft">Entwurf</string>
|
<string name="git_status_draft">Entwurf</string>
|
||||||
<string name="git_repo_tab_overview">Übersicht</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_tab_patches">Patches und PRs</string>
|
||||||
<string name="git_repo_section_about">Über</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_maintainers">Maintainer</string>
|
||||||
<string name="git_repo_section_topics">Themen</string>
|
<string name="git_repo_section_topics">Themen</string>
|
||||||
<string name="git_repo_personal_fork">Persönlicher Fork</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>
|
<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. -->
|
<!-- 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_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_fundraiser">Nowhere Spendenaktion</string>
|
||||||
<string name="nowhere_link_card_store">Nowhere Shop</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_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_art">Nowhere Kunst</string>
|
||||||
|
<string name="nowhere_link_card_forum">Nowhere Forum</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<item quantity="one">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
|
<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>
|
<item quantity="other">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
|
||||||
</plurals>
|
</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">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="post_not_found_short">👀</string>
|
||||||
<string name="channel_image">Csatorna profilképe</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_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="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
|
||||||
<string name="pictures">Képek</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="calendars">Naptárak</string>
|
||||||
<string name="shorts">Rövidek</string>
|
<string name="shorts">Rövidek</string>
|
||||||
<string name="public_chats">Nyilvános csevegések</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="posting_policy">Közzétételi szabályzat</string>
|
||||||
<string name="privacy_policy">Adatvédelmi irányelvek</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="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 & Jogi információk</string>
|
||||||
<string name="not_available_acronym">Nem érhető el</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_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>
|
<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_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="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
|
||||||
<string name="pictures">Zdjęcia</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="calendars">Kalendarze</string>
|
||||||
<string name="shorts">Filmiki</string>
|
<string name="shorts">Filmiki</string>
|
||||||
<string name="public_chats">Czaty publiczne</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="posting_policy">Polityka publikowania</string>
|
||||||
<string name="privacy_policy">Polityka Prywatności</string>
|
<string name="privacy_policy">Polityka Prywatności</string>
|
||||||
<string name="terms_and_conditions">Zasady i warunki użytkowania</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 & Informacje Prawne</string>
|
||||||
<string name="not_available_acronym">Nie dotyczy</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_error_messages">Błędy i powiadomienia z tego transmitera</string>
|
||||||
<string name="relay_monitor_reports">Raporty z monitoringu transmitera</string>
|
<string name="relay_monitor_reports">Raporty z monitoringu transmitera</string>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<item quantity="one">Det här inlägget har fler än %1$d hashtagg</item>
|
<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>
|
<item quantity="other">Det här inlägget har fler än %1$d hashtaggar</item>
|
||||||
</plurals>
|
</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">Inlägg hittades inte</string>
|
||||||
<string name="post_not_found_short">👀</string>
|
<string name="post_not_found_short">👀</string>
|
||||||
<string name="channel_image">Kanal bild</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_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="profile_badges_empty">Du har inte fått några märken ännu.</string>
|
||||||
<string name="pictures">Bilder</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="calendars">Kalendrar</string>
|
||||||
<string name="shorts">Kortfilmer</string>
|
<string name="shorts">Kortfilmer</string>
|
||||||
<string name="public_chats">Offentliga chattar</string>
|
<string name="public_chats">Offentliga chattar</string>
|
||||||
@@ -1184,6 +1193,8 @@
|
|||||||
<string name="posting_policy">Publiceringspolicy</string>
|
<string name="posting_policy">Publiceringspolicy</string>
|
||||||
<string name="privacy_policy">Integritetspolicy</string>
|
<string name="privacy_policy">Integritetspolicy</string>
|
||||||
<string name="terms_and_conditions">Villkor & bestämmelser</string>
|
<string name="terms_and_conditions">Villkor & 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="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
|
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
|
||||||
<string name="relay_monitor_reports">Reläövervakningsrapporter</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_relative_ongoing_with_end">%1$s · slutar %2$s</string>
|
||||||
<string name="calendar_export_share_title">Dela kalenderhändelse</string>
|
<string name="calendar_export_share_title">Dela kalenderhändelse</string>
|
||||||
<string name="calendar_export_event">Exportera till kalender (.ics)</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_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_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>
|
<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_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_title">Påminnelsetid</string>
|
||||||
<string name="calendar_reminder_settings_lead_subtitle">Hur många minuter före händelsen du vill bli notifierad.</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">Dela som Nostr-länk</string>
|
||||||
<string name="calendar_share_nostr_title">Dela kalenderlänk</string>
|
<string name="calendar_share_nostr_title">Dela kalenderlänk</string>
|
||||||
<string name="calendar_filter_all">Alla kalendrar</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_store">Nowhere Butik</string>
|
||||||
<string name="nowhere_link_card_petition">Nowhere Namninsamling</string>
|
<string name="nowhere_link_card_petition">Nowhere Namninsamling</string>
|
||||||
<string name="nowhere_link_card_message">Nowhere Meddelande</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_art">Nowhere Konst</string>
|
||||||
|
<string name="nowhere_link_card_forum">Nowhere Forum</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<plurals name="post_was_hidden_due_to_too_many_hashtags">
|
<plurals name="post_was_hidden_due_to_too_many_hashtags">
|
||||||
<item quantity="other">此帖有超过 %1$d 个话题标签</item>
|
<item quantity="other">此帖有超过 %1$d 个话题标签</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="nip82_assets_count">
|
||||||
|
<item quantity="other">捆绑了 %1$d 个资产</item>
|
||||||
|
</plurals>
|
||||||
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
|
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
|
||||||
<string name="post_not_found_short">👀</string>
|
<string name="post_not_found_short">👀</string>
|
||||||
<string name="channel_image">频道图片</string>
|
<string name="channel_image">频道图片</string>
|
||||||
@@ -546,6 +549,11 @@
|
|||||||
<string name="profile_badges_description">选择要在个人资料中显示的已收到的徽章。</string>
|
<string name="profile_badges_description">选择要在个人资料中显示的已收到的徽章。</string>
|
||||||
<string name="profile_badges_empty">您还没有收到任何徽章。</string>
|
<string name="profile_badges_empty">您还没有收到任何徽章。</string>
|
||||||
<string name="pictures">图片</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="calendars">日历</string>
|
||||||
<string name="shorts">短视频</string>
|
<string name="shorts">短视频</string>
|
||||||
<string name="public_chats">公共聊天</string>
|
<string name="public_chats">公共聊天</string>
|
||||||
@@ -1182,6 +1190,8 @@
|
|||||||
<string name="posting_policy">发布政策</string>
|
<string name="posting_policy">发布政策</string>
|
||||||
<string name="privacy_policy">隐私政策</string>
|
<string name="privacy_policy">隐私政策</string>
|
||||||
<string name="terms_and_conditions">使用条款</string>
|
<string name="terms_and_conditions">使用条款</string>
|
||||||
|
<string name="child_safety_standards">儿童安全标准</string>
|
||||||
|
<string name="about_legal">关于 & 法律</string>
|
||||||
<string name="not_available_acronym">N/A</string>
|
<string name="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">该中继的错误和通知</string>
|
<string name="relay_error_messages">该中继的错误和通知</string>
|
||||||
<string name="relay_monitor_reports">中继监视器报告</string>
|
<string name="relay_monitor_reports">中继监视器报告</string>
|
||||||
|
|||||||
@@ -1318,6 +1318,8 @@
|
|||||||
<string name="posting_policy">Posting policy</string>
|
<string name="posting_policy">Posting policy</string>
|
||||||
<string name="privacy_policy">Privacy Policy</string>
|
<string name="privacy_policy">Privacy Policy</string>
|
||||||
<string name="terms_and_conditions">Terms & Conditions</string>
|
<string name="terms_and_conditions">Terms & Conditions</string>
|
||||||
|
<string name="child_safety_standards">Child Safety Standards</string>
|
||||||
|
<string name="about_legal">About & Legal</string>
|
||||||
<string name="not_available_acronym">N/A</string>
|
<string name="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Errors and Notices from this Relay</string>
|
<string name="relay_error_messages">Errors and Notices from this Relay</string>
|
||||||
<string name="relay_monitor_reports">Relay Monitor Reports</string>
|
<string name="relay_monitor_reports">Relay Monitor Reports</string>
|
||||||
|
|||||||
@@ -38,6 +38,23 @@
|
|||||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+2663
File diff suppressed because it is too large
Load Diff
+57
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-3
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.foundation.layout.Row
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@@ -33,9 +33,26 @@ import com.vitorpamplona.amethyst.ui.components.appendLink
|
|||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AcceptTerms(
|
fun TermsGate(
|
||||||
checked: Boolean,
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(
|
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" />
|
||||||
+489
@@ -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.
@@ -12,6 +12,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||||
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
|
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
|
||||||
alias(libs.plugins.serialization)
|
alias(libs.plugins.serialization)
|
||||||
|
alias(libs.plugins.googleKsp) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared app version for all subprojects — read from gradle/libs.versions.toml.
|
// 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)
|
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 -> {
|
else -> {
|
||||||
System.err.println("unknown subcommand: $head")
|
System.err.println("unknown subcommand: $head")
|
||||||
printUsage()
|
printUsage()
|
||||||
@@ -327,6 +343,28 @@ private fun printUsage() {
|
|||||||
| [--since TS] [--until TS]
|
| [--since TS] [--until TS]
|
||||||
| [--timeout SECS]
|
| [--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):
|
|Direct messages (NIP-17):
|
||||||
| dm send RECIPIENT TEXT send a gift-wrapped DM
|
| dm send RECIPIENT TEXT send a gift-wrapped DM
|
||||||
| [--allow-fallback] (default: only deliver to recipient's kind:10050)
|
| [--allow-fallback] (default: only deliver to recipient's kind:10050)
|
||||||
|
|||||||
@@ -95,4 +95,24 @@ object Commands {
|
|||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
): Int = StoreCommands.dispatch(dataDir, tail)
|
): 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.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Output
|
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.filterGiftWrapsToPubkey
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
|
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.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
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.NIP17Factory
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
@@ -86,8 +86,7 @@ object DmCommands {
|
|||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val recipient = ctx.requireUserHex(rest[0])
|
val recipient = ctx.requireUserHex(rest[0])
|
||||||
val template = ChatMessageEvent.build(text, listOf(PTag(recipient)))
|
val result = DmActions.buildTextDm(ctx.signer, recipient, text)
|
||||||
val result = NIP17Factory().createMessageNIP17(template, ctx.signer)
|
|
||||||
return publishWraps(ctx, result, allowFallback)
|
return publishWraps(ctx, result, allowFallback)
|
||||||
} finally {
|
} finally {
|
||||||
ctx.close()
|
ctx.close()
|
||||||
@@ -124,27 +123,34 @@ object DmCommands {
|
|||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val recipient = ctx.requireUserHex(recipientInput)
|
val recipient = ctx.requireUserHex(recipientInput)
|
||||||
|
|
||||||
val (template, summary) =
|
val (result, summary) =
|
||||||
if (args.flag("file") != null) {
|
if (args.flag("file") != null) {
|
||||||
buildUploadModeTemplate(ctx, recipient, args)
|
buildUploadedFileDm(ctx, recipient, args)
|
||||||
?: return 1
|
?: return 1
|
||||||
} else {
|
} else {
|
||||||
buildReferenceModeTemplate(args, recipient)
|
buildReferencedFileDm(ctx, recipient, args)
|
||||||
?: return 1
|
?: return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer)
|
|
||||||
return publishWraps(ctx, result, allowFallback, extra = summary)
|
return publishWraps(ctx, result, allowFallback, extra = summary)
|
||||||
} finally {
|
} finally {
|
||||||
ctx.close()
|
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,
|
ctx: Context,
|
||||||
recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey,
|
recipient: HexKey,
|
||||||
args: Args,
|
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"))
|
val file = java.io.File(args.requireFlag("file"))
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Output.error("bad_args", "file does not exist: ${file.absolutePath}")
|
Output.error("bad_args", "file does not exist: ${file.absolutePath}")
|
||||||
@@ -169,9 +175,10 @@ object DmCommands {
|
|||||||
.DimensionTag(w, h)
|
.DimensionTag(w, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val template =
|
val result =
|
||||||
ChatMessageEncryptedFileHeaderEvent.build(
|
DmActions.buildFileDmReference(
|
||||||
to = listOf(PTag(recipient)),
|
signer = ctx.signer,
|
||||||
|
recipient = recipient,
|
||||||
url = uploadedUrl,
|
url = uploadedUrl,
|
||||||
cipher = cipher,
|
cipher = cipher,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
@@ -181,8 +188,6 @@ object DmCommands {
|
|||||||
blurhash = uploaded.metadata.blurhash,
|
blurhash = uploaded.metadata.blurhash,
|
||||||
originalHash = uploaded.metadata.sha256,
|
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 =
|
val summary =
|
||||||
mapOf(
|
mapOf(
|
||||||
"url" to uploadedUrl,
|
"url" to uploadedUrl,
|
||||||
@@ -193,13 +198,19 @@ object DmCommands {
|
|||||||
"original_hash" to uploaded.metadata.sha256,
|
"original_hash" to uploaded.metadata.sha256,
|
||||||
"mime_type" to mimeType,
|
"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,
|
args: Args,
|
||||||
recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey,
|
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
|
||||||
): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
|
|
||||||
val url =
|
val url =
|
||||||
args.positionalOrNull(0) ?: run {
|
args.positionalOrNull(0) ?: run {
|
||||||
Output.error("bad_args", USAGE_SEND_FILE)
|
Output.error("bad_args", USAGE_SEND_FILE)
|
||||||
@@ -236,9 +247,10 @@ object DmCommands {
|
|||||||
val cipher =
|
val cipher =
|
||||||
com.vitorpamplona.quartz.utils.ciphers
|
com.vitorpamplona.quartz.utils.ciphers
|
||||||
.AESGCM(keyBytes, nonceBytes)
|
.AESGCM(keyBytes, nonceBytes)
|
||||||
val template =
|
val result =
|
||||||
ChatMessageEncryptedFileHeaderEvent.build(
|
DmActions.buildFileDmReference(
|
||||||
to = listOf(PTag(recipient)),
|
signer = ctx.signer,
|
||||||
|
recipient = recipient,
|
||||||
url = url,
|
url = url,
|
||||||
cipher = cipher,
|
cipher = cipher,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
@@ -248,7 +260,7 @@ object DmCommands {
|
|||||||
blurhash = blurhash,
|
blurhash = blurhash,
|
||||||
originalHash = originalHash,
|
originalHash = originalHash,
|
||||||
)
|
)
|
||||||
return template to emptyMap()
|
return result to emptyMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val USAGE_SEND_FILE: String =
|
private const val USAGE_SEND_FILE: String =
|
||||||
@@ -278,7 +290,7 @@ object DmCommands {
|
|||||||
"wrap_id" to wrap.id,
|
"wrap_id" to wrap.id,
|
||||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||||
"relays_tried" to resolution.relays.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
|
* Cache-first relay lookup. If amy has previously seen the recipient's
|
||||||
* has advertised in their kind:10050. When that list is empty:
|
* kind:10050 / 10051 / 10002 events, use the local copy and skip the
|
||||||
* - strict (default): refuse with no_dm_relays — caller must fix or
|
* network drain entirely. Otherwise drain `seedRelays` for them. Then
|
||||||
* explicitly opt into a fallback.
|
* hands the resulting [RecipientRelayFetcher.Lists] off to
|
||||||
* - allowFallback=true: fall through to the NIP-65 read marker and then
|
* [DmActions.resolveDmRelays] which applies the strict-kind:10050 /
|
||||||
* to our bootstrap pool.
|
* fallback policy.
|
||||||
*/
|
*/
|
||||||
private suspend fun resolveDmRelays(
|
private suspend fun resolveDmRelays(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
recipient: HexKey,
|
recipient: HexKey,
|
||||||
allowFallback: Boolean,
|
allowFallback: Boolean,
|
||||||
): RelaySet {
|
): DmActions.DmRelaySet {
|
||||||
val seed = ctx.bootstrapRelays()
|
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 =
|
val lists =
|
||||||
ctx.cachedRelayListsOf(recipient)
|
ctx.cachedRelayListsOf(recipient)
|
||||||
?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed)
|
?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed)
|
||||||
val dmInbox = lists.dmInbox.toSet()
|
return DmActions.resolveDmRelays(
|
||||||
if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050")
|
recipientLists = lists,
|
||||||
if (!allowFallback) return RelaySet(emptySet(), "kind_10050")
|
bootstrap = seed,
|
||||||
val nip65Read = lists.nip65Read().toSet()
|
allowFallback = allowFallback,
|
||||||
if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read")
|
)
|
||||||
return RelaySet(seed, "bootstrap")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class RelaySet(
|
|
||||||
val relays: Set<NormalizedRelayUrl>,
|
|
||||||
val source: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private sealed interface DecryptedDm {
|
private sealed interface DecryptedDm {
|
||||||
val id: HexKey
|
val id: HexKey
|
||||||
val wrapId: 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
@@ -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 {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":quartz"))
|
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
|
// Compose Multiplatform
|
||||||
implementation(libs.jetbrains.compose.ui)
|
implementation(libs.jetbrains.compose.ui)
|
||||||
@@ -61,13 +62,17 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.compose.material3)
|
implementation(libs.jetbrains.compose.material3)
|
||||||
implementation(libs.jetbrains.compose.ui.tooling.preview)
|
implementation(libs.jetbrains.compose.ui.tooling.preview)
|
||||||
|
|
||||||
// Lifecycle ViewModel (KMP since 2.8.0)
|
// Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
// 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)
|
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.compose)
|
||||||
implementation(libs.coil.okhttp)
|
|
||||||
|
|
||||||
// LruCache (KMP-ready)
|
// LruCache (KMP-ready)
|
||||||
implementation(libs.androidx.collection)
|
implementation(libs.androidx.collection)
|
||||||
@@ -75,13 +80,12 @@ kotlin {
|
|||||||
// Immutable collections
|
// Immutable collections
|
||||||
api(libs.kotlinx.collections.immutable)
|
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
|
// Compose Multiplatform Resources
|
||||||
implementation(libs.jetbrains.compose.components.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") {
|
create("jvmAndroid") {
|
||||||
dependsOn(commonMain.get())
|
dependsOn(commonMain.get())
|
||||||
dependencies {
|
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") {
|
getByName("androidHostTest") {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.junit)
|
implementation(libs.junit)
|
||||||
@@ -150,3 +188,68 @@ compose.resources {
|
|||||||
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
|
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
|
||||||
generateResClass = always
|
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) }
|
||||||
|
|||||||
+171
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -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 {
|
): String {
|
||||||
val buffer = CharArray(length)
|
val buffer = CharArray(length)
|
||||||
encode(value, length, buffer, 0)
|
encode(value, length, buffer, 0)
|
||||||
return String(buffer)
|
return buffer.concatToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encode(
|
fun encode(
|
||||||
|
|||||||
+4
-3
@@ -27,6 +27,7 @@ import kotlin.math.floor
|
|||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToLong
|
||||||
import kotlin.math.withSign
|
import kotlin.math.withSign
|
||||||
|
|
||||||
class BlurHashEncoder {
|
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 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 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))))
|
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 {
|
private fun encodeDC(value: DoubleArray): Long {
|
||||||
@@ -126,7 +127,7 @@ class BlurHashEncoder {
|
|||||||
val actualMaximumValue = max(factors, 1, factors.size)
|
val actualMaximumValue = max(factors, 1, factors.size)
|
||||||
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
|
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
|
||||||
maximumValue = (quantisedMaximumValue + 1) / 166
|
maximumValue = (quantisedMaximumValue + 1) / 166
|
||||||
encode(Math.round(quantisedMaximumValue), 1, hash, 1)
|
encode(quantisedMaximumValue.roundToLong(), 1, hash, 1)
|
||||||
} else {
|
} else {
|
||||||
maximumValue = 1.0
|
maximumValue = 1.0
|
||||||
encode(0, 1, hash, 1)
|
encode(0, 1, hash, 1)
|
||||||
@@ -138,6 +139,6 @@ class BlurHashEncoder {
|
|||||||
for (i in 1 until factors.size) {
|
for (i in 1 until factors.size) {
|
||||||
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
|
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
|
||||||
}
|
}
|
||||||
return String(hash)
|
return hash.concatToString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-25
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.call
|
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
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +48,7 @@ class PeerSessionManager(
|
|||||||
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
|
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val lock = Any()
|
private val lock = KmpLock()
|
||||||
private val sessions = mutableMapOf<HexKey, SessionEntry>()
|
private val sessions = mutableMapOf<HexKey, SessionEntry>()
|
||||||
|
|
||||||
/** Candidates received before a session exists for the sender. */
|
/** Candidates received before a session exists for the sender. */
|
||||||
@@ -58,7 +60,7 @@ class PeerSessionManager(
|
|||||||
peerPubKey: HexKey,
|
peerPubKey: HexKey,
|
||||||
session: PeerSession,
|
session: PeerSession,
|
||||||
): SessionEntry =
|
): SessionEntry =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
|
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
|
||||||
val entry = SessionEntry(session)
|
val entry = SessionEntry(session)
|
||||||
entry.pendingIceCandidates.addAll(globalPending)
|
entry.pendingIceCandidates.addAll(globalPending)
|
||||||
@@ -66,17 +68,17 @@ class PeerSessionManager(
|
|||||||
entry
|
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? =
|
fun removeSession(peerPubKey: HexKey): SessionEntry? =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
globalPendingIce.remove(peerPubKey)
|
globalPendingIce.remove(peerPubKey)
|
||||||
sessions.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) ----
|
// ---- ICE candidate routing (two-layer buffering) ----
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ class PeerSessionManager(
|
|||||||
senderPubKey: HexKey,
|
senderPubKey: HexKey,
|
||||||
candidate: IceCandidateData,
|
candidate: IceCandidateData,
|
||||||
): IceRouteAction =
|
): IceRouteAction =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
val entry = sessions[senderPubKey]
|
val entry = sessions[senderPubKey]
|
||||||
when {
|
when {
|
||||||
entry != null && entry.remoteDescriptionSet -> {
|
entry != null && entry.remoteDescriptionSet -> {
|
||||||
@@ -117,21 +119,21 @@ class PeerSessionManager(
|
|||||||
* Called after setRemoteDescription succeeds.
|
* Called after setRemoteDescription succeeds.
|
||||||
*/
|
*/
|
||||||
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
|
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
|
||||||
val candidates: List<IceCandidateData>
|
val (entry, candidates) =
|
||||||
val entry: SessionEntry
|
lock.withLock {
|
||||||
synchronized(lock) {
|
val entry = sessions[peerPubKey] ?: return@withLock null
|
||||||
entry = sessions[peerPubKey] ?: return 0
|
entry.remoteDescriptionSet = true
|
||||||
entry.remoteDescriptionSet = true
|
val candidates = entry.pendingIceCandidates.toList()
|
||||||
candidates = entry.pendingIceCandidates.toList()
|
entry.pendingIceCandidates.clear()
|
||||||
entry.pendingIceCandidates.clear()
|
entry to candidates
|
||||||
}
|
} ?: return 0
|
||||||
candidates.forEach { entry.session.addIceCandidate(it) }
|
candidates.forEach { entry.session.addIceCandidate(it) }
|
||||||
return candidates.size
|
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 ----
|
// ---- Renegotiation glare handling ----
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ class PeerSessionManager(
|
|||||||
remoteSdpOffer: String,
|
remoteSdpOffer: String,
|
||||||
onAcceptRemote: (SessionEntry) -> Unit,
|
onAcceptRemote: (SessionEntry) -> Unit,
|
||||||
): GlareResolution {
|
): 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()
|
val signalingState = entry.session.getSignalingState()
|
||||||
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
|
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
|
||||||
@@ -184,7 +186,7 @@ class PeerSessionManager(
|
|||||||
peerPubKey: HexKey,
|
peerPubKey: HexKey,
|
||||||
sdpAnswer: String,
|
sdpAnswer: String,
|
||||||
): AnswerRouteAction {
|
): 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()
|
val signalingState = entry.session.getSignalingState()
|
||||||
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
|
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
|
||||||
@@ -199,12 +201,13 @@ class PeerSessionManager(
|
|||||||
// ---- Cleanup ----
|
// ---- Cleanup ----
|
||||||
|
|
||||||
fun disposeAll() {
|
fun disposeAll() {
|
||||||
val entries: List<SessionEntry>
|
val entries =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
entries = sessions.values.toList()
|
val snapshot = sessions.values.toList()
|
||||||
sessions.clear()
|
sessions.clear()
|
||||||
globalPendingIce.clear()
|
globalPendingIce.clear()
|
||||||
}
|
snapshot
|
||||||
|
}
|
||||||
for (entry in entries) {
|
for (entry in entries) {
|
||||||
entry.session.dispose()
|
entry.session.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-5
@@ -20,6 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
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.
|
* Global registry of accepted game IDs.
|
||||||
*
|
*
|
||||||
@@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess
|
|||||||
*/
|
*/
|
||||||
object AcceptedGamesRegistry {
|
object AcceptedGamesRegistry {
|
||||||
private val acceptedGameIds = mutableSetOf<String>()
|
private val acceptedGameIds = mutableSetOf<String>()
|
||||||
private val lock = Any()
|
private val lock = KmpLock()
|
||||||
|
|
||||||
fun markAsAccepted(gameId: String) {
|
fun markAsAccepted(gameId: String) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
acceptedGameIds.add(gameId)
|
acceptedGameIds.add(gameId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun wasAccepted(gameId: String): Boolean =
|
fun wasAccepted(gameId: String): Boolean =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
acceptedGameIds.contains(gameId)
|
acceptedGameIds.contains(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
acceptedGameIds.clear()
|
acceptedGameIds.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove old entries - call periodically to prevent memory leak */
|
/** Remove old entries - call periodically to prevent memory leak */
|
||||||
fun clearOldEntries(keepGameIds: Set<String>) {
|
fun clearOldEntries(keepGameIds: Set<String>) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
acceptedGameIds.retainAll(keepGameIds)
|
acceptedGameIds.retainAll(keepGameIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-27
@@ -20,16 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||||
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collects and aggregates Jester chess events for a game from any source.
|
* Collects and aggregates Jester chess events for a game from any source.
|
||||||
@@ -67,11 +69,15 @@ class ChessEventCollector(
|
|||||||
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
|
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
|
||||||
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
|
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
|
||||||
|
|
||||||
// Move events (deduplicated by event ID)
|
// Move events (deduplicated by event ID). String keys are Comparable, so
|
||||||
private val moves = ConcurrentHashMap<String, JesterEvent>()
|
// LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works.
|
||||||
|
private val moves = LargeCache<String, JesterEvent>()
|
||||||
|
|
||||||
// Track all processed event IDs for fast deduplication
|
// Track all processed event IDs for fast deduplication. A plain HashSet
|
||||||
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
|
// 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)
|
// Flow that emits when any event is added (for reactive updates)
|
||||||
private val _eventCount = MutableStateFlow(0)
|
private val _eventCount = MutableStateFlow(0)
|
||||||
@@ -80,7 +86,11 @@ class ChessEventCollector(
|
|||||||
/**
|
/**
|
||||||
* Check if an event has already been processed.
|
* 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.
|
* 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
|
* @return true if the event was added, false if already exists or invalid
|
||||||
*/
|
*/
|
||||||
fun addEvent(event: JesterEvent): Boolean {
|
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)}" }
|
Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,12 +139,12 @@ class ChessEventCollector(
|
|||||||
* @return true if the event was added, false if already exists or invalid
|
* @return true if the event was added, false if already exists or invalid
|
||||||
*/
|
*/
|
||||||
private fun addStartEvent(event: JesterEvent): Boolean {
|
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.isStartEvent()) return false
|
||||||
if (event.id != startEventId) return false
|
if (event.id != startEventId) return false
|
||||||
|
|
||||||
if (_startEvent.compareAndSet(null, event)) {
|
if (_startEvent.compareAndSet(null, event)) {
|
||||||
processedEventIds.add(event.id)
|
markProcessed(event.id)
|
||||||
incrementEventCount()
|
incrementEventCount()
|
||||||
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
|
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
|
||||||
return true
|
return true
|
||||||
@@ -149,14 +159,15 @@ class ChessEventCollector(
|
|||||||
* @return true if the event was added, false if already exists or invalid
|
* @return true if the event was added, false if already exists or invalid
|
||||||
*/
|
*/
|
||||||
private fun addMoveEvent(event: JesterEvent): Boolean {
|
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.isMoveEvent()) return false
|
||||||
if (event.startEventId() != startEventId) return false
|
if (event.startEventId() != startEventId) return false
|
||||||
|
|
||||||
if (moves.putIfAbsent(event.id, event) == null) {
|
// createIfAbsent returns true iff the entry was added (no prior entry).
|
||||||
processedEventIds.add(event.id)
|
if (moves.createIfAbsent(event.id) { event }) {
|
||||||
|
markProcessed(event.id)
|
||||||
incrementEventCount()
|
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 true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -168,7 +179,7 @@ class ChessEventCollector(
|
|||||||
fun getEvents(): JesterGameEvents =
|
fun getEvents(): JesterGameEvents =
|
||||||
JesterGameEvents(
|
JesterGameEvents(
|
||||||
startEvent = _startEvent.value,
|
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).
|
* 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).
|
* 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.
|
* Get the number of moves collected.
|
||||||
*/
|
*/
|
||||||
fun moveCount(): Int = moves.size
|
fun moveCount(): Int = moves.size()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the latest move (with longest history).
|
* 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.
|
* Clear all collected events.
|
||||||
@@ -202,12 +213,12 @@ class ChessEventCollector(
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
_startEvent.value = null
|
_startEvent.value = null
|
||||||
moves.clear()
|
moves.clear()
|
||||||
processedEventIds.clear()
|
processedEventIdsLock.withLock { processedEventIds.clear() }
|
||||||
_eventCount.value = 0
|
_eventCount.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun incrementEventCount() {
|
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.
|
* such as in a chess lobby or when spectating multiple games.
|
||||||
*/
|
*/
|
||||||
class ChessEventCollectorManager {
|
class ChessEventCollectorManager {
|
||||||
private val collectors = ConcurrentHashMap<String, ChessEventCollector>()
|
private val collectors = LargeCache<String, ChessEventCollector>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a collector for a game.
|
* Get or create a collector for a game.
|
||||||
*
|
*
|
||||||
* @param startEventId The start event ID (game identifier)
|
* @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.
|
* Get a collector if it exists.
|
||||||
*
|
*
|
||||||
* @param startEventId The start event ID (game identifier)
|
* @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.
|
* Remove a collector for a game.
|
||||||
@@ -244,13 +255,13 @@ class ChessEventCollectorManager {
|
|||||||
/**
|
/**
|
||||||
* Get all active game IDs (start event IDs).
|
* Get all active game IDs (start event IDs).
|
||||||
*/
|
*/
|
||||||
fun activeGameIds(): Set<String> = collectors.keys.toSet()
|
fun activeGameIds(): Set<String> = collectors.keys()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all collectors.
|
* Clear all collectors.
|
||||||
*/
|
*/
|
||||||
fun clear() {
|
fun clear() {
|
||||||
collectors.values.forEach { it.clear() }
|
collectors.values().forEach { it.clear() }
|
||||||
collectors.clear()
|
collectors.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +286,8 @@ class ChessEventCollectorManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val collector =
|
val collector =
|
||||||
collectors[startId] ?: run {
|
collectors.get(startId) ?: run {
|
||||||
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" }
|
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return collector.addEvent(event)
|
return collector.addEvent(event)
|
||||||
|
|||||||
+49
-28
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
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.ChessGameEnd
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
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.nip64Chess.jester.JesterGameEvents
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -129,18 +132,21 @@ class ChessLobbyLogic(
|
|||||||
) {
|
) {
|
||||||
val state = ChessLobbyState(userPubkey, scope)
|
val state = ChessLobbyState(userPubkey, scope)
|
||||||
|
|
||||||
|
private val dismissedGameIdsLock = KmpLock()
|
||||||
private val dismissedGameIds: MutableSet<String> =
|
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
|
// Track when games were last loaded to prevent duplicate fetches
|
||||||
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
|
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it).
|
||||||
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
// String keys are Comparable, so LargeCache works.
|
||||||
|
private val recentlyLoadedGames = LargeCache<String, Long>()
|
||||||
|
|
||||||
// Dedup incoming events (same event delivered by multiple relays)
|
// Dedup incoming events (same event delivered by multiple relays).
|
||||||
// Bounded LRU: evict oldest when exceeding capacity
|
// Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns
|
||||||
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
|
// 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 seenEventIdsMax = 500
|
||||||
|
|
||||||
private val pollingDelegate =
|
private val pollingDelegate =
|
||||||
@@ -207,15 +213,21 @@ class ChessLobbyLogic(
|
|||||||
if (!event.isStartEvent() && !event.isMoveEvent()) return
|
if (!event.isStartEvent() && !event.isMoveEvent()) return
|
||||||
|
|
||||||
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
|
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
|
||||||
synchronized(seenEventIds) {
|
val isNew =
|
||||||
if (!seenEventIds.add(event.id)) return
|
seenEventIdsLock.withLock {
|
||||||
if (seenEventIds.size > seenEventIdsMax) {
|
if (!seenEventIds.add(event.id)) {
|
||||||
seenEventIds.iterator().let {
|
false
|
||||||
it.next()
|
} else {
|
||||||
it.remove()
|
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}" }
|
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 {
|
when {
|
||||||
@@ -438,13 +450,13 @@ class ChessLobbyLogic(
|
|||||||
*/
|
*/
|
||||||
fun handleGameAccepted(startEventId: String) {
|
fun handleGameAccepted(startEventId: String) {
|
||||||
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
|
// 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) {
|
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Mark immediately to prevent concurrent launches
|
// 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" }
|
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
@@ -456,7 +468,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
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}" }
|
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
|
||||||
state.addActiveGame(startEventId, result.liveState)
|
state.addActiveGame(startEventId, result.liveState)
|
||||||
pollingDelegate.addGameId(startEventId)
|
pollingDelegate.addGameId(startEventId)
|
||||||
@@ -608,7 +620,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
recentlyLoadedGames.put(startEventId, TimeUtils.now())
|
||||||
state.addSpectatingGame(startEventId, result.liveState)
|
state.addSpectatingGame(startEventId, result.liveState)
|
||||||
pollingDelegate.addGameId(startEventId)
|
pollingDelegate.addGameId(startEventId)
|
||||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||||
@@ -641,7 +653,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
recentlyLoadedGames.put(startEventId, TimeUtils.now())
|
||||||
if (result.liveState.isSpectator) {
|
if (result.liveState.isSpectator) {
|
||||||
state.addSpectatingGame(startEventId, result.liveState)
|
state.addSpectatingGame(startEventId, result.liveState)
|
||||||
} else {
|
} else {
|
||||||
@@ -678,13 +690,13 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
private suspend fun refreshGame(startEventId: String) {
|
private suspend fun refreshGame(startEventId: String) {
|
||||||
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
|
// 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) {
|
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||||
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Mark immediately to prevent concurrent fetches for the same game
|
// 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" }
|
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
|
||||||
val events = fetcher.fetchGameEvents(startEventId)
|
val events = fetcher.fetchGameEvents(startEventId)
|
||||||
@@ -873,16 +885,17 @@ class ChessLobbyLogic(
|
|||||||
.map { it.gameId }
|
.map { it.gameId }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
|
val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }
|
||||||
for (startEventId in newGameIds) {
|
for (startEventId in newGameIds) {
|
||||||
if (startEventId in completedGameIds) continue
|
if (startEventId in completedGameIds) continue
|
||||||
if (startEventId in dismissedGameIds) continue
|
if (startEventId in dismissedSnapshot) continue
|
||||||
|
|
||||||
val events = fetcher.fetchGameEvents(startEventId)
|
val events = fetcher.fetchGameEvents(startEventId)
|
||||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
recentlyLoadedGames.put(startEventId, TimeUtils.now())
|
||||||
// If the discovered game is already finished, send it straight to completed
|
// If the discovered game is already finished, send it straight to completed
|
||||||
val gameStatus = result.liveState.gameStatus.value
|
val gameStatus = result.liveState.gameStatus.value
|
||||||
if (gameStatus is GameStatus.Finished) {
|
if (gameStatus is GameStatus.Finished) {
|
||||||
@@ -951,15 +964,23 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
fun dismissCompletedGame(gameId: String) {
|
fun dismissCompletedGame(gameId: String) {
|
||||||
state.removeCompletedGame(gameId)
|
state.removeCompletedGame(gameId)
|
||||||
dismissedGameIds.add(gameId)
|
val snapshot =
|
||||||
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
|
dismissedGameIdsLock.withLock {
|
||||||
|
dismissedGameIds.add(gameId)
|
||||||
|
dismissedGameIds.toSet()
|
||||||
|
}
|
||||||
|
dismissedStorage?.save(userPubkey, snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAllCompletedGames() {
|
fun dismissAllCompletedGames() {
|
||||||
val allIds = state.completedGames.value.map { it.gameId }
|
val allIds = state.completedGames.value.map { it.gameId }
|
||||||
state.clearCompletedGames()
|
state.clearCompletedGames()
|
||||||
dismissedGameIds.addAll(allIds)
|
val snapshot =
|
||||||
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
|
dismissedGameIdsLock.withLock {
|
||||||
|
dismissedGameIds.addAll(allIds)
|
||||||
|
dismissedGameIds.toSet()
|
||||||
|
}
|
||||||
|
dismissedStorage?.save(userPubkey, snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-9
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Challenge expiry: 24 hours
|
* Challenge expiry: 24 hours
|
||||||
@@ -245,9 +244,9 @@ class ChessLobbyState(
|
|||||||
private val _selectedGameId = MutableStateFlow<String?>(null)
|
private val _selectedGameId = MutableStateFlow<String?>(null)
|
||||||
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
|
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
|
||||||
|
|
||||||
// State version counter - increments on every game state update
|
// State version counter — bumped on every game state update so the UI
|
||||||
// UI can observe this to force recomposition when internal state changes
|
// can force recomposition when internal state changes. MutableStateFlow.update
|
||||||
private val stateVersionCounter = AtomicLong(0)
|
// is itself atomic, so a separate counter is not needed.
|
||||||
private val _stateVersion = MutableStateFlow(0L)
|
private val _stateVersion = MutableStateFlow(0L)
|
||||||
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
|
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
|
||||||
|
|
||||||
@@ -383,13 +382,11 @@ class ChessLobbyState(
|
|||||||
|
|
||||||
if (inActiveGames) {
|
if (inActiveGames) {
|
||||||
_activeGames.update { it + (gameId to stateToUse) }
|
_activeGames.update { it + (gameId to stateToUse) }
|
||||||
// Increment version to force UI recomposition even if map equals() returns true
|
// Bump version to force UI recomposition even if map equals() returns true
|
||||||
val newVersion = stateVersionCounter.incrementAndGet()
|
_stateVersion.update { it + 1 }
|
||||||
_stateVersion.value = newVersion
|
|
||||||
} else if (inSpectatingGames) {
|
} else if (inSpectatingGames) {
|
||||||
_spectatingGames.update { it + (gameId to stateToUse) }
|
_spectatingGames.update { it + (gameId to stateToUse) }
|
||||||
val newVersion = stateVersionCounter.incrementAndGet()
|
_stateVersion.update { it + 1 }
|
||||||
_stateVersion.value = newVersion
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -20,15 +20,19 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
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
|
* Progress callback for relay fetch operations
|
||||||
@@ -67,6 +71,7 @@ class ChessRelayFetchHelper(
|
|||||||
* @param onProgress Optional callback for progress updates per relay
|
* @param onProgress Optional callback for progress updates per relay
|
||||||
* @return Deduplicated list of events received before timeout/EOSE
|
* @return Deduplicated list of events received before timeout/EOSE
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
suspend fun fetchEvents(
|
suspend fun fetchEvents(
|
||||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||||
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
|
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
|
||||||
@@ -74,16 +79,22 @@ class ChessRelayFetchHelper(
|
|||||||
): List<Event> {
|
): List<Event> {
|
||||||
if (filters.isEmpty()) return emptyList()
|
if (filters.isEmpty()) return emptyList()
|
||||||
|
|
||||||
val events = ConcurrentHashMap<String, Event>()
|
val events = LargeCache<String, Event>()
|
||||||
val relayCount = filters.keys.size
|
val relayCount = filters.keys.size
|
||||||
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
|
// Per-relay event counters. Each counter is independently atomic, so
|
||||||
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
|
// 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 allEose = CompletableDeferred<Unit>()
|
||||||
val subId = newSubId()
|
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 ->
|
filters.keys.forEach { relay ->
|
||||||
relayEventCounts[relay] = 0
|
relayEventCounts.getOrCreate(relay) { AtomicInt(0) }
|
||||||
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
|
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +106,8 @@ class ChessRelayFetchHelper(
|
|||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
forFilters: List<Filter>?,
|
forFilters: List<Filter>?,
|
||||||
) {
|
) {
|
||||||
events[event.id] = event
|
events.put(event.id, event)
|
||||||
val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
|
val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1)
|
||||||
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
|
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,11 +115,15 @@ class ChessRelayFetchHelper(
|
|||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
forFilters: List<Filter>?,
|
forFilters: List<Filter>?,
|
||||||
) {
|
) {
|
||||||
eoseReceived.add(relay)
|
val newEoseSize =
|
||||||
val count = relayEventCounts[relay] ?: 0
|
eoseLock.withLock {
|
||||||
|
eoseReceived.add(relay)
|
||||||
|
eoseReceived.size
|
||||||
|
}
|
||||||
|
val count = relayEventCounts.get(relay)?.load() ?: 0
|
||||||
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
|
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
|
||||||
// Complete when all relays respond
|
// Complete when all relays respond
|
||||||
if (eoseReceived.size >= relayCount) {
|
if (newEoseSize >= relayCount) {
|
||||||
allEose.complete(Unit)
|
allEose.complete(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,9 +134,10 @@ class ChessRelayFetchHelper(
|
|||||||
|
|
||||||
// Mark timed-out relays
|
// Mark timed-out relays
|
||||||
if (eoseResult == null) {
|
if (eoseResult == null) {
|
||||||
|
val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() }
|
||||||
filters.keys.forEach { relay ->
|
filters.keys.forEach { relay ->
|
||||||
if (relay !in eoseReceived) {
|
if (relay !in eoseSnapshot) {
|
||||||
val count = relayEventCounts[relay] ?: 0
|
val count = relayEventCounts.get(relay)?.load() ?: 0
|
||||||
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
|
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +145,6 @@ class ChessRelayFetchHelper(
|
|||||||
|
|
||||||
client.unsubscribe(subId)
|
client.unsubscribe(subId)
|
||||||
|
|
||||||
return events.values.toList()
|
return events.values().toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-45
@@ -20,6 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.emojicoder
|
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 {
|
object EmojiCoder {
|
||||||
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
|
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
|
||||||
// VS1..=VS16
|
// VS1..=VS16
|
||||||
@@ -35,8 +39,8 @@ object EmojiCoder {
|
|||||||
Array<CharArray>(256) {
|
Array<CharArray>(256) {
|
||||||
// converts to UTF-16. Always char[2] back
|
// converts to UTF-16. Always char[2] back
|
||||||
when (it) {
|
when (it) {
|
||||||
in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it)
|
in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it)
|
||||||
in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
|
in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
|
||||||
else -> throw RuntimeException("This should never happen")
|
else -> throw RuntimeException("This should never happen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,11 +59,11 @@ object EmojiCoder {
|
|||||||
fun isCoded(text: String): Boolean {
|
fun isCoded(text: String): Boolean {
|
||||||
if (text.length <= 3) return false
|
if (text.length <= 3) return false
|
||||||
|
|
||||||
if (!isVariationChar(text.codePointAt(text.length - 2))) {
|
if (!isVariationChar(text.codePointAtKmp(text.length - 2))) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) {
|
if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ object EmojiCoder {
|
|||||||
emoji: String,
|
emoji: String,
|
||||||
text: String,
|
text: String,
|
||||||
): String {
|
): String {
|
||||||
val input = text.toByteArray(Charsets.UTF_8)
|
val input = text.encodeToByteArray()
|
||||||
val out = CharArray(input.size * 2)
|
val out = CharArray(input.size * 2)
|
||||||
var outIdx = 0
|
var outIdx = 0
|
||||||
for (i in 0 until input.size) {
|
for (i in 0 until input.size) {
|
||||||
@@ -78,51 +82,34 @@ object EmojiCoder {
|
|||||||
out[outIdx++] = chars[0]
|
out[outIdx++] = chars[0]
|
||||||
out[outIdx++] = chars[1]
|
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 {
|
fun decode(text: String): String {
|
||||||
val decoded = mutableListOf<Int>()
|
val (_, bytes) = scanVariationBytes(text)
|
||||||
|
return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cropToFirstMessage(text: String): String {
|
fun cropToFirstMessage(text: String): String {
|
||||||
val decoded = mutableListOf<Int>()
|
val (endIndex, _) = scanVariationBytes(text)
|
||||||
|
return text.substring(0, endIndex)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class FeedBuilderState(
|
class FeedBuilderState(
|
||||||
@@ -66,6 +69,7 @@ class FeedBuilderState(
|
|||||||
|
|
||||||
private val editId: String? = initial?.id
|
private val editId: String? = initial?.id
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
fun toDefinition(): FeedDefinition {
|
fun toDefinition(): FeedDefinition {
|
||||||
val source =
|
val source =
|
||||||
FeedSource.Filter(
|
FeedSource.Filter(
|
||||||
@@ -77,17 +81,14 @@ class FeedBuilderState(
|
|||||||
kinds = kinds.toImmutableList(),
|
kinds = kinds.toImmutableList(),
|
||||||
)
|
)
|
||||||
return FeedDefinition(
|
return FeedDefinition(
|
||||||
id =
|
id = editId ?: Uuid.random().toString(),
|
||||||
editId ?: java.util.UUID
|
|
||||||
.randomUUID()
|
|
||||||
.toString(),
|
|
||||||
name = name,
|
name = name,
|
||||||
emoji = emoji,
|
emoji = emoji,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
pinOrder = Int.MAX_VALUE,
|
pinOrder = Int.MAX_VALUE,
|
||||||
source = source,
|
source = source,
|
||||||
refreshMode = refreshMode,
|
refreshMode = refreshMode,
|
||||||
createdAt = System.currentTimeMillis() / 1000,
|
createdAt = TimeUtils.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -21,7 +21,10 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.feeds.custom
|
package com.vitorpamplona.amethyst.commons.feeds.custom
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
class FeedDefinitionBuilder {
|
class FeedDefinitionBuilder {
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
@@ -70,13 +73,11 @@ class FeedDefinitionBuilder {
|
|||||||
pinOrder = Int.MAX_VALUE,
|
pinOrder = Int.MAX_VALUE,
|
||||||
source = source ?: error("FeedDefinition requires a source"),
|
source = source ?: error("FeedDefinition requires a source"),
|
||||||
refreshMode = refreshMode,
|
refreshMode = refreshMode,
|
||||||
createdAt = System.currentTimeMillis() / 1000,
|
createdAt = TimeUtils.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun generateId(): String =
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
java.util.UUID
|
private fun generateId(): String = Uuid.random().toString()
|
||||||
.randomUUID()
|
|
||||||
.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilterBuilder {
|
class FilterBuilder {
|
||||||
|
|||||||
+84
-61
@@ -20,29 +20,37 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.feeds.custom
|
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.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 {
|
object FeedDefinitionSerializer {
|
||||||
private val mapper = ObjectMapper()
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun serializeList(feeds: List<FeedDefinition>): String {
|
fun serializeList(feeds: List<FeedDefinition>): String {
|
||||||
val array = mapper.createArrayNode()
|
val array =
|
||||||
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
|
buildJsonArray {
|
||||||
return mapper.writeValueAsString(array)
|
feeds.forEach { add(serializeFeed(it)) }
|
||||||
|
}
|
||||||
|
return json.encodeToString(JsonArray.serializer(), array)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deserializeList(json: String): List<FeedDefinition> {
|
fun deserializeList(jsonString: String): List<FeedDefinition> {
|
||||||
if (json.isBlank()) return emptyList()
|
if (jsonString.isBlank()) return emptyList()
|
||||||
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
|
val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList()
|
||||||
return array.mapNotNull { node -> deserializeFeed(node) }
|
return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
|
private fun serializeFeed(feed: FeedDefinition): JsonObject =
|
||||||
mapper.createObjectNode().apply {
|
buildJsonObject {
|
||||||
put("id", feed.id)
|
put("id", feed.id)
|
||||||
put("name", feed.name)
|
put("name", feed.name)
|
||||||
put("emoji", feed.emoji)
|
put("emoji", feed.emoji)
|
||||||
@@ -50,25 +58,25 @@ object FeedDefinitionSerializer {
|
|||||||
put("pinOrder", feed.pinOrder)
|
put("pinOrder", feed.pinOrder)
|
||||||
put("refreshMode", feed.refreshMode.name)
|
put("refreshMode", feed.refreshMode.name)
|
||||||
put("createdAt", feed.createdAt)
|
put("createdAt", feed.createdAt)
|
||||||
set<ObjectNode>("source", serializeSource(feed.source))
|
put("source", serializeSource(feed.source))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
|
private fun deserializeFeed(node: JsonObject): FeedDefinition? {
|
||||||
val id = node.get("id")?.asText() ?: return null
|
val id = node.string("id") ?: return null
|
||||||
val name = node.get("name")?.asText() ?: return null
|
val name = node.string("name") ?: return null
|
||||||
val emoji = node.get("emoji")?.asText() ?: ""
|
val emoji = node.string("emoji") ?: ""
|
||||||
val pinned = node.get("pinned")?.asBoolean() ?: false
|
val pinned = node.bool("pinned") ?: false
|
||||||
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
|
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
|
||||||
val refreshMode =
|
val refreshMode =
|
||||||
node.get("refreshMode")?.asText()?.let {
|
node.string("refreshMode")?.let {
|
||||||
try {
|
try {
|
||||||
RefreshMode.valueOf(it)
|
RefreshMode.valueOf(it)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
RefreshMode.LIVE_STREAM
|
RefreshMode.LIVE_STREAM
|
||||||
}
|
}
|
||||||
} ?: RefreshMode.LIVE_STREAM
|
} ?: RefreshMode.LIVE_STREAM
|
||||||
val createdAt = node.get("createdAt")?.asLong() ?: 0L
|
val createdAt = node.long("createdAt") ?: 0L
|
||||||
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
|
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
|
||||||
|
|
||||||
return FeedDefinition(
|
return FeedDefinition(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -82,17 +90,20 @@ object FeedDefinitionSerializer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serializeSource(source: FeedSource): ObjectNode =
|
private fun serializeSource(source: FeedSource): JsonObject =
|
||||||
mapper.createObjectNode().apply {
|
buildJsonObject {
|
||||||
when (source) {
|
when (source) {
|
||||||
is FeedSource.Filter -> {
|
is FeedSource.Filter -> {
|
||||||
put("type", "filter")
|
put("type", "filter")
|
||||||
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
|
put("hashtags", stringArray(source.hashtags))
|
||||||
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
|
put("authors", stringArray(source.authors))
|
||||||
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
|
put("relays", stringArray(source.relays))
|
||||||
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
|
put("excludeAuthors", stringArray(source.excludeAuthors))
|
||||||
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
|
put("excludeKeywords", stringArray(source.excludeKeywords))
|
||||||
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
|
put(
|
||||||
|
"kinds",
|
||||||
|
buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is FeedSource.PeopleList -> {
|
is FeedSource.PeopleList -> {
|
||||||
@@ -131,61 +142,73 @@ object FeedDefinitionSerializer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserializeSource(node: JsonNode): FeedSource? {
|
private fun deserializeSource(node: JsonObject): FeedSource? {
|
||||||
val type = node.get("type")?.asText() ?: return null
|
val type = node.string("type") ?: return null
|
||||||
return when (type) {
|
return when (type) {
|
||||||
"filter" -> {
|
"filter" -> {
|
||||||
FeedSource.Filter(
|
FeedSource.Filter(
|
||||||
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
|
hashtags = node.stringList("hashtags") ?: return null,
|
||||||
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
|
authors = node.stringList("authors") ?: return null,
|
||||||
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
|
relays = node.stringList("relays") ?: return null,
|
||||||
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
|
excludeAuthors = node.stringList("excludeAuthors") ?: return null,
|
||||||
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
|
excludeKeywords = node.stringList("excludeKeywords") ?: return null,
|
||||||
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
|
kinds = node.intList("kinds") ?: return null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"people_list" -> {
|
"people_list" -> {
|
||||||
FeedSource.PeopleList(
|
FeedSource.PeopleList(
|
||||||
kind = node.get("kind")?.asInt() ?: 30000,
|
kind = node.int("kind") ?: 30000,
|
||||||
pubkey = node.get("pubkey")?.asText() ?: return null,
|
pubkey = node.string("pubkey") ?: return null,
|
||||||
dTag = node.get("dTag")?.asText() ?: return null,
|
dTag = node.string("dTag") ?: return null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"interest_set" -> {
|
"interest_set" -> {
|
||||||
FeedSource.InterestSet(
|
FeedSource.InterestSet(
|
||||||
kind = node.get("kind")?.asInt() ?: 30015,
|
kind = node.int("kind") ?: 30015,
|
||||||
pubkey = node.get("pubkey")?.asText() ?: return null,
|
pubkey = node.string("pubkey") ?: return null,
|
||||||
dTag = node.get("dTag")?.asText() ?: return null,
|
dTag = node.string("dTag") ?: return null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"dvm" -> {
|
"dvm" -> {
|
||||||
FeedSource.DVM(
|
FeedSource.DVM(
|
||||||
kind = node.get("kind")?.asInt() ?: 31990,
|
kind = node.int("kind") ?: 31990,
|
||||||
pubkey = node.get("pubkey")?.asText() ?: return null,
|
pubkey = node.string("pubkey") ?: return null,
|
||||||
dTag = node.get("dTag")?.asText() ?: return null,
|
dTag = node.string("dTag") ?: return null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"single_relay" -> {
|
"single_relay" -> {
|
||||||
FeedSource.SingleRelay(
|
FeedSource.SingleRelay(
|
||||||
url = node.get("url")?.asText() ?: return null,
|
url = node.string("url") ?: return null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"global" -> {
|
"global" -> FeedSource.Global
|
||||||
FeedSource.Global
|
"following" -> FeedSource.Following
|
||||||
}
|
else -> null
|
||||||
|
|
||||||
"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
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
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.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
abstract class Channel : NotesGatherer {
|
abstract class Channel : NotesGatherer {
|
||||||
@@ -41,10 +43,14 @@ abstract class Channel : NotesGatherer {
|
|||||||
|
|
||||||
private var relays = mapOf<NormalizedRelayUrl, Counter>()
|
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>> {
|
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||||
val current = changesFlow.get()
|
val current = changesFlow?.get()
|
||||||
if (current != null) return current
|
if (current != null) return current
|
||||||
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
|
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
|
||||||
changesFlow = WeakReference(new)
|
changesFlow = WeakReference(new)
|
||||||
@@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer {
|
|||||||
abstract fun toBestDisplayName(): String
|
abstract fun toBestDisplayName(): String
|
||||||
|
|
||||||
open fun relays(): Set<NormalizedRelayUrl> =
|
open fun relays(): Set<NormalizedRelayUrl> =
|
||||||
relays.keys
|
relays.entries
|
||||||
.toSortedSet { o1, o2 ->
|
.sortedByDescending { it.value.number }
|
||||||
val o1Count = relays[o1]?.number ?: 0
|
.mapTo(LinkedHashSet(relays.size)) { it.key }
|
||||||
val o2Count = relays[o2]?.number ?: 0
|
|
||||||
o2Count.compareTo(o1Count) // descending
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateChannelInfo() {
|
fun updateChannelInfo() {
|
||||||
flowSet?.metadata?.invalidateData()
|
flowSet?.metadata?.invalidateData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addRelaySync(briefInfo: NormalizedRelayUrl) =
|
||||||
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
|
syncLock.withLock {
|
||||||
if (briefInfo !in relays) {
|
if (briefInfo !in relays) {
|
||||||
relays = relays + Pair(briefInfo, Counter(1))
|
relays = relays + Pair(briefInfo, Counter(1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun addRelay(relay: NormalizedRelayUrl) {
|
fun addRelay(relay: NormalizedRelayUrl) {
|
||||||
val counter = relays[relay]
|
val counter = relays[relay]
|
||||||
@@ -107,7 +110,7 @@ abstract class Channel : NotesGatherer {
|
|||||||
addRelay(relay)
|
addRelay(relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.Addition(note))
|
changesFlow?.get()?.tryEmit(ListChange.Addition(note))
|
||||||
|
|
||||||
flowSet?.notes?.invalidateData()
|
flowSet?.notes?.invalidateData()
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ abstract class Channel : NotesGatherer {
|
|||||||
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
|
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
|
changesFlow?.get()?.tryEmit(ListChange.Deletion(note))
|
||||||
|
|
||||||
flowSet?.notes?.invalidateData()
|
flowSet?.notes?.invalidateData()
|
||||||
}
|
}
|
||||||
@@ -140,7 +143,7 @@ abstract class Channel : NotesGatherer {
|
|||||||
|
|
||||||
toBeRemoved.forEach { notes.remove(it.idHex) }
|
toBeRemoved.forEach { notes.remove(it.idHex) }
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
|
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
|
||||||
|
|
||||||
flowSet?.notes?.invalidateData()
|
flowSet?.notes?.invalidateData()
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ abstract class Channel : NotesGatherer {
|
|||||||
|
|
||||||
hidden.forEach { notes.remove(it.idHex) }
|
hidden.forEach { notes.remove(it.idHex) }
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
|
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden))
|
||||||
|
|
||||||
flowSet?.notes?.invalidateData()
|
flowSet?.notes?.invalidateData()
|
||||||
|
|
||||||
@@ -165,18 +168,18 @@ abstract class Channel : NotesGatherer {
|
|||||||
|
|
||||||
var flowSet: ChannelFlowSet? = null
|
var flowSet: ChannelFlowSet? = null
|
||||||
|
|
||||||
@Synchronized
|
fun createOrDestroyFlowSync(create: Boolean) =
|
||||||
fun createOrDestroyFlowSync(create: Boolean) {
|
syncLock.withLock {
|
||||||
if (create) {
|
if (create) {
|
||||||
if (flowSet == null) {
|
if (flowSet == null) {
|
||||||
flowSet = ChannelFlowSet(this)
|
flowSet = ChannelFlowSet(this)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||||
flowSet = null
|
flowSet = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun flow(): ChannelFlowSet {
|
fun flow(): ChannelFlowSet {
|
||||||
if (flowSet == null) {
|
if (flowSet == null) {
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Immutable
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache
|
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache
|
||||||
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
|
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
|
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
|
||||||
import com.vitorpamplona.amethyst.commons.util.replace
|
import com.vitorpamplona.amethyst.commons.util.replace
|
||||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
|
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
|
||||||
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
|
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||||
@@ -62,17 +64,19 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
|||||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.BigDecimal
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import com.vitorpamplona.quartz.utils.anyAsync
|
import com.vitorpamplona.quartz.utils.anyAsync
|
||||||
import com.vitorpamplona.quartz.utils.containsAny
|
import com.vitorpamplona.quartz.utils.containsAny
|
||||||
import com.vitorpamplona.quartz.utils.launchAndWaitAll
|
import com.vitorpamplona.quartz.utils.launchAndWaitAll
|
||||||
|
import com.vitorpamplona.quartz.utils.plus
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import java.math.BigDecimal
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
interface NotesGatherer {
|
interface NotesGatherer {
|
||||||
fun removeNote(note: Note)
|
fun removeNote(note: Note)
|
||||||
@@ -105,6 +109,11 @@ class AddressableNote(
|
|||||||
open class Note(
|
open class Note(
|
||||||
val idHex: HexKey,
|
val idHex: HexKey,
|
||||||
) : NotesGatherer {
|
) : NotesGatherer {
|
||||||
|
// Per-instance lock shared by the previously @Synchronized methods (zap /
|
||||||
|
// onchain-zap / zap-payment / relay-add / flowSet lifecycle). Replaces the
|
||||||
|
// JVM-only @Synchronized annotation so this class compiles on iOS.
|
||||||
|
private val syncLock = KmpLock()
|
||||||
|
|
||||||
// These fields are only available after the Text Note event is received.
|
// These fields are only available after the Text Note event is received.
|
||||||
// They are immutable after that.
|
// They are immutable after that.
|
||||||
var event: Event? = null
|
var event: Event? = null
|
||||||
@@ -154,7 +163,7 @@ open class Note(
|
|||||||
var zaps = mapOf<Note, Note?>()
|
var zaps = mapOf<Note, Note?>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var zapsAmount: BigDecimal = BigDecimal.ZERO
|
var zapsAmount: BigDecimal = BigDecimal(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-BC onchain zaps targeting this note.
|
* NIP-BC onchain zaps targeting this note.
|
||||||
@@ -165,7 +174,7 @@ open class Note(
|
|||||||
* only counts CONFIRMED amounts.
|
* only counts CONFIRMED amounts.
|
||||||
*
|
*
|
||||||
* `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope`
|
* `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope`
|
||||||
* (inside the @Synchronized inner methods) and reads happen on the Compose Main
|
* (inside the syncLock-guarded inner methods) and reads happen on the Compose Main
|
||||||
* thread (gallery recomposition + the reverification driver's `any { … }` check).
|
* thread (gallery recomposition + the reverification driver's `any { … }` check).
|
||||||
*/
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -349,7 +358,7 @@ open class Note(
|
|||||||
onchainZaps = mapOf()
|
onchainZaps = mapOf()
|
||||||
onchainZapResolved = false
|
onchainZapResolved = false
|
||||||
zapPayments = mapOf()
|
zapPayments = mapOf()
|
||||||
zapsAmount = BigDecimal.ZERO
|
zapsAmount = BigDecimal(0)
|
||||||
relays = listOf()
|
relays = listOf()
|
||||||
|
|
||||||
if (repliesChanged) flowSet?.replies?.invalidateData()
|
if (repliesChanged) flowSet?.replies?.invalidateData()
|
||||||
@@ -421,19 +430,18 @@ open class Note(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun innerAddZap(
|
private fun innerAddZap(
|
||||||
zapRequest: Note,
|
zapRequest: Note,
|
||||||
zap: Note?,
|
zap: Note?,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
if (zaps[zapRequest] == null) {
|
syncLock.withLock {
|
||||||
zaps = zaps + Pair(zapRequest, zap)
|
if (zaps[zapRequest] == null) {
|
||||||
return true
|
zaps = zaps + Pair(zapRequest, zap)
|
||||||
|
return@withLock true
|
||||||
|
}
|
||||||
|
return@withLock false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addZap(
|
fun addZap(
|
||||||
zapRequest: Note,
|
zapRequest: Note,
|
||||||
zap: Note?,
|
zap: Note?,
|
||||||
@@ -447,48 +455,48 @@ open class Note(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun innerAddOnchainZap(
|
private fun innerAddOnchainZap(
|
||||||
txid: String,
|
txid: String,
|
||||||
entry: OnchainZapEntry,
|
entry: OnchainZapEntry,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
val existing = onchainZaps[txid]
|
syncLock.withLock {
|
||||||
if (existing != null) {
|
val existing = onchainZaps[txid]
|
||||||
// Exact structural duplicate (same source Note + same fields) — typical
|
if (existing != null) {
|
||||||
// relay echo of the same event. Skip the rewrite to avoid spurious
|
// Exact structural duplicate (same source Note + same fields) — typical
|
||||||
// flowSet invalidation.
|
// relay echo of the same event. Skip the rewrite to avoid spurious
|
||||||
if (entry == existing) return false
|
// flowSet invalidation.
|
||||||
// Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
|
if (entry == existing) return@withLock false
|
||||||
// so the upgrade contract survives future enum reordering or insertions.
|
// Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
|
||||||
if (entry.status.level < existing.status.level) return false
|
// so the upgrade contract survives future enum reordering or insertions.
|
||||||
// Same level: accept only when verifiedSats grows OR the source differs
|
if (entry.status.level < existing.status.level) return@withLock false
|
||||||
// (legitimate alternate signer republishing a split-zap receipt). A strictly
|
// Same level: accept only when verifiedSats grows OR the source differs
|
||||||
// smaller verifiedSats is a backend downgrade and we ignore it.
|
// (legitimate alternate signer republishing a split-zap receipt). A strictly
|
||||||
if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false
|
// smaller verifiedSats is a backend downgrade and we ignore it.
|
||||||
|
if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return@withLock false
|
||||||
|
}
|
||||||
|
onchainZaps = onchainZaps + Pair(txid, entry)
|
||||||
|
return@withLock true
|
||||||
}
|
}
|
||||||
onchainZaps = onchainZaps + Pair(txid, entry)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun innerRemoveOnchainZapForSource(
|
private fun innerRemoveOnchainZapForSource(
|
||||||
txid: String,
|
txid: String,
|
||||||
sourceAuthorPubKey: HexKey,
|
sourceAuthorPubKey: HexKey,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
val existing = onchainZaps[txid] ?: return false
|
syncLock.withLock {
|
||||||
// Anti-spoof: only remove the entry if its source matches the rejecting event.
|
val existing = onchainZaps[txid] ?: return@withLock false
|
||||||
// Otherwise a malicious third party could erase a legitimate CONFIRMED entry
|
// Anti-spoof: only remove the entry if its source matches the rejecting event.
|
||||||
// by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
|
// Otherwise a malicious third party could erase a legitimate CONFIRMED entry
|
||||||
// Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
|
// by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
|
||||||
// CONFIRMED replacement should change it; a transient backend hiccup must not
|
// Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
|
||||||
// wipe a previously-CONFIRMED entry just because some other target on the same
|
// CONFIRMED replacement should change it; a transient backend hiccup must not
|
||||||
// event is still UNVERIFIED.
|
// wipe a previously-CONFIRMED entry just because some other target on the same
|
||||||
if (existing.status == OnchainZapStatus.CONFIRMED) return false
|
// event is still UNVERIFIED.
|
||||||
if (existing.source.author?.pubkeyHex == null) return false
|
if (existing.status == OnchainZapStatus.CONFIRMED) return@withLock false
|
||||||
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false
|
if (existing.source.author?.pubkeyHex == null) return@withLock false
|
||||||
onchainZaps = onchainZaps - txid
|
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return@withLock false
|
||||||
return true
|
onchainZaps = onchainZaps - txid
|
||||||
}
|
return@withLock true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own
|
* Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own
|
||||||
@@ -535,19 +543,18 @@ open class Note(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun innerAddZapPayment(
|
private fun innerAddZapPayment(
|
||||||
zapPaymentRequest: Note,
|
zapPaymentRequest: Note,
|
||||||
zapPayment: Note?,
|
zapPayment: Note?,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
if (zapPayments[zapPaymentRequest] == null) {
|
syncLock.withLock {
|
||||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
if (zapPayments[zapPaymentRequest] == null) {
|
||||||
return true
|
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||||
|
return@withLock true
|
||||||
|
}
|
||||||
|
return@withLock false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addZapPayment(
|
fun addZapPayment(
|
||||||
zapPaymentRequest: Note,
|
zapPaymentRequest: Note,
|
||||||
zapPayment: Note?,
|
zapPayment: Note?,
|
||||||
@@ -589,12 +596,12 @@ open class Note(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addRelaySync(relay: NormalizedRelayUrl) =
|
||||||
fun addRelaySync(relay: NormalizedRelayUrl) {
|
syncLock.withLock {
|
||||||
if (relay !in relays) {
|
if (relay !in relays) {
|
||||||
relays = relays + relay
|
relays = relays + relay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun hasRelay(relay: NormalizedRelayUrl) = relay in relays
|
fun hasRelay(relay: NormalizedRelayUrl) = relay in relays
|
||||||
|
|
||||||
@@ -737,13 +744,13 @@ open class Note(
|
|||||||
}.flatten()
|
}.flatten()
|
||||||
|
|
||||||
private fun updateZapTotal() {
|
private fun updateZapTotal() {
|
||||||
var sumOfAmounts = BigDecimal.ZERO
|
var sumOfAmounts = BigDecimal(0)
|
||||||
|
|
||||||
// Regular Zap Receipts
|
// Regular Zap Receipts
|
||||||
zaps.forEach {
|
zaps.forEach {
|
||||||
val noteEvent = it.value?.event
|
val noteEvent = it.value?.event
|
||||||
if (noteEvent is LnZapEvent) {
|
if (noteEvent is LnZapEvent) {
|
||||||
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
|
sumOfAmounts += noteEvent.amount ?: BigDecimal(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +758,7 @@ open class Note(
|
|||||||
// Unverified/pending entries are tracked but excluded from the total per spec.
|
// Unverified/pending entries are tracked but excluded from the total per spec.
|
||||||
onchainZaps.values.forEach { entry ->
|
onchainZaps.values.forEach { entry ->
|
||||||
if (entry.status == OnchainZapStatus.CONFIRMED) {
|
if (entry.status == OnchainZapStatus.CONFIRMED) {
|
||||||
sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats)
|
sumOfAmounts += BigDecimal(entry.verifiedSats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,7 +835,7 @@ open class Note(
|
|||||||
val amount =
|
val amount =
|
||||||
try {
|
try {
|
||||||
LnInvoiceUtil.getAmountInSats(invoice)
|
LnInvoiceUtil.getAmountInSats(invoice)
|
||||||
} catch (e: java.lang.Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -879,7 +886,7 @@ open class Note(
|
|||||||
.any {
|
.any {
|
||||||
val pledgeValue =
|
val pledgeValue =
|
||||||
try {
|
try {
|
||||||
BigDecimal(it.event?.content)
|
it.event?.content?.let { content -> BigDecimal(content) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
null
|
null
|
||||||
@@ -889,7 +896,13 @@ open class Note(
|
|||||||
pledgeValue != null && it.author == user
|
pledgeValue != null && it.author == user
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO }
|
// Manual fold rather than Iterable.sumOf { -> BigDecimal } because that
|
||||||
|
// overload is JVM-only; the common stdlib only ships sumOf for the
|
||||||
|
// primitive numeric types.
|
||||||
|
fun pledgedAmountByOthers(): BigDecimal =
|
||||||
|
replies.fold(BigDecimal(0)) { acc, note ->
|
||||||
|
acc + (note.event?.addedRewardValue() ?: BigDecimal(0))
|
||||||
|
}
|
||||||
|
|
||||||
fun hasAnyReports(): Boolean {
|
fun hasAnyReports(): Boolean {
|
||||||
val dayAgo = TimeUtils.oneDayAgo()
|
val dayAgo = TimeUtils.oneDayAgo()
|
||||||
@@ -978,7 +991,7 @@ open class Note(
|
|||||||
boosts = emptyList()
|
boosts = emptyList()
|
||||||
reports = emptyMap()
|
reports = emptyMap()
|
||||||
zaps = emptyMap()
|
zaps = emptyMap()
|
||||||
zapsAmount = BigDecimal.ZERO
|
zapsAmount = BigDecimal(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean {
|
fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean {
|
||||||
@@ -1035,18 +1048,18 @@ open class Note(
|
|||||||
|
|
||||||
var flowSet: NoteFlowSet? = null
|
var flowSet: NoteFlowSet? = null
|
||||||
|
|
||||||
@Synchronized
|
fun createOrDestroyFlowSync(create: Boolean) =
|
||||||
fun createOrDestroyFlowSync(create: Boolean) {
|
syncLock.withLock {
|
||||||
if (create) {
|
if (create) {
|
||||||
if (flowSet == null) {
|
if (flowSet == null) {
|
||||||
flowSet = NoteFlowSet(this)
|
flowSet = NoteFlowSet(this)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||||
flowSet = null
|
flowSet = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun flow(): NoteFlowSet {
|
fun flow(): NoteFlowSet {
|
||||||
if (flowSet == null) {
|
if (flowSet == null) {
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
|||||||
+69
-64
@@ -26,13 +26,15 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd
|
|||||||
import com.vitorpamplona.amethyst.commons.model.ListChange
|
import com.vitorpamplona.amethyst.commons.model.ListChange
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.WeakReference
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a Marmot MLS group chat room.
|
* Represents a Marmot MLS group chat room.
|
||||||
@@ -84,31 +86,34 @@ class MarmotGroupChatroom(
|
|||||||
return adminPubkeys.value.any { it in followingKeySet }
|
return adminPubkeys.value.any { it in followingKeySet }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-instance lock shared by previously @Synchronized methods.
|
||||||
|
private val syncLock = KmpLock()
|
||||||
|
|
||||||
// Synthetic note used by list views to represent the group when no
|
// Synthetic note used by list views to represent the group when no
|
||||||
// messages have been received yet. Lazily created and kept stable so
|
// messages have been received yet. Lazily created and kept stable so
|
||||||
// equality-based feed diffing treats it as the same row across refreshes.
|
// equality-based feed diffing treats it as the same row across refreshes.
|
||||||
private var cachedPlaceholder: Note? = null
|
private var cachedPlaceholder: Note? = null
|
||||||
|
|
||||||
@Synchronized
|
fun placeholderNote(): Note =
|
||||||
fun placeholderNote(): Note {
|
syncLock.withLock {
|
||||||
val existing = cachedPlaceholder
|
val existing = cachedPlaceholder
|
||||||
if (existing != null) return existing
|
if (existing != null) return@withLock existing
|
||||||
val created =
|
val created =
|
||||||
Note(placeholderIdHex(nostrGroupId)).apply {
|
Note(placeholderIdHex(nostrGroupId)).apply {
|
||||||
addGatherer(this@MarmotGroupChatroom)
|
addGatherer(this@MarmotGroupChatroom)
|
||||||
}
|
}
|
||||||
cachedPlaceholder = created
|
cachedPlaceholder = created
|
||||||
return created
|
created
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
|
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
|
||||||
|
|
||||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||||
val current = changesFlow.get()
|
val current = changesFlow?.get()
|
||||||
if (current != null) return current
|
if (current != null) return current
|
||||||
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
|
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
|
||||||
changesFlow = WeakReference(new)
|
changesFlow = WeakReference(new)
|
||||||
@@ -119,23 +124,23 @@ class MarmotGroupChatroom(
|
|||||||
removeMessageSync(note)
|
removeMessageSync(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addMessageSync(msg: Note): Boolean =
|
||||||
fun addMessageSync(msg: Note): Boolean {
|
syncLock.withLock {
|
||||||
if (msg !in messages) {
|
if (msg !in messages) {
|
||||||
messages = messages + msg
|
messages = messages + msg
|
||||||
msg.addGatherer(this)
|
msg.addGatherer(this)
|
||||||
|
|
||||||
val createdAt = msg.createdAt() ?: 0L
|
val createdAt = msg.createdAt() ?: 0L
|
||||||
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
||||||
newestMessage = msg
|
newestMessage = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadCount.value += 1
|
||||||
|
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
|
||||||
|
return@withLock true
|
||||||
}
|
}
|
||||||
|
return@withLock false
|
||||||
unreadCount.value += 1
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a message that is being restored from persistent storage on app
|
* Add a message that is being restored from persistent storage on app
|
||||||
@@ -143,38 +148,38 @@ class MarmotGroupChatroom(
|
|||||||
* count — restored messages were already seen by the user in a previous
|
* count — restored messages were already seen by the user in a previous
|
||||||
* session.
|
* session.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
fun restoreMessageSync(msg: Note): Boolean =
|
||||||
fun restoreMessageSync(msg: Note): Boolean {
|
syncLock.withLock {
|
||||||
if (msg !in messages) {
|
if (msg !in messages) {
|
||||||
messages = messages + msg
|
messages = messages + msg
|
||||||
msg.addGatherer(this)
|
msg.addGatherer(this)
|
||||||
|
|
||||||
val createdAt = msg.createdAt() ?: 0L
|
val createdAt = msg.createdAt() ?: 0L
|
||||||
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
||||||
newestMessage = msg
|
newestMessage = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
|
||||||
|
return@withLock true
|
||||||
}
|
}
|
||||||
|
return@withLock false
|
||||||
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
fun removeMessageSync(msg: Note): Boolean =
|
||||||
fun removeMessageSync(msg: Note): Boolean {
|
syncLock.withLock {
|
||||||
if (msg in messages) {
|
if (msg in messages) {
|
||||||
messages = messages - msg
|
messages = messages - msg
|
||||||
msg.removeGatherer(this)
|
msg.removeGatherer(this)
|
||||||
|
|
||||||
if (msg == newestMessage) {
|
if (msg == newestMessage) {
|
||||||
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
|
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
|
||||||
|
return@withLock true
|
||||||
}
|
}
|
||||||
|
return@withLock false
|
||||||
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsRead() {
|
fun markAsRead() {
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
@@ -199,7 +204,7 @@ class MarmotGroupChatroom(
|
|||||||
val toRemove = messages.minus(toKeep)
|
val toRemove = messages.minus(toKeep)
|
||||||
messages = toKeep
|
messages = toKeep
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||||
return toRemove
|
return toRemove
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,14 +214,14 @@ class MarmotGroupChatroom(
|
|||||||
* decrypted inner notes become eligible for GC out of LocalCache (which
|
* decrypted inner notes become eligible for GC out of LocalCache (which
|
||||||
* holds them weakly).
|
* holds them weakly).
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
fun clearAllMessagesSync(): Set<Note> =
|
||||||
fun clearAllMessagesSync(): Set<Note> {
|
syncLock.withLock {
|
||||||
val toRemove = messages
|
val toRemove = messages
|
||||||
if (toRemove.isEmpty()) return toRemove
|
if (toRemove.isEmpty()) return@withLock toRemove
|
||||||
messages = emptySet()
|
messages = emptySet()
|
||||||
newestMessage = null
|
newestMessage = null
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||||
return toRemove
|
toRemove
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -21,10 +21,13 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model.nip01Core
|
package com.vitorpamplona.amethyst.commons.model.nip01Core
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.WeakReference
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import java.lang.ref.WeakReference
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
data class RelayInfo(
|
data class RelayInfo(
|
||||||
@@ -52,10 +55,16 @@ val DefaultOrder =
|
|||||||
@Stable
|
@Stable
|
||||||
class UserRelaysCache {
|
class UserRelaysCache {
|
||||||
var data: Map<NormalizedRelayUrl, RelayInfo> = mapOf()
|
var data: Map<NormalizedRelayUrl, RelayInfo> = mapOf()
|
||||||
|
|
||||||
|
// @Volatile is required for the double-checked locking in flow() to be
|
||||||
|
// safe on weak-memory platforms (K/N + ARM). Without it, the outer
|
||||||
|
// fast-path read could observe a partially-published WeakReference.
|
||||||
|
@Volatile
|
||||||
private var flow: WeakReference<MutableStateFlow<Wrapper>>? = null
|
private var flow: WeakReference<MutableStateFlow<Wrapper>>? = null
|
||||||
|
private val flowLock = KmpLock()
|
||||||
|
|
||||||
fun flow() =
|
fun flow() =
|
||||||
flow?.get() ?: synchronized(this) {
|
flow?.get() ?: flowLock.withLock {
|
||||||
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
|
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-45
@@ -21,18 +21,19 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model.nip02FollowList
|
package com.vitorpamplona.amethyst.commons.model.nip02FollowList
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.amethyst.commons.actions.FollowActions
|
||||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -112,52 +113,27 @@ class Kind3FollowListState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun follow(users: List<User>): ContactListEvent {
|
suspend fun follow(users: List<User>): ContactListEvent =
|
||||||
val contactList = getFollowListEvent()
|
FollowActions.buildFollowBatch(
|
||||||
|
signer = signer,
|
||||||
|
pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() },
|
||||||
|
currentContactList = getFollowListEvent(),
|
||||||
|
)
|
||||||
|
|
||||||
val contacts =
|
suspend fun follow(user: User): ContactListEvent =
|
||||||
users.map {
|
FollowActions.buildFollow(
|
||||||
ContactTag(it.pubkeyHex, it.bestRelayHint(), null)
|
signer = signer,
|
||||||
}
|
pubkeyToFollow = user.pubkeyHex,
|
||||||
|
currentContactList = getFollowListEvent(),
|
||||||
|
relayHint = user.bestRelayHint(),
|
||||||
|
)
|
||||||
|
|
||||||
return if (contactList != null) {
|
suspend fun unfollow(user: User): ContactListEvent? =
|
||||||
ContactListEvent.followUsers(contactList, contacts, signer)
|
FollowActions.buildUnfollow(
|
||||||
} else {
|
signer = signer,
|
||||||
ContactListEvent.createFromScratch(
|
pubkeyToUnfollow = user.pubkeyHex,
|
||||||
followUsers = contacts,
|
currentContactList = getFollowListEvent(),
|
||||||
relayUse = emptyMap(),
|
)
|
||||||
signer = signer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun follow(user: User): ContactListEvent {
|
|
||||||
val contactList = getFollowListEvent()
|
|
||||||
|
|
||||||
return if (contactList != null) {
|
|
||||||
ContactListEvent.followUser(contactList, user.pubkeyHex, signer)
|
|
||||||
} else {
|
|
||||||
ContactListEvent.createFromScratch(
|
|
||||||
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
|
|
||||||
relayUse = emptyMap(),
|
|
||||||
signer = signer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun unfollow(user: User): ContactListEvent? {
|
|
||||||
val contactList = getFollowListEvent()
|
|
||||||
|
|
||||||
return if (contactList != null && contactList.tags.isNotEmpty()) {
|
|
||||||
ContactListEvent.unfollowUser(
|
|
||||||
contactList,
|
|
||||||
user.pubkeyHex,
|
|
||||||
signer,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
settings.backupContactList?.let {
|
settings.backupContactList?.let {
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ object LongFormPublishAction {
|
|||||||
throw IllegalStateException("Cannot publish: signer is not writeable")
|
throw IllegalStateException("Cannot publish: signer is not writeable")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
|
if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) {
|
||||||
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
|
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -32,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
|
|||||||
+1
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
|||||||
+22
-35
@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.commons.model.observables
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
|
||||||
val CreatedAtIdHexComparator: Comparator<Note> =
|
val CreatedAtIdHexComparator: Comparator<Note> =
|
||||||
Comparator { note1, note2 ->
|
Comparator { note1, note2 ->
|
||||||
// Sort by created_at and idhex, but define uniqueness by reference
|
// Sort by created_at and idhex, but define uniqueness by reference
|
||||||
if (note1 === note2) return@Comparator 0
|
if (note1 === note2) return@Comparator 0
|
||||||
|
|
||||||
if (note1 == null) return@Comparator 1 // null is greater, moves to last
|
|
||||||
if (note2 == null) return@Comparator -1 // null is greater, moves to last
|
|
||||||
|
|
||||||
val createdAt1 = note1.createdAt()
|
val createdAt1 = note1.createdAt()
|
||||||
val createdAt2 = note2.createdAt()
|
val createdAt2 = note2.createdAt()
|
||||||
|
|
||||||
@@ -49,42 +47,31 @@ val CreatedAtIdHexComparator: Comparator<Note> =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inline fun <T> compareByCreatedAt(
|
||||||
|
a: T,
|
||||||
|
b: T,
|
||||||
|
eventOf: (T) -> Event?,
|
||||||
|
): Int {
|
||||||
|
val firstEvent = eventOf(a)
|
||||||
|
val secondEvent = eventOf(b)
|
||||||
|
return when {
|
||||||
|
firstEvent == null && secondEvent == null -> 0
|
||||||
|
firstEvent == null -> 1
|
||||||
|
secondEvent == null -> -1
|
||||||
|
else -> firstEvent.createdAt.compareTo(secondEvent.createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object CreatedAtComparator : Comparator<Note> {
|
object CreatedAtComparator : Comparator<Note> {
|
||||||
override fun compare(
|
override fun compare(
|
||||||
first: Note?,
|
a: Note,
|
||||||
second: Note?,
|
b: Note,
|
||||||
): Int {
|
): Int = compareByCreatedAt(a, b) { it.event }
|
||||||
val firstEvent = first?.event
|
|
||||||
val secondEvent = second?.event
|
|
||||||
|
|
||||||
return if (firstEvent == null && secondEvent == null) {
|
|
||||||
0
|
|
||||||
} else if (firstEvent == null) {
|
|
||||||
1
|
|
||||||
} else if (secondEvent == null) {
|
|
||||||
-1
|
|
||||||
} else {
|
|
||||||
firstEvent.createdAt.compareTo(secondEvent.createdAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
|
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
|
||||||
override fun compare(
|
override fun compare(
|
||||||
first: AddressableNote?,
|
a: AddressableNote,
|
||||||
second: AddressableNote?,
|
b: AddressableNote,
|
||||||
): Int {
|
): Int = compareByCreatedAt(a, b) { it.event }
|
||||||
val firstEvent = first?.event
|
|
||||||
val secondEvent = second?.event
|
|
||||||
|
|
||||||
return if (firstEvent == null && secondEvent == null) {
|
|
||||||
0
|
|
||||||
} else if (firstEvent == null) {
|
|
||||||
1
|
|
||||||
} else if (secondEvent == null) {
|
|
||||||
-1
|
|
||||||
} else {
|
|
||||||
firstEvent.createdAt.compareTo(secondEvent.createdAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-62
@@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.WeakReference
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||||
@@ -33,7 +36,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class Chatroom : NotesGatherer {
|
class Chatroom : NotesGatherer {
|
||||||
@@ -44,10 +46,13 @@ class Chatroom : NotesGatherer {
|
|||||||
var ownerSentMessage: Boolean = false
|
var ownerSentMessage: Boolean = false
|
||||||
var newestMessage: Note? = null
|
var newestMessage: Note? = null
|
||||||
|
|
||||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
// Per-instance lock shared by previously @Synchronized methods.
|
||||||
|
private val syncLock = KmpLock()
|
||||||
|
|
||||||
|
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
|
||||||
|
|
||||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||||
val current = changesFlow.get()
|
val current = changesFlow?.get()
|
||||||
if (current != null) return current
|
if (current != null) return current
|
||||||
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
|
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
|
||||||
changesFlow = WeakReference(new)
|
changesFlow = WeakReference(new)
|
||||||
@@ -58,68 +63,68 @@ class Chatroom : NotesGatherer {
|
|||||||
removeMessageSync(note)
|
removeMessageSync(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addMessageSync(msg: Note): Boolean =
|
||||||
fun addMessageSync(msg: Note): Boolean {
|
syncLock.withLock {
|
||||||
if (msg !in messages) {
|
if (msg !in messages) {
|
||||||
messages = messages + msg
|
messages = messages + msg
|
||||||
msg.addGatherer(this)
|
msg.addGatherer(this)
|
||||||
|
|
||||||
msg.author?.let { author ->
|
msg.author?.let { author ->
|
||||||
if (author !in activeSenders) {
|
if (author !in activeSenders) {
|
||||||
activeSenders + author
|
activeSenders + author
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val createdAt = msg.createdAt() ?: 0L
|
|
||||||
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
|
||||||
newestMessage = msg
|
|
||||||
}
|
|
||||||
|
|
||||||
val newSubject = msg.event?.subject()
|
|
||||||
|
|
||||||
if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
|
|
||||||
subject.tryEmit(newSubject)
|
|
||||||
subjectCreatedAt = msg.createdAt()
|
|
||||||
}
|
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeMessageSync(msg: Note): Boolean {
|
|
||||||
if (msg in messages) {
|
|
||||||
messages = messages - msg
|
|
||||||
msg.removeGatherer(this)
|
|
||||||
|
|
||||||
if (msg == newestMessage) {
|
|
||||||
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.event?.subject() == subject.value) {
|
|
||||||
messages
|
|
||||||
.maxByOrNull {
|
|
||||||
val noteEvent = it.event
|
|
||||||
if (noteEvent?.subject() != null) {
|
|
||||||
noteEvent.createdAt
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}?.let {
|
|
||||||
subject.tryEmit(it.event?.subject())
|
|
||||||
subjectCreatedAt = it.createdAt()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val createdAt = msg.createdAt() ?: 0L
|
||||||
|
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
|
||||||
|
newestMessage = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
val newSubject = msg.event?.subject()
|
||||||
|
|
||||||
|
if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
|
||||||
|
subject.tryEmit(newSubject)
|
||||||
|
subjectCreatedAt = msg.createdAt()
|
||||||
|
}
|
||||||
|
|
||||||
|
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
|
||||||
|
|
||||||
|
return@withLock true
|
||||||
}
|
}
|
||||||
|
return@withLock false
|
||||||
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
|
}
|
||||||
|
|
||||||
return true
|
fun removeMessageSync(msg: Note): Boolean =
|
||||||
|
syncLock.withLock {
|
||||||
|
if (msg in messages) {
|
||||||
|
messages = messages - msg
|
||||||
|
msg.removeGatherer(this)
|
||||||
|
|
||||||
|
if (msg == newestMessage) {
|
||||||
|
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.event?.subject() == subject.value) {
|
||||||
|
messages
|
||||||
|
.maxByOrNull {
|
||||||
|
val noteEvent = it.event
|
||||||
|
if (noteEvent?.subject() != null) {
|
||||||
|
noteEvent.createdAt
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}?.let {
|
||||||
|
subject.tryEmit(it.event?.subject())
|
||||||
|
subjectCreatedAt = it.createdAt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
|
||||||
|
|
||||||
|
return@withLock true
|
||||||
|
}
|
||||||
|
return@withLock false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
|
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
|
||||||
|
|
||||||
@@ -138,7 +143,7 @@ class Chatroom : NotesGatherer {
|
|||||||
val toRemove = messages.minus(toKeep)
|
val toRemove = messages.minus(toKeep)
|
||||||
messages = toKeep
|
messages = toKeep
|
||||||
|
|
||||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||||
|
|
||||||
return toRemove
|
return toRemove
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
|
|||||||
import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter
|
import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter
|
||||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
|||||||
+13
-9
@@ -21,7 +21,8 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This allows composables to directly register their queries
|
* This allows composables to directly register their queries
|
||||||
@@ -32,13 +33,16 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
abstract class ComposeSubscriptionManager<T> :
|
abstract class ComposeSubscriptionManager<T> :
|
||||||
ComposeSubscriptionManagerControls,
|
ComposeSubscriptionManagerControls,
|
||||||
Subscribable<T> {
|
Subscribable<T> {
|
||||||
private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap()
|
// T has no Comparable bound — see the note in MutableComposeSubscriptionManager
|
||||||
|
// for why LargeCache isn't used here.
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val composeSubscriptions = mutableMapOf<T, T>()
|
||||||
|
|
||||||
// This is called by main. Keep it really fast.
|
// This is called by main. Keep it really fast.
|
||||||
override fun subscribe(query: T?) {
|
override fun subscribe(query: T?) {
|
||||||
if (query == null) return
|
if (query == null) return
|
||||||
|
|
||||||
composeSubscriptions.put(query, query)
|
lock.withLock { composeSubscriptions[query] = query }
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
}
|
}
|
||||||
@@ -47,7 +51,7 @@ abstract class ComposeSubscriptionManager<T> :
|
|||||||
override fun unsubscribe(query: T?) {
|
override fun unsubscribe(query: T?) {
|
||||||
if (query == null) return
|
if (query == null) return
|
||||||
|
|
||||||
composeSubscriptions.remove(query)
|
lock.withLock { composeSubscriptions.remove(query) }
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
}
|
}
|
||||||
@@ -55,8 +59,8 @@ abstract class ComposeSubscriptionManager<T> :
|
|||||||
override fun subscribe(query: List<T>) {
|
override fun subscribe(query: List<T>) {
|
||||||
if (query.isEmpty()) return
|
if (query.isEmpty()) return
|
||||||
|
|
||||||
query.forEach {
|
lock.withLock {
|
||||||
composeSubscriptions.put(it, it)
|
query.forEach { composeSubscriptions[it] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
@@ -66,14 +70,14 @@ abstract class ComposeSubscriptionManager<T> :
|
|||||||
override fun unsubscribe(query: List<T>) {
|
override fun unsubscribe(query: List<T>) {
|
||||||
if (query.isEmpty()) return
|
if (query.isEmpty()) return
|
||||||
|
|
||||||
query.forEach {
|
lock.withLock {
|
||||||
composeSubscriptions.remove(it)
|
query.forEach { composeSubscriptions.remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun allKeys() = composeSubscriptions.keys
|
fun allKeys(): Set<T> = lock.withLock { composeSubscriptions.keys.toSet() }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Subscribable<T> {
|
interface Subscribable<T> {
|
||||||
|
|||||||
+23
-12
@@ -21,12 +21,13 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This allows composables to directly register their queries
|
* This allows composables to directly register their queries
|
||||||
@@ -41,19 +42,26 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
|
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
|
||||||
val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
) : ComposeSubscriptionManagerControls {
|
) : ComposeSubscriptionManagerControls {
|
||||||
private var composeSubscriptions: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
|
// T is generic and not required to be Comparable, so this can't use
|
||||||
|
// LargeCache (ConcurrentSkipListMap-backed on JVM). A plain map guarded by
|
||||||
|
// KmpLock gives the same atomicity guarantees ConcurrentHashMap did
|
||||||
|
// previously, with no Comparable requirement.
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val composeSubscriptions = mutableMapOf<T, Job>()
|
||||||
|
|
||||||
// This is called by main. Keep it really fast.
|
// This is called by main. Keep it really fast.
|
||||||
fun subscribe(query: T?) {
|
fun subscribe(query: T?) {
|
||||||
if (query == null) return
|
if (query == null) return
|
||||||
|
|
||||||
composeSubscriptions[query]?.cancel()
|
lock.withLock {
|
||||||
composeSubscriptions[query] =
|
composeSubscriptions[query]?.cancel()
|
||||||
scope.launch {
|
composeSubscriptions[query] =
|
||||||
query.flow().collectLatest {
|
scope.launch {
|
||||||
invalidateKeys()
|
query.flow().collectLatest {
|
||||||
|
invalidateKeys()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
}
|
}
|
||||||
@@ -62,16 +70,19 @@ abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
|
|||||||
fun unsubscribe(query: T?) {
|
fun unsubscribe(query: T?) {
|
||||||
if (query == null) return
|
if (query == null) return
|
||||||
|
|
||||||
composeSubscriptions[query]?.cancel()
|
lock.withLock {
|
||||||
composeSubscriptions.remove(query)
|
composeSubscriptions[query]?.cancel()
|
||||||
|
composeSubscriptions.remove(query)
|
||||||
|
}
|
||||||
|
|
||||||
invalidateKeys()
|
invalidateKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun allKeys() = composeSubscriptions.keys
|
fun allKeys(): Set<T> = lock.withLock { composeSubscriptions.keys.toSet() }
|
||||||
|
|
||||||
fun forEachSubscriber(action: (T) -> Unit) {
|
fun forEachSubscriber(action: (T) -> Unit) {
|
||||||
composeSubscriptions.keys.forEach(action)
|
val snapshot = lock.withLock { composeSubscriptions.keys.toList() }
|
||||||
|
snapshot.forEach(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
|
||||||
abstract class BaseEoseManager<T>(
|
abstract class BaseEoseManager<T>(
|
||||||
val client: INostrClient,
|
val client: INostrClient,
|
||||||
|
|||||||
+13
-11
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.relays
|
package com.vitorpamplona.amethyst.commons.relays
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,14 +35,14 @@ open class EOSECache<K : Any>(
|
|||||||
private val maxSize: Int = 200,
|
private val maxSize: Int = 200,
|
||||||
) {
|
) {
|
||||||
private val cache = linkedMapOf<K, EOSERelayList>()
|
private val cache = linkedMapOf<K, EOSERelayList>()
|
||||||
private val lock = Any()
|
private val lock = KmpLock()
|
||||||
|
|
||||||
fun addOrUpdate(
|
fun addOrUpdate(
|
||||||
key: K,
|
key: K,
|
||||||
relayUrl: NormalizedRelayUrl,
|
relayUrl: NormalizedRelayUrl,
|
||||||
time: Long,
|
time: Long,
|
||||||
) {
|
) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
val relayList = cache[key]
|
val relayList = cache[key]
|
||||||
if (relayList == null) {
|
if (relayList == null) {
|
||||||
// Evict oldest if at capacity
|
// Evict oldest if at capacity
|
||||||
@@ -57,7 +59,7 @@ open class EOSECache<K : Any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun since(key: K): SincePerRelayMap? =
|
fun since(key: K): SincePerRelayMap? =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache[key]?.relayList?.toMutableMap()
|
cache[key]?.relayList?.toMutableMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,18 +70,18 @@ open class EOSECache<K : Any>(
|
|||||||
) = addOrUpdate(key, relayUrl, time)
|
) = addOrUpdate(key, relayUrl, time)
|
||||||
|
|
||||||
fun remove(key: K) {
|
fun remove(key: K) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun size(): Int = synchronized(lock) { cache.size }
|
fun size(): Int = lock.withLock { cache.size }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +93,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
|||||||
private val innerMaxSize: Int = 200,
|
private val innerMaxSize: Int = 200,
|
||||||
) {
|
) {
|
||||||
private val cache = linkedMapOf<K1, EOSECache<K2>>()
|
private val cache = linkedMapOf<K1, EOSECache<K2>>()
|
||||||
private val lock = Any()
|
private val lock = KmpLock()
|
||||||
|
|
||||||
fun addOrUpdate(
|
fun addOrUpdate(
|
||||||
outerKey: K1,
|
outerKey: K1,
|
||||||
@@ -99,7 +101,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
|||||||
relayUrl: NormalizedRelayUrl,
|
relayUrl: NormalizedRelayUrl,
|
||||||
time: Long,
|
time: Long,
|
||||||
) {
|
) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
val innerCache = cache[outerKey]
|
val innerCache = cache[outerKey]
|
||||||
if (innerCache == null) {
|
if (innerCache == null) {
|
||||||
// Evict oldest if at capacity
|
// Evict oldest if at capacity
|
||||||
@@ -119,7 +121,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
|||||||
outerKey: K1,
|
outerKey: K1,
|
||||||
innerKey: K2,
|
innerKey: K2,
|
||||||
): SincePerRelayMap? =
|
): SincePerRelayMap? =
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache[outerKey]?.since(innerKey)
|
cache[outerKey]?.since(innerKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +133,13 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
|||||||
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
|
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
|
||||||
|
|
||||||
fun removeOuter(key: K1) {
|
fun removeOuter(key: K1) {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
synchronized(lock) {
|
lock.withLock {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-9
@@ -20,21 +20,20 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.richtext
|
package com.vitorpamplona.amethyst.commons.richtext
|
||||||
|
|
||||||
import java.util.Base64
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
object Base64Image {
|
object Base64Image {
|
||||||
val pattern = Patterns.BASE64_IMAGE
|
val pattern = Patterns.BASE64_IMAGE
|
||||||
|
|
||||||
fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content)
|
fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
fun parse(content: String): ByteArray {
|
fun parse(content: String): ByteArray {
|
||||||
val matcher = pattern.find(content)
|
val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content")
|
||||||
if (matcher != null) {
|
val base64String =
|
||||||
val base64String = matcher.groups[2]?.value
|
match.groups[2]?.value
|
||||||
val byteArray = Base64.getDecoder().decode(base64String)
|
?: throw Exception("Unable to convert base64 to image $content")
|
||||||
return byteArray
|
return Base64.decode(base64String)
|
||||||
}
|
|
||||||
|
|
||||||
throw Exception("Unable to convert base64 to image $content")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-42
@@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext
|
|||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||||
import java.io.File
|
|
||||||
|
// URL-based media content models (KMP). The locally-cached variants
|
||||||
|
// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in
|
||||||
|
// MediaLocalContent.kt under the jvmAndroid source set because they depend
|
||||||
|
// on java.io.File.
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
abstract class BaseMediaContent(
|
abstract class BaseMediaContent(
|
||||||
@@ -123,44 +127,3 @@ class EncryptedMediaUrlVideo(
|
|||||||
thumbhash: String? = null,
|
thumbhash: String? = null,
|
||||||
authorPubKey: String? = null,
|
authorPubKey: String? = null,
|
||||||
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey)
|
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey)
|
||||||
|
|
||||||
@Immutable
|
|
||||||
abstract class MediaPreloadedContent(
|
|
||||||
val localFile: File?,
|
|
||||||
description: String? = null,
|
|
||||||
val mimeType: String? = null,
|
|
||||||
val isVerified: Boolean? = null,
|
|
||||||
dim: DimensionTag? = null,
|
|
||||||
blurhash: String? = null,
|
|
||||||
val uri: String,
|
|
||||||
val id: String? = null,
|
|
||||||
thumbhash: String? = null,
|
|
||||||
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
|
|
||||||
fun localFileExists() = localFile != null && localFile.exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
class MediaLocalImage(
|
|
||||||
localFile: File?,
|
|
||||||
mimeType: String? = null,
|
|
||||||
description: String? = null,
|
|
||||||
dim: DimensionTag? = null,
|
|
||||||
blurhash: String? = null,
|
|
||||||
isVerified: Boolean? = null,
|
|
||||||
uri: String,
|
|
||||||
thumbhash: String? = null,
|
|
||||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
class MediaLocalVideo(
|
|
||||||
localFile: File?,
|
|
||||||
mimeType: String? = null,
|
|
||||||
description: String? = null,
|
|
||||||
dim: DimensionTag? = null,
|
|
||||||
blurhash: String? = null,
|
|
||||||
isVerified: Boolean? = null,
|
|
||||||
uri: String,
|
|
||||||
val artworkUri: String? = null,
|
|
||||||
val authorName: String? = null,
|
|
||||||
thumbhash: String? = null,
|
|
||||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
|
|
||||||
|
|||||||
+2
-18
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.isValidUrl
|
||||||
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
|
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||||
@@ -40,9 +41,6 @@ import kotlinx.collections.immutable.persistentListOf
|
|||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import java.net.MalformedURLException
|
|
||||||
import java.net.URI
|
|
||||||
import java.net.URISyntaxException
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
class RichTextParser {
|
class RichTextParser {
|
||||||
@@ -489,21 +487,7 @@ class RichTextParser {
|
|||||||
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isValidURL(url: String?): Boolean =
|
fun isValidURL(url: String?): Boolean = isValidUrl(url)
|
||||||
try {
|
|
||||||
if (url != null) {
|
|
||||||
URI(url).toURL()
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} catch (e: MalformedURLException) {
|
|
||||||
false
|
|
||||||
} catch (e: URISyntaxException) {
|
|
||||||
false
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
||||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||||
|
|||||||
+1
-1
@@ -168,7 +168,7 @@ class RobohashAssembler {
|
|||||||
Hex.decode(msg)
|
Hex.decode(msg)
|
||||||
} else {
|
} else {
|
||||||
Log.w("Robohash") { "$msg is not a hex" }
|
Log.w("Robohash") { "$msg is not a hex" }
|
||||||
sha256(msg.toByteArray())
|
sha256(msg.encodeToByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme))
|
val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme))
|
||||||
|
|||||||
+8
-5
@@ -20,15 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.search
|
package com.vitorpamplona.amethyst.commons.search
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
|
||||||
class EventDeduplicator {
|
class EventDeduplicator {
|
||||||
private val lock = Any()
|
private val lock = KmpLock()
|
||||||
private val seenIds = mutableSetOf<String>()
|
private val seenIds = mutableSetOf<String>()
|
||||||
|
|
||||||
fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) }
|
fun tryAdd(id: String): Boolean = lock.withLock { seenIds.add(id) }
|
||||||
|
|
||||||
fun contains(id: String): Boolean = synchronized(lock) { id in seenIds }
|
fun contains(id: String): Boolean = lock.withLock { id in seenIds }
|
||||||
|
|
||||||
fun clear() = synchronized(lock) { seenIds.clear() }
|
fun clear() = lock.withLock { seenIds.clear() }
|
||||||
|
|
||||||
val size: Int get() = synchronized(lock) { seenIds.size }
|
val size: Int get() = lock.withLock { seenIds.size }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
|||||||
+7
-9
@@ -21,6 +21,8 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.thumbhash
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -50,14 +52,14 @@ object ThumbHashDecoder {
|
|||||||
// (size, components) pairs across the entire app lifetime, each table is
|
// (size, components) pairs across the entire app lifetime, each table is
|
||||||
// a few KB, so the memory ceiling is tiny.
|
// a few KB, so the memory ceiling is tiny.
|
||||||
private val cosineCache = HashMap<Long, DoubleArray>()
|
private val cosineCache = HashMap<Long, DoubleArray>()
|
||||||
private val cosineCacheLock = Any()
|
private val cosineCacheLock = KmpLock()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the cosine table cache. Tables are tiny but callers under memory
|
* Clear the cosine table cache. Tables are tiny but callers under memory
|
||||||
* pressure can release them; they will be recomputed on demand.
|
* pressure can release them; they will be recomputed on demand.
|
||||||
*/
|
*/
|
||||||
fun clearCache() {
|
fun clearCache() {
|
||||||
synchronized(cosineCacheLock) { cosineCache.clear() }
|
cosineCacheLock.withLock { cosineCache.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cosTable(
|
private fun cosTable(
|
||||||
@@ -65,9 +67,8 @@ object ThumbHashDecoder {
|
|||||||
components: Int,
|
components: Int,
|
||||||
): DoubleArray {
|
): DoubleArray {
|
||||||
val key = (size.toLong() shl 32) or components.toLong()
|
val key = (size.toLong() shl 32) or components.toLong()
|
||||||
synchronized(cosineCacheLock) {
|
val cached = cosineCacheLock.withLock { cosineCache[key] }
|
||||||
cosineCache[key]?.let { return it }
|
if (cached != null) return cached
|
||||||
}
|
|
||||||
val table = DoubleArray(size * components)
|
val table = DoubleArray(size * components)
|
||||||
val piOverSize = PI / size
|
val piOverSize = PI / size
|
||||||
for (i in 0 until size) {
|
for (i in 0 until size) {
|
||||||
@@ -77,10 +78,7 @@ object ThumbHashDecoder {
|
|||||||
table[rowOffset + c] = cos(phase * c)
|
table[rowOffset + c] = cos(phase * c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
synchronized(cosineCacheLock) {
|
return cosineCacheLock.withLock { cosineCache.getOrPut(key) { table } }
|
||||||
cosineCache.getOrPut(key) { table }
|
|
||||||
}
|
|
||||||
return table
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ fun FeedErrorState(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRetry: (() -> Unit)? = null,
|
onRetry: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage)
|
val formattedMessage = stringResource(Res.string.error_loading_feed, errorMessage)
|
||||||
|
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = formattedMessage,
|
message = formattedMessage,
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
+9
-6
@@ -26,8 +26,10 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import kotlin.concurrent.atomics.AtomicInt
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
sealed class SigningOpState {
|
sealed class SigningOpState {
|
||||||
@@ -44,26 +46,27 @@ sealed class SigningOpState {
|
|||||||
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
|
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
|
||||||
* Observe [globalState] from a screen-level composable to show a persistent status bar.
|
* Observe [globalState] from a screen-level composable to show a persistent status bar.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
object GlobalSigningStatus {
|
object GlobalSigningStatus {
|
||||||
var globalState by mutableStateOf<SigningOpState>(SigningOpState.Idle)
|
var globalState by mutableStateOf<SigningOpState>(SigningOpState.Idle)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val activeCount = AtomicInteger(0)
|
private val activeCount = AtomicInt(0)
|
||||||
|
|
||||||
fun onPending() {
|
fun onPending() {
|
||||||
activeCount.incrementAndGet()
|
activeCount.addAndFetch(1)
|
||||||
globalState = SigningOpState.Pending
|
globalState = SigningOpState.Pending
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onIdle() {
|
fun onIdle() {
|
||||||
if (activeCount.decrementAndGet() <= 0) {
|
if (activeCount.addAndFetch(-1) <= 0) {
|
||||||
activeCount.set(0)
|
activeCount.store(0)
|
||||||
globalState = SigningOpState.Idle
|
globalState = SigningOpState.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onError(message: String) {
|
fun onError(message: String) {
|
||||||
activeCount.decrementAndGet()
|
activeCount.addAndFetch(-1)
|
||||||
globalState = SigningOpState.Error(message)
|
globalState = SigningOpState.Error(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.util
|
||||||
|
|
||||||
|
// KMP code-point helpers — replacements for `java.lang.Character.*` and the
|
||||||
|
// JVM-only `String.codePointAt(Int)` / `String.offsetByCodePoints(Int, Int)`
|
||||||
|
// extensions. UTF-16 surrogate-pair aware so emoji and other supplementary
|
||||||
|
// code points are handled correctly on Native targets.
|
||||||
|
|
||||||
|
/** Number of UTF-16 code units (1 or 2) needed for [codePoint]. */
|
||||||
|
fun codePointCharCount(codePoint: Int): Int = if (codePoint >= 0x10000) 2 else 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode [codePoint] as UTF-16. Returns a 1-char array for BMP code points and
|
||||||
|
* a high+low surrogate pair for supplementary code points (≥ U+10000).
|
||||||
|
*/
|
||||||
|
fun codePointToChars(codePoint: Int): CharArray =
|
||||||
|
if (codePoint < 0x10000) {
|
||||||
|
charArrayOf(codePoint.toChar())
|
||||||
|
} else {
|
||||||
|
val offset = codePoint - 0x10000
|
||||||
|
charArrayOf(
|
||||||
|
(0xD800 + (offset ushr 10)).toChar(),
|
||||||
|
(0xDC00 + (offset and 0x3FF)).toChar(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the Unicode code point that starts at [index]. Mirrors
|
||||||
|
* `java.lang.Character.codePointAt(CharSequence, int)`: returns the
|
||||||
|
* supplementary code point when [index] points at a well-formed high surrogate
|
||||||
|
* followed by a low surrogate; otherwise returns the raw `Char.code`.
|
||||||
|
*/
|
||||||
|
fun String.codePointAtKmp(index: Int): Int {
|
||||||
|
val high = this[index]
|
||||||
|
if (high.isHighSurrogate() && index + 1 < length) {
|
||||||
|
val low = this[index + 1]
|
||||||
|
if (low.isLowSurrogate()) {
|
||||||
|
return 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return high.code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the index that is [codePointOffset] code points away from [index].
|
||||||
|
* Negative offsets are not supported (matches the only call site usage today).
|
||||||
|
*/
|
||||||
|
fun String.offsetByCodePointsKmp(
|
||||||
|
index: Int,
|
||||||
|
codePointOffset: Int,
|
||||||
|
): Int {
|
||||||
|
var i = index
|
||||||
|
repeat(codePointOffset) {
|
||||||
|
i += codePointCharCount(codePointAtKmp(i))
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.util
|
|||||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||||
|
|
||||||
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
|
fun String.isUTF16Char(pos: Int): Boolean = codePointCharCount(this.codePointAtKmp(pos)) == 2
|
||||||
|
|
||||||
fun String.firstFullCharOld(): String {
|
fun String.firstFullCharOld(): String {
|
||||||
return when (this.length) {
|
return when (this.length) {
|
||||||
@@ -64,11 +64,11 @@ fun String.firstFullChar(): String {
|
|||||||
var i = 0
|
var i = 0
|
||||||
|
|
||||||
while (i < this.length) {
|
while (i < this.length) {
|
||||||
codePoint = codePointAt(i)
|
codePoint = codePointAtKmp(i)
|
||||||
|
|
||||||
// Skips if it starts with the join char 0x200D
|
// Skips if it starts with the join char 0x200D
|
||||||
if (codePoint == 0x200D && previousCharLength == 0) {
|
if (codePoint == 0x200D && previousCharLength == 0) {
|
||||||
next = offsetByCodePoints(i, 1)
|
next = offsetByCodePointsKmp(i, 1)
|
||||||
start = next
|
start = next
|
||||||
} else {
|
} else {
|
||||||
// If join, searches for the next char
|
// If join, searches for the next char
|
||||||
@@ -78,7 +78,7 @@ fun String.firstFullChar(): String {
|
|||||||
} else {
|
} else {
|
||||||
// stops when two chars are not joined together
|
// stops when two chars are not joined together
|
||||||
if (previousCharLength > 0 && !isInJoin) {
|
if (previousCharLength > 0 && !isInJoin) {
|
||||||
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
|
if (codePointCharCount(codePoint) == 1 || hasHadSecondChance) {
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
hasHadSecondChance = true
|
hasHadSecondChance = true
|
||||||
@@ -91,7 +91,7 @@ fun String.firstFullChar(): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// next char to evaluate
|
// next char to evaluate
|
||||||
next = offsetByCodePoints(i, 1)
|
next = offsetByCodePointsKmp(i, 1)
|
||||||
previousCharLength += (next - i)
|
previousCharLength += (next - i)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KMP-friendly reentrant lock. JVM/Android map to `java.util.concurrent.locks.ReentrantLock`;
|
||||||
|
* iOS maps to `NSRecursiveLock`.
|
||||||
|
*
|
||||||
|
* Use [withLock] in preference to manual lock/unlock — it guarantees release on
|
||||||
|
* exception. The class is reentrant on every platform, so a thread that already
|
||||||
|
* holds the lock can re-enter without deadlocking.
|
||||||
|
*/
|
||||||
|
expect class KmpLock() {
|
||||||
|
fun lock()
|
||||||
|
|
||||||
|
fun unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> KmpLock.withLock(block: () -> T): T {
|
||||||
|
lock()
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff [url] is a syntactically valid absolute URL with a recognized
|
||||||
|
* scheme. JVM/Android delegate to `java.net.URI(url).toURL()`; the iOS actual
|
||||||
|
* will use `NSURL(string:)` when that target is added.
|
||||||
|
*
|
||||||
|
* Called per-URL during feed render — actuals should keep this fast and
|
||||||
|
* allocation-light.
|
||||||
|
*/
|
||||||
|
expect fun isValidUrl(url: String?): Boolean
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KMP-friendly weak reference. JVM/Android map to `java.lang.ref.WeakReference`;
|
||||||
|
* iOS will map to `kotlin.native.ref.WeakReference` when that target is added.
|
||||||
|
*
|
||||||
|
* The referent may be reclaimed by GC at any time after this reference is
|
||||||
|
* constructed — callers must always null-check the result of [get].
|
||||||
|
*/
|
||||||
|
expect class WeakReference<T : Any>(
|
||||||
|
referent: T,
|
||||||
|
) {
|
||||||
|
fun get(): T?
|
||||||
|
}
|
||||||
+1
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user