Merge remote-tracking branch 'origin/main' into claude/brave-clarke-hJ0PK

This commit is contained in:
Claude
2026-05-26 15:32:47 +00:00
109 changed files with 3927 additions and 899 deletions
@@ -15,6 +15,18 @@ Extract string resource keys from the default `values/strings.xml` that are abse
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Background: Crowdin strip-identical behavior
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
Consequences for this skill:
1. **A "missing" key on disk is not always actionable.** It may be Crowdin-stripped (translator already chose source-identical and Crowdin didn't export it) rather than genuinely new.
2. **Don't add source-identical fallbacks locally.** Android's resource resolution falls back to `values/strings.xml` at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway.
3. **The only actionable cases are keys Crowdin has never exported.** Whether the translator picked "use English" or simply hasn't translated the key yet, both states are owned by Crowdin and look identical on disk. The local repo cannot distinguish them.
The Step 2.5 filter below uses the **most recent Crowdin export commit reachable from `HEAD`** (subject: `"New Crowdin translations by GitHub Action"`) as the cutoff: any key added to `values/strings.xml` after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the `l10n_crowdin_translations` branch after merging.
## Target Locales
The default set of locales (unless the user specifies otherwise):
@@ -51,6 +63,59 @@ comm -23 \
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
> **Caveat:** Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5.
### 2.5. Filter out keys Crowdin has already seen (sync-timestamp check)
A missing key is **only actionable if Crowdin has never exported it**. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported `strings.xml`. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical.
The reliable signal is **time**: compare when the key was added to `values/strings.xml` against the timestamp of the **most recent Crowdin export that has been merged into the current branch**. Crowdin's GitHub Action produces commits with the literal subject `New Crowdin translations by GitHub Action`; finding the latest such commit reachable from `HEAD` works even if the `l10n_crowdin_translations` branch has been deleted post-merge (a common cleanup workflow).
- Key added **before** that commit → Crowdin saw it on a prior export; translator made a decision; the absence on disk is a deliberate "use English" or "leave blank" choice. **Skip.**
- Key added **after** → Crowdin has not exported it yet; genuinely new and actionable.
```bash
# Latest Crowdin export reachable from HEAD (survives branch deletion).
sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null)
if [ -z "$sync_ts" ]; then
echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2
sync_ts=0
else
echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')"
fi
# For each locale, list only keys added after the Crowdin sync (truly new).
for locale in cs-rCZ de-rDE sv-rSE; do
echo "=== $locale: genuinely new (post-sync) keys ==="
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
done
```
**Why this beats using the `l10n_crowdin_translations` branch tip:**
- The branch is often deleted after merge — the branch tip then doesn't exist or points to a stale ref.
- The branch tip may include Crowdin commits that haven't been merged to main yet. Those changes aren't in our working tree, so they don't affect what's on disk for us. The "reachable from HEAD" cutoff matches what we can actually observe in `values-*/strings.xml`.
**Only the listed (post-sync) keys are actionable.** Anything older is either:
- A deliberate "use English" choice in Crowdin (brand terms like `Nowhere X`, loanwords like `Apps` / `Feed` / `Issues`, version prefixes like `v%1$s`), or
- A pending translation the translator hasn't filled in yet — still Crowdin's job, not ours.
In both cases, Android's resource resolution falls back to `values/strings.xml` at runtime, so there is no user-visible bug. Adding source-identical fallbacks locally is noise that the next sync will strip again.
Report the pre-sync skipped count as a one-liner ("N keys predate the last Crowdin sync, skipped — Crowdin owns them"). Do not list them or propose translations.
If no Crowdin export commit can be found in history (`sync_ts=0` fallback), warn the user and fall back to treating all missing keys as actionable — but flag that the workflow is degraded.
### 3. Get English values for missing keys
For each missing key, extract its English value:
@@ -198,7 +263,10 @@ When adding translated strings to locale files:
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
- **Treating every missing key as actionable** — Crowdin strips on export any translation the translator marked as "use English", and we cannot distinguish that from "never seen" by looking at disk. Use the Step 2.5 sync-timestamp filter: only keys added to `values/strings.xml` after the last `l10n_crowdin_translations` sync are genuinely new.
- **Trying to detect "stripped" from git history alone** — the on-disk locale file only sees keys the translator typed a non-identical value for. The "translator opened the key and picked English from the start" case never touches disk, so a history-only check misses it. Use the sync-timestamp cutoff instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so there is no user-visible bug to fix.
- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale, then apply the sync-timestamp filter.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
+60 -1
View File
@@ -39,7 +39,7 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
run: ./gradlew spotlessCheck
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
build-desktop:
needs: lint
@@ -186,6 +186,65 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
# so JVM-only imports can't sneak into commonMain unnoticed. The
# `verifyKmpPurity` task in the lint job is the fast pre-check (Linux,
# ~1s); this job is the real one — compiles for the device variant
# and actually runs the simulator test suite.
needs: lint
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Two tasks, two purposes:
# - iosSimulatorArm64Test runs the existing iosTest suite on the
# simulator (NIP-04 / NIP-17 / NIP-19 / NIP-49 / AES-GCM /
# Chatroom keys), exercising secp256k1 and CryptoKit-backed
# primitives on a real Apple toolchain.
# - compileTestKotlinIosArm64 catches any device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical
# device to run on. Compile-only is enough — running on-device
# would require xcodebuild + a provisioning profile.
- name: Test Quartz on iOS
run: |
./gradlew \
:quartz:iosSimulatorArm64Test \
:quartz:compileTestKotlinIosArm64
# :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the
# iOS plan. Compile-only for now — actual UI / lifecycle wiring will
# land with the iosApp module in Phase 3. The container that runs Claude
# Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit),
# so this is the first place commonMain Compose code is actually
# type-checked against an Apple Native frontend.
- name: Compile Commons for iOS
run: |
./gradlew \
:commons:compileKotlinIosSimulatorArm64 \
:commons:compileKotlinIosArm64
- name: Upload iOS Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: Quartz iOS Test Reports
path: quartz/build/reports
test-and-build-android:
needs: lint
runs-on: ubuntu-latest
+86 -26
View File
@@ -1,54 +1,114 @@
# Amethyst Privacy Policy and Terms of Use
## Privacy Policy
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**Last updated:** 2026-05-24
Effective as of Jun 12, 2023
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
The Amethyst app for Android does not collect or process any personal information from its users.
Amethyst lets you browse content from third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies.
The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
## Privacy
Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
### Data sent off-device
### Privacy with Relay services
Using the app causes the following data to leave your phone:
Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online.
- **Nostr events** you publish, sent to the relays you have configured.
- **Subscriptions** (filters describing what you want to read), sent to those relays.
- **Media uploads** (images, audio, video), sent to the media server you select.
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
The developer does not run any server that aggregates or stores this data.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
### Data stored on your device
While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link.
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
### Visibility & Permanence of Your Content on Nostr Relays
### What relays can see
#### Information Visibility
A relay you connect to sees:
Content that you share can be shared with other relays by any user of the network.
The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you.
- Your IP address (or the Tor exit node when using it).
- Your public key.
- The events you publish (posts, reactions, reposts, reports, etc.).
- The filters you subscribe to.
#### Information Permanence
A relay does **not** see the plaintext of:
Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
- Private Direct Messages (encrypted to the recipient under NIP-17 / NIP-44).
- Private Zaps.
## Child safety standards
A relay can still see *that* you and another user are exchanging DMs even though it cannot read them. To reduce what a relay can correlate to you, route the app over a VPN or Tor.
Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user.
### Media uploads
Uploads go to the media server you select. That server is independent of Amethyst and has its own policy. Anyone holding the resulting link — including media attached to a DM — can fetch the file.
### Public content is effectively permanent
Anything you publish to a relay can be copied to other relays or clients. Once published, you should assume it cannot be reliably deleted from the network.
## Child Safety Standards
These are the published Child Safety Standards for **Amethyst**, the Android Nostr client published on Google Play by **Vitor Pamplona**. They are published under Google Play's Child Safety Standards policy.
They are a community standard, not a license restriction. Amethyst's source code remains licensed under the MIT License in `LICENSE`.
### Prohibition
Using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material — including child sexual abuse material (CSAM) — or to groom, exploit, or harm a minor is prohibited and is illegal in essentially every jurisdiction.
### In-app tools
Amethyst provides:
- **Report Post** and **Report Account** — publish a signed Nostr report (including the "Illegal Content" reason) so relays and other clients can act on it.
- **Block Post** / **Block Account** — hide content locally on your device.
- **Block Relay** — add a relay to your NIP-51 Blocked Relay List so the app stops fetching from or publishing to it. This is the strongest tool the app offers against a relay that refuses to moderate.
- **Mute Words / Hashtags** — filter unwanted content from your feeds.
### Addressing CSAM
Amethyst does not host content, so the app cannot remove CSAM. Only the relay hosting the content can remove it. In the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children (NCMEC).
If you encounter CSAM through Amethyst:
1. Report the content in-app and select "Illegal Content."
2. Add the hosting relay to your Blocked Relay List.
3. Report directly to **NCMEC** at https://report.cybertip.org/ (United States) or to an **INHOPE** hotline at https://www.inhope.org/ (other jurisdictions). These bodies can compel the hosting provider to act.
4. You may also email **amethyst@vitorpamplona.com** with the relay URL and event ID. The developer cannot remove content from third-party relays, but may forward the report to relay operators it is in contact with and may stop recommending the offending relay in any list shipped with the app.
### Compliance
Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law. Obligations attached to the **hosting** of content rest with relay operators.
### Age rating
Amethyst's Google Play listing is rated 17+. The app does not request or store age information.
## Terms of Use
### For versions downloaded from Google's Play Store
### Google Play build
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
You agree not to use the Google Play build of Amethyst to submit Objectionable Content to relays. Objectionable Content includes:
### For versions downloaded from F-Droid
- Sexually explicit material.
- Obscene, defamatory, libelous, slanderous, violent, or unlawful content.
- Content that infringes third-party rights (copyright, trademark, privacy, publicity).
- Content that is deceptive or fraudulent.
- Content promoting illegal drugs, tobacco, firearms, ammunition, or illegal gambling.
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
These Terms apply only to the Google Play distribution of Amethyst.
## Other Notes
### F-Droid and other source-built distributions
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
The MIT License in `LICENSE` is the only instrument governing your right to use, study, modify, and redistribute the software. No additional terms are imposed on these builds. Any dispute over distribution through F-Droid is between you and F-Droid.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
## Updates
This document may change. The current version is published at https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md.
+390
View File
@@ -0,0 +1,390 @@
# iOS Support for Amethyst
**Date:** 2026-05-24
**Status:** Phase 1 complete; Phase 2 in flight
**Owner:** TBD
Plan to incrementally bring Amethyst to iOS by extending the existing
KMP layers from the bottom up. Each phase is independently shippable —
we can pause between any two phases without leaving the tree in a
broken state.
## Why this is tractable today
The structural work that usually dooms a KMP-to-iOS effort is already
done:
- `quartz/` has `iosArm64` + `iosSimulatorArm64` targets configured,
a working `Platform.ios.kt` actual, and 6 iOS test files that pass.
- Jackson and OkHttp — the two big JVM-only dependencies — are *already
isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`,
`quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except
for the obvious `kotlinx.*` stack.
- `commons/commonMain` has exactly **one** Jackson reference
(`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest
of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`,
which is where it belongs.
- Compose Multiplatform 1.10.3 is in use, which supports iOS officially.
- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and
`androidx.lifecycle.viewmodel.compose` are KMP since 2.8.
What this means: we are not embarking on a months-long "purify
commonMain" migration before any iOS code can compile. Phase 1 is
mostly **add iOS to CI** and **patch the last few leaks**.
## Module-by-module dep matrix
Status legend:
- ✅ iOS-ready (targets configured, no JVM-only deps in shared code)
- 🟡 Partial (intermediate source sets need adding, but no major dep blockers)
- 🔴 Blocked (significant native work required)
- ⛔ Out of scope (won't ship on iOS)
| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|---|
| `quartz/` | ✅ | CI + audit | — | — | — | — |
| `commons/` (non-UI) | 🟡 | — | ✅ | — | — | — |
| `commons/` (UI) | 🟡 | — | — | ✅ | — | — |
| `iosApp/` (new) | n/a | — | — | scaffold | feature-complete | — |
| `quic/` | 🔴 | — | — | — | — | iOS actuals |
| `nestsClient/` | 🔴 | — | — | — | — | iOS actuals |
| `amethyst/` (app) | ⛔ | — | — | — | — | — |
| `desktopApp/` | ⛔ | — | — | — | — | — |
| `cli/` | ⛔ | — | — | — | — | — |
## Source-set diagram (target end state)
```
commons/src/
├── commonMain/ ── all targets
│ ├── coreMain/ ── ViewModels, state, DAL (no Compose)
│ │ ├── jvmAndroidCore/ ── Android + Desktop
│ │ │ ├── androidCore/
│ │ │ └── jvmCore/
│ │ └── nativeCore/ ── iOS
│ │ ├── iosArm64Core/
│ │ └── iosSimArm64Core/
│ └── uiMain/ ── Compose UI, icons, resources
│ ├── jvmAndroidUi/
│ │ ├── androidUi/
│ │ └── jvmUi/
│ └── nativeUi/ ── iOS Compose
```
(Names sketched for clarity; in practice we'll fold `coreMain` /
`uiMain` together once *every* file in `uiMain` compiles for iOS —
the split is a transitional scaffold for Phase 2 ↔ Phase 3.)
`quartz/`, `quic/`, `nestsClient/` already use a `jvmAndroid` shared
source set; we'll add a sibling `nativeMain` (or just `iosMain` where
that's simpler) when each module turns on iOS.
---
## Phase 1 — Lock down Quartz on iOS
**Duration estimate:** 1–2 weeks
**Deliverable:** `./gradlew :quartz:iosSimulatorArm64Test` runs in CI on every PR.
### Tasks
1. **Add iOS to CI for `:quartz`.**
- GitHub Actions macOS runner step: `iosSimulatorArm64Test` +
`iosArm64SourceSetTest` (compile only).
- This is the single most valuable change in the entire plan — it
prevents anyone from accidentally re-adding a JVM-only import to
`commonMain`.
2. **Audit the `jvmAndroid` boundary.**
- Confirm everything Jackson/OkHttp-related lives in `jvmAndroid`
(it does today — keep it that way).
- Add a checkstyle / detekt rule, or a simple grep gate in CI, that
fails the build if `com.fasterxml.jackson` or `okhttp3` shows up
in `commonMain`.
3. **Validate `secp256k1` iOS path.**
- Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green
on `iosSimulatorArm64Test`.
- The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we
just need to surface them in CI.
4. **Plan the `expect`/`actual` for iOS HTTP.**
- Phase 1 only sketches the design; the actual `Ktor-darwin` wiring
lands in Phase 2 when `:commons` needs it.
- Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS.
**Recommendation:** keep OkHttp on JVM/Android (we use OkHttp-specific
features in relay reconnect logic) and add an iOS-only Ktor actual.
### Risks
- None major. The work here is mostly defensive.
---
## Phase 2 — Bring `:commons` to iOS, non-UI first
**Duration estimate:** 2–3 weeks
**Deliverable:** `./gradlew :commons:iosSimulatorArm64Test` compiles every
shared ViewModel and state class.
### Tasks
1. **Add iOS targets to `commons/build.gradle.kts`.**
- `iosArm64()` + `iosSimulatorArm64()`.
- Introduce intermediate source sets `coreMain` (all targets) and
`uiMain` (JVM + Android only, for now).
2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.**
- Move to `kotlinx.serialization`, OR
- Push it down into `jvmAndroidCore` and create a `nativeCore` actual.
**Recommendation:** migrate. It's one file; one-time cost is small;
reduces split-actual surface area forever.
3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.**
| Concern | JVM/Android | iOS actual |
|---|---|---|
| HTTP client | OkHttp | Ktor + `Ktor-darwin` |
| Secure key storage | Android Keystore / java-keyring | Keychain Services |
| EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) |
| File I/O paths | `java.io.File` | `NSFileManager` / `okio` |
| Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start |
4. **Compile-only iOS for `:commons` ViewModels.**
- At the end of Phase 2 we have ViewModels, account state, LocalCache
wrappers, filter assemblers, and `ComposeSubscriptionManager` building
on iOS — but no UI yet.
- Smoke test: write a small `commonTest` that constructs an `Account`,
subscribes to a stub relay, and verifies a follow event lands in
`LocalCache`. Run it on iOS simulator.
### Risks
- **Ktor migration scope creep.** Hold the line: Phase 2 only wraps HTTP
behind `expect`. Don't refactor the relay pool. That's a separate PR.
- **Coroutines dispatcher differences.** `Dispatchers.IO` does not exist on
Kotlin/Native by default — code that explicitly references it needs a
`KmpDispatchers.IO` shim. Audit before Phase 2 starts.
### Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory
Audit of all 335 .kt files in `commons/src/commonMain/`. Better than feared
— most files are already KMP-clean. The actual blockers are 21 files
across ~6 distinct concerns. Each row below is a small mergeable PR.
**By blocker category:**
| Blocker | Files | Fix |
|---|---|---|
| `java.util.Base64` | 1 (`Base64Image.kt`) | `kotlin.io.encoding.Base64` (stdlib since 1.8) |
| `AtomicLong` / `AtomicInteger` | 2 (`ChessLobbyState.kt`, `SigningState.kt`) | `kotlinx.atomicfu.atomic` |
| `ConcurrentHashMap` | 4 (`ChessRelayFetchHelper.kt`, `ChessEventCollector.kt`, `ComposeSubscriptionManager.kt`, `MutableComposeSubscriptionManager.kt`) | `androidx.collection.MutableScatterMap` (KMP) — synchronization most likely already provided by enclosing scope; audit per file |
| `SortedSet` + `ConcurrentSkipListSet` | 2 (`EventListMatchingFilter.kt`, `NoteListMatchingFilter.kt`) | Switch to `mutableListOf` + sort-on-access, or `androidx.collection.MutableScatterSet` with manual order |
| `WeakReference` | 5 (`Channel.kt`, `Chatroom.kt`, `MarmotGroupChatroom.kt`, `UserRelaysCache.kt`, **+1**) | `expect class KmpWeakReference<T>` actuals: JVM `java.lang.ref.WeakReference`; iOS `kotlin.native.ref.WeakReference` |
| `BigDecimal` | 1 (`Note.kt`) | Either KMP bignum lib (`com.ionspin:bignum`) or move the BigDecimal-using helper to `jvmAndroid` and stub on iOS |
| `java.io.File` | 1 (`MediaContentModels.kt`) | Replace with `String` path, or `okio.Path` |
| `java.net.URI` / `MalformedURLException` | 2 (`RichTextParser.kt`, `UrlInfoItem.kt`) | KMP URL lib (`io.ktor:ktor-http`) or stay JVM via expect/actual `parseUrl()` |
| `java.nio.charset.Charset` | 1 (`HtmlCharsetParser.kt`) | `kotlin.text.Charsets` for UTF-8/16; for arbitrary charsets, expect/actual |
| `:nestsClient` project dep | 2 (`NestViewModel.kt`, `ActiveSubscription.kt`) | Move both files to `jvmAndroid` source set (audio rooms are Phase 5 anyway) |
| `com.halilibo.richtext.*` | 1 (`RenderMarkdown.kt`) | Verify iOS artifact; if missing, move to `jvmAndroid` until Phase 3 markdown decision |
**Files that look scary but aren't:**
- 183 files import `androidx.compose.*` — these all map to JetBrains Compose
Multiplatform's iOS artifacts (identical package paths). No work needed.
- 7 files import `androidx.lifecycle.*` — KMP since 2.8.0. No work needed.
- 0 files import `coil3.network.okhttp` (Coil network is already isolated).
- 0 files import `javax.*` or `android.*` directly from commonMain.
**Recommended PR order** (ascending cost, descending obviousness):
1. ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration).
2. **Base64** (1 file, ~2 LOC change). Demonstrates the pattern.
3. **Atomics** (2 files, atomicfu plugin + ~10 LOC).
4. **ConcurrentHashMap** (4 files; needs concurrency audit per file).
5. **WeakReference** (5 files + 1 new expect/actual).
6. **`:nestsClient` files → jvmAndroid** (2 files; pure source-set move).
7. **`RenderMarkdown.kt` → jvmAndroid OR iOS verification** (1 file; depends on lib check).
8. **URL parsing** (2 files; either ktor-http dep or expect/actual).
9. **Charsets, BigDecimal, File** (3 files; small per-file decisions).
10. After ~9 lands: add iOS targets to `:commons`, expect failures to be down
to ~zero, run `compileKotlinIosSimulatorArm64` to confirm.
11. Then proceed with the original Phase 2 plan items (Ktor for HTTP,
SecureKeyStore expect/actual, etc.) for the cross-cutting deps.
---
## Phase 3 — Compose Multiplatform UI on iOS
**Duration estimate:** 3–4 weeks
**Deliverable:** A read-only iOS `.ipa` on TestFlight internal that connects
to relays and renders a feed.
### Tasks
1. **Flip `uiMain` to target = all (including iOS).**
- Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font
and other Compose Resources already work cross-platform.
2. **Audit UI deps for iOS.**
| Dep | Status | Action |
|---|---|---|
| `jetbrains.compose.*` (1.10.3) | ✅ | None |
| `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None |
| `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split |
| `markdown-ui` / `markdown-ui-material3` | ⚠️ Verify | Likely OK on iOS; if not, fall back to commonmark + custom renderer |
| `kotlinx-collections-immutable` | ✅ | None |
| Material Symbols font | ✅ | None (already via Compose Resources) |
3. **Create the `iosApp/` module.**
- SwiftUI `App` + `UIViewControllerRepresentable` hosting
`ComposeUIViewController { App() }`.
- Tab bar (UIKit) for top-level navigation, Compose for each tab's
content area. **Same split philosophy as Desktop**: native shell,
shared content.
- Add Xcode project + Gradle Kotlin/Native framework wiring (no
CocoaPods; use the JetBrains-recommended `embedAndSignAppleFrameworkForXcode`).
4. **Ship a "read-only Nostr browser" first cut.**
- Profile view, single-feed home, NoteCard rendering, image loading,
basic navigation.
- No posting, no DMs, no audio rooms.
- This validates the *entire* stack — relay client, LocalCache, feed DAL,
NoteCard composable, Coil 3, Compose Resources, font rendering — without
touching signing.
### Risks
- **Compose iOS performance on large feeds.** Profile early with a realistic
`LocalCache` (10k+ notes) before locking screen architecture. If recomposition
storms appear, lean harder on `compose-stability-diagnostics` and
`compose-state-deferred-reads` skills.
- **Touch interactions vs Android conventions.** Pull-to-refresh, swipe
back, long-press menus all differ on iOS. Some screens may need
platform-specific gesture handling.
- **Markdown rendering library iOS support.** If `markdown-ui-material3`
doesn't ship iOS artifacts, this is a half-week detour to switch
renderers. Verify in week 1 of Phase 3.
---
## Phase 4 — Write paths: signing, posting, settings
**Duration estimate:** 2–3 weeks
**Deliverable:** Fully read/write iOS client, minus audio rooms.
### Tasks
1. **Wire `NostrSignerInternal` to Keychain.**
- The signer is already KMP — only the key storage actual needs
adding (done in Phase 2's `SecureKeyStore` abstraction).
2. **Make `NostrSignerRemote` (NIP-46 bunker) work on iOS.**
- Should be KMP-clean once Ktor migration is done. Audit for any
stray Jackson / OkHttp inside the NIP-46 path.
3. **NIP-55 alternative.**
- **There is no Amber on iOS.** Plan replacements:
- Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker,
remote nostr-connect URIs).
- URL-scheme handoff to native iOS signers (`nos2x-fhe`, `Nostore`)
*if* they expose a sign API. Track separately.
- Onboarding screen needs an iOS-specific copy variant.
4. **Posting, reactions, zaps.**
- Mostly free — ViewModels already in `:commons`. Wire UI buttons and
test end-to-end on TestFlight.
5. **Settings UI.**
- Share via Compose. iOS-native preference screens are a polish item
for later.
### Risks
- **Apple App Review on cryptocurrency / zaps.** Lightning zaps via LNURL
are fine (no in-app crypto purchase). Anything that looks like an
in-app wallet or onchain send may need legal review and / or feature
gating per-region. Start review conversations early.
- **Push notifications.** APNs is the only path on iOS. Nostr DM push
relays don't speak APNs natively. Likely needs a small relay-proxy
(similar to `notify.damus.io`'s architecture). Design doc in
`amethyst/plans/` before Phase 4 ends.
---
## Phase 5 — `:quic` + `:nestsClient` for audio rooms (optional)
**Duration estimate:** 4–6 weeks
**Status:** Defer until 1–4 are solid. App is shippable on iOS without
audio rooms.
### Tasks
1. **`:quic` — add iOS actuals.**
- UDP socket via `Network.framework` (`NWConnection` with `.udp`).
- AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit
(`AES.GCM.SealedBox`, `ChaChaPoly`).
- TLS state machine is already pure Kotlin in `commonMain` — no change.
2. **`:nestsClient` — add iOS actuals.**
- Opus encode/decode: `libopus` via cinterop, or pull `opus.framework`
from a Swift Package / CocoaPods spec.
- Mic + speaker: `AVAudioEngine` (input/output nodes) instead of
`AudioRecord` / `AudioTrack`.
3. **moq-lite listener path first** (the production path per CLAUDE.md),
then speaker.
### Risks
- **Background audio on iOS.** Audio rooms in the background need a
proper `AVAudioSession` category + the `audio` background mode in
`Info.plist`. Apple sometimes rejects apps that abuse this. Worth a
separate audit before submission.
- **Opus framework distribution.** `libopus` via Swift Package is
cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby.
Decide before Phase 5 starts.
---
## Phase 6 — Ship polish (ongoing, post-Phase 4)
- App Store metadata, screenshots, privacy manifest
(`NSPrivacyAccessedAPI*` declarations — file access, user defaults).
- Localizations carry over automatically via Compose Resources.
- Background fetch limits — iOS is far stricter than Android. Tune
feed prefetch + relay reconnect for background launch budgets.
- TestFlight beta → public release.
---
## Cross-cutting risks (track from day one)
| Risk | Mitigation | First chance to catch |
|---|---|---|
| `commonMain` regresses with a JVM-only import | Add iOS to CI on every PR | Phase 1, task 1 |
| Coroutines `Dispatchers.IO` ergonomics on iOS | Audit + introduce `KmpDispatchers` shim | Phase 2, task 3 |
| Compose iOS performance on big feeds | Early profiling with realistic `LocalCache` | Phase 3 risk section |
| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section |
| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 |
| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase |
| Background audio policy | `AVAudioSession` audit + `Info.plist` review | Phase 5 risk section |
## Suggested first PR
Smallest useful start: **Phase 1, tasks 1 + 2** — add `:quartz` iOS to
CI and add the import-gate that prevents Jackson / OkHttp regressions
in `commonMain`. That single PR de-risks the rest of the plan without
touching any product code.
## Open questions
- Do we want a `:cli` analogue on iOS (a "headless" Nostr daemon)? Out
of scope for this plan, but iosArm64 *could* host one if we ever need
a CLI-on-phone story.
- Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We
could theoretically also ship Catalyst from the iOS build, but that's
three "desktop"-ish targets to maintain. Recommendation: punt.
- iPad layout: do we want a separate split-view UI like `desktopApp`,
or just scale up the iPhone layout? Phase 3 keeps the iPhone layout;
iPad polish is a Phase 6 item.
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.runtime.Composable
// F-Droid distributes Amethyst as MIT-licensed free software; the build must
// not surface links to external (e.g. GitHub-hosted) policy documents.
@Composable
fun LegalSettingsSection() {
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal
import androidx.compose.runtime.Composable
// F-Droid distributes Amethyst as MIT-licensed free software; there is no
// terms-of-use acceptance layered on top of the source license, and the build
// must not link to any external (e.g. GitHub) policy document.
@Composable
@Suppress("UNUSED_PARAMETER")
fun TermsGate(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
showError: Boolean,
) {
}
@@ -261,6 +261,8 @@ fun AllSettingsScreen(
)
}
LegalSettingsSection()
SettingsSection(R.string.danger_zone, isDanger = true) {
if (hasPrivateKey) {
SettingsItem(
@@ -72,8 +72,8 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -197,18 +197,11 @@ fun LoginPage(
}
if (loginViewModel.isFirstLogin) {
AcceptTerms(
TermsGate(
checked = loginViewModel.acceptedTerms,
onCheckedChange = loginViewModel::updateAcceptedTerms,
showError = loginViewModel.termsAcceptanceIsRequiredError,
)
if (loginViewModel.termsAcceptanceIsRequiredError) {
Text(
text = stringRes(R.string.acceptance_of_terms_is_required),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(modifier = Modifier.height(Size10dp))
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@@ -68,7 +69,7 @@ class LoginViewModel : ViewModel() {
) {
clear()
this.isFirstLogin = isFirstLogin
acceptedTerms = !isFirstLogin
acceptedTerms = !isFirstLogin || BuildConfig.FLAVOR != "play"
if (newAccountKey != null) {
key = TextFieldValue(newAccountKey)
}
@@ -79,7 +80,7 @@ class LoginViewModel : ViewModel() {
password = TextFieldValue("")
errorManager.clearErrors()
acceptedTerms = false
acceptedTerms = BuildConfig.FLAVOR != "play"
processingLogin = false
isTemporary = false
offerTemporaryLogin = false
@@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -187,19 +187,12 @@ fun SignUpPage(
},
)
AcceptTerms(
TermsGate(
checked = signUpViewModel.acceptedTerms,
onCheckedChange = signUpViewModel::updateAcceptedTerms,
showError = signUpViewModel.termsAcceptanceIsRequiredError,
)
if (signUpViewModel.termsAcceptanceIsRequiredError) {
Text(
text = stringRes(R.string.acceptance_of_terms_is_required),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
@@ -40,7 +41,7 @@ class SignUpViewModel : ViewModel() {
var displayName by mutableStateOf(TextFieldValue(""))
var acceptedTerms by mutableStateOf(false)
var acceptedTerms by mutableStateOf(BuildConfig.FLAVOR != "play")
var termsAcceptanceIsRequiredError by mutableStateOf(false)
fun init(accountSessionManager: AccountSessionManager) {
@@ -14,6 +14,12 @@
<item quantity="many">Tento příspěvek má více než %1$d hashtagů</item>
<item quantity="other">Tento příspěvek má více než %1$d hashtagů</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d majetek je zbalený</item>
<item quantity="few">%1$d majetky jsou zbalený</item>
<item quantity="many">%1$d majetků jsou zbalený</item>
<item quantity="other">%1$d majetků jsou zbalený</item>
</plurals>
<string name="post_not_found">Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Obrázek kanálu</string>
@@ -562,6 +568,11 @@
<string name="profile_badges_description">Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu.</string>
<string name="profile_badges_empty">Zatím jste neobdrželi žádné odznaky.</string>
<string name="pictures">Obrázky</string>
<string name="software_apps">Aplikace</string>
<string name="route_software_apps">Aplikace</string>
<string name="nip82_repository_label">Zdroj: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Stáhnout</string>
<string name="calendars">Kalendáře</string>
<string name="shorts">Krátká videa</string>
<string name="public_chats">Veřejné chaty</string>
@@ -1199,6 +1210,8 @@
<string name="posting_policy">Politika příspěvků</string>
<string name="privacy_policy">Zásady ochrany soukromí</string>
<string name="terms_and_conditions">Podmínky &amp; ujednání</string>
<string name="child_safety_standards">Standardy ochrany dětí</string>
<string name="about_legal">O aplikaci a právní informace</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
<string name="relay_monitor_reports">Zprávy monitoru relé</string>
@@ -1770,6 +1783,7 @@
<string name="calendar_relative_ongoing_with_end">%1$s · končí %2$s</string>
<string name="calendar_export_share_title">Sdílet událost kalendáře</string>
<string name="calendar_export_event">Exportovat do kalendáře (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Připomenutí kalendáře</string>
<string name="calendar_reminder_channel_description">Upozornění, když je událost, které se účastníte, blízko začátku.</string>
<string name="calendar_reminder_default_title">Událost kalendáře</string>
@@ -1797,6 +1811,12 @@
<string name="calendar_reminder_settings_enabled_subtitle">Oznámení se spustí, když je událost, které se účastníte, blízko začátku.</string>
<string name="calendar_reminder_settings_lead_title">Doba předstihu připomenutí</string>
<string name="calendar_reminder_settings_lead_subtitle">Kolik minut před událostí chcete být upozorněni.</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d min</item>
<item quantity="few">%1$d min</item>
<item quantity="many">%1$d min</item>
<item quantity="other">%1$d min</item>
</plurals>
<string name="calendar_share_nostr">Sdílet jako Nostr odkaz</string>
<string name="calendar_share_nostr_title">Sdílet odkaz na kalendář</string>
<string name="calendar_filter_all">Všechny kalendáře</string>
@@ -1828,6 +1848,7 @@
<string name="boost_or_quote_description">Zvýšit nebo citovat</string>
<string name="like_description">Olajkovat</string>
<string name="zap_description">Zap</string>
<string name="onchain_zap_description">On-chain Bitcoin zap</string>
<string name="onchain_zap_pending">Čeká na potvrzení</string>
<string name="change_reaction">Změnit rychlé reakce</string>
<string name="bottom_bar_settings">Spodní navigační lišta</string>
@@ -2788,6 +2809,7 @@
<string name="nowhere_link_card_store">Nowhere Obchod</string>
<string name="nowhere_link_card_petition">Nowhere Petice</string>
<string name="nowhere_link_card_message">Nowhere Zpráva</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Umění</string>
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
</resources>
@@ -12,6 +12,10 @@
<item quantity="one">Dieser Beitrag hat mehr als %1$d Hashtag</item>
<item quantity="other">Dieser Beitrag hat mehr als %1$d Hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d Asset gebündelt</item>
<item quantity="other">%1$d Assets bündelt</item>
</plurals>
<string name="post_not_found">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanalbild</string>
@@ -556,6 +560,11 @@ anz der Bedingungen ist erforderlich</string>
<string name="profile_badges_description">Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.</string>
<string name="profile_badges_empty">Du hast noch keine Abzeichen erhalten.</string>
<string name="pictures">Bilder</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="nip82_repository_label">Quelle: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Herunterladen</string>
<string name="calendars">Kalender</string>
<string name="shorts">Kurzvideos</string>
<string name="public_chats">Öffentliche Chats</string>
@@ -1190,6 +1199,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="privacy_policy">Datenschutzerklärung</string>
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="child_safety_standards">Kinderschutzstandards</string>
<string name="about_legal">Über &amp; Rechtliches</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
<string name="relay_monitor_reports">Relay-Überwachungsberichte</string>
@@ -1681,6 +1692,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="edit_calendar_collection">Kalender bearbeiten</string>
<string name="calendar_collection_events_section">Termine in diesem Kalender (%1$d)</string>
<string name="calendar_collection_no_events_yet">Du hast noch keine Kalendertermine erstellt.</string>
<string name="calendar_view_feed">Feed</string>
<string name="calendar_view_month">Monat</string>
<string name="calendar_view_week">Woche</string>
<string name="calendar_view_day">Tag</string>
@@ -1746,6 +1758,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="calendar_rsvp_going">Komme</string>
<string name="calendar_rsvp_maybe">Vielleicht</string>
<string name="calendar_rsvp_not_going">Komme nicht</string>
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
<string name="calendar_rsvp_none">Noch keine Antworten.</string>
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
<string name="calendar_event_in_calendars">In Kalendern (%1$d)</string>
@@ -1755,6 +1768,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="calendar_relative_ongoing_with_end">%1$s · endet %2$s</string>
<string name="calendar_export_share_title">Kalendertermin teilen</string>
<string name="calendar_export_event">In Kalender exportieren (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Kalendererinnerungen</string>
<string name="calendar_reminder_channel_description">Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.</string>
<string name="calendar_reminder_default_title">Kalendertermin</string>
@@ -1991,8 +2005,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="git_status_closed">Geschlossen</string>
<string name="git_status_draft">Entwurf</string>
<string name="git_repo_tab_overview">Übersicht</string>
<string name="git_repo_tab_issues">Tickets</string>
<string name="git_repo_tab_patches">Patches und PRs</string>
<string name="git_repo_section_about">Über</string>
<string name="git_repo_section_links">Links</string>
<string name="git_repo_section_maintainers">Maintainer</string>
<string name="git_repo_section_topics">Themen</string>
<string name="git_repo_personal_fork">Persönlicher Fork</string>
@@ -2757,8 +2773,12 @@ anz der Bedingungen ist erforderlich</string>
<string name="community_rules_violation_stale_rules">Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt.</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">Nowhere-Seite</string>
<string name="nowhere_link_card_event">Nowhere Ereignis</string>
<string name="nowhere_link_card_fundraiser">Nowhere Spendenaktion</string>
<string name="nowhere_link_card_store">Nowhere Shop</string>
<string name="nowhere_link_card_petition">Nowhere Petition</string>
<string name="nowhere_link_card_message">Nowhere Nachricht</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Kunst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
</resources>
@@ -12,6 +12,10 @@
<item quantity="one">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
<item quantity="other">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d asset egybecsomagolva</item>
<item quantity="other">%1$d asset egybecsomagolva</item>
</plurals>
<string name="post_not_found">Az esemény épp betöltődik vagy nem található az átjátszólistában</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Csatorna profilképe</string>
@@ -552,6 +556,11 @@
<string name="profile_badges_description">Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban.</string>
<string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
<string name="pictures">Képek</string>
<string name="software_apps">Alkalmazások</string>
<string name="route_software_apps">Alkalmazások</string>
<string name="nip82_repository_label">Forrás: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Letöltés</string>
<string name="calendars">Naptárak</string>
<string name="shorts">Rövidek</string>
<string name="public_chats">Nyilvános csevegések</string>
@@ -1189,6 +1198,8 @@
<string name="posting_policy">Közzétételi szabályzat</string>
<string name="privacy_policy">Adatvédelmi irányelvek</string>
<string name="terms_and_conditions">Általános szerződési feltételek</string>
<string name="child_safety_standards">Gyermekbiztonsági szabványok</string>
<string name="about_legal">Névjegy &amp; Jogi információk</string>
<string name="not_available_acronym">Nem érhető el</string>
<string name="relay_error_messages">Hibák és megjegyzések ettől az átjátszótól</string>
<string name="relay_monitor_reports">Átjátszófigyelési jelentések</string>
@@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="profile_badges_description">Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.</string>
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
<string name="pictures">Zdjęcia</string>
<string name="software_apps">Aplikacje</string>
<string name="route_software_apps">Aplikacje</string>
<string name="nip82_repository_label">Źródło: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Pobierz</string>
<string name="calendars">Kalendarze</string>
<string name="shorts">Filmiki</string>
<string name="public_chats">Czaty publiczne</string>
@@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="posting_policy">Polityka publikowania</string>
<string name="privacy_policy">Polityka Prywatności</string>
<string name="terms_and_conditions">Zasady i warunki użytkowania</string>
<string name="child_safety_standards">Normy bezpieczeństwa dzieci</string>
<string name="about_legal">O Nas &amp; Informacje Prawne</string>
<string name="not_available_acronym">Nie dotyczy</string>
<string name="relay_error_messages">Błędy i powiadomienia z tego transmitera</string>
<string name="relay_monitor_reports">Raporty z monitoringu transmitera</string>
@@ -12,6 +12,10 @@
<item quantity="one">Det här inlägget har fler än %1$d hashtagg</item>
<item quantity="other">Det här inlägget har fler än %1$d hashtaggar</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d tillgång paketerad</item>
<item quantity="other">%1$d tillgångar buntade</item>
</plurals>
<string name="post_not_found">Inlägg hittades inte</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanal bild</string>
@@ -550,6 +554,11 @@
<string name="profile_badges_description">Välj vilka av märkena du fått som ska visas på din profil.</string>
<string name="profile_badges_empty">Du har inte fått några märken ännu.</string>
<string name="pictures">Bilder</string>
<string name="software_apps">Appar</string>
<string name="route_software_apps">Appar</string>
<string name="nip82_repository_label">Källa: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Ladda ner</string>
<string name="calendars">Kalendrar</string>
<string name="shorts">Kortfilmer</string>
<string name="public_chats">Offentliga chattar</string>
@@ -1184,6 +1193,8 @@
<string name="posting_policy">Publiceringspolicy</string>
<string name="privacy_policy">Integritetspolicy</string>
<string name="terms_and_conditions">Villkor &amp; bestämmelser</string>
<string name="child_safety_standards">Standarder för barnsäkerhet</string>
<string name="about_legal">Om och juridiskt</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
<string name="relay_monitor_reports">Reläövervakningsrapporter</string>
@@ -1751,6 +1762,7 @@
<string name="calendar_relative_ongoing_with_end">%1$s · slutar %2$s</string>
<string name="calendar_export_share_title">Dela kalenderhändelse</string>
<string name="calendar_export_event">Exportera till kalender (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Kalenderpåminnelser</string>
<string name="calendar_reminder_channel_description">Påminnelse när en händelse du deltar i snart ska börja.</string>
<string name="calendar_reminder_default_title">Kalenderhändelse</string>
@@ -1776,6 +1788,10 @@
<string name="calendar_reminder_settings_enabled_subtitle">En notis visas när en händelse du deltar i snart ska börja.</string>
<string name="calendar_reminder_settings_lead_title">Påminnelsetid</string>
<string name="calendar_reminder_settings_lead_subtitle">Hur många minuter före händelsen du vill bli notifierad.</string>
<plurals name="calendar_reminder_settings_lead_choice">
<item quantity="one">%1$d min</item>
<item quantity="other">%1$d min</item>
</plurals>
<string name="calendar_share_nostr">Dela som Nostr-länk</string>
<string name="calendar_share_nostr_title">Dela kalenderlänk</string>
<string name="calendar_filter_all">Alla kalendrar</string>
@@ -2756,5 +2772,7 @@
<string name="nowhere_link_card_store">Nowhere Butik</string>
<string name="nowhere_link_card_petition">Nowhere Namninsamling</string>
<string name="nowhere_link_card_message">Nowhere Meddelande</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Konst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
</resources>
@@ -11,6 +11,9 @@
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="other">此帖有超过 %1$d 个话题标签</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="other">捆绑了 %1$d 个资产</item>
</plurals>
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">频道图片</string>
@@ -546,6 +549,11 @@
<string name="profile_badges_description">选择要在个人资料中显示的已收到的徽章。</string>
<string name="profile_badges_empty">您还没有收到任何徽章。</string>
<string name="pictures">图片</string>
<string name="software_apps">应用</string>
<string name="route_software_apps">应用</string>
<string name="nip82_repository_label">来源: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">下载</string>
<string name="calendars">日历</string>
<string name="shorts">短视频</string>
<string name="public_chats">公共聊天</string>
@@ -1182,6 +1190,8 @@
<string name="posting_policy">发布政策</string>
<string name="privacy_policy">隐私政策</string>
<string name="terms_and_conditions">使用条款</string>
<string name="child_safety_standards">儿童安全标准</string>
<string name="about_legal">关于 &amp; 法律</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">该中继的错误和通知</string>
<string name="relay_monitor_reports">中继监视器报告</string>
+2
View File
@@ -1318,6 +1318,8 @@
<string name="posting_policy">Posting policy</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_and_conditions">Terms &amp; Conditions</string>
<string name="child_safety_standards">Child Safety Standards</string>
<string name="about_legal">About &amp; Legal</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Errors and Notices from this Relay</string>
<string name="relay_monitor_reports">Relay Monitor Reports</string>
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@Composable
fun LegalSettingsSection() {
val uriHandler = LocalUriHandler.current
SettingsSection(R.string.about_legal) {
SettingsItem(
title = R.string.privacy_policy,
icon = MaterialSymbols.Lock,
onClick = {
runCatching {
uriHandler.openUri(
"https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md",
)
}
},
)
SettingsDivider()
SettingsItem(
title = R.string.child_safety_standards,
icon = MaterialSymbols.Shield,
onClick = {
runCatching {
uriHandler.openUri(
"https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards",
)
}
},
)
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff
package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Checkbox
@@ -33,9 +33,26 @@ import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun AcceptTerms(
fun TermsGate(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
onCheckedChange: (Boolean) -> Unit,
showError: Boolean,
) {
AcceptTerms(checked = checked, onCheckedChange = onCheckedChange)
if (showError) {
Text(
text = stringRes(R.string.acceptance_of_terms_is_required),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun AcceptTerms(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
+117 -14
View File
@@ -44,15 +44,16 @@ kotlin {
}
}
// iOS targets — Phase 2 spike. Compile-only for now (no framework binary
// configured yet). Reveals which transitive deps need iOS variants and
// which commonMain files still reach for platform-only APIs.
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain {
dependencies {
implementation(project(":quartz"))
// Audio-rooms ViewModel needs the listener orchestration + audio
// pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer
// interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals
// stay in :nestsClient's platform source sets.
implementation(project(":nestsClient"))
// Compose Multiplatform
implementation(libs.jetbrains.compose.ui)
@@ -61,13 +62,17 @@ kotlin {
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui.tooling.preview)
// Lifecycle ViewModel (KMP since 2.8.0)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and
// lifecycle-runtime-compose ship iOS variants;
// lifecycle-viewmodel-compose (the viewModel() Composable
// helper) is Android-only and lives in jvmAndroid below.
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
// Image loading (Coil 3 - KMP)
// Image loading (Coil 3 - KMP). The okhttp network fetcher is
// JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
// when that target wires its actual.
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
// LruCache (KMP-ready)
implementation(libs.androidx.collection)
@@ -75,13 +80,12 @@ kotlin {
// Immutable collections
api(libs.kotlinx.collections.immutable)
// JSON for custom-feed definitions (KMP — replaces Jackson
// for the one commonMain serializer that was blocking iOS).
implementation(libs.kotlinx.serialization.json)
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
// Markdown rendering (richtext-commonmark)
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
}
}
@@ -97,6 +101,32 @@ kotlin {
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Audio-rooms ViewModel needs the listener orchestration +
// audio pipeline types (NestsListener, AudioRoomPlayer,
// AudioPlayer interface). The :nestsClient module is
// jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track
// stacks are JVM-bound), so the dep lives here, not in
// commonMain. iOS will need an audio-rooms reroute when
// Phase 5 lands.
implementation(project(":nestsClient"))
// Coil's OkHttp network fetcher (JVM-only). iOS will use
// coil-ktor when the iOS Compose UI ships.
implementation(libs.coil.okhttp)
// Markdown rendering (richtext-commonmark). The single
// consumer (RenderMarkdown.kt) already lives in jvmAndroid.
// iOS support pending Phase 3 markdown decision.
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
// viewModel() Compose helper. AndroidX publishes this
// artifact for android/jvmStubs/linuxx64Stubs but not iOS,
// so it stays in jvmAndroid until we either swap to the
// org.jetbrains.androidx.lifecycle variant or accept a
// platform-specific ViewModel access pattern on iOS.
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
}
@@ -127,6 +157,14 @@ kotlin {
}
}
// iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code.
val iosMain =
create("iosMain") {
dependsOn(commonMain.get())
}
getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
getByName("androidHostTest") {
dependencies {
implementation(libs.junit)
@@ -150,3 +188,68 @@ compose.resources {
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
// JSON / HTTP deps.
val verifyKmpPurity by tasks.registering {
group = "verification"
description = "Fails if iOS-targeted source sets import JVM-only deps."
val checkedDirs =
listOf(
"src/commonMain", "src/commonTest",
"src/appleMain", "src/appleTest",
"src/nativeMain", "src/nativeTest",
"src/iosMain", "src/iosTest",
"src/iosArm64Main", "src/iosArm64Test",
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
"src/linuxMain", "src/linuxTest",
"src/linuxX64Main", "src/linuxX64Test",
"src/macosMain", "src/macosTest",
"src/macosArm64Main", "src/macosArm64Test",
).map { layout.projectDirectory.dir(it).asFile }
.filter { it.exists() }
inputs.files(checkedDirs)
doLast {
// Each pattern is paired with a short hint so the failure message
// points at the canonical KMP replacement.
val forbidden =
listOf(
"com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
"okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
"System.currentTimeMillis" to "use TimeUtils.now()",
"Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
"java.util.UUID" to "use kotlin.uuid.Uuid",
"kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
"kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
)
val offenders =
checkedDirs.flatMap { dir ->
dir.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex().mapNotNull { (idx, line) ->
val trimmed = line.trimStart()
// Skip KDoc / line-comment lines — those legitimately
// mention forbidden names (migration notes, doc refs).
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
return@mapNotNull null
}
forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
"${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
}
}
}
}
if (offenders.isNotEmpty()) {
throw GradleException(
"iOS-targeted source sets must not reference JVM-only APIs. " +
"Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
offenders.joinToString("\n "),
)
}
}
}
tasks.named("check").configure { dependsOn(verifyKmpPurity) }
@@ -37,7 +37,7 @@ object Base83 {
): String {
val buffer = CharArray(length)
encode(value, length, buffer, 0)
return String(buffer)
return buffer.concatToString()
}
fun encode(
@@ -27,6 +27,7 @@ import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToLong
import kotlin.math.withSign
class BlurHashEncoder {
@@ -42,7 +43,7 @@ class BlurHashEncoder {
val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))))
val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))))
val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))))
return Math.round(quantR * 19 * 19 + quantG * 19 + quantB)
return (quantR * 19 * 19 + quantG * 19 + quantB).roundToLong()
}
private fun encodeDC(value: DoubleArray): Long {
@@ -126,7 +127,7 @@ class BlurHashEncoder {
val actualMaximumValue = max(factors, 1, factors.size)
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = (quantisedMaximumValue + 1) / 166
encode(Math.round(quantisedMaximumValue), 1, hash, 1)
encode(quantisedMaximumValue.roundToLong(), 1, hash, 1)
} else {
maximumValue = 1.0
encode(0, 1, hash, 1)
@@ -138,6 +139,6 @@ class BlurHashEncoder {
for (i in 1 until factors.size) {
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
}
return String(hash)
return hash.concatToString()
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
@@ -46,7 +48,7 @@ class PeerSessionManager(
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
)
private val lock = Any()
private val lock = KmpLock()
private val sessions = mutableMapOf<HexKey, SessionEntry>()
/** Candidates received before a session exists for the sender. */
@@ -58,7 +60,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry =
synchronized(lock) {
lock.withLock {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
@@ -66,17 +68,17 @@ class PeerSessionManager(
entry
}
fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] }
fun getSession(peerPubKey: HexKey): SessionEntry? = lock.withLock { sessions[peerPubKey] }
fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) }
fun hasSession(peerPubKey: HexKey): Boolean = lock.withLock { sessions.containsKey(peerPubKey) }
fun removeSession(peerPubKey: HexKey): SessionEntry? =
synchronized(lock) {
lock.withLock {
globalPendingIce.remove(peerPubKey)
sessions.remove(peerPubKey)
}
fun allSessionKeys(): Set<HexKey> = synchronized(lock) { sessions.keys.toSet() }
fun allSessionKeys(): Set<HexKey> = lock.withLock { sessions.keys.toSet() }
// ---- ICE candidate routing (two-layer buffering) ----
@@ -92,7 +94,7 @@ class PeerSessionManager(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction =
synchronized(lock) {
lock.withLock {
val entry = sessions[senderPubKey]
when {
entry != null && entry.remoteDescriptionSet -> {
@@ -117,21 +119,21 @@ class PeerSessionManager(
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
val candidates: List<IceCandidateData>
val entry: SessionEntry
synchronized(lock) {
entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
}
val (entry, candidates) =
lock.withLock {
val entry = sessions[peerPubKey] ?: return@withLock null
entry.remoteDescriptionSet = true
val candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
entry to candidates
} ?: return 0
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 }
fun globalPendingCount(peerPubKey: HexKey): Int = lock.withLock { globalPendingIce[peerPubKey]?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = lock.withLock { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
// ---- Renegotiation glare handling ----
@@ -147,7 +149,7 @@ class PeerSessionManager(
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val entry = lock.withLock { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -184,7 +186,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val entry = lock.withLock { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -199,12 +201,13 @@ class PeerSessionManager(
// ---- Cleanup ----
fun disposeAll() {
val entries: List<SessionEntry>
synchronized(lock) {
entries = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
}
val entries =
lock.withLock {
val snapshot = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
snapshot
}
for (entry in entries) {
entry.session.dispose()
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* Global registry of accepted game IDs.
*
@@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess
*/
object AcceptedGamesRegistry {
private val acceptedGameIds = mutableSetOf<String>()
private val lock = Any()
private val lock = KmpLock()
fun markAsAccepted(gameId: String) {
synchronized(lock) {
lock.withLock {
acceptedGameIds.add(gameId)
}
}
fun wasAccepted(gameId: String): Boolean =
synchronized(lock) {
lock.withLock {
acceptedGameIds.contains(gameId)
}
fun clear() {
synchronized(lock) {
lock.withLock {
acceptedGameIds.clear()
}
}
/** Remove old entries - call periodically to prevent memory leak */
fun clearOldEntries(keepGameIds: Set<String>) {
synchronized(lock) {
lock.withLock {
acceptedGameIds.retainAll(keepGameIds)
}
}
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Collects and aggregates Jester chess events for a game from any source.
@@ -67,11 +69,15 @@ class ChessEventCollector(
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
// Move events (deduplicated by event ID). String keys are Comparable, so
// LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works.
private val moves = LargeCache<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
// Track all processed event IDs for fast deduplication. A plain HashSet
// guarded by KmpLock — simpler than a LargeCache<K, Boolean> for set-shaped
// membership.
private val processedEventIdsLock = KmpLock()
private val processedEventIds = mutableSetOf<String>()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
@@ -80,7 +86,11 @@ class ChessEventCollector(
/**
* Check if an event has already been processed.
*/
fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId)
fun hasEvent(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.contains(eventId) }
private fun markProcessed(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.add(eventId) }
private fun processedEventCount(): Int = processedEventIdsLock.withLock { processedEventIds.size }
/**
* Add a Jester event for this game.
@@ -89,7 +99,7 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) {
if (hasEvent(event.id)) {
Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" }
return false
}
@@ -129,12 +139,12 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addStartEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (hasEvent(event.id)) return false
if (!event.isStartEvent()) return false
if (event.id != startEventId) return false
if (_startEvent.compareAndSet(null, event)) {
processedEventIds.add(event.id)
markProcessed(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
return true
@@ -149,14 +159,15 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addMoveEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (hasEvent(event.id)) return false
if (!event.isMoveEvent()) return false
if (event.startEventId() != startEventId) return false
if (moves.putIfAbsent(event.id, event) == null) {
processedEventIds.add(event.id)
// createIfAbsent returns true iff the entry was added (no prior entry).
if (moves.createIfAbsent(event.id) { event }) {
markProcessed(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" }
Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size()}" }
return true
}
return false
@@ -168,7 +179,7 @@ class ChessEventCollector(
fun getEvents(): JesterGameEvents =
JesterGameEvents(
startEvent = _startEvent.value,
moves = moves.values.toList(),
moves = moves.values().toList(),
)
/**
@@ -179,22 +190,22 @@ class ChessEventCollector(
/**
* Check if the game has any moves (indicates game is active).
*/
fun hasMoves(): Boolean = moves.isNotEmpty()
fun hasMoves(): Boolean = !moves.isEmpty()
/**
* Check if the game has ended (has a move with result).
*/
fun hasEnded(): Boolean = moves.values.any { it.result() != null }
fun hasEnded(): Boolean = moves.values().any { it.result() != null }
/**
* Get the number of moves collected.
*/
fun moveCount(): Int = moves.size
fun moveCount(): Int = moves.size()
/**
* Get the latest move (with longest history).
*/
fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size }
fun latestMove(): JesterEvent? = moves.values().maxByOrNull { it.history().size }
/**
* Clear all collected events.
@@ -202,12 +213,12 @@ class ChessEventCollector(
fun clear() {
_startEvent.value = null
moves.clear()
processedEventIds.clear()
processedEventIdsLock.withLock { processedEventIds.clear() }
_eventCount.value = 0
}
private fun incrementEventCount() {
_eventCount.value = processedEventIds.size
_eventCount.value = processedEventCount()
}
}
@@ -218,21 +229,21 @@ class ChessEventCollector(
* such as in a chess lobby or when spectating multiple games.
*/
class ChessEventCollectorManager {
private val collectors = ConcurrentHashMap<String, ChessEventCollector>()
private val collectors = LargeCache<String, ChessEventCollector>()
/**
* Get or create a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) }
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrCreate(startEventId) { ChessEventCollector(it) }
/**
* Get a collector if it exists.
*
* @param startEventId The start event ID (game identifier)
*/
fun get(startEventId: String): ChessEventCollector? = collectors[startEventId]
fun get(startEventId: String): ChessEventCollector? = collectors.get(startEventId)
/**
* Remove a collector for a game.
@@ -244,13 +255,13 @@ class ChessEventCollectorManager {
/**
* Get all active game IDs (start event IDs).
*/
fun activeGameIds(): Set<String> = collectors.keys.toSet()
fun activeGameIds(): Set<String> = collectors.keys()
/**
* Clear all collectors.
*/
fun clear() {
collectors.values.forEach { it.clear() }
collectors.values().forEach { it.clear() }
collectors.clear()
}
@@ -275,8 +286,8 @@ class ChessEventCollectorManager {
return false
}
val collector =
collectors[startId] ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" }
collectors.get(startId) ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" }
return false
}
return collector.addEvent(event)
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -129,18 +132,21 @@ class ChessLobbyLogic(
) {
val state = ChessLobbyState(userPubkey, scope)
private val dismissedGameIdsLock = KmpLock()
private val dismissedGameIds: MutableSet<String> =
java.util.Collections.synchronizedSet(
dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
)
(dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf())
// Track when games were last loaded to prevent duplicate fetches
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it).
// String keys are Comparable, so LargeCache works.
private val recentlyLoadedGames = LargeCache<String, Long>()
// Dedup incoming events (same event delivered by multiple relays)
// Bounded LRU: evict oldest when exceeding capacity
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
// Dedup incoming events (same event delivered by multiple relays).
// Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns
// LinkedHashSet on every KMP target, preserving insertion-order iteration
// required for LRU eviction below.
private val seenEventIdsLock = KmpLock()
private val seenEventIds = mutableSetOf<String>()
private val seenEventIdsMax = 500
private val pollingDelegate =
@@ -207,15 +213,21 @@ class ChessLobbyLogic(
if (!event.isStartEvent() && !event.isMoveEvent()) return
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
synchronized(seenEventIds) {
if (!seenEventIds.add(event.id)) return
if (seenEventIds.size > seenEventIdsMax) {
seenEventIds.iterator().let {
it.next()
it.remove()
val isNew =
seenEventIdsLock.withLock {
if (!seenEventIds.add(event.id)) {
false
} else {
if (seenEventIds.size > seenEventIdsMax) {
seenEventIds.iterator().let {
it.next()
it.remove()
}
}
true
}
}
}
if (!isNew) return
Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" }
when {
@@ -438,13 +450,13 @@ class ChessLobbyLogic(
*/
fun handleGameAccepted(startEventId: String) {
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
val lastLoaded = recentlyLoadedGames[startEventId]
val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent launches
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
scope.launch(Dispatchers.Default) {
@@ -456,7 +468,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
@@ -608,7 +620,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
@@ -641,7 +653,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
if (result.liveState.isSpectator) {
state.addSpectatingGame(startEventId, result.liveState)
} else {
@@ -678,13 +690,13 @@ class ChessLobbyLogic(
private suspend fun refreshGame(startEventId: String) {
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
val lastLoaded = recentlyLoadedGames[startEventId]
val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent fetches for the same game
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
val events = fetcher.fetchGameEvents(startEventId)
@@ -873,16 +885,17 @@ class ChessLobbyLogic(
.map { it.gameId }
.toSet()
val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }
for (startEventId in newGameIds) {
if (startEventId in completedGameIds) continue
if (startEventId in dismissedGameIds) continue
if (startEventId in dismissedSnapshot) continue
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
recentlyLoadedGames.put(startEventId, TimeUtils.now())
// If the discovered game is already finished, send it straight to completed
val gameStatus = result.liveState.gameStatus.value
if (gameStatus is GameStatus.Finished) {
@@ -951,15 +964,23 @@ class ChessLobbyLogic(
fun dismissCompletedGame(gameId: String) {
state.removeCompletedGame(gameId)
dismissedGameIds.add(gameId)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
val snapshot =
dismissedGameIdsLock.withLock {
dismissedGameIds.add(gameId)
dismissedGameIds.toSet()
}
dismissedStorage?.save(userPubkey, snapshot)
}
fun dismissAllCompletedGames() {
val allIds = state.completedGames.value.map { it.gameId }
state.clearCompletedGames()
dismissedGameIds.addAll(allIds)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
val snapshot =
dismissedGameIdsLock.withLock {
dismissedGameIds.addAll(allIds)
dismissedGameIds.toSet()
}
dismissedStorage?.save(userPubkey, snapshot)
}
/**
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.atomic.AtomicLong
/**
* Challenge expiry: 24 hours
@@ -245,9 +244,9 @@ class ChessLobbyState(
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// State version counter - increments on every game state update
// UI can observe this to force recomposition when internal state changes
private val stateVersionCounter = AtomicLong(0)
// State version counter — bumped on every game state update so the UI
// can force recomposition when internal state changes. MutableStateFlow.update
// is itself atomic, so a separate counter is not needed.
private val _stateVersion = MutableStateFlow(0L)
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
@@ -383,13 +382,11 @@ class ChessLobbyState(
if (inActiveGames) {
_activeGames.update { it + (gameId to stateToUse) }
// Increment version to force UI recomposition even if map equals() returns true
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
// Bump version to force UI recomposition even if map equals() returns true
_stateVersion.update { it + 1 }
} else if (inSpectatingGames) {
_spectatingGames.update { it + (gameId to stateToUse) }
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
_stateVersion.update { it + 1 }
}
}
@@ -20,15 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Progress callback for relay fetch operations
@@ -67,6 +71,7 @@ class ChessRelayFetchHelper(
* @param onProgress Optional callback for progress updates per relay
* @return Deduplicated list of events received before timeout/EOSE
*/
@OptIn(ExperimentalAtomicApi::class)
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
@@ -74,16 +79,22 @@ class ChessRelayFetchHelper(
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val events = LargeCache<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
// Per-relay event counters. Each counter is independently atomic, so
// increments don't need to lock the whole map.
val relayEventCounts = LargeCache<NormalizedRelayUrl, AtomicInt>()
// Small bounded set; KmpLock-guarded plain set is fine for the size we
// expect (one entry per relay in the filter map).
val eoseLock = KmpLock()
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
// Initialize all relays as WAITING
// Initialize all relays as WAITING. Eagerly create AtomicInt counters
// so onEose / timeout paths can read load() without a put race.
filters.keys.forEach { relay ->
relayEventCounts[relay] = 0
relayEventCounts.getOrCreate(relay) { AtomicInt(0) }
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
}
@@ -95,8 +106,8 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events[event.id] = event
val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
events.put(event.id, event)
val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1)
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
}
@@ -104,11 +115,15 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
val count = relayEventCounts[relay] ?: 0
val newEoseSize =
eoseLock.withLock {
eoseReceived.add(relay)
eoseReceived.size
}
val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
// Complete when all relays respond
if (eoseReceived.size >= relayCount) {
if (newEoseSize >= relayCount) {
allEose.complete(Unit)
}
}
@@ -119,9 +134,10 @@ class ChessRelayFetchHelper(
// Mark timed-out relays
if (eoseResult == null) {
val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() }
filters.keys.forEach { relay ->
if (relay !in eoseReceived) {
val count = relayEventCounts[relay] ?: 0
if (relay !in eoseSnapshot) {
val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
}
}
@@ -129,6 +145,6 @@ class ChessRelayFetchHelper(
client.unsubscribe(subId)
return events.values.toList()
return events.values().toList()
}
}
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.emojicoder
import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
import com.vitorpamplona.amethyst.commons.util.codePointCharCount
import com.vitorpamplona.amethyst.commons.util.codePointToChars
object EmojiCoder {
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
// VS1..=VS16
@@ -35,8 +39,8 @@ object EmojiCoder {
Array<CharArray>(256) {
// converts to UTF-16. Always char[2] back
when (it) {
in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it)
in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it)
in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
else -> throw RuntimeException("This should never happen")
}
}
@@ -55,11 +59,11 @@ object EmojiCoder {
fun isCoded(text: String): Boolean {
if (text.length <= 3) return false
if (!isVariationChar(text.codePointAt(text.length - 2))) {
if (!isVariationChar(text.codePointAtKmp(text.length - 2))) {
return false
}
if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) {
if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) {
return false
}
@@ -70,7 +74,7 @@ object EmojiCoder {
emoji: String,
text: String,
): String {
val input = text.toByteArray(Charsets.UTF_8)
val input = text.encodeToByteArray()
val out = CharArray(input.size * 2)
var outIdx = 0
for (i in 0 until input.size) {
@@ -78,51 +82,34 @@ object EmojiCoder {
out[outIdx++] = chars[0]
out[outIdx++] = chars[1]
}
return emoji + String(out)
return emoji + out.concatToString()
}
/**
* Scans [text] collecting variation-selector bytes until a non-selector is found
* after at least one byte has been collected.
* Returns the end index (exclusive) in [text] and the collected byte values.
*/
private fun scanVariationBytes(text: String): Pair<Int, List<Int>> {
val bytes = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAtKmp(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && bytes.isNotEmpty()) break
i += codePointCharCount(codePoint) // Advance by correct char count
if (byte != null) bytes.add(byte)
}
return i to bytes
}
fun decode(text: String): String {
val decoded = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAt(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && decoded.isNotEmpty()) {
break
} else if (byte == null) {
i += Character.charCount(codePoint) // Advance index by correct number of chars
continue
}
decoded.add(byte)
i += Character.charCount(codePoint) // Advance index by correct number of chars
}
val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() }
return String(decodedArray, Charsets.UTF_8)
val (_, bytes) = scanVariationBytes(text)
return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString()
}
fun cropToFirstMessage(text: String): String {
val decoded = mutableListOf<Int>()
var i = 0
while (i < text.length) {
val codePoint = text.codePointAt(i)
val byte = fromVariationSelector(codePoint)
if (byte == null && decoded.isNotEmpty()) {
break
} else if (byte == null) {
i += Character.charCount(codePoint) // Advance index by correct number of chars
continue
}
decoded.add(byte)
i += Character.charCount(codePoint) // Advance index by correct number of chars
}
return text.substring(0, i)
val (endIndex, _) = scanVariationBytes(text)
return text.substring(0, endIndex)
}
}
@@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Stable
class FeedBuilderState(
@@ -66,6 +69,7 @@ class FeedBuilderState(
private val editId: String? = initial?.id
@OptIn(ExperimentalUuidApi::class)
fun toDefinition(): FeedDefinition {
val source =
FeedSource.Filter(
@@ -77,17 +81,14 @@ class FeedBuilderState(
kinds = kinds.toImmutableList(),
)
return FeedDefinition(
id =
editId ?: java.util.UUID
.randomUUID()
.toString(),
id = editId ?: Uuid.random().toString(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source,
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
createdAt = TimeUtils.now(),
)
}
}
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class FeedDefinitionBuilder {
var name: String = ""
@@ -70,13 +73,11 @@ class FeedDefinitionBuilder {
pinOrder = Int.MAX_VALUE,
source = source ?: error("FeedDefinition requires a source"),
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
createdAt = TimeUtils.now(),
)
private fun generateId(): String =
java.util.UUID
.randomUUID()
.toString()
@OptIn(ExperimentalUuidApi::class)
private fun generateId(): String = Uuid.random().toString()
}
class FilterBuilder {
@@ -20,29 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
object FeedDefinitionSerializer {
private val mapper = ObjectMapper()
private val json = Json { ignoreUnknownKeys = true }
fun serializeList(feeds: List<FeedDefinition>): String {
val array = mapper.createArrayNode()
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
return mapper.writeValueAsString(array)
val array =
buildJsonArray {
feeds.forEach { add(serializeFeed(it)) }
}
return json.encodeToString(JsonArray.serializer(), array)
}
fun deserializeList(json: String): List<FeedDefinition> {
if (json.isBlank()) return emptyList()
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
return array.mapNotNull { node -> deserializeFeed(node) }
fun deserializeList(jsonString: String): List<FeedDefinition> {
if (jsonString.isBlank()) return emptyList()
val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList()
return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } }
}
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeFeed(feed: FeedDefinition): JsonObject =
buildJsonObject {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
@@ -50,25 +58,25 @@ object FeedDefinitionSerializer {
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
set<ObjectNode>("source", serializeSource(feed.source))
put("source", serializeSource(feed.source))
}
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
val id = node.get("id")?.asText() ?: return null
val name = node.get("name")?.asText() ?: return null
val emoji = node.get("emoji")?.asText() ?: ""
val pinned = node.get("pinned")?.asBoolean() ?: false
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
private fun deserializeFeed(node: JsonObject): FeedDefinition? {
val id = node.string("id") ?: return null
val name = node.string("name") ?: return null
val emoji = node.string("emoji") ?: ""
val pinned = node.bool("pinned") ?: false
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
node.get("refreshMode")?.asText()?.let {
node.string("refreshMode")?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.get("createdAt")?.asLong() ?: 0L
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
val createdAt = node.long("createdAt") ?: 0L
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
@@ -82,17 +90,20 @@ object FeedDefinitionSerializer {
)
}
private fun serializeSource(source: FeedSource): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeSource(source: FeedSource): JsonObject =
buildJsonObject {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
put("hashtags", stringArray(source.hashtags))
put("authors", stringArray(source.authors))
put("relays", stringArray(source.relays))
put("excludeAuthors", stringArray(source.excludeAuthors))
put("excludeKeywords", stringArray(source.excludeKeywords))
put(
"kinds",
buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } },
)
}
is FeedSource.PeopleList -> {
@@ -131,61 +142,73 @@ object FeedDefinitionSerializer {
}
}
private fun deserializeSource(node: JsonNode): FeedSource? {
val type = node.get("type")?.asText() ?: return null
private fun deserializeSource(node: JsonObject): FeedSource? {
val type = node.string("type") ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
hashtags = node.stringList("hashtags") ?: return null,
authors = node.stringList("authors") ?: return null,
relays = node.stringList("relays") ?: return null,
excludeAuthors = node.stringList("excludeAuthors") ?: return null,
excludeKeywords = node.stringList("excludeKeywords") ?: return null,
kinds = node.intList("kinds") ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
kind = node.get("kind")?.asInt() ?: 30000,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30000,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
kind = node.get("kind")?.asInt() ?: 30015,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30015,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
kind = node.get("kind")?.asInt() ?: 31990,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 31990,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
url = node.get("url")?.asText() ?: return null,
url = node.string("url") ?: return null,
)
}
"global" -> {
FeedSource.Global
}
"following" -> {
FeedSource.Following
}
else -> {
null
}
"global" -> FeedSource.Global
"following" -> FeedSource.Following
else -> null
}
}
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
private fun JsonObject.stringList(key: String) =
(this[key] as? JsonArray)
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
?.toImmutableList()
private fun JsonObject.intList(key: String) =
(this[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
?.toImmutableList()
}
@@ -21,13 +21,15 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
@@ -41,10 +43,14 @@ abstract class Channel : NotesGatherer {
private var relays = mapOf<NormalizedRelayUrl, Counter>()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
// Single per-instance lock that previously @Synchronized methods share.
// Replaces JVM-only @Synchronized so this class compiles on iOS.
private val syncLock = KmpLock()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer {
abstract fun toBestDisplayName(): String
open fun relays(): Set<NormalizedRelayUrl> =
relays.keys
.toSortedSet { o1, o2 ->
val o1Count = relays[o1]?.number ?: 0
val o2Count = relays[o2]?.number ?: 0
o2Count.compareTo(o1Count) // descending
}
relays.entries
.sortedByDescending { it.value.number }
.mapTo(LinkedHashSet(relays.size)) { it.key }
fun updateChannelInfo() {
flowSet?.metadata?.invalidateData()
}
@Synchronized
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
fun addRelaySync(briefInfo: NormalizedRelayUrl) =
syncLock.withLock {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
}
}
}
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
@@ -107,7 +110,7 @@ abstract class Channel : NotesGatherer {
addRelay(relay)
}
changesFlow.get()?.tryEmit(ListChange.Addition(note))
changesFlow?.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
@@ -122,7 +125,7 @@ abstract class Channel : NotesGatherer {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
changesFlow?.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
@@ -140,7 +143,7 @@ abstract class Channel : NotesGatherer {
toBeRemoved.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
@@ -156,7 +159,7 @@ abstract class Channel : NotesGatherer {
hidden.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
@@ -165,18 +168,18 @@ abstract class Channel : NotesGatherer {
var flowSet: ChannelFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
fun createOrDestroyFlowSync(create: Boolean) =
syncLock.withLock {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
}
fun flow(): ChannelFlowSet {
if (flowSet == null) {
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -62,17 +64,19 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.BigDecimal
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quartz.utils.launchAndWaitAll
import com.vitorpamplona.quartz.utils.plus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import java.math.BigDecimal
import kotlin.concurrent.Volatile
interface NotesGatherer {
fun removeNote(note: Note)
@@ -105,6 +109,11 @@ class AddressableNote(
open class Note(
val idHex: HexKey,
) : NotesGatherer {
// Per-instance lock shared by the previously @Synchronized methods (zap /
// onchain-zap / zap-payment / relay-add / flowSet lifecycle). Replaces the
// JVM-only @Synchronized annotation so this class compiles on iOS.
private val syncLock = KmpLock()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
@@ -154,7 +163,7 @@ open class Note(
var zaps = mapOf<Note, Note?>()
private set
var zapsAmount: BigDecimal = BigDecimal.ZERO
var zapsAmount: BigDecimal = BigDecimal(0)
/**
* NIP-BC onchain zaps targeting this note.
@@ -165,7 +174,7 @@ open class Note(
* only counts CONFIRMED amounts.
*
* `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope`
* (inside the @Synchronized inner methods) and reads happen on the Compose Main
* (inside the syncLock-guarded inner methods) and reads happen on the Compose Main
* thread (gallery recomposition + the reverification driver's `any { … }` check).
*/
@Volatile
@@ -349,7 +358,7 @@ open class Note(
onchainZaps = mapOf()
onchainZapResolved = false
zapPayments = mapOf()
zapsAmount = BigDecimal.ZERO
zapsAmount = BigDecimal(0)
relays = listOf()
if (repliesChanged) flowSet?.replies?.invalidateData()
@@ -421,19 +430,18 @@ open class Note(
}
}
@Synchronized
private fun innerAddZap(
zapRequest: Note,
zap: Note?,
): Boolean {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
return true
): Boolean =
syncLock.withLock {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
return@withLock true
}
return@withLock false
}
return false
}
fun addZap(
zapRequest: Note,
zap: Note?,
@@ -447,48 +455,48 @@ open class Note(
}
}
@Synchronized
private fun innerAddOnchainZap(
txid: String,
entry: OnchainZapEntry,
): Boolean {
val existing = onchainZaps[txid]
if (existing != null) {
// Exact structural duplicate (same source Note + same fields) — typical
// relay echo of the same event. Skip the rewrite to avoid spurious
// flowSet invalidation.
if (entry == existing) return false
// Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
// so the upgrade contract survives future enum reordering or insertions.
if (entry.status.level < existing.status.level) return false
// Same level: accept only when verifiedSats grows OR the source differs
// (legitimate alternate signer republishing a split-zap receipt). A strictly
// smaller verifiedSats is a backend downgrade and we ignore it.
if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false
): Boolean =
syncLock.withLock {
val existing = onchainZaps[txid]
if (existing != null) {
// Exact structural duplicate (same source Note + same fields) — typical
// relay echo of the same event. Skip the rewrite to avoid spurious
// flowSet invalidation.
if (entry == existing) return@withLock false
// Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
// so the upgrade contract survives future enum reordering or insertions.
if (entry.status.level < existing.status.level) return@withLock false
// Same level: accept only when verifiedSats grows OR the source differs
// (legitimate alternate signer republishing a split-zap receipt). A strictly
// smaller verifiedSats is a backend downgrade and we ignore it.
if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return@withLock false
}
onchainZaps = onchainZaps + Pair(txid, entry)
return@withLock true
}
onchainZaps = onchainZaps + Pair(txid, entry)
return true
}
@Synchronized
private fun innerRemoveOnchainZapForSource(
txid: String,
sourceAuthorPubKey: HexKey,
): Boolean {
val existing = onchainZaps[txid] ?: return false
// Anti-spoof: only remove the entry if its source matches the rejecting event.
// Otherwise a malicious third party could erase a legitimate CONFIRMED entry
// by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
// Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
// CONFIRMED replacement should change it; a transient backend hiccup must not
// wipe a previously-CONFIRMED entry just because some other target on the same
// event is still UNVERIFIED.
if (existing.status == OnchainZapStatus.CONFIRMED) return false
if (existing.source.author?.pubkeyHex == null) return false
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false
onchainZaps = onchainZaps - txid
return true
}
): Boolean =
syncLock.withLock {
val existing = onchainZaps[txid] ?: return@withLock false
// Anti-spoof: only remove the entry if its source matches the rejecting event.
// Otherwise a malicious third party could erase a legitimate CONFIRMED entry
// by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
// Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
// CONFIRMED replacement should change it; a transient backend hiccup must not
// wipe a previously-CONFIRMED entry just because some other target on the same
// event is still UNVERIFIED.
if (existing.status == OnchainZapStatus.CONFIRMED) return@withLock false
if (existing.source.author?.pubkeyHex == null) return@withLock false
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return@withLock false
onchainZaps = onchainZaps - txid
return@withLock true
}
/**
* Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own
@@ -535,19 +543,18 @@ open class Note(
}
}
@Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
): Boolean {
if (zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
): Boolean =
syncLock.withLock {
if (zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return@withLock true
}
return@withLock false
}
return false
}
fun addZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
@@ -589,12 +596,12 @@ open class Note(
}
}
@Synchronized
fun addRelaySync(relay: NormalizedRelayUrl) {
if (relay !in relays) {
relays = relays + relay
fun addRelaySync(relay: NormalizedRelayUrl) =
syncLock.withLock {
if (relay !in relays) {
relays = relays + relay
}
}
}
fun hasRelay(relay: NormalizedRelayUrl) = relay in relays
@@ -737,13 +744,13 @@ open class Note(
}.flatten()
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
var sumOfAmounts = BigDecimal(0)
// Regular Zap Receipts
zaps.forEach {
val noteEvent = it.value?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
sumOfAmounts += noteEvent.amount ?: BigDecimal(0)
}
}
@@ -751,7 +758,7 @@ open class Note(
// Unverified/pending entries are tracked but excluded from the total per spec.
onchainZaps.values.forEach { entry ->
if (entry.status == OnchainZapStatus.CONFIRMED) {
sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats)
sumOfAmounts += BigDecimal(entry.verifiedSats)
}
}
@@ -828,7 +835,7 @@ open class Note(
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -879,7 +886,7 @@ open class Note(
.any {
val pledgeValue =
try {
BigDecimal(it.event?.content)
it.event?.content?.let { content -> BigDecimal(content) }
} catch (e: Exception) {
if (e is CancellationException) throw e
null
@@ -889,7 +896,13 @@ open class Note(
pledgeValue != null && it.author == user
}
fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO }
// Manual fold rather than Iterable.sumOf { -> BigDecimal } because that
// overload is JVM-only; the common stdlib only ships sumOf for the
// primitive numeric types.
fun pledgedAmountByOthers(): BigDecimal =
replies.fold(BigDecimal(0)) { acc, note ->
acc + (note.event?.addedRewardValue() ?: BigDecimal(0))
}
fun hasAnyReports(): Boolean {
val dayAgo = TimeUtils.oneDayAgo()
@@ -978,7 +991,7 @@ open class Note(
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
zapsAmount = BigDecimal.ZERO
zapsAmount = BigDecimal(0)
}
fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean {
@@ -1035,18 +1048,18 @@ open class Note(
var flowSet: NoteFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = NoteFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
fun createOrDestroyFlowSync(create: Boolean) =
syncLock.withLock {
if (create) {
if (flowSet == null) {
flowSet = NoteFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
}
fun flow(): NoteFlowSet {
if (flowSet == null) {
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -26,13 +26,15 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
/**
* Represents a Marmot MLS group chat room.
@@ -84,31 +86,34 @@ class MarmotGroupChatroom(
return adminPubkeys.value.any { it in followingKeySet }
}
// Per-instance lock shared by previously @Synchronized methods.
private val syncLock = KmpLock()
// Synthetic note used by list views to represent the group when no
// messages have been received yet. Lazily created and kept stable so
// equality-based feed diffing treats it as the same row across refreshes.
private var cachedPlaceholder: Note? = null
@Synchronized
fun placeholderNote(): Note {
val existing = cachedPlaceholder
if (existing != null) return existing
val created =
Note(placeholderIdHex(nostrGroupId)).apply {
addGatherer(this@MarmotGroupChatroom)
}
cachedPlaceholder = created
return created
}
fun placeholderNote(): Note =
syncLock.withLock {
val existing = cachedPlaceholder
if (existing != null) return@withLock existing
val created =
Note(placeholderIdHex(nostrGroupId)).apply {
addGatherer(this@MarmotGroupChatroom)
}
cachedPlaceholder = created
created
}
companion object {
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
}
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -119,23 +124,23 @@ class MarmotGroupChatroom(
removeMessageSync(note)
}
@Synchronized
fun addMessageSync(msg: Note): Boolean {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
fun addMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
unreadCount.value += 1
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return@withLock true
}
unreadCount.value += 1
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
return@withLock false
}
return false
}
/**
* Add a message that is being restored from persistent storage on app
@@ -143,38 +148,38 @@ class MarmotGroupChatroom(
* count — restored messages were already seen by the user in a previous
* session.
*/
@Synchronized
fun restoreMessageSync(msg: Note): Boolean {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
fun restoreMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return@withLock true
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
return@withLock false
}
return false
}
@Synchronized
fun removeMessageSync(msg: Note): Boolean {
if (msg in messages) {
messages = messages - msg
msg.removeGatherer(this)
fun removeMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg in messages) {
messages = messages - msg
msg.removeGatherer(this)
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
}
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
return@withLock true
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
return true
return@withLock false
}
return false
}
fun markAsRead() {
unreadCount.value = 0
@@ -199,7 +204,7 @@ class MarmotGroupChatroom(
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
@@ -209,14 +214,14 @@ class MarmotGroupChatroom(
* decrypted inner notes become eligible for GC out of LocalCache (which
* holds them weakly).
*/
@Synchronized
fun clearAllMessagesSync(): Set<Note> {
val toRemove = messages
if (toRemove.isEmpty()) return toRemove
messages = emptySet()
newestMessage = null
unreadCount.value = 0
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
fun clearAllMessagesSync(): Set<Note> =
syncLock.withLock {
val toRemove = messages
if (toRemove.isEmpty()) return@withLock toRemove
messages = emptySet()
newestMessage = null
unreadCount.value = 0
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
toRemove
}
}
@@ -21,10 +21,13 @@
package com.vitorpamplona.amethyst.commons.model.nip01Core
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
import kotlin.concurrent.Volatile
@Stable
data class RelayInfo(
@@ -52,10 +55,16 @@ val DefaultOrder =
@Stable
class UserRelaysCache {
var data: Map<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 val flowLock = KmpLock()
fun flow() =
flow?.get() ?: synchronized(this) {
flow?.get() ?: flowLock.withLock {
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
}
@@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -58,7 +58,7 @@ object LongFormPublishAction {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) {
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
val CreatedAtIdHexComparator: Comparator<Note> =
Comparator { note1, note2 ->
// Sort by created_at and idhex, but define uniqueness by reference
if (note1 === note2) return@Comparator 0
if (note1 == null) return@Comparator 1 // null is greater, moves to last
if (note2 == null) return@Comparator -1 // null is greater, moves to last
val createdAt1 = note1.createdAt()
val createdAt2 = note2.createdAt()
@@ -49,42 +47,31 @@ val CreatedAtIdHexComparator: Comparator<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> {
override fun compare(
first: Note?,
second: Note?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt.compareTo(secondEvent.createdAt)
}
}
a: Note,
b: Note,
): Int = compareByCreatedAt(a, b) { it.event }
}
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
override fun compare(
first: AddressableNote?,
second: AddressableNote?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt.compareTo(secondEvent.createdAt)
}
}
a: AddressableNote,
b: AddressableNote,
): Int = compareByCreatedAt(a, b) { it.event }
}
@@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -33,7 +36,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
class Chatroom : NotesGatherer {
@@ -44,10 +46,13 @@ class Chatroom : NotesGatherer {
var ownerSentMessage: Boolean = false
var newestMessage: Note? = null
private var changesFlow: WeakReference<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>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -58,68 +63,68 @@ class Chatroom : NotesGatherer {
removeMessageSync(note)
}
@Synchronized
fun addMessageSync(msg: Note): Boolean {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
fun addMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
msg.author?.let { author ->
if (author !in activeSenders) {
activeSenders + author
}
}
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
subject.tryEmit(newSubject)
subjectCreatedAt = msg.createdAt()
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
}
return false
}
@Synchronized
fun removeMessageSync(msg: Note): Boolean {
if (msg in messages) {
messages = messages - msg
msg.removeGatherer(this)
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
}
if (msg.event?.subject() == subject.value) {
messages
.maxByOrNull {
val noteEvent = it.event
if (noteEvent?.subject() != null) {
noteEvent.createdAt
} else {
0
}
}?.let {
subject.tryEmit(it.event?.subject())
subjectCreatedAt = it.createdAt()
msg.author?.let { author ->
if (author !in activeSenders) {
activeSenders + author
}
}
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
subject.tryEmit(newSubject)
subjectCreatedAt = msg.createdAt()
}
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return@withLock true
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
return true
return@withLock false
}
fun removeMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg in messages) {
messages = messages - msg
msg.removeGatherer(this)
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
}
if (msg.event?.subject() == subject.value) {
messages
.maxByOrNull {
val noteEvent = it.event
if (noteEvent?.subject() != null) {
noteEvent.createdAt
} else {
0
}
}?.let {
subject.tryEmit(it.event?.subject())
subjectCreatedAt = it.createdAt()
}
}
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
return@withLock true
}
return@withLock false
}
return false
}
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
@@ -138,7 +143,7 @@ class Chatroom : NotesGatherer {
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
@@ -21,7 +21,8 @@
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
import androidx.compose.runtime.Stable
import java.util.concurrent.ConcurrentHashMap
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* This allows composables to directly register their queries
@@ -32,13 +33,16 @@ import java.util.concurrent.ConcurrentHashMap
abstract class ComposeSubscriptionManager<T> :
ComposeSubscriptionManagerControls,
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.
override fun subscribe(query: T?) {
if (query == null) return
composeSubscriptions.put(query, query)
lock.withLock { composeSubscriptions[query] = query }
invalidateKeys()
}
@@ -47,7 +51,7 @@ abstract class ComposeSubscriptionManager<T> :
override fun unsubscribe(query: T?) {
if (query == null) return
composeSubscriptions.remove(query)
lock.withLock { composeSubscriptions.remove(query) }
invalidateKeys()
}
@@ -55,8 +59,8 @@ abstract class ComposeSubscriptionManager<T> :
override fun subscribe(query: List<T>) {
if (query.isEmpty()) return
query.forEach {
composeSubscriptions.put(it, it)
lock.withLock {
query.forEach { composeSubscriptions[it] = it }
}
invalidateKeys()
@@ -66,14 +70,14 @@ abstract class ComposeSubscriptionManager<T> :
override fun unsubscribe(query: List<T>) {
if (query.isEmpty()) return
query.forEach {
composeSubscriptions.remove(it)
lock.withLock {
query.forEach { composeSubscriptions.remove(it) }
}
invalidateKeys()
}
fun allKeys() = composeSubscriptions.keys
fun allKeys(): Set<T> = lock.withLock { composeSubscriptions.keys.toSet() }
}
interface Subscribable<T> {
@@ -21,12 +21,13 @@
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* This allows composables to directly register their queries
@@ -41,19 +42,26 @@ import java.util.concurrent.ConcurrentHashMap
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
val scope: CoroutineScope,
) : 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.
fun subscribe(query: T?) {
if (query == null) return
composeSubscriptions[query]?.cancel()
composeSubscriptions[query] =
scope.launch {
query.flow().collectLatest {
invalidateKeys()
lock.withLock {
composeSubscriptions[query]?.cancel()
composeSubscriptions[query] =
scope.launch {
query.flow().collectLatest {
invalidateKeys()
}
}
}
}
invalidateKeys()
}
@@ -62,16 +70,19 @@ abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
fun unsubscribe(query: T?) {
if (query == null) return
composeSubscriptions[query]?.cancel()
composeSubscriptions.remove(query)
lock.withLock {
composeSubscriptions[query]?.cancel()
composeSubscriptions.remove(query)
}
invalidateKeys()
}
fun allKeys() = composeSubscriptions.keys
fun allKeys(): Set<T> = lock.withLock { composeSubscriptions.keys.toSet() }
fun forEachSubscriber(action: (T) -> Unit) {
composeSubscriptions.keys.forEach(action)
val snapshot = lock.withLock { composeSubscriptions.keys.toList() }
snapshot.forEach(action)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
abstract class BaseEoseManager<T>(
val client: INostrClient,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.relays
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
@@ -33,14 +35,14 @@ open class EOSECache<K : Any>(
private val maxSize: Int = 200,
) {
private val cache = linkedMapOf<K, EOSERelayList>()
private val lock = Any()
private val lock = KmpLock()
fun addOrUpdate(
key: K,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
synchronized(lock) {
lock.withLock {
val relayList = cache[key]
if (relayList == null) {
// Evict oldest if at capacity
@@ -57,7 +59,7 @@ open class EOSECache<K : Any>(
}
fun since(key: K): SincePerRelayMap? =
synchronized(lock) {
lock.withLock {
cache[key]?.relayList?.toMutableMap()
}
@@ -68,18 +70,18 @@ open class EOSECache<K : Any>(
) = addOrUpdate(key, relayUrl, time)
fun remove(key: K) {
synchronized(lock) {
lock.withLock {
cache.remove(key)
}
}
fun clear() {
synchronized(lock) {
lock.withLock {
cache.clear()
}
}
fun size(): Int = synchronized(lock) { cache.size }
fun size(): Int = lock.withLock { cache.size }
}
/**
@@ -91,7 +93,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
private val innerMaxSize: Int = 200,
) {
private val cache = linkedMapOf<K1, EOSECache<K2>>()
private val lock = Any()
private val lock = KmpLock()
fun addOrUpdate(
outerKey: K1,
@@ -99,7 +101,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
relayUrl: NormalizedRelayUrl,
time: Long,
) {
synchronized(lock) {
lock.withLock {
val innerCache = cache[outerKey]
if (innerCache == null) {
// Evict oldest if at capacity
@@ -119,7 +121,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
outerKey: K1,
innerKey: K2,
): SincePerRelayMap? =
synchronized(lock) {
lock.withLock {
cache[outerKey]?.since(innerKey)
}
@@ -131,13 +133,13 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
fun removeOuter(key: K1) {
synchronized(lock) {
lock.withLock {
cache.remove(key)
}
}
fun clear() {
synchronized(lock) {
lock.withLock {
cache.clear()
}
}
@@ -20,21 +20,20 @@
*/
package com.vitorpamplona.amethyst.commons.richtext
import java.util.Base64
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
object Base64Image {
val pattern = Patterns.BASE64_IMAGE
fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content)
@OptIn(ExperimentalEncodingApi::class)
fun parse(content: String): ByteArray {
val matcher = pattern.find(content)
if (matcher != null) {
val base64String = matcher.groups[2]?.value
val byteArray = Base64.getDecoder().decode(base64String)
return byteArray
}
throw Exception("Unable to convert base64 to image $content")
val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content")
val base64String =
match.groups[2]?.value
?: throw Exception("Unable to convert base64 to image $content")
return Base64.decode(base64String)
}
}
@@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import java.io.File
// URL-based media content models (KMP). The locally-cached variants
// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in
// MediaLocalContent.kt under the jvmAndroid source set because they depend
// on java.io.File.
@Immutable
abstract class BaseMediaContent(
@@ -123,44 +127,3 @@ class EncryptedMediaUrlVideo(
thumbhash: String? = null,
authorPubKey: String? = null,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey)
@Immutable
abstract class MediaPreloadedContent(
val localFile: File?,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
val uri: String,
val id: String? = null,
thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
fun localFileExists() = localFile != null && localFile.exists()
}
@Immutable
class MediaLocalImage(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@Immutable
class MediaLocalVideo(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
val artworkUri: String? = null,
val authorName: String? = null,
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.util.isValidUrl
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -40,9 +41,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toPersistentList
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
import kotlin.coroutines.cancellation.CancellationException
class RichTextParser {
@@ -489,21 +487,7 @@ class RichTextParser {
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
try {
if (url != null) {
URI(url).toURL()
true
} else {
false
}
} catch (e: MalformedURLException) {
false
} catch (e: URISyntaxException) {
false
} catch (e: IllegalArgumentException) {
false
}
fun isValidURL(url: String?): Boolean = isValidUrl(url)
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
@@ -168,7 +168,7 @@ class RobohashAssembler {
Hex.decode(msg)
} else {
Log.w("Robohash") { "$msg is not a hex" }
sha256(msg.toByteArray())
sha256(msg.encodeToByteArray())
}
val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme))
@@ -20,15 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
class EventDeduplicator {
private val lock = Any()
private val lock = KmpLock()
private val seenIds = mutableSetOf<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 }
}
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.PI
@@ -50,14 +52,14 @@ object ThumbHashDecoder {
// (size, components) pairs across the entire app lifetime, each table is
// a few KB, so the memory ceiling is tiny.
private val cosineCache = HashMap<Long, DoubleArray>()
private val cosineCacheLock = Any()
private val cosineCacheLock = KmpLock()
/**
* Clear the cosine table cache. Tables are tiny but callers under memory
* pressure can release them; they will be recomputed on demand.
*/
fun clearCache() {
synchronized(cosineCacheLock) { cosineCache.clear() }
cosineCacheLock.withLock { cosineCache.clear() }
}
private fun cosTable(
@@ -65,9 +67,8 @@ object ThumbHashDecoder {
components: Int,
): DoubleArray {
val key = (size.toLong() shl 32) or components.toLong()
synchronized(cosineCacheLock) {
cosineCache[key]?.let { return it }
}
val cached = cosineCacheLock.withLock { cosineCache[key] }
if (cached != null) return cached
val table = DoubleArray(size * components)
val piOverSize = PI / size
for (i in 0 until size) {
@@ -77,10 +78,7 @@ object ThumbHashDecoder {
table[rowOffset + c] = cos(phase * c)
}
}
synchronized(cosineCacheLock) {
cosineCache.getOrPut(key) { table }
}
return table
return cosineCacheLock.withLock { cosineCache.getOrPut(key) { table } }
}
/**
@@ -175,7 +175,7 @@ fun FeedErrorState(
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage)
val formattedMessage = stringResource(Res.string.error_loading_feed, errorMessage)
ErrorState(
message = formattedMessage,
@@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@@ -26,8 +26,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.cancellation.CancellationException
sealed class SigningOpState {
@@ -44,26 +46,27 @@ sealed class SigningOpState {
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
*/
@OptIn(ExperimentalAtomicApi::class)
object GlobalSigningStatus {
var globalState by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
private val activeCount = AtomicInteger(0)
private val activeCount = AtomicInt(0)
fun onPending() {
activeCount.incrementAndGet()
activeCount.addAndFetch(1)
globalState = SigningOpState.Pending
}
fun onIdle() {
if (activeCount.decrementAndGet() <= 0) {
activeCount.set(0)
if (activeCount.addAndFetch(-1) <= 0) {
activeCount.store(0)
globalState = SigningOpState.Idle
}
}
fun onError(message: String) {
activeCount.decrementAndGet()
activeCount.addAndFetch(-1)
globalState = SigningOpState.Error(message)
}
}
@@ -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.model.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
fun String.isUTF16Char(pos: Int): Boolean = codePointCharCount(this.codePointAtKmp(pos)) == 2
fun String.firstFullCharOld(): String {
return when (this.length) {
@@ -64,11 +64,11 @@ fun String.firstFullChar(): String {
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
codePoint = codePointAtKmp(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
next = offsetByCodePointsKmp(i, 1)
start = next
} else {
// If join, searches for the next char
@@ -78,7 +78,7 @@ fun String.firstFullChar(): String {
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
if (codePointCharCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
@@ -91,7 +91,7 @@ fun String.firstFullChar(): String {
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
next = offsetByCodePointsKmp(i, 1)
previousCharLength += (next - i)
}
@@ -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()
}
}
@@ -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
@@ -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?
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
@Stable
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
@Stable
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -212,6 +212,71 @@ class FeedDefinitionSerializerTest {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
@Test
fun serializesToExpectedWireFormat() {
// Pins the byte-exact JSON output so any future change to the
// serializer (field order, null handling, number formatting) is
// caught here. Users have feeds saved on disk in this exact format
// and may share JSON between app versions; downstream consumers
// depend on the field order being stable.
val feed =
FeedDefinition(
id = "test-1",
name = "Bitcoin",
emoji = "₿",
pinned = true,
pinOrder = 0,
source =
FeedSource.Filter(
hashtags = persistentListOf("bitcoin", "btc"),
authors = persistentListOf("abc123"),
relays = persistentListOf("wss://relay.damus.io"),
excludeAuthors = persistentListOf("spammer"),
excludeKeywords = persistentListOf("scam"),
kinds = persistentListOf(1, 6),
),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 1000L,
)
val expected =
"""[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" +
""""pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" +
""""source":{"type":"filter","hashtags":["bitcoin","btc"],""" +
""""authors":["abc123"],"relays":["wss://relay.damus.io"],""" +
""""excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" +
""""kinds":[1,6]}}]"""
assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed)))
}
@Test
fun parsesLegacyJacksonOutput() {
// Wire format previously emitted by ObjectMapper. Users have feed
// definitions stored on disk in exactly this shape — the new
// kotlinx.serialization-backed parser must keep accepting it.
val legacy =
"""[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" +
""""refreshMode":"LIVE_STREAM","createdAt":1000,""" +
""""source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" +
""""relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]"""
val feeds = FeedDefinitionSerializer.deserializeList(legacy)
assertEquals(1, feeds.size)
val feed = feeds[0]
assertEquals("x", feed.id)
assertEquals("Bitcoin", feed.name)
assertEquals("₿", feed.emoji)
assertTrue(feed.pinned)
assertEquals(0, feed.pinOrder)
assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode)
assertEquals(1000L, feed.createdAt)
val source = feed.source as FeedSource.Filter
assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags)
assertEquals(persistentListOf("abc"), source.authors)
assertEquals(persistentListOf(1, 6), source.kinds)
}
@Test
fun defaultFeedsAreValid() {
val defaults = defaultFeeds()
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Lock in the descending-by-usage ordering and equal-count preservation of
* [Channel.relays]. Phase 2 KMP migration replaced the JVM `toSortedSet`
* (TreeSet) implementation — which silently dropped duplicates whose
* comparator returned 0 — with a LinkedHashSet built from a pre-sorted list.
* That keeps every distinct relay even when usage counts tie.
*/
class ChannelRelaysTest {
/** Minimal concrete subclass — the base [Channel] is abstract. */
private class TestChannel : Channel() {
override fun toBestDisplayName(): String = "test"
}
private val relayA = NormalizedRelayUrl("wss://a.example/")
private val relayB = NormalizedRelayUrl("wss://b.example/")
private val relayC = NormalizedRelayUrl("wss://c.example/")
@Test
fun emptyWhenNoRelaysAdded() {
assertEquals(emptySet<NormalizedRelayUrl>(), TestChannel().relays())
}
@Test
fun singleRelayIsReturned() {
val channel = TestChannel()
channel.addRelay(relayA)
assertEquals(setOf(relayA), channel.relays())
}
@Test
fun relaysAreOrderedDescendingByUsageCount() {
val channel = TestChannel()
// C will end up with the highest count.
channel.addRelay(relayA) // A: 1
channel.addRelay(relayB) // B: 1
channel.addRelay(relayB) // B: 2
channel.addRelay(relayC) // C: 1
channel.addRelay(relayC) // C: 2
channel.addRelay(relayC) // C: 3
// LinkedHashSet preserves insertion order, which is the
// descending-by-count order produced by sortedByDescending.
assertEquals(listOf(relayC, relayB, relayA), channel.relays().toList())
}
/**
* Behavioural change from the JVM-only implementation: the previous
* `toSortedSet` returned a [java.util.TreeSet] whose membership uses the
* comparator for equality. Two relays with equal counts would be treated
* as duplicates by the TreeSet and one would be silently dropped. The
* KMP-friendly replacement uses [LinkedHashSet], whose membership uses
* `equals`, so every distinct relay is preserved regardless of count.
*/
@Test
fun relaysWithEqualUsageCountsAreAllPreserved() {
val channel = TestChannel()
channel.addRelay(relayA) // A: 1
channel.addRelay(relayB) // B: 1
channel.addRelay(relayC) // C: 1
// All three relays share count 1. The old behaviour would return
// exactly one of them; the new behaviour returns all three.
assertEquals(setOf(relayA, relayB, relayC), channel.relays())
assertEquals(3, channel.relays().size)
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.math.BigDecimal
import com.vitorpamplona.quartz.utils.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -67,7 +67,7 @@ class NoteOnchainZapTest {
assertEquals(1000L, entry?.claimedSats)
assertEquals(0L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -82,7 +82,7 @@ class NoteOnchainZapTest {
assertSame(src, entry?.source)
assertEquals(1000L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -93,7 +93,7 @@ class NoteOnchainZapTest {
target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED)
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount)
assertEquals(BigDecimal(5000L), target.zapsAmount)
}
@Test
@@ -109,7 +109,7 @@ class NoteOnchainZapTest {
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(2400L, entry?.verifiedSats)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -124,7 +124,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount)
assertEquals(BigDecimal(2500L), target.zapsAmount)
}
@Test
@@ -139,7 +139,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount)
assertEquals(BigDecimal(7500L), target.zapsAmount)
}
@Test
@@ -188,7 +188,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(999L, entry?.verifiedSats)
assertEquals(BigDecimal.valueOf(999L), target.zapsAmount)
assertEquals(BigDecimal(999L), target.zapsAmount)
}
@Test
@@ -206,7 +206,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(BigDecimal.valueOf(1000L), target.zapsAmount)
assertEquals(BigDecimal(1000L), target.zapsAmount)
}
@Test
@@ -224,7 +224,7 @@ class NoteOnchainZapTest {
val secondEntry = target.onchainZaps["tx1"]
assertSame(firstEntry, secondEntry)
assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
assertEquals(BigDecimal(100L), target.zapsAmount)
}
@Test
@@ -238,13 +238,13 @@ class NoteOnchainZapTest {
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING)
// PENDING entries don't contribute to total per spec.
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertEquals(BigDecimal(0), target.zapsAmount)
assertNotNull(target.onchainZaps["tx1"])
target.removeOnchainZapForSource("tx1", srcKey)
assertNull(target.onchainZaps["tx1"])
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -277,13 +277,13 @@ class NoteOnchainZapTest {
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
assertEquals(BigDecimal(4200L), target.zapsAmount)
target.removeOnchainZapForSource("tx1", srcKey)
assertNotNull(target.onchainZaps["tx1"])
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
assertEquals(BigDecimal(4200L), target.zapsAmount)
}
@Test
@@ -342,7 +342,7 @@ class NoteOnchainZapTest {
target.addOnchainZap(sourceNote("82".repeat(32)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(sourceNote("93".repeat(32)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount)
assertEquals(BigDecimal(3000L), target.zapsAmount)
assertEquals(4, target.onchainZaps.size)
assertTrue(target.onchainZaps.containsKey("tx3"))
assertTrue(target.onchainZaps.containsKey("tx4"))
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
class CodePointsTest {
// ---- codePointCharCount ----
@Test
fun charCountIsOneForBmp() {
assertEquals(1, codePointCharCount(0x0041)) // 'A'
assertEquals(1, codePointCharCount(0xFFFF)) // last BMP code point
}
@Test
fun charCountIsTwoForSupplementary() {
assertEquals(2, codePointCharCount(0x10000)) // first supplementary code point
assertEquals(2, codePointCharCount(0x1F600)) // 😀 grinning face
assertEquals(2, codePointCharCount(0x10FFFF)) // last valid code point
}
// ---- codePointToChars ----
@Test
fun toCharsRoundTripsAscii() {
assertArrayEquals(charArrayOf('A'), codePointToChars(0x0041))
}
@Test
fun toCharsRoundTripsLastBmp() {
assertArrayEquals(charArrayOf('￿'), codePointToChars(0xFFFF))
}
@Test
fun toCharsProducesSurrogatePairForGrinningFace() {
// U+1F600 (😀) is encoded as the surrogate pair (0xD83D, 0xDE00).
assertArrayEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600))
}
@Test
fun toCharsProducesSurrogatePairForFirstSupplementary() {
// U+10000 -> (0xD800, 0xDC00).
assertArrayEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000))
}
@Test
fun toCharsProducesSurrogatePairForLastCodePoint() {
// U+10FFFF -> (0xDBFF, 0xDFFF).
assertArrayEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF))
}
// ---- String.codePointAtKmp ----
@Test
fun codePointAtReturnsAsciiCodeForBmp() {
assertEquals(0x0041, "Aa".codePointAtKmp(0))
assertEquals(0x0061, "Aa".codePointAtKmp(1))
}
@Test
fun codePointAtDecodesSurrogatePair() {
// "😀X" — surrogate pair followed by 'X'.
val s = "😀X"
assertEquals(0x1F600, s.codePointAtKmp(0))
}
@Test
fun codePointAtReturnsLowSurrogateWhenIndexIsOnLowHalf() {
// Indexing into the middle of a surrogate pair returns the low surrogate's raw code unit.
val s = "😀"
assertEquals(0xDE00, s.codePointAtKmp(1))
}
@Test
fun codePointAtReturnsHighSurrogateWhenStringEndsOnLoneHighSurrogate() {
// Lone high surrogate at end of string: `index + 1 < length` is false, so we
// fall back to returning the surrogate's raw code unit. This matches
// java.lang.Character.codePointAt(CharSequence, int).
val s = "\uD83D"
assertEquals(0xD83D, s.codePointAtKmp(0))
}
@Test
fun codePointAtReturnsHighSurrogateWhenFollowedByNonSurrogate() {
// High surrogate followed by a non-low-surrogate char: return the high surrogate raw.
val s = "\uD83DA"
assertEquals(0xD83D, s.codePointAtKmp(0))
assertEquals(0x0041, s.codePointAtKmp(1))
}
// ---- String.offsetByCodePointsKmp ----
@Test
fun offsetByOneStepsOverBmp() {
// Two ASCII chars; offset 1 from index 0 lands on index 1.
assertEquals(1, "Aa".offsetByCodePointsKmp(0, 1))
}
@Test
fun offsetByOneStepsOverSupplementaryPair() {
// 😀 is 2 UTF-16 code units; offset 1 from index 0 lands on index 2.
val s = "😀X"
assertEquals(2, s.offsetByCodePointsKmp(0, 1))
// Then a single step lands on the end of the string.
assertEquals(3, s.offsetByCodePointsKmp(2, 1))
}
@Test
fun offsetByZeroIsIdentity() {
assertEquals(0, "Aa".offsetByCodePointsKmp(0, 0))
assertEquals(2, "Aa".offsetByCodePointsKmp(2, 0))
}
// ---- Round-trip ----
@Test
fun encodeDecodeRoundTripCoversFullCodePointRange() {
// Spot-check a handful of code points across BMP and supplementary planes.
val samples = intArrayOf(0x0001, 0x0041, 0x00FF, 0x4F60, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF)
for (cp in samples) {
val chars = codePointToChars(cp)
val asString = chars.concatToString()
assertEquals("round-trip code point U+${cp.toString(16).uppercase()}", cp, asString.codePointAtKmp(0))
assertEquals(
"char count for U+${cp.toString(16).uppercase()}",
chars.size,
codePointCharCount(cp),
)
}
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
// Phase 2 compile-only iOS actual. Holds an ARGB pixel buffer + size. Phase 3
// will swap to a UIImage/CGImage-backed implementation when the iosApp module
// lands and BlurHash/ThumbHash output needs to render to UIKit.
actual class PlatformImage internal constructor(
private val pixels: IntArray,
actual val width: Int,
actual val height: Int,
) {
actual fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
) {
for (row in 0 until height) {
val srcStart = (y + row) * this.width + x
val dstStart = offset + row * stride
this.pixels.copyInto(
destination = pixels,
destinationOffset = dstStart,
startIndex = srcStart,
endIndex = srcStart + width,
)
}
}
actual fun scale(
width: Int,
height: Int,
): PlatformImage {
// Nearest-neighbour resample. Adequate for the Phase 2 compile-only target;
// Phase 3 should swap to CoreGraphics scaling for production quality.
val out = IntArray(width * height)
for (j in 0 until height) {
val srcY = (j.toLong() * this.height / height).toInt().coerceAtMost(this.height - 1)
for (i in 0 until width) {
val srcX = (i.toLong() * this.width / width).toInt().coerceAtMost(this.width - 1)
out[j * width + i] = pixels[srcY * this.width + srcX]
}
}
return PlatformImage(out, width, height)
}
actual companion object {
actual fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage = PlatformImage(pixels.copyOf(), width, height)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
// Phase 2 compile-only iOS actual. In-memory only; persistence via
// NSUserDefaults arrives with the iosApp module in Phase 3.
actual class ChessDismissedGamesStorage private actual constructor() {
private val dismissed = mutableMapOf<String, Set<String>>()
actual companion object {
actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
}
actual fun load(userPubkey: String): Set<String> = dismissed[userPubkey] ?: emptySet()
actual fun save(
userPubkey: String,
ids: Set<String>,
) {
if (ids.isEmpty()) {
dismissed.remove(userPubkey)
} else {
dismissed[userPubkey] = ids
}
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.keystorage
// Phase 2 compile-only iOS actual. Keychain Services wiring lands in Phase 4
// (write paths: signing, posting, settings) per amethyst/plans/2026-05-24-ios-support.md.
actual class SecureKeyStorage private actual constructor() {
actual companion object {
actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage()
}
actual suspend fun savePrivateKey(
npub: String,
privKeyHex: String,
): Unit = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSLocale
import platform.Foundation.NSTimeZone
import platform.Foundation.dateWithTimeIntervalSince1970
import platform.Foundation.localTimeZone
// Format pattern matches the JVM actual ("uuuu-MM-dd-HH:mm:ss"). For the
// post-1970 timestamps Nostr events use, `yyyy` and `uuuu` are equivalent
// (proleptic Gregorian, year >= 1), so NSDateFormatter's `yyyy` is fine.
private val levelFormatter: NSDateFormatter =
NSDateFormatter().apply {
dateFormat = "yyyy-MM-dd-HH:mm:ss"
timeZone = NSTimeZone.localTimeZone
locale = NSLocale("en_US_POSIX")
}
// NSDateFormatter is not thread-safe for concurrent stringFromDate calls
// (Apple docs). ThreadLevelCalculator.replyLevelSignature runs under
// Dispatchers.IO via LevelFeedViewModel, so multiple threads can hit
// formattedDateTime simultaneously. Guard the shared formatter rather than
// allocating one per call (cheaper for the O(notes-per-thread-sort) call rate).
private val levelFormatterLock = KmpLock()
actual fun formattedDateTime(timestamp: Long): String =
levelFormatterLock.withLock {
levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble()))
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.threading
// Mirrors the jvmMain no-op. The iosApp module (Phase 3) can swap this for an
// `NSThread.isMainThread` check if iOS-specific main-thread restrictions need
// enforcement; today there are none.
actual fun checkNotInMainThread() {
// No-op
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import platform.Foundation.NSRecursiveLock
actual class KmpLock {
// NSRecursiveLock mirrors ReentrantLock semantics on JVM/Android — a
// thread that already holds the lock can re-enter without deadlock.
private val delegate = NSRecursiveLock()
actual fun lock() = delegate.lock()
actual fun unlock() = delegate.unlock()
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.vitorpamplona.amethyst.commons.util
import platform.Foundation.NSNumber
import platform.Foundation.NSNumberFormatter
import platform.Foundation.NSNumberFormatterDecimalStyle
import platform.Foundation.numberWithLongLong
actual class PlatformNumberFormatter {
private val formatter: NSNumberFormatter =
NSNumberFormatter().apply {
numberStyle = NSNumberFormatterDecimalStyle
}
actual fun format(value: Long): String = formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString()
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import platform.Foundation.NSURL
// Schemes that JVM's URI.toURL() requires a host for (network schemes).
// "http:" without a host throws MalformedURLException on JVM; NSURL accepts
// it. Explicitly reject to keep behavior aligned.
private val HOST_REQUIRED_SCHEMES = setOf("http", "https", "ws", "wss", "ftp")
actual fun isValidUrl(url: String?): Boolean {
if (url == null) return false
// NSURL.URLWithString returns null for syntactically invalid URLs. It is
// more permissive than JVM's URI(url).toURL() — it accepts scheme-less
// relative URLs ("foo", "/bar") and scheme-only inputs ("http:"). Match
// the JVM contract (absolute URL with a known scheme and, for network
// schemes, a host).
val nsUrl = NSURL.URLWithString(url) ?: return false
val scheme = nsUrl.scheme?.lowercase() ?: return false
if (scheme.isEmpty()) return false
if (scheme in HOST_REQUIRED_SCHEMES && nsUrl.host.isNullOrEmpty()) return false
return true
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class)
package com.vitorpamplona.amethyst.commons.util
actual class WeakReference<T : Any> actual constructor(
referent: T,
) {
private val ref = kotlin.native.ref.WeakReference(referent)
actual fun get(): T? = ref.get()
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.utils
// iosApp (Phase 3) can flip this from a build flag (`DEBUG` Swift compile
// condition surfaced through the framework). Compile-only target ships false.
actual val isDebug: Boolean = false
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import java.io.File
// Locally-cached media content. Lives in jvmAndroid because java.io.File is
// JVM-only; iOS support will arrive when we either swap to okio.Path or pull
// these classes back into commonMain behind an expect/actual File abstraction.
// URL-based media classes (MediaUrlImage, MediaUrlVideo, …) remain in
// commonMain in MediaContentModels.kt.
@Immutable
abstract class MediaPreloadedContent(
val localFile: File?,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
val uri: String,
val id: String? = null,
thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
fun localFileExists() = localFile != null && localFile.exists()
}
@Immutable
class MediaLocalImage(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@Immutable
class MediaLocalVideo(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
val artworkUri: String? = null,
val authorName: String? = null,
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import java.util.concurrent.locks.ReentrantLock
actual class KmpLock {
private val delegate = ReentrantLock()
actual fun lock() = delegate.lock()
actual fun unlock() = delegate.unlock()
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
actual fun isValidUrl(url: String?): Boolean =
try {
if (url != null) {
URI(url).toURL()
true
} else {
false
}
} catch (_: MalformedURLException) {
false
} catch (_: URISyntaxException) {
false
} catch (_: IllegalArgumentException) {
false
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
actual typealias WeakReference<T> = java.lang.ref.WeakReference<T>
@@ -1043,6 +1043,7 @@ fun App(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
localCache = localCache,
replyTo = replyToNote,
)
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
object DesktopCachedRichTextParser {
private const val MAX_CACHE_SIZE = 50
private val cache =
java.util.Collections.synchronizedMap(
object : LinkedHashMap<String, RichTextViewerState>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, RichTextViewerState>) = size > MAX_CACHE_SIZE
},
)
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
cache[content]?.let { return it }
val state = RichTextParser().parseText(content, tags, callbackUri)
cache[content] = state
return state
}
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
@@ -21,24 +21,32 @@
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -46,14 +54,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -98,6 +111,7 @@ fun ComposeNoteDialog(
onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null,
replyTo: Event? = null,
quoteOf: Event? = null,
) {
@@ -111,8 +125,43 @@ fun ComposeNoteDialog(
""
}
}
var content by remember { mutableStateOf(initialContent) }
var contentField by remember {
mutableStateOf(
TextFieldValue(
text = initialContent,
selection = TextRange(initialContent.length),
),
)
}
val content = contentField.text
var isPosting by remember { mutableStateOf(false) }
// Mention autocomplete state
var mentionSuggestions by remember { mutableStateOf<List<User>>(emptyList()) }
var mentionQuery by remember { mutableStateOf<String?>(null) }
var mentionWordStart by remember { mutableStateOf(0) }
LaunchedEffect(contentField) {
val cursor = contentField.selection.end
val text = contentField.text
if (cursor > 0 && localCache != null) {
// Find the word at cursor
val wordStart = text.lastIndexOf(' ', cursor - 1) + 1
val wordAtCursor = text.substring(wordStart, cursor)
if (wordAtCursor.startsWith("@") && wordAtCursor.length > 1) {
val query = wordAtCursor.removePrefix("@")
mentionQuery = query
mentionWordStart = wordStart
mentionSuggestions = localCache.findUsersStartingWith(query, 5)
} else {
mentionQuery = null
mentionSuggestions = emptyList()
}
} else {
mentionQuery = null
mentionSuggestions = emptyList()
}
}
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val attachedFiles = remember { mutableStateListOf<File>() }
@@ -208,22 +257,55 @@ fun ComposeNoteDialog(
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = if (postAsPicture) "" else content,
onValueChange = {
content = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
label = {
Text(
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
)
},
placeholder = { Text(if (postAsPicture) "" else "Write your note...") },
enabled = !isPosting && !postAsPicture,
maxLines = if (postAsPicture) 1 else 10,
)
Box {
OutlinedTextField(
value = if (postAsPicture) TextFieldValue("") else contentField,
onValueChange = {
contentField = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
label = {
Text(
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
)
},
placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") },
enabled = !isPosting && !postAsPicture,
maxLines = if (postAsPicture) 1 else 10,
)
// Mention autocomplete dropdown
if (mentionSuggestions.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
items(mentionSuggestions, key = { it.pubkeyHex }) { user ->
MentionSuggestionRow(
user = user,
onClick = {
val npub = user.pubkeyNpub()
val replacement = "nostr:$npub "
val cursorEnd = contentField.selection.end
val newText =
contentField.text.replaceRange(
mentionWordStart,
cursorEnd,
replacement,
)
val newCursor = mentionWordStart + replacement.length
contentField = TextFieldValue(newText, TextRange(newCursor))
mentionSuggestions = emptyList()
mentionQuery = null
},
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
@@ -590,3 +672,39 @@ private suspend fun publishNote(
relayManager.publish(signedEvent, relays)
}
}
@Composable
private fun MentionSuggestionRow(
user: User,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 28.dp,
contentDescription = null,
)
Column {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
Text(
text = user.pubkeyNpub().take(24) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}

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