diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e0a074e5f7..21e4f49dab 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -224,9 +224,21 @@ version. `quartz/` is protocol-only — no composables. # Build Quartz for all targets ./gradlew :quartz:build -# Run tests +# Run the JVM tests of every module, KMP ones included. KMP modules register no +# `test` task of their own; the root build aliases it onto their jvmTest. ./gradlew test +# A single module. For a KMP module name jvmTest directly: +./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip52Calendar.*" + +# NOT covered by `test`: each KMP module's OTHER targets, notably +# :quartz:testAndroidHostTest (~3.9k tests, its own androidHostTest source set). +# Nothing runs it today - not `test`, not pre-push, not CI - and it has 4 known +# failures on main (NostrServerTest x3, LiveNegentropyIndexStoreTest x1), which +# is why the alias maps to jvmTest rather than allTests. Run it explicitly when +# touching relay-server or store code: +./gradlew :quartz:testAndroidHostTest + # Format code ./gradlew spotlessApply ``` diff --git a/.claude/settings.json b/.claude/settings.json index c0238b52b5..e7a86ad229 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,8 @@ { + "env": { + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8" + }, "hooks": { "PreToolUse": [ { diff --git a/.claude/skills/android-expert/references/proguard-rules.md b/.claude/skills/android-expert/references/proguard-rules.md index b09f3e1b5d..185ef708f1 100644 --- a/.claude/skills/android-expert/references/proguard-rules.md +++ b/.claude/skills/android-expert/references/proguard-rules.md @@ -11,230 +11,178 @@ Proguard configuration for optimizing and obfuscating Android APK while preservi ## Amethyst Proguard Configuration -**File:** `amethyst/proguard-rules.pro` +**Files:** -### Keep Kotlin Metadata +- `amethyst/proguard-rules.pro` — the app's rules. Read it before adding + anything: it is commented rule by rule. +- `quartz/consumer-rules.pro` — merged into the R8 configuration of **every** + app that depends on Quartz, this one included (wired via + `optimization.consumerKeepRules` in `quartz/build.gradle.kts`). A rule added + here silently applies to somebody else's whole program. +- The AGP default `proguard-android-optimize.txt`, which already contributes the + Android-wide basics (Parcelable CREATOR fields, `native `, the enum + `values()`/`valueOf()` pair, …). Don't restate its rules. +- `commons/`, `commonsUI/` and the other library modules deliberately have **no** + rules files. They are not minified and they wire no consumer rules, so a file + there would be dead configuration. + +### The policy: keep only what is reached BY NAME + +Google Play measures how much of a shipped app's DEX R8 actually optimized and +renamed, and warns — then restricts store visibility and publishing — below 25% +in either category. Amethyst has been on the wrong side of that line: a +`-dontobfuscate` plus `-keepnames class ** { *; }` at the top of both +`proguard-rules.pro` and `quartz/consumer-rules.pro`, and outright +`-keep class com.vitorpamplona.** { *; }` for the app's own code, produced 0% +obfuscation and 13% optimization. (`-keepnames` is not the mild rule it looks +like: it expands to `-keep,allowshrinking`, which permits shrinking but neither +renaming nor optimization — applied to `**` it disables R8 for the entire +program, libraries included.) + +So the standing rule is: **a keep needs a named runtime mechanism that reads the +name.** In this app those are, exhaustively: + +| Mechanism | Example | Rule shape | +|---|---|---| +| JNI symbol `Java__` | `ArtiNative`, secp256k1 | covered by the default `native ` rule | +| Native code calling *back* by name | `ArtiLogCallback.onLogLine`, looked up with `GetMethodID` in `tools/arti-build/src/lib.rs` | `-keep class …ArtiLogCallback { *; }` | +| Generated code reflected over by a library | the AppFunctions bridge (`appfunctions.**`, play flavor only) | `-keep class .** { *; }` | +| Enum constant persisted as a string | `UISharedPreferences` writes `enum.name`, reads `Type.valueOf(s)` | `-keepclassmembers enum * { ; … }` | +| Class name in a manifest `` | `AmethystCastOptionsProvider` | explicit `-keep` — AGP generates keeps from component `android:name`, **not** from meta-data | +| Class name in WorkManager's database | the three `CoroutineWorker`s | `-keep class * extends androidx.work.ListenableWorker { (...); }` | + +What does **not** need a keep, and where the temptation usually comes from: + +- **Quartz events and tags.** `Event`, `Filter`, `Message`, `Command`, `Rumor`, + `EventTemplate`, `TagArray`, the NIP-46 Bunker messages and the NIP-55 intent + results all go through hand-written `StdSerializer`/`StdDeserializer` pairs + registered on `JacksonMapper` / `JsonMapperNip55`. Those read and write + property names as string literals, and `EventFactory` dispatches on kind with + a `when`, not by reflection. Renaming their fields changes nothing on the wire. +- **`@Serializable` (kotlinx) classes**, including the type-safe navigation + routes. The compiler plugin generates a descriptor holding the serial name and + every property name as **compile-time string literals**, so obfuscation cannot + reach them. kotlinx-serialization ships its own consumer rules for the + `$$serializer`/`Companion` plumbing. +- **Compose, Coil, OkHttp, Media3, Firebase, kotlin-reflect.** Every one of them + ships consumer rules inside its own artifact. Check + `build/outputs/mapping//configuration.txt` — the fully merged + configuration — before writing a rule for a third-party library. +- **Manifest-declared components** (activities, services, receivers, providers) + and classes named in layout/`res/xml`. AGP generates those keeps itself, which + is why `Intent().setClassName(ctx, "…NappletBrowserService")` is safe. + +### Attributes ```proguard -# Kotlin metadata is required for reflection --keep class kotlin.Metadata { *; } --keep class kotlin.** { *; } --dontwarn kotlin.** - -# Kotlin serialization --keepattributes *Annotation*, InnerClasses --dontnote kotlinx.serialization.AnnotationsKt --dontnote kotlinx.serialization.SerializationKt - --keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; } --keepclassmembers class com.vitorpamplona.** { - *** Companion; -} --keepclasseswithmembers class com.vitorpamplona.** { - kotlinx.serialization.KSerializer serializer(...); -} -``` - -### Keep Nostr Event Classes - -```proguard -# Nostr events are serialized/deserialized --keep class com.vitorpamplona.quartz.events.** { *; } --keep class com.vitorpamplona.quartz.encoders.** { *; } - -# Keep event builders --keep class com.vitorpamplona.quartz.builders.** { *; } - -# Keep tag classes --keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; } -``` - -### Keep Data Classes - -```proguard -# Data classes used in ViewModels and serialization --keep @kotlinx.serialization.Serializable class * { *; } - -# Keep all data classes --keep class com.vitorpamplona.amethyst.model.** { *; } --keep class com.vitorpamplona.amethyst.service.model.** { *; } -``` - -### Keep Compose Classes - -```proguard -# Jetpack Compose --keep class androidx.compose.** { *; } --dontwarn androidx.compose.** - -# Compose runtime --keep class androidx.compose.runtime.** { *; } - -# Compose UI --keep class androidx.compose.ui.** { *; } - -# Material3 --keep class androidx.compose.material3.** { *; } - -# Navigation Compose - Keep serializable routes --keep class * implements java.io.Serializable { *; } --keepclassmembers class * implements java.io.Serializable { - static final long serialVersionUID; - private static final java.io.ObjectStreamField[] serialPersistentFields; - !static !transient ; - private void writeObject(java.io.ObjectOutputStream); - private void readObject(java.io.ObjectInputStream); - java.lang.Object writeReplace(); - java.lang.Object readResolve(); -} -``` - -### Keep OkHttp/Retrofit - -```proguard -# OkHttp --dontwarn okhttp3.** --dontwarn okio.** --keep class okhttp3.** { *; } --keep class okio.** { *; } - -# OkHttp WebSockets (for Nostr relays) --keep class okhttp3.internal.ws.** { *; } - -# Retrofit (if used) --keepattributes Signature --keepattributes Exceptions --keep class retrofit2.** { *; } -``` - -### Keep Jackson (JSON) - -```proguard -# Jackson JSON library --keep class com.fasterxml.jackson.** { *; } --keep class org.codehaus.** { *; } --keepclassmembers class * { - @com.fasterxml.jackson.annotation.* ; -} - -# Jackson polymorphic types --keepattributes RuntimeVisibleAnnotations --keep @com.fasterxml.jackson.annotation.JsonTypeInfo class * -``` - -### Keep Secp256k1 (Crypto) - -```proguard -# Secp256k1 native library --keep class fr.acinq.secp256k1.** { *; } - -# Keep native methods --keepclasseswithmembernames class * { - native ; -} -``` - -### Keep Tor - -```proguard -# Tor library --keep class com.msopentech.thali.toronionproxy.** { *; } --dontwarn com.msopentech.thali.toronionproxy.** -``` - -### Keep ExoPlayer (Media) - -```proguard -# ExoPlayer (Media3) --keep class androidx.media3.** { *; } --dontwarn androidx.media3.** - --keep class com.google.android.exoplayer2.** { *; } --dontwarn com.google.android.exoplayer2.** -``` - -### Keep Coil (Image Loading) - -```proguard -# Coil image loading --keep class coil.** { *; } --keep class coil3.** { *; } --dontwarn coil.** --dontwarn coil3.** -``` - -### Keep ViewModels - -```proguard -# ViewModel classes --keep class * extends androidx.lifecycle.ViewModel { - (); -} - -# ViewModel factories --keep class * extends androidx.lifecycle.ViewModelProvider$Factory { - (...); -} - -# Keep ViewModel constructors for reflection --keepclassmembers class * extends androidx.lifecycle.ViewModel { - (...); -} -``` - -### Keep Parcelable - -```proguard -# Parcelable --keep class * implements android.os.Parcelable { - public static final android.os.Parcelable$Creator *; -} - --keepclassmembers class * implements android.os.Parcelable { - public ; - private ; -} -``` - -### Keep Enums - -```proguard -# Enums --keepclassmembers enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} -``` - -### Remove Logging (Production) - -```proguard -# Remove debug logging in release builds --assumenosideeffects class android.util.Log { - public static *** d(...); - public static *** v(...); - public static *** i(...); -} - -# Keep error/warning logs --assumenosideeffects class android.util.Log { - public static *** e(...) return false; - public static *** w(...) return false; -} -``` - -### Keep Crashlytics/Firebase - -```proguard -# Firebase Crashlytics +# What makes a crash report retraceable. -keepattributes SourceFile,LineNumberTable --keep public class * extends java.lang.Exception -# Firebase --keep class com.google.firebase.** { *; } --dontwarn com.google.firebase.** +# jackson-module-kotlin reads @kotlin.Metadata; R8 requires InnerClasses and +# EnclosingMethod alongside Signature. +-keepattributes *Annotation*,Signature,Exceptions,InnerClasses,EnclosingMethod ``` +`LocalVariableTable`, `LocalVariableTypeTable`, `MethodParameters` and +`-keepparameternames` are debug metadata that nothing in the app reads — +jackson-module-kotlin takes parameter names from `@kotlin.Metadata`, not from +`MethodParameters`. They were removed; don't add them back. + +`-renamesourcefileattribute` is deliberately **not** set, and the reason is not +the usual one. + +Once R8 is minifying it rewrites every class's `SourceFile` to the marker +`r8-map-id-` on its own — there is no rule that restores the original +per-class `.kt` name. Verified by building it both ways: with +`-renamesourcefileattribute SourceFile`, all 24,440 classes report the literal +`"SourceFile"`; without it, they report the marker. So the rule cannot buy +readability, it can only *destroy* the marker — and that marker is the +`pg_map_id` header of the mapping that produced the build, which is what lets a +pasted stack trace name the exact mapping file it needs. + +Two related facts worth knowing before someone tries to "fix" stack traces with +keep rules: + +- **Raw line numbers are no longer source line numbers.** R8 renumbers them so + that one obfuscated line can encode a whole inlined frame stack. This is a + cost of *optimization*, not of renaming — it is new only because the old + blanket `-keepnames` had optimization switched off across the program, which + is the thing Play was flagging. +- **Retrace therefore returns more than the old raw traces did**: it expands + the frames R8 inlined instead of collapsing them into one misleading line. A + one-frame crash can retrace to three. + +The workflow is `scripts/retrace.sh [trace]`, documented in +[`RELEASE_OPS.md` § 7](../../../../RELEASE_OPS.md). Every GitHub Release carries +`amethyst-{googleplay,fdroid}-mapping-.txt.gz`; Play Console needs +nothing because AGP embeds the mapping in the `.aab` under +`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`. + +### Verifying a change to these rules + +R8 cannot see reflection, so a wrong keep rule fails **only in a release build, +at runtime**. Before changing them: + +```bash +./gradlew :amethyst:assemblePlayRelease -PdisableAbiSplits=true -PdisableUniversalApk=true +``` + +then read `amethyst/build/outputs/mapping/playRelease/`: + +- `configuration.txt` — every rule R8 actually saw, including each AAR's + consumer rules. This is the file that answers "does library X already keep + itself?" +- `mapping.txt` — what got renamed. Lines whose left and right sides are equal + are classes a keep rule pinned; scan them for anything you did not intend. +- `seeds.txt` / `usage.txt` — what the keeps matched, and what was removed. + +### The contract check + +R8 cannot see reflection, so a keep rule that quietly stops matching — a class +moved to another package, a rule deleted in a merge, a DTO renamed — gives you a +green build and an APK that breaks on a user's device. +`tools/r8-verify/reflection-contract.txt` lists every name resolved from outside +the DEX, and the verifier asserts each against what R8 actually emitted: + +```bash +python3 tools/r8-verify/verify_reflection_contract.py \ + amethyst/build/outputs/mapping/playRelease/ +``` + +Under a second, no device. The release workflow runs it for both flavors before +collecting assets, so a break fails the release instead of shipping. + +**Adding reflection means adding two things**: the keep rule, and a line in the +contract. A rule with no contract line is unverified and will rot. + +Scope a line to one flavor with a leading `@play` / `@fdroid` when the class is +only compiled into that variant — play-only code is absent from the F-Droid APK, +and "absent" is indistinguishable from "R8 deleted it", so an unscoped line for +it fails that release. + +Reflection that names a class R8 *cannot* rename needs no rule and no line: the +platform trust manager `quic` probes for a 3-arg `checkServerTrusted` ships in +Android, not in our DEX. The opposite case is the one to watch — a name of +*ours* used as a value. `AmethystAppFunctions` compared +`signer::class.qualifiedName` against `"…NostrSignerExternal"`; R8 renames that +class, so the branch silently stopped running the day obfuscation was turned on. +Prefer `is` over a name comparison; there is no keep rule that makes the latter +safe to write. + +It reads both `mapping.txt` and `usage.txt`, because neither is enough alone — +mapping.txt records only what *changed* (an intact `-keep ... { *; }` class has +an empty body, and an unrenamed member has no line at all, so absence there +means "preserved"), while a member R8 *deleted* appears only in usage.txt. It +also cross-checks that the two files come from the same R8 run: mapping.txt is +written during packaging, not at minify time, so a minify-only rebuild leaves a +stale one behind next to a fresh usage.txt. + +What it cannot cover is reflection nobody wrote down. For that: a staged Play +rollout (the crash reporter retraces itself, so breaks are legible within +hours), and exercising NIP-47 wallet connect, NIP-46 bunker login, Tor, +scheduled posts and a settings round-trip (change theme/font, kill, relaunch) on +a minified build — those are the paths the keeps above exist for. + ## Build Configuration ### Enable R8 in build.gradle @@ -356,13 +304,15 @@ adb install app/build/outputs/apk/release/app-release.apk ### Issue: Compose Navigation Crashes -**Cause:** @Serializable route classes were obfuscated. +**Not** the route classes being obfuscated — that cannot happen. A type-safe +route's pattern comes from its kotlinx-serialization descriptor, and the compiler +plugin bakes the serial name and every property name in as string literals, so +renaming the class leaves the route string untouched. Adding +`-keep @kotlinx.serialization.Serializable class …routes.** { *; }` pins a large +tree for no reason and hides the real cause. -**Solution:** -```proguard -# Keep all route classes --keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; } -``` +Look instead at whether `navigation-common`'s own consumer rules made it into +`configuration.txt`, and at the stack trace retraced through `mapping.txt`. ### Issue: Native Library Crashes diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 844a4f2a59..55dc9ad12f 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.16.0` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.15.2" +quartz = "1.16.0" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.2") + implementation("com.vitorpamplona.quartz:quartz:1.16.0") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 88f0eadc5b..e574aac9ff 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.15.2 +com.vitorpamplona.quartz:quartz:1.16.0 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.15.2" +quartz = "1.16.0" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.2") + implementation("com.vitorpamplona.quartz:quartz:1.16.0") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.2") + implementation("com.vitorpamplona.quartz:quartz:1.16.0") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index b025f7fea3..8cfa8328c9 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -2,9 +2,9 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()` expression. **Update this file in the same PR as any change to the searchable set or to an -`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25. +`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17. -Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds; +Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds; kind 30063 has a collision — see the footnote). File paths are under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. @@ -55,6 +55,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` | | 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` | | 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` | +| 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` | | 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` | | 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL | | 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL | @@ -129,6 +130,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | | 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | | 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL | +| 37516 | GeocacheListingEvent | nipCCGeocaching/listing | `listOfNotNull(cacheName(), content)` NL (the `hint` is deliberately not indexed — matching a hint is spoiling it) | +| 37517 | GeocacheCurationListEvent | nipCCGeocaching/curation | `listOfNotNull(title(), description(), content)` NL | | 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` | | 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL | | 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) | diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 5821e1d1d0..7faea9d18a 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -16,11 +16,21 @@ echo "Running test... " # variants of :amethyst (play/fdroid × debug/release/benchmark) plus full # native-libs merging per variant — ~6× the work of one variant. CI runs the # multi-flavor matrix on push to main; pre-push only needs one happy path. +# +# Every KMP module's tests live under :jvmTest - the Kotlin Multiplatform +# plugin registers no plain `test` task - so a module missing from this list is a +# module nobody runs before pushing. Add new KMP modules here when they gain tests. +# +# `./gradlew test` now reaches the KMP modules too (see the `test` alias in the root +# build), but this hook stays explicit: a bare `test` would also pull in :desktopApp:test, +# which needs a display server and is deliberately skipped on CLAUDE_CODE_REMOTE below. TASKS=( :quartz:jvmTest :commons:jvmTest + :commonsUI:jvmTest :nestsClient:jvmTest :quic:jvmTest + :marmotQuic:jvmTest :amethyst:testPlayDebugUnitTest :cli:test ) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7665ea2bb6..bc5c96b109 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,15 +102,16 @@ jobs: - name: Test + Build Desktop (gradle) run: | - CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}" + CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :quic:jvmTest :nestsClient:jvmTest :marmotQuic:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}" if [ "${{ runner.os }}" = "Linux" ]; then xvfb-run --auto-servernum $CMD else $CMD fi - # This job runs five test suites (:quartz, :commons, :nestsClient, :cli, - # :desktopApp) but, unlike test-geode / test-quartz-ios / + # This job runs the JVM test suites (:quartz, :commons, :commonsUI, :quic, + # :nestsClient, :marmotQuic, :cli, :desktopApp) but, unlike test-geode / + # test-quartz-ios / # test-and-build-android, published nothing when one of them failed. The # console line names the failing test and the exception class and stops # there, so the message is lost with the runner. That is how the diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 78bb1aa0f6..4683d17921 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1018,6 +1018,23 @@ jobs: env: BUILD_TOOLS_VERSION: "36.0.0" + # R8 cannot see reflection, so a keep rule that silently stops matching — a + # class moved to another package, a rule deleted, a DTO renamed — produces + # a green build and an APK that fails on a user's device. This asserts the + # reflective surface (JNI symbols, Jackson DTO field names, enum constants + # persisted in DataStore, WorkManager worker class names, the Cast + # OptionsProvider named in a manifest meta-data value) actually survived, + # against what R8 emitted for BOTH flavors. Runs before the assets are + # collected so a break fails the release rather than shipping. + - name: Verify R8 reflection contract + run: | + set -euo pipefail + for variant in playRelease fdroidRelease; do + echo "== $variant" + python3 tools/r8-verify/verify_reflection_contract.py \ + "amethyst/build/outputs/mapping/${variant}/" + done + - name: Collect Android assets (rename to canonical scheme) run: | set -euo pipefail @@ -1041,6 +1058,34 @@ jobs: "dist/amethyst-googleplay-${TAG}.aab" cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \ "dist/amethyst-fdroid-${TAG}.aab" + + # R8 mapping files — the ONLY way a crash report from this build is + # ever readable again. The release build is minified, so every class + # in every artifact above reports `r8-map-id-` as its source + # file and a renamed class/method; `scripts/retrace.sh ` + # turns that back into real names, files and lines (and expands the + # frames R8 inlined). + # + # Play Console deobfuscates by itself because AGP embeds the mapping + # in the .aab it was given. Nothing else does: a trace from an + # F-Droid, Zapstore, Accrescent or GitHub-APK user is unreadable + # without the matching file, and the mapping only exists on this + # runner. If it is not published here it is gone when the job ends. + # + # Gzipped because the raw text mapping is ~500 MB (~29 MB + # compressed). retrace.sh reads the .gz directly. + for flavor in play fdroid; do + case "$flavor" in + play) name=googleplay ;; + fdroid) name=fdroid ;; + esac + src="amethyst/build/outputs/mapping/${flavor}Release/mapping.txt" + if [ ! -f "$src" ]; then + echo "::error::$src is missing — the release would ship with no way to read its crash reports." + exit 1 + fi + gzip -c "$src" > "dist/amethyst-${name}-mapping-${TAG}.txt.gz" + done ls -la dist # Accrescent does not accept AABs or monolithic APKs — it requires a signed diff --git a/BUILDING.md b/BUILDING.md index c65217c52a..3e0d1a3658 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -89,8 +89,8 @@ cd amethyst ## Generated & vendored artifacts -Two build inputs are **generated by tools but committed to the repo**, so a -normal build or release does **not** run either — Gradle just consumes the +Three build inputs are **generated by tools but committed to the repo**, so a +normal build or release does **not** run any of them — Gradle just consumes the checked-in output. You only regenerate them under the specific conditions below, and each has its own guide: @@ -98,16 +98,44 @@ and each has its own guide: |---|---|---|---| | **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` | | **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) | +| **zxing-cpp (QR decoder) native libs** | `amethyst/src/main/jniLibs/*/libzxingcpp_android.so` | You bump `ZXING_CPP_VERSION`, change the JNI wrapper, or want to reproduce the binaries | [`tools/zxing-cpp-build/README.md`](tools/zxing-cpp-build/README.md) | > **Material Symbols is mandatory after icon changes.** The bundled font is a > ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at > runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt` > change. Reusing an existing codepoint needs no regeneration. -Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a -Rust toolchain + the exact Android NDK revision pinned in -`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs — -they are **not** required to build Amethyst from the committed sources. +Each tool has its own prerequisites (`fonttools`/`brotli` for the font; a Rust +toolchain for Arti; `cmake` + `ninja` for zxing-cpp; and, for both native +builds, the exact Android NDK revision pinned in +`tools/arti-build/ANDROID_NDK_VERSION`) documented in their READMEs — none of +them are required to build Amethyst from the committed sources. + +That NDK pin is a single file for the whole repo, not a copy per tool: +`build-arti.sh`, `build-zxingcpp.sh` and `:amethyst`'s `ndkVersion` all read it, +so bumping it moves every native build and the packaging toolchain together and +they cannot drift apart. (It lives under `tools/arti-build/` for history; it is +not Arti's alone.) + +The NDK half of that pin reaches the ordinary Android build. AGP strips every +native library it packages with the NDK's `llvm-strip`, so `:amethyst` sets +`ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the libraries we +build and the ones we merge from dependencies. Both of our own libraries are +then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`): +they are already stripped by the pinned toolchain, so skipping the pass costs +no size and lets the `.so` inside an APK be compared byte-for-byte against the +committed, independently reproducible one. The Rust toolchain stays irrelevant +either way; Studio/AGP fetches the pinned NDK on demand, or pre-install it with +`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`. + +> **Every ABI split needs its own copy of each library.** The APK is split four +> ways (`arm64-v8a`, `x86_64`, `armeabi-v7a`, `x86`). A split missing +> `libarti_android.so` installs and runs with Tor silently unavailable; one +> missing `libzxingcpp_android.so` installs with the QR scanner broken. The +> `verifyNativeAbis` Gradle task fails the build if a library's ABI list drifts +> from the split list, and names the `build-arti.sh --target=…` / +> `build-zxingcpp.sh --abi …` to run — and rejects a file that is not an ELF +> built for that architecture, which a missing-file check would pass. --- @@ -334,7 +362,7 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify** — the GH Release should hold **47 assets**: +4. **Verify** — the GH Release should hold **49 assets**: - **14 desktop**, one per matrix leg × format: - macOS arm64: `dmg` (1) - Windows x64: `msi` + portable `zip` (2) @@ -349,8 +377,12 @@ Quartz library in one pipeline. ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why the x64 leg gets an MSI). Revisit if that image gains WiX, or if jpackage learns the WiX 4+ `wix build` CLI. - - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the - F-Droid `.apks` set built for Accrescent. + - **15 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the + F-Droid `.apks` set built for Accrescent + **2 R8 mapping files** + (`amethyst-{googleplay,fdroid}-mapping-.txt.gz`). The mappings + are not optional extras: the release build is minified, so without them + no crash report from an APK/`.apks` user can be read. See + [`RELEASE_OPS.md` § Crash reports](RELEASE_OPS.md#7-crash-reports--retrace). - **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64), `deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the one arch-independent no-JRE `amy--jvm.tar.gz` for Homebrew-core. @@ -534,7 +566,7 @@ reads an optional per-release changelog from ## Bootstrap runbook (one-time) -> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the +> **Status as of v1.16.0:** both Homebrew packages are now live upstream — the > `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy` > formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so > `bump-homebrew.yml` finally has something to bump. **Winget is still not @@ -588,7 +620,7 @@ The token then lives only in that maintainer's shell: ```bash export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope -scripts/bump-homebrew-cask.sh v1.15.2 +scripts/bump-homebrew-cask.sh v1.16.0 ``` Create one at @@ -604,7 +636,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives runs fine from macOS or Linux: ```bash -scripts/bump-winget.sh v1.15.2 +scripts/bump-winget.sh v1.16.0 ``` CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the @@ -690,7 +722,7 @@ Caveats that the maintainer must weigh before submitting: - **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked Compose/Skiko jars onto the CLI classpath. Compose UI now lives in `:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB - and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release + and the jlink image tarball ~80 MB (1.16.0, Linux x64). The release workflow caps every amy asset at 120 MB. After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot diff --git a/PRIVACY.md b/PRIVACY.md index 42a65123c3..384e0677af 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -3,7 +3,7 @@ **App:** Amethyst (Android Nostr client)
**Publisher:** Vitor Pamplona
**Contact:** amethyst@vitorpamplona.com
-**Last updated:** 2026-09-12 +**Last updated:** 2026-09-15 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. @@ -32,33 +32,39 @@ Configuration, cached events, keys, drafts, and other operational data live in t ### Health and fitness data (Health Connect) -Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post. +Amethyst's **My Fitness** screen (drawer → You → My Fitness) summarises your own training for you: how much you did this week against last week, how your time splits across activities, your best efforts, how many days you trained, and your current streak. It builds that picture from the workouts your watch or fitness app has already saved to **Android Health Connect**. -The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch. +This is what the health permissions are for. The summary is computed on your phone and shown to you; nothing is sent anywhere to produce it, and you never have to post anything to use it. + +My Fitness also counts the workouts you have logged in Amethyst itself, so it works whether or not you connect Health Connect. Connecting adds the sessions your watch recorded and the details a hand-typed workout does not carry — heart rate, steps and climb. + +Separately, you may choose to publish one workout as a Nostr post (a NIP-101e kind 1301 event) so the people who follow you can see it. That takes a deliberate tap on "Share this workout", shows you the pre-filled post, and waits for you to confirm. It is never automatic. + +The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open My Fitness or the New Workout composer — never on first launch. **What Amethyst reads, and what each type is for:** | Health Connect data type | Permission | What it is used for | | --- | --- | --- | -| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. | -| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. | -| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. | -| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. | -| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. | -| Steps | `READ_STEPS` | The step count of a run, walk or hike. | -| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. | +| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start and end. Drives your workout count, training time, per-activity breakdown, active days and streak. | +| Distance | `READ_DISTANCE` | Weekly distance, the change against last week, your weekly average, distance per activity, and your longest distance. | +| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | Weekly energy burned and its week-over-week change. | +| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback for the same figure, for watches and apps that only record total energy. | +| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate per workout, your duration-weighted average for the period, and your highest heart rate. | +| Steps | `READ_STEPS` | Your weekly step average and your highest step count. | +| ElevationGained | `READ_ELEVATION_GAINED` | Your weekly climb average and your biggest climb. | Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately. **Limits on this access:** - **Read-only.** Amethyst never writes to Health Connect. -- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker. -- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`. +- **Foreground only.** Reads happen only while the My Fitness screen or the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker. +- **Last four weeks only.** Amethyst reads a rolling 28-day window and cannot see anything older. It does not request `READ_HEALTH_DATA_HISTORY`. - **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout. -- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make. +- **Nothing is uploaded automatically.** Health data stays on your device. The My Fitness summary is computed locally and never transmitted. A workout only leaves your phone if you tap "Share this workout", review the pre-filled post, and publish it yourself — one workout at a time. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make. - **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party. -- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately. +- **Revocable.** Revoke the permissions in Health Connect at any time — My Fitness immediately drops back to its prompt — or turn the composer suggestions off under Settings → Compose Settings → "Suggest workouts to share". Amethyst keeps the summary and the suggestions only in memory; revoking access stops all reads immediately. ### What relays can see diff --git a/README.md b/README.md index 92edd3525e..5930ce6d13 100644 --- a/README.md +++ b/README.md @@ -328,16 +328,16 @@ repositories { Add the following line to your `commonMain` dependencies: ```gradle -implementation('com.vitorpamplona.quartz:quartz:1.15.2') +implementation('com.vitorpamplona.quartz:quartz:1.16.0') ``` Variations to each platform are also available: ```gradle -implementation('com.vitorpamplona.quartz:quartz-android:1.15.2') -implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2') -implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2') -implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2') +implementation('com.vitorpamplona.quartz:quartz-android:1.16.0') +implementation('com.vitorpamplona.quartz:quartz-jvm:1.16.0') +implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.16.0') +implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.16.0') ``` Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index d1ac130fc0..85414d10bb 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -130,6 +130,11 @@ Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md § Verify). macOS is **arm64-only** — there is no Intel DMG, so a single `amethyst-desktop--macos-arm64.dmg` is the expected, correct result. +Two of those assets are the R8 mapping files +(`amethyst-{googleplay,fdroid}-mapping-.txt.gz`). Do not prune them +from old releases — they are the only way to read a crash report from a build +that old (§ 7). + ### Google Play — manual upload 1. Download `amethyst-googleplay-.aab` from the GH Release. 2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the @@ -240,9 +245,9 @@ readable by anyone with push access here), so a maintainer runs the last step: ```bash # after merging the sync PRs export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope -scripts/bump-homebrew-cask.sh v1.15.2 +scripts/bump-homebrew-cask.sh v1.16.0 -scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth +scripts/bump-winget.sh v1.16.0 # no token — uses your `gh` auth ``` Both scripts re-verify the published artifact's sha256 before submitting, and @@ -304,7 +309,7 @@ Owner assignments and rotation reminders live with the team (issue tracker). ## 6. Post-release verification -- [ ] GH Release: 47 assets, sizes sane, and the asset-name set matches the +- [ ] GH Release: 49 assets, sizes sane, and the asset-name set matches the previous release (see the `diff` one-liner in BUILDING.md § Release runbook). macOS is arm64-only — do **not** look for an Intel DMG. - [ ] Maven Central: `quartz:` resolves (allow tens of minutes of @@ -325,3 +330,112 @@ Owner assignments and rotation reminders live with the team (issue tracker). see § 4); UnifiedPush still works on an `fdroid` build. If anything ships broken, see [`BUILDING.md` § Incident response](BUILDING.md#incident-response). + +--- + +## 7. Crash reports & retrace + +Release builds are minified **and obfuscated** (they have to be: Play Console +drops apps whose DEX is under 25% optimized or obfuscated out of store surfaces +— see `amethyst/proguard-rules.pro` for the whole story). So a raw stack trace +from a release build looks like this: + +``` +java.lang.IllegalStateException: something blew up + at onh.B(r8-map-id-12c710927a584543dbe1e2e867db95460bc44482efe53283f86798648c1cfc00:7) +``` + +That is not lost information, it is encoded information. Paste the report in and +run it: + +```bash +scripts/retrace.sh crash-report.txt +pbpaste | scripts/retrace.sh # or straight off the clipboard +``` + +Nothing else to supply. A report's first line names its own build — +`java.lang.IllegalStateException: 1.16.0-PLAY` — so the script resolves the tag +(`v1.16.0`) and the flavor (`PLAY` → `googleplay`), downloads that release's +mapping asset and caches it. For a bare stack trace with no such header, name +the build yourself with `--release v1.16.0 --flavor play`; for a build you made +locally, pass its `mapping.txt` directly. + +``` +java.lang.IllegalStateException: something blew up + at androidx.compose.foundation.text.input.TextFieldCharSequence.getText(TextFieldCharSequence.kt:58) + at androidx.compose.foundation.text.input.TextFieldState.getText(TextFieldState.kt:146) + at com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel.onMessageChanged(ShortNotePostViewModel.kt:1727) +``` + +Note that retrace gave back **three** frames where the crash reported one: R8 +had inlined two of them. That is worth internalising — it is the reason a raw +trace's line number cannot be trusted even in the pre-obfuscation builds, where +optimization was already inlining. Retracing is not a tax obfuscation imposed; +it is how you read an optimized build at all. + +**The wrong mapping is worse than none** — it produces confident, wrong names. +So the script refuses to guess: the `r8-map-id-` in the trace *is* the +`pg_map_id` header of the mapping that built it, and the two are compared before +anything is printed: + +``` +error: this mapping did not build this report. + report: deadbeef... + mapping: 12c71092... (amethyst-googleplay-mapping-v1.16.0.txt.gz) +``` + +`--force` overrides if you really mean it. (`googleplay` vs `fdroid` matters — +the two flavors are separate R8 runs with different mappings.) + +**Per channel:** + +| Where the report came from | What to do | +|---|---| +| Play Console / Android vitals | Nothing. AGP embeds the mapping in the `.aab` (`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`), so Play deobfuscates automatically. | +| A NIP-17 DM from the in-app crash reporter, a GitHub issue, F-Droid, Zapstore, Accrescent | `scripts/retrace.sh ` — it reads the build off line 1 and fetches the mapping. | +| A build you made locally | `scripts/retrace.sh amethyst/build/outputs/mapping//mapping.txt report.txt` | + +`scripts/retrace.sh` downloads the R8 version named in the mapping's own header +from Google's Maven and caches it, so it needs no pinned tooling and keeps +working across AGP bumps. It needs no `gh` auth either — release assets on a +public repo are plain HTTPS downloads. + +Two details of the report format in `ReportAssembler` exist for this and should +not be "tidied" away: the headline carries the **fully qualified** exception +class (a bare `simpleName` obfuscates to `a`, which retrace cannot resolve +because it has no package), and stack frames are written as ` at ` +(retrace only rewrites frames it recognises, and it recognises them by the +leading `at`). The script repairs the missing `at` on reports from older builds, +but new reports should not need repairing. + +**Do not delete mapping assets from old releases.** They are the only copy — +CI's are gone when the job ends, and a mapping cannot be regenerated after the +fact (it would need a bit-identical rebuild, and R8's renaming is not stable +across runs). + +### Did obfuscation break anything? + +R8 cannot see reflection, so nothing in the build tells you that a keep rule +stopped matching. `tools/r8-verify/reflection-contract.txt` lists every place +something outside the DEX resolves a name at runtime — JNI symbols, Jackson DTO +field names, enum constants persisted in DataStore, WorkManager's stored worker +class names, the Cast `OptionsProvider` named in a manifest `` value +— and the release workflow asserts each one against what R8 actually emitted, +for both flavors, before any asset is collected: + +```bash +python3 tools/r8-verify/verify_reflection_contract.py \ + amethyst/build/outputs/mapping/playRelease/ +``` + +Run it on any local minified build too. It takes under a second and needs no +device. + +**Adding reflection means adding two things**: the keep rule, and a line in the +contract. A rule with no contract line is unverified and will rot silently. + +What this does *not* cover is reflection nobody wrote down. For that the honest +controls are a staged Play rollout (the crash reporter retraces itself now, so +a break is legible within hours) and exercising NIP-47 wallet connect, NIP-46 +bunker login, Tor, scheduled posts and a settings round-trip on a minified +build before shipping. diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 0ea720bc54..1f432394be 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -67,6 +67,27 @@ afterEvaluate { } } +// Every ABI we split the APK for, and therefore every ABI that needs its own copy of each +// library we build and commit ourselves under src/main/jniLibs/. The lists drifted once: the +// splits shipped four ABIs while Arti was built for two, so the armeabi-v7a and x86 APKs +// installed and ran with the dependencies' native libraries all present (secp256k1's JNI ships +// every ABI) and Tor alone dead for the life of the install. `verifyNativeAbis` below keeps +// them in step. +val shippedAbis = listOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + +// The libraries that guard covers, and how to rebuild one when it is missing. Both are built +// from source by tools/ rather than pulled prebuilt, so both can go missing the same way — and +// a QR scanner that cannot load is as silently broken on that install as a dead Tor. +val committedNativeLibs = + mapOf( + "libarti_android.so" to { abi: String, triple: String -> + "./tools/arti-build/build-arti.sh --target=$triple" + }, + "libzxingcpp_android.so" to { abi: String, _: String -> + "./tools/zxing-cpp-build/build-zxingcpp.sh --abi $abi" + }, + ) + android { namespace = "com.vitorpamplona.amethyst" compileSdk = @@ -74,6 +95,33 @@ android { .get() .toInt() + // Packaging toolchain: AGP runs the NDK's llvm-strip over everything that + // lands in jniLibs — our committed libarti_android.so included — so the + // NDK revision is a build input for the APK, not just for whoever compiles + // Arti. Left unset it silently follows AGP's own default (28.2.13676358 on + // AGP 9.4.0), which moves with every AGP bump and is a different toolchain + // from the one that produced the .so, while a machine with no NDK at all + // packages the library unstripped ("Unable to strip the following + // libraries") — three different APKs from the same source, which is + // exactly what F-Droid's rebuild verification cannot have. + // + // Read straight from the pin rather than copied into the version catalog: + // the two can then never drift, and bumping ANDROID_NDK_VERSION (which also + // means rebuilding the .so files) moves the packaging toolchain with it. + // That one file is the repo's only NDK pin -- tools/arti-build/build-arti.sh + // and tools/zxing-cpp-build/build-zxingcpp.sh read it too, so every + // committed .so is produced and stripped by the same revision. It lives + // under tools/arti-build for history; it is not Arti's alone. See + // tools/arti-build/README.md → "Reproducible builds". + ndkVersion = + providers + .fileContents(layout.settingsDirectory.file("tools/arti-build/ANDROID_NDK_VERSION")) + .asText + .orNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: error("tools/arti-build/ANDROID_NDK_VERSION is missing or empty — it pins the NDK that strips src/main/jniLibs") + defaultConfig { applicationId = "com.vitorpamplona.amethyst" minSdk = @@ -89,7 +137,7 @@ android { .get() .toInt() versionName = generateVersionName(libs.versions.app.get(), rootDir) - buildConfigField("String", "RELEASE_NOTES_ID", "\"8fce45589ea44df75e828a04c7d70bb4fabedd6ffc1946a920b2f0c7c990ff9f\"") + buildConfigField("String", "RELEASE_NOTES_ID", "\"f7914e7a7e293988485439eb2bea29c09c388d54c452c4a19f89e106dbf1969e\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -258,7 +306,7 @@ android { abi { isEnable = !disableAbiSplits reset() - include("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + include(*shippedAbis.toTypedArray()) isUniversalApk = !disableUniversalApk } } @@ -295,6 +343,33 @@ android { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib") } + + jniLibs { + // Reproducible builds, part two: ship the Arti library exactly as + // tools/arti-build produced it. Its Cargo release profile already + // strips it (no .symtab, no .debug_*), so AGP's + // `llvm-strip --strip-unneeded` pass has nothing left to remove — but it + // still rewrites the file: llvm-strip rebuilds .comment, the section that + // records the rustc / clang / lld version stamps, which measurably changes + // 273 bytes on arm64-v8a. That made the packaged bytes a function of + // whichever NDK did the stripping, so the .so in an APK could never be + // compared against the committed, independently reproducible one. + // Excluding it from the strip step costs nothing in size (there are no + // symbols to drop) and makes that comparison exact. Dependency .so files + // are still stripped, with the NDK pinned by ndkVersion above. + keepDebugSymbols += "**/libarti_android.so" + + // Same guarantee for the QR decoder, for a different reason. Unlike Arti's, this + // library is *not* currently rewritten by AGP's pass -- verified by running the + // pinned NDK's `llvm-strip --strip-unneeded` over the committed file and getting + // identical bytes -- because tools/zxing-cpp-build strips it with that very same + // llvm-strip, which makes a second pass idempotent. That idempotence is a property + // of one NDK revision, though, and reading it back from the APK should not depend + // on a strip pass staying a no-op across bumps. Excluding it makes + // `unzip -p app.apk lib//libzxingcpp_android.so | sha256sum` match + // src/main/jniLibs by construction, at no size cost. + keepDebugSymbols += "**/libzxingcpp_android.so" + } } lint { @@ -305,8 +380,9 @@ android { unitTests.isReturnDefaultValues = true // Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android") // find the desktop-host build of our Arti JNI shim. The Android .so - // variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded - // on-device — this Linux x86_64 .so is just for JVM unit-test runs. + // variants live in src/main/jniLibs// — one per ABI in [shippedAbis] + // — and are loaded on-device; this Linux x86_64 .so is just for JVM + // unit-test runs. // -Pamethyst.arti.integration=true opts the (slow, network-dependent) // tests in; see TorArtiNativeIntegrationTest.kdoc. unitTests.all { test -> @@ -317,10 +393,103 @@ android { project .findProperty("amethyst.arti.integration") ?.let { test.systemProperty("amethyst.arti.integration", it.toString()) } + // Opts QrCorpusBaselineTest into rewriting the QR decode corpus under + // src/androidTest/assets/qr. Off by default so an ordinary run never dirties + // the working tree; Gradle forks the test JVM, so -D alone would not reach it. + project + .findProperty("amethyst.qr.corpus.export") + ?.let { test.systemProperty("amethyst.qr.corpus.export", it.toString()) } } } } +// Every ABI split must carry Arti, or it ships an APK that is whole except for +// Tor. Nothing else catches that: AGP happily assembles a split out of whatever +// .so files the dependencies provide, the APK installs and runs, and the gap +// only surfaces at System.loadLibrary time on a user's device — where +// TorManager's flow swallows the UnsatisfiedLinkError and leaves the status Off +// forever. Checked at build time instead, against the same list the splits use. +val verifyNativeAbis = + tasks.register("verifyNativeAbis") { + group = "verification" + description = "Checks that every ABI in the APK splits has each committed native library, built for that architecture." + + val jniLibs = file("src/main/jniLibs") + val abis = shippedAbis + val libs = committedNativeLibs + // Per ABI: the Rust target triple (so an Arti failure names the exact build command) and + // the ELF identity every library must have — 32/64-bit class (header byte 4) and + // e_machine (bytes 18-19, little-endian on every Android ABI we ship). Existence alone is + // not enough: a truncated file, an empty placeholder, or arm64's .so copied into x86/ all + // load as nothing on device, which is the same silent failure this task exists to prevent + // — and unlike a missing file, those look fine in git. + val expected = + mapOf( + "arm64-v8a" to Triple("aarch64-linux-android", 2, 0xB7), + "x86_64" to Triple("x86_64-linux-android", 2, 0x3E), + "armeabi-v7a" to Triple("armv7-linux-androideabi", 1, 0x28), + "x86" to Triple("i686-linux-android", 1, 0x03), + ) + + doLast { + val bitness = mapOf(1 to "32-bit", 2 to "64-bit") + val problems = mutableListOf>() + + libs.keys.forEach { libName -> + abis.forEach { abi -> + val lib = File(jniLibs, "$abi/$libName") + val want = expected[abi] + val header = ByteArray(20) + val read = if (lib.isFile) lib.inputStream().use { it.read(header) } else -1 + + val problem = + when { + !lib.isFile -> "no $libName" + want == null -> "no expected ELF identity recorded for this ABI" + read < header.size || + header[0] != 0x7F.toByte() || + header[1] != 'E'.code.toByte() || + header[2] != 'L'.code.toByte() || + header[3] != 'F'.code.toByte() -> "not an ELF file (truncated or corrupt)" + header[4].toInt() != want.second -> + "${bitness[header[4].toInt()] ?: "unknown-class"} ELF, expected ${bitness[want.second]}" + else -> { + val machine = (header[18].toInt() and 0xFF) or ((header[19].toInt() and 0xFF) shl 8) + if (machine != want.third) { + "built for ELF machine 0x%02x, expected 0x%02x".format(machine, want.third) + } else { + null + } + } + } + + if (problem != null) problems += Triple(libName, abi, problem) + } + } + + if (problems.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("Committed native libraries are missing or wrong for ${problems.size} (library, ABI split) pair(s):") + problems.forEach { (libName, abi, problem) -> appendLine(" $libName / $abi: $problem") } + appendLine("Those APK splits would install with that library permanently unavailable.") + appendLine("Rebuild them:") + problems.forEach { (libName, abi, _) -> + val triple = expected[abi]?.first ?: "" + val rebuild = libs[libName]?.invoke(abi, triple) ?: "" + appendLine(" $rebuild") + } + append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.") + }, + ) + } + } + } + +tasks.named("preBuild") { + dependsOn(verifyNativeAbis) +} + // androidx.appfunctions-compiler runs in a per-module mode by default, // emitting only the dispatcher Kotlin code. The aggregator that builds // the `app_functions.xml` asset (which the system reads to discover our @@ -504,8 +673,11 @@ dependencies { implementation(libs.accompanist.permissions) // For QR generation + // ZXing core encodes the QR codes we display. Decoding is zxing-cpp, which we build + // from source ourselves -- see tools/zxing-cpp-build -- rather than pulling a prebuilt + // AAR nobody in this tree could verify; the .so lives in src/main/jniLibs and its + // Kotlin wrapper is vendored at src/main/java/zxingcpp. implementation(libs.zxing) - implementation(libs.zxing.embedded) // OpenStreetMap tiles for road event location maps (kind 1315/1316) implementation(libs.osmdroid.android) diff --git a/amethyst/plans/2026-09-15-qr-reader-overhaul.md b/amethyst/plans/2026-09-15-qr-reader-overhaul.md new file mode 100644 index 0000000000..96c78d9aab --- /dev/null +++ b/amethyst/plans/2026-09-15-qr-reader-overhaul.md @@ -0,0 +1,405 @@ +# QR Reader Overhaul + +**Status:** phases 1-5 implemented (see §7 for what shipped and what did not); phase 0 outstanding +**Goal:** make scanning a QR code in Amethyst fast and near-certain — codes that are small, +far, dim, glossy, tilted, inverted, on a screen, or already sitting in the gallery should all +resolve on the first try, and a code the app *can't* route should say so instead of silently +dropping the user back where they started. + +**Scope:** the reader (`amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt` and the four +screens that call it), plus a short second section on the *display* side, because half of +"scan this npub" is how legibly we draw the code for the other phone. + +--- + +## 1. What we have today + +`SimpleQrCodeScanner` is 30 lines that hand the whole job to +[zxing-android-embedded](https://github.com/journeyapps/zxing-android-embedded) 4.3.0 via +`ScanContract`, which launches its own `CaptureActivity`: + +```kotlin +ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(stringRes(id = Res.string.point_to_the_qr_code)) + setBeepEnabled(false) + setOrientationLocked(false) + addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) +} +``` + +Four call sites: + +| Call site | Payload expected | +| --- | --- | +| `ShowQRScreen.kt:182` (`NIP19QrCodeScanner`) | any `nostr:` / NIP-19 entity → `Route` | +| `KeyTextField.kt:106` | `nsec` / `ncryptsec` / bunker login | +| `AddNwcWalletScreen.kt:207`, `AddClinkDebitWalletScreen.kt:172` | `nostr+walletconnect://` | +| `Nip46SignerScreen.kt:174` | `bunker://` | + +`libs.zxing` (ZXing **core**) is used only for *encoding* (`QrCodeDrawer.kt`, +desktop `QrCodeCanvas.kt`). The decoder we actually run is the one bundled inside +zxing-android-embedded. + +### 1.1 Why it is hard to scan — verified causes + +Each of these was checked against the library's source (4.x `master`) or our own code, not +recalled: + +1. **It is a Camera1 app.** `com.journeyapps.barcodescanner.camera.CameraManager` imports + `android.hardware.Camera`. Everything downstream inherits Camera1's limits: legacy-HAL + preview sizes, no per-frame 3A control, and no zoom API wired up at all. +2. **Autofocus is a 2-second timer, not continuous.** `CameraSettings` defaults to + `focusMode = FocusMode.AUTO` (`continuousFocusEnabled = false`), and `AutoFocusManager` + re-triggers `Camera.autoFocus()` on `AUTO_FOCUS_INTERVAL_MS = 2000L`. Between triggers the + frame can stay out of focus for up to two seconds — this is the "hold it still… still… + still…" feel, and it is fatal for close-up codes where the lens needs to rack to macro. +3. **`MIXED_SCAN` halves our decode rate.** We pass `Intents.Scan.MIXED_SCAN`, which selects + `MixedDecoder`, whose `toBitmap()` flips a boolean and inverts *alternate frames*. So for a + normal dark-on-light QR — i.e. essentially every code we meet — half of all frames are spent + decoding an inverted image that can never match. +4. **No zoom, at all.** Not pinch, not a button, not automatic. A code on a laptop screen across + a desk, or a small printed code on a sticker, simply never resolves enough modules. +5. **No discoverable torch.** The stock `CaptureActivity` layout has no torch button; + `DecoratedBarcodeView` only maps it to the volume keys. In a bar or a meetup hallway — the + exact place people swap npubs — there is no way to light the code. +6. **The decode is cropped.** `BarcodeView` sets `decoderThread.setCropRect(getPreviewFramingRect())`, + and `CameraPreview` defaults to `marginFraction = 0.1d`, so anything outside the centred + viewfinder box is discarded even though the user can see it in the preview. +7. **One decode attempt per frame, no retry ladder.** `DecoderThread.decode()` builds a single + `HybridBinarizer` over the cropped luminance and calls the reader once. No `TRY_HARDER`, no + rotation retry, no multi-scale, no denoise. ZXing-Java's detector needs three clean finder + patterns; glare, a crease, or a ~15° tilt past its tolerance and the frame is simply lost. +8. **It leaves the app.** `ScanContract` starts a separate activity with its own theme — no + Material3, no edge-to-edge, a visible cold-start hitch, and `setOrientationLocked(false)` + means rotating the phone recreates that activity and restarts the camera mid-scan. +9. **Failure is silent and ambiguous.** `NIP19QrCodeScanner` maps both "user cancelled" and + "decoded fine, but `uriToRoute` returned null" to `onScan(null)`, and `ShowQRBody` reacts by + flipping `presenting = true`. The user sees the QR screen again with no message. A decoded + but unroutable payload (a bare hex pubkey, an `nsec1…`, a plain `https://` link, another + app's code) is indistinguishable from "the camera never read it" — which is very likely part + of why the reader *feels* broken even in cases where the decode succeeded. This is the same + complaint as [#417](https://github.com/vitorpamplona/amethyst/issues/417), which was closed + in 2023 but is still the current behaviour. +10. **No way in except the live camera.** You cannot scan a QR you were *sent* — a screenshot, a + photo in the gallery, an image in a DM — nor paste one from the clipboard. Today the only + path is to get a second screen to display it and point the phone at it. + +### 1.2 One happy accident + +`amethyst/build.gradle.kts:622-626` already declares `camera-core`, `camera-camera2`, +`camera-lifecycle`, `camera-view` and `camera-extensions`, and **nothing in the repo imports +`androidx.camera`** (grep across `.kt`/`.xml` returns only a `PreviewView` line in the generated +baseline profile). CameraX is already paid for in APK size and in the licence audit; we just +haven't used it. This plan finally does. + +--- + +## 2. Target architecture + +Replace the external-activity scanner with an in-app Compose screen: + +``` +Route.QrScanner ──► QrScannerScreen (Compose, Material3, edge-to-edge) + ├─ CameraX Preview (PreviewView) ─────┐ + ├─ CameraX ImageAnalysis ──► BarcodeDecoder ──► ScanResult + ├─ ScannerOverlay (cutout, hit boxes, torch, zoom, gallery) + └─ ScanOutcomeSheet (routes, or explains why it can't) + │ + gallery / clipboard / shared image ──► BarcodeDecoder.decode(Bitmap) ┘ +``` + +**`BarcodeDecoder`** is a small interface with two entry points — +`decode(ImageProxy): List` and `decode(Bitmap, cropRect, rotation): List` +— so the camera plumbing, the UI, and the tests are all independent of which engine sits behind +it. + +### 2.1 Decoder choice: `io.github.zxing-cpp:android` + +**Recommended: `io.github.zxing-cpp:android:3.1.1`, replacing `com.journeyapps:zxing-android-embedded`.** + +Licence (per CLAUDE.md, verified against the published POM at +`repo1.maven.org/maven2/io/github/zxing-cpp/android/3.1.1/android-3.1.1.pom`): +**Apache License 2.0 → permissive → OK, proceed.** No GPL family, no linking-exception question. + +Why it is the right engine here: + +- Its `Options` are exactly the knobs our failure modes need — `tryHarder`, `tryRotate`, + `tryInvert`, `tryDownscale`, `tryDenoise`, `binarizer`, `maxNumberOfSymbols`. In particular + `tryInvert` does inverted detection **inside one call as a fallback pass**, which is what + `MIXED_SCAN` was reaching for without throwing away half our frames. +- `read(image: ImageProxy)` reads the Y plane straight out of the CameraX buffer + (`planes[0].buffer`, `rowStride`, `cropRect`, `rotationDegrees`) — no YUV→RGB copy, no + `Bitmap` per frame. +- Every `Result` carries a `Position` (four corners + orientation), which we need for the + tappable multi-code overlay and for the auto-zoom heuristic. +- `sequenceId` / `sequenceIndex` / `sequenceSize` give us Structured Append (multi-part QR) for + free — the door to large payloads later. +- `minSdkVersion 21` (from the AAR manifest) against our `minSdk = 26`. Fine. + +Cost: a prebuilt `.so` per ABI — `arm64-v8a` 1.75 MB, `armeabi-v7a` 1.23 MB, `x86` 1.85 MB, +`x86_64` 1.82 MB uncompressed. With `splits.abi` already enabled, an arm64 APK grows by roughly +0.8 MB compressed, partly offset by dropping zxing-android-embedded. We already ship prebuilt +JNI (`secp256k1-kmp-jni-android`), so F-Droid precedent exists — but **confirm with the F-Droid +packaging before landing**, since a new prebuilt binary is the kind of thing their build +metadata cares about. + +**Why not ML Kit barcode scanning:** it is proprietary and GMS-shaped, so it could only ship in +the `play` flavor and `fdroid` would need a FOSS decoder anyway — meaning we'd build the +zxing-cpp path regardless and then maintain two. Because `BarcodeDecoder` is an interface, an +ML Kit implementation in `amethyst/src/play/` (the pattern `MLKitImageLabelService.kt` already +uses in both flavor trees) stays a cheap, later, measurement-driven option. Phase 0 exists to +decide whether it's ever needed. + +**Fallback if the native lib is rejected:** keep `libs.zxing` (already present, ZXing-Java, zero +new bytes) behind the same interface. Phases 1, 3 and 4 below — the camera stack, the feedback +model, and image import — are decoder-independent and carry most of the win on their own. + +--- + +## 3. Phases + +### Phase 0 — Build the ruler before cutting (do this first) + +Nothing here ships; it exists so every later claim is measured rather than asserted. + +- [ ] Assemble a fixture corpus under `amethyst/src/androidTest/assets/qr/`: ~60 PNGs of real + Amethyst payloads (`npub`, `nprofile` with relay hints, `nevent`, `naddr`, + `nostr+walletconnect://`, `bunker://`, a Concord invite) rendered by our own + `QrCodeDrawer` and then degraded — gaussian blur, 8/15/30° tilt, perspective, 30 %/10 % + contrast, inverted, JPEG artefacts, partial glare, photographed-off-a-screen moiré, and + a "small in frame" set at 120/80/48 px across. +- [ ] `QrDecodeCorpusTest` (androidTest): decode every fixture with (a) today's ZXing-Java + + `HybridBinarizer` path and (b) zxing-cpp with the Phase 2 options. Report a pass rate per + category and per-image median decode time. +- [ ] Record the baseline numbers in this file. **Gate:** if zxing-cpp does not clearly beat + ZXing-Java on the degraded sets, drop §2.1 and keep ZXing-Java behind `BarcodeDecoder`. + +### Phase 1 — In-app CameraX scanner (the structural fix) + +- [ ] `BarcodeDecoder` interface + `ScanResult` (text, bytes, format, corner positions, + sequence info) in `amethyst/.../ui/screen/loggedIn/qrcode/decode/`. +- [ ] `QrScannerScreen` — `PreviewView` + `ImageAnalysis` bound to `LocalLifecycleOwner` via + `ProcessCameraProvider`, `STRATEGY_KEEP_ONLY_LATEST`, `OUTPUT_IMAGE_FORMAT_YUV_420_888`, + analysis resolution requested at 1280×720 with a 1920×1080 fallback via + `ResolutionSelector`. Analysis runs on a single background executor; results hop back to + the main thread through a `StateFlow`. +- [ ] Register `Route.QrScanner` in `Routes.kt` / `AppNavigation.kt` and move the four call + sites onto it. `SimpleQrCodeScanner` keeps its `(String?) -> Unit` signature as a thin + wrapper so the wallet/bunker/login screens change by one import, not a rewrite. +- [ ] Camera permission with `accompanist-permissions` (already a dependency, used by + `TakePicture.kt`): inline rationale, and an "Open settings" path when permanently denied — + today a denied permission just bounces the user out of `CaptureActivity` with no + explanation. +- [ ] Focus: rely on CameraX's continuous AF, plus tap-to-focus through + `PreviewView.meteringPointFactory` → `FocusMeteringAction` with AF+AE, auto-cancel on. + This alone removes cause §1.1.2. +- [ ] Remove `com.journeyapps:zxing-android-embedded` from `libs.versions.toml` and + `amethyst/build.gradle.kts`. **Keep `libs.zxing`** — encoding still needs it. +- [ ] Delete the decorative crop: analyse the full frame (or a ≥90 % ROI), and let the + viewfinder cutout be purely visual. Fixes §1.1.6. + +### Phase 2 — Make each frame count + +- [ ] Decoder options: `formats = {QR_CODE, MICRO_QR_CODE, RMQR_CODE}`, `tryInvert = true`, + `tryRotate = true`, `tryDownscale = true`, `binarizer = LOCAL_AVERAGE`, + `maxNumberOfSymbols = 5`. `tryHarder` on the still/import path always; on the live path + alternate a cheap pass with a `tryHarder`/`tryDenoise` pass every Nth frame so the frame + rate stays interactive. +- [ ] **Zoom.** Pinch-to-zoom (`CameraControl.setZoomRatio` driven by a + `TransformableState`) plus a 1×/2× quick toggle. Then auto-zoom: after ~1.2 s with no + decode, ramp the zoom 1.0 → 2.0 → back over a couple of seconds; if a code *is* decoded + but its `Position` spans less than ~25 % of the frame's short side, zoom so it fills + ~60 % and re-read. Fixes §1.1.4, the single biggest "it just won't read" cause. +- [ ] **Torch.** A visible toggle in the overlay, plus auto-suggest: the analysis frame's Y plane + gives mean luminance for free — under a threshold for ~1 s, surface the torch button + prominently (never auto-fire it; that's rude in a bar and worse in a meeting). +- [ ] Dedupe: ignore an identical payload re-decoded within 1.5 s so a held-steady code doesn't + fire the handler repeatedly. +- [ ] Structured Append: accumulate parts keyed by `sequenceId`, show "2 of 3 captured", emit + once complete, time out after 30 s. Groundwork for large/animated payloads. + +### Phase 3 — Tell the user what happened + +This is the cheapest phase and probably the largest perceived improvement. + +- [ ] Split today's single `null` into an explicit outcome type: `Cancelled`, + `PermissionDenied`, `Decoded(text) → Route`, and `Decoded(text) → unroutable`. +- [ ] On a decode: haptic tick (`HapticFeedbackType.LongPress`, matching `SwipeToDelete.kt` and + the wallet wizard) plus a brief highlight drawn over the code's four corners. +- [ ] On **unroutable** content, show a bottom sheet with the decoded text, what we think it is, + and actions — *Open link* for `http(s)`, *Copy*, *Search*, *Try again*. Closes the + ambiguity behind §1.1.9 and [#417](https://github.com/vitorpamplona/amethyst/issues/417). +- [ ] When ≥2 codes are in frame, draw a tappable box over each and let the user pick, instead + of silently taking whichever ZXing found first. +- [ ] Extract payload classification out of `MainActivity.uriToRoute` into a pure, JVM-testable + `classifyScannedPayload(text): ScannedPayload` (nostr entity / wallet connect / bunker / + nostrconnect / http(s) / lightning / unknown). `uriToRoute` keeps routing; the scanner + gets a testable "what is this?" answer, and a bare 64-char hex pubkey — issue #417's + original case — becomes trivial to accept. + +### Phase 4 — Scan things that aren't in front of the camera + +- [ ] **From the gallery:** a picker button in the overlay + (`ActivityResultContracts.PickVisualMedia`) → decode the bitmap with the full + `tryHarder + tryRotate + tryInvert + tryDenoise` option set, and on failure retry at + 2× and 0.5× scale before giving up. +- [ ] **From the clipboard:** if the clipboard holds an image, offer "Scan copied image"; if it + holds text that `classifyScannedPayload` recognises, offer to use it directly. Covers the + overwhelmingly common "someone sent me a screenshot of their npub" flow. +- [ ] **Shared in:** accept `ACTION_SEND` with an `image/*` MIME type into the scanner, so + "Share → Amethyst" from a gallery or chat app resolves the code. (The manifest already has + an `ACTION_SEND` image target for new posts — this needs to be a distinct, explicitly + labelled entry, not a hijack of that one.) + +### Phase 5 — The display side + +Half of a scan is the code on the *other* screen. + +- [ ] `ShowQRScreen` does **not** boost brightness, while `ShareNoteAsQrScreen.kt:238-249` + already does. Extract that into a `KeepScreenBrightAndOn()` composable (brightness 1f + + `FLAG_KEEP_SCREEN_ON`, restoring the previous value on dispose) and use it on both. On a + dim OLED in dark mode this is the difference between scannable and not. +- [ ] `QrCodeDrawer.kt` hardcodes `CornerRadius(20f)` for the finder patterns regardless of how + many pixels a module is — at small draw sizes that rounds a meaningful fraction of the + finder away. Scale the radius with module size (cap around 20 % of a module). +- [ ] Error correction is fixed at `ErrorCorrectionLevel.Q`. For long payloads (an `nprofile` + with two relay hints, a Concord invite) Q pushes the version up, so modules get smaller — + and small modules, not error correction, are what actually defeats a camera at arm's + length. Measure Q vs M across our real payload lengths on the Phase 0 corpus and pick per + length rather than globally. +- [ ] Verify the `.clip(QuoteBorder)` 15 dp rounding never eats into the 4-module quiet zone at + the sizes we actually render. + +--- + +## 4. Testing + +| Level | What | +| --- | --- | +| JVM unit | `classifyScannedPayload` over every payload we claim to accept, plus junk, plus the bare-hex and `nsec` cases; Structured Append accumulator (ordering, duplicates, timeout). | +| androidTest | `QrDecodeCorpusTest` (Phase 0) as a permanent regression gate — pass rate per degradation category, asserted against the recorded baseline so a decoder or option change can't quietly regress. | +| androidTest | Bitmap-import path end to end: fixture → `decode(Bitmap)` → `ScannedPayload`. | +| Manual matrix | Printed sticker at 10/30/60 cm; phone screen at 30/60/100 cm; laptop screen across a desk; dark room with and without torch; behind glossy glass; 15°/30°/45° tilt; two codes in frame; inverted (light-on-dark) code. Record pass/fail before and after. | +| Macrobenchmark | Time from tapping "Scan QR" to first preview frame — the current external-activity cold start is part of what the change should erase. | + +## 5. Risks + +- **APK size** — ~0.8 MB compressed on arm64 for the native decoder, partly returned by + dropping zxing-android-embedded. Phase 0's gate is what justifies it. +- **F-Droid** — a new prebuilt `.so` from Maven Central; check with the F-Droid packaging before + landing rather than after. The ZXing-Java fallback keeps this from being a dead end. +- **Camera device variance** — CameraX is far more uniform than Camera1, but zoom ratio ranges + and torch availability still vary; every control must degrade to hidden rather than broken. +- **Scope creep into the display side** — Phase 5 is deliberately last and independent. +- **Desktop** — `desktopApp` has no scanner today and this plan doesn't add one. If webcam + scanning is ever wanted, `BarcodeDecoder` + the payload classifier are the reusable halves; + the CameraX screen is not. + +## 6. Out of scope + +Animated / BC-UR multi-frame QR *generation*, NFC handoff, and any change to what the four +existing call sites do with a successful payload. + + +--- + +## 7. What shipped, and what did not + +Implemented in this branch: + +| Phase | State | +| --- | --- | +| 0 — measurement corpus | **Half done.** The corpus and the ZXing-Java baseline are built and measured (see §8); the zxing-cpp half needs a device and is written but unrun, so the gate is armed rather than passed. | +| 1 — in-app CameraX scanner | Done. | +| 2 — per-frame decode quality | Done. | +| 3 — explicit outcomes | Done. | +| 4 — gallery / clipboard import | Done, minus the `ACTION_SEND` share-in target. | +| 5 — display side | Done. | + +### Deviations from the plan above + +- **A dialog, not a `Route`.** §2 proposed registering `Route.QrScanner`. One of the four call + sites is `KeyTextField` on the *logged-out* login screen, which lives outside the navigation + graph entirely, so a route could not serve it. `QrCodeScannerDialog` is a full-screen + `Dialog` instead, which also let all four call sites keep their existing + `SimpleQrCodeScanner { }` shape — the diff at each is one import. +- **`ScanOutcome` instead of "close, then explain".** For the sheet in §3 to appear, the scanner + has to still be open when the caller decides it cannot use the payload. So the callback returns + `ScanOutcome.Handled` / `ScanOutcome.NotSupported` rather than `Unit`, and the camera keeps + running through the explanation. +- **No "found but too small" auto-zoom branch.** §2 listed zooming toward a code whose + `Position` spans too little of the frame. That branch is unreachable: if the code decoded, we + are done with it. Auto-zoom now only sweeps while *nothing* is decoding, which is the case + that actually fails. +- **`ACTION_SEND` image share-in not wired.** The manifest already has an `ACTION_SEND` image + target aimed at new posts; adding a second, distinctly-labelled one is a manifest and routing + change that belongs with its own testing rather than bolted onto this branch. + +### Still to do + +1. **Phase 0, retroactively.** Build the corpus, run `QrDecodeCorpusTest` on a device, record + the numbers here, and confirm the engine choice against them. Until that happens, "zxing-cpp + beats ZXing-Java on degraded codes" is a well-founded expectation, not a measurement. +2. **The manual matrix in §4.** None of it has been run — there is no camera in this + environment. Every camera-facing behaviour in phases 1, 2 and 4 (binding, focus, torch, + auto-zoom, the overlay's coordinate mapping, the photo picker) is compile-verified and + reasoned-through only. +3. **F-Droid packaging sign-off** on the new prebuilt `.so`, per §5. +4. **The ECC level question** in §5 — still open, and still wants the corpus to answer it. + +--- + +## 8. Phase 0: the baseline + +`QrCorpus` renders three payloads — an `npub`, an `nprofile` with relay hints, and an `nevent` +(69, ~330 and ~140 characters, so three different symbol versions) — at 4 pixels per module, then +degrades each one sixteen ways. Every degradation is expressed as a fraction of a *module*, so +changing the render scale cannot quietly re-tune the corpus's difficulty. + +`QrCorpusBaselineTest` measures **ZXing-Java**, the decoder the old zxing-android-embedded scanner +used, over that corpus on the JVM. Measured 2026-09-15: + +| category | ZXing-Java | what it stands for | +| --- | --- | --- | +| clean | 3/3 | sanity — if this ever fails the corpus is broken | +| blur | 3/3 | a quarter-module out of focus | +| blur-heavy | **0/3** | half a module out of focus | +| tilt15 / tilt30 / tilt45 | 2/3 each | held at an angle | +| perspective | **0/3** | seen off-axis — a code on a wall or table, photographed from the side | +| low-contrast | 3/3 | a dim screen | +| very-low-contrast | **0/3** | a very dim screen, or worn print | +| inverted | 3/3 | light-on-dark | +| glare | 3/3 | a highlight burning out one corner | +| moire | 3/3 | photographed off another screen | +| noise | 2/3 | sensor noise in poor light | +| far-3px | 3/3 | three pixels per module | +| far-2px | 2/3 | two pixels per module | +| far-1.5px | **0/3** | one and a half pixels per module | +| **TOTAL** | **31/48** | | + +Read it as a map of where the old reader gave up. **Perspective is a total loss** — an off-axis +code, one of the most ordinary framings there is, was simply unreadable. So are heavy blur, very +low contrast, and anything under two pixels per module. + +Two caveats on the number, both of which make 31/48 *flattering* to the old scanner: + +- It decodes the **full image** and retries **inverted**. The shipped scanner cropped to a + viewfinder rect and alternated inversion across frames, so it had strictly fewer chances. +- It measures a decoder on a still. It says nothing about focus, zoom or torch, which is where + most of this branch's other work went. + +So a win measured here is a floor on the real-world difference, not the whole of it. + +**The gate is not yet passed.** `QrDecodeCorpusTest` runs zxing-cpp over the identical committed +images and fails if it reads fewer in any category. It needs a device. Until someone runs it, the +decoder swap rests on the reasoning in §2.1 — this section just means the measurement is now one +command away instead of unbuilt. + +The corpus costs 776 KB in `amethyst/src/androidTest/assets/qr/` (test APK only, never shipped). +Regenerate it with: + +```bash +./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' \ + -Pamethyst.qr.corpus.export=true +``` diff --git a/amethyst/plans/2026-09-17-nip-cc-geocaching.md b/amethyst/plans/2026-09-17-nip-cc-geocaching.md new file mode 100644 index 0000000000..837d0de01c --- /dev/null +++ b/amethyst/plans/2026-09-17-nip-cc-geocaching.md @@ -0,0 +1,521 @@ +# NIP-CC (Geocaching) — gap analysis for Quartz and Amethyst + +Status: **steps 1–2 done** — the Quartz protocol package (`quartz/…/nipCCGeocaching/`) with its +tests, and the Amethyst read path: caches and found logs are stored, rendered, searchable, and +appear in the geohash/AroundMe feeds. Steps 3 onwards (cache detail screen, composing, the +verification QR flow, curation lists) are unstarted. + +Spec: (merged into +`master`; listed in the NIPs README at line 117 and in the kind tables for +`7516`, `7517`, `37516`, `37517`). Read at 2026-09-17. + +## 1. What the spec defines + +| Kind | Class | Purpose | +|---------|-------------|-------------------------------------------------------------------------| +| `37516` | addressable | Geocache listing — name, location, difficulty/terrain/size, hint, … | +| `7516` | regular | Found log — claims a find, optionally carrying an embedded `7517` | +| `7517` | regular | Verification — **signed by the cache's verification key**, not the finder | +| `37517` | addressable | Curation list — ordered `a` references to `37516` listings | +| `1111` | existing | DNF / note / maintenance / archived logs, as NIP-22 comments on the cache | + +Secondary mechanics: `n` type modifiers (`first-to-find`, `art`) with a +"one per category, ignore unknown" rule, an `F` tag that locks in the +first-to-find winner, a `mission` ("Key Quest") tag, and a client SHOULD to +ROT13 hints so they don't spoil. + +### Confirmed with a grep, not from memory + +At the time of the survey, +``` +grep -rn "37516\|7517\|37517\|[Gg]eocach" --include=*.kt +``` +returned only incidental hex substrings in unrelated tests — no geocaching code anywhere in +`quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`. The Quartz half of that gap is now +closed; the rest stands. + +## 2. Quartz — new code + +New package `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/`, +laid out like `experimental/roadstr/` (event + `TagArrayExt` + `TagArrayBuilderExt` ++ `tags/`), which is the closest existing analogue: a geohash-anchored, +user-submitted place event. + +``` +nipCCGeocaching/ +├── listing/ +│ ├── GeocacheListingEvent.kt # 37516, BaseAddressableEvent + SearchableEvent +│ ├── TagArrayExt.kt / TagArrayBuilderExt.kt +│ └── tags/ +│ ├── CacheNameTag.kt # "name" (required) +│ ├── DifficultyTag.kt # "D", 1..5, reject out-of-range +│ ├── TerrainTag.kt # "T", 1..5 +│ ├── CacheSizeTag.kt # "S" -> enum micro|small|regular|large|other +│ ├── CacheTypeTag.kt # "t", defaults to `traditional` when absent +│ ├── TypeModifierTag.kt # "n" -> first-to-find | art (+ category) +│ ├── HintTag.kt # "hint" +│ ├── MissionTag.kt # "mission", first-wins if duplicated +│ ├── VerificationKeyTag.kt # "verification" (hex pubkey) +│ └── FirstToFindWinnerTag.kt # "F" (hex pubkey), first-wins +├── log/ +│ ├── GeocacheFoundLogEvent.kt # 7516 +│ └── tags/EmbeddedVerificationTag.kt # "verification" holding a 7517 as JSON +├── verification/ +│ ├── GeocacheVerificationEvent.kt # 7517 +│ ├── tags/FinderCacheTag.kt # the composite "a" tag — see §2.3 +│ └── GeocacheVerificationValidator.kt # the 4-step check from the spec +├── curation/ +│ ├── GeocacheCurationListEvent.kt # 37517, BaseAddressableEvent + SearchableEvent +│ └── tags/ThemeTag.kt, MapStyleTag.kt # "theme", "map" +├── comment/GeocacheLogTypeTag.kt # "t" on kind 1111: dnf|note|maintenance|archived +└── FirstToFindResolver.kt # winner selection + F lock-in precedence +``` + +### 2.1 Reuse — do not re-implement + +The listing event is mostly existing tags wearing a new kind number: + +| Spec tag | Reuse | +|-----------------|------------------------------------------------------------------------| +| `d` | `nip01Core/tags/dTag` | +| `g` | `nip01Core/tags/geohash/GeoHashTag` + `geohashes()` | +| `image` | `nip23LongContent/tags/ImageTag` | +| `r` | `nip51Lists/tags/RelayTag` (`"r"`) | +| `a` | `nip01Core/tags/aTag/ATag` (curation list + found log) | +| `title`, `description` (37517) | `nip51Lists/tags/TitleTag`, `.../DescriptionTag` | +| embedded 7517 | `Event.fromJson(...)` (same trick `LnZapEvent.zapRequest()` uses) | +| signature check | `Event.verifySignature()` in `nip01Core/crypto/EventExt.kt` | +| kind 1111 logs | `nip22Comments/CommentEvent` **as-is** — it already models `A/K/P` + `a/k/p` addressable root+parent (`rootAddress()`, `replyAddress()`, `hasRootAddress()`). Only the `t` log-type vocabulary is new. | +| required-tag gate | `containsAllTagNamesWithValues(REQUIRED_FIELDS)`, as `ClassifiedsEvent.isWellFormed()` does | + +`GeoHashTag.geohashMipMap()` already emits the full precision ladder, but +**reversed and starting at 1 char**; the spec asks for precisions 3–9 in +coarse-to-fine order. Add a bounded variant (`geohashMipMap(min, max)`) rather +than publishing 1- and 2-character geohashes that are useless for proximity +search and noisy on relays. + +### 2.2 Type modifiers need a category model, not a string set + +Rule 2 ("at most one `n` per category, first occurrence wins") and rule 4 +("ignore unrecognised values") mean `n` cannot be a flat `Set`. Model it +as `enum class TypeModifierCategory { CLAIM_SEMANTICS, PRIZE_NATURE }` with +`first-to-find -> CLAIM_SEMANTICS`, `art -> PRIZE_NATURE`, and a parser that +keeps the first value per category and drops unknowns. Forward compatibility is +an explicit spec requirement, so unknown values must not make the event +unparseable. + +### 2.3 Spec warts to handle defensively + +1. **The `7517` `a` tag is not a NIP-01 `a` tag.** It is + `":"` — two colon-joined parts, where a + real `a` value is `kind:pubkey:d`. `Address.parse` will not parse it: a 2-part + value fails the `parts.size > 2` guard, falls through to the `naddr1` branch, + doesn't start with `naddr1` either, and returns null **after logging a + warning** — so nothing silently misreads it, but any generic `a`-tag consumer + that meets a `7517` will spam `AddressableId` warnings. It needs its own + `FinderCacheTag` parser, and the `naddr` half goes + through `nip19Bech32/entities/NAddress`. Worth an upstream issue; implement + the spec as written, tolerate the sane `kind:pubkey:d` form on read. +2. **`7517` is signed by a key the user does not own.** The finder scans a QR at + the cache carrying the verification *private* key, then signs a `7517` with + it. That cannot go through the account's `NostrSigner` — it needs an ephemeral + `NostrSignerInternal(KeyPair(privKeyFromQr))`. That private key must never + touch the Keystore, account state, or a log line. See §4. +3. **`created_at` is forgeable, so first-to-find ordering is advisory.** The spec + says so itself: earliest `created_at`, ties broken by ascending event `id`, + *until* the owner publishes an `F` tag — after which `F` wins unconditionally. + `FirstToFindResolver` should encode exactly that precedence and the UI should + mark a pre-`F` winner as provisional. +4. **`37515` is referenced but never defined.** The 37517 `a`-tag prose says + "kind 37516 or 37515". Accept both on read; only ever write `37516`. +5. **`NIP-GD.md` (Good Deed) does not exist.** The `mission` tag's cross-reference + is a dangling link (`raw.githubusercontent.com/.../NIP-GD.md` → 404). Implement + `mission` as free text; do not build a Good-Deed integration against a spec + that isn't published. +6. **`archived` is an `n`-modifier-shaped value living in `t`.** Owners archive a + cache by adding `["t", "archived"]` to the *listing*, while `t` on the listing + otherwise means cache *type* (`traditional`/`multi`/`mystery`) and `t` on a + *comment* means log type. Three meanings, one tag name — parsers must be + scoped per kind, not shared. + +### 2.4 Registration points (each one is a real integration, not a formality) + +- `quartz/utils/EventFactory.kt` — 4 new `KIND ->` branches. Without this the + events parse as bare `Event` and every accessor above is dead code. +- `quartz/kinds/KindNames.kt` — 4 `KindName(..., "CC")` entries. +- `quartz/nip50Search/SearchableKinds.kt` — add `7516`, `37516`, `37517` if they + implement `SearchableEvent`. **`SearchableKindsTest` sweeps kinds 0–65535 and + asserts this list is exactly the searchable set**, so a missed entry lands as a + failing test naming the number. `indexableContent()` for `37516` should be + name + content; the `hint` deliberately should *not* be indexed (searching for + a hint is spoiling). +- Tests under `quartz/src/commonTest/.../nipCCGeocaching/`: round-trip each kind + against the spec's own example JSON, the `D`/`T` range guards, the + one-`n`-per-category rule, the `F`-beats-`created_at` precedence, and a + verification event whose signature does *not* match the `verification` pubkey. + +## 3. Amethyst — new code + +Good news: the expensive parts already exist and were built for road events +(kind 1315/1316). + +**Reuse as-is:** +- `amethyst/ui/note/creators/location/LocationPickerMap.kt`, + `LocationPreviewMap.kt`, `MapPinIcon.kt`, `GeohashLocationPickerDialog.kt` — + an osmdroid/OSM interactive map with markers, tap-to-place, and a geohash + picker dialog. +- `service/location/LocationState` + the `AroundMe` top-nav feed for "caches + near me". +- `commons/.../relayClient/geohash/FilterPostsByGeohash.kt` — adding + `GeocacheListingEvent.KIND` to `PostsByGeohashKinds` is a one-line change that + puts caches into the existing geohash feed. +- NIP-22 comment rendering and composing — DNF/note/maintenance logs are already + displayable threads once the root kind is known. + +**New:** +- `commonsUI/.../ui/note/GeocacheCard.kt` — cache card (name, D/T/S badges, + type + modifier badges, distance, ROT13-toggle hint), modelled on + `RoadEventCard.kt`. +- `amethyst/ui/note/types/Geocache.kt` + dispatch branches in + `ui/note/NoteCompose.kt` (~line 1405/1450 is where `ClassifiedsEvent` and + `RoadEventReportEvent` branch) and in `ThreadFeedView.kt`. +- `LocalCache.kt` — consume the four kinds (the `is ClassifiedsEvent,` / + `is RoadEventReportEvent,` groups at ~3759/3933 are the template). +- Cache detail screen: map, logs tab, "Log a find" / "Log DNF" actions, and — + for a `first-to-find` cache with any valid verified find — the spec's + "render as effectively archived, hide find affordances, show the winner". +- Cache composer (owner side): map picker → geohash ladder, D/T/S pickers, hint, + optional verification keypair generation + QR export, mission. +- QR scan → `7517` signing flow (see §4). +- Curation list (37517) browse + detail, honouring `theme`/`map` as *defaults* + the user can override. +- ROT13 helper — **does not exist anywhere in the repo** (`grep -i rot13` is + empty). Small, belongs in `commons` next to the other text utils, with the + hint rendered rotated and a tap to reveal. +- Strings for every label above (`commonsUI` `composeResources`), plus any new + Material Symbol codepoints — which means running + `./tools/material-symbols-subset/subset.sh` and committing the regenerated + `.ttf`, or the icon renders as tofu. + +## 3.5 Interoperability — checked against the network, not just the spec + +Two other implementations exist: **treasures.to** (the originating client, whose caches are the +spec's examples) and **Lightning Piggy** (`BenGWeeks/lightning-piggy-mobile`, whose +`src/services/nostrPlacesService.ts` is a readable second opinion). A corpus of real events was +pulled off relay.damus.io / nos.lol / relay.primal.net / nostr.wine with `amy fetch` and is +pinned at `quartz/src/commonTest/resources/nipcc.interop.json`; `NipCCInteropTest` parses it. + +What the network confirmed: + +- **The `g` ladder really is 3..9.** All 60 sampled listings published exactly that band, and + Lightning Piggy's builder loops `for (n = 3; n <= 9; n++)`. The bounded `geoMipMap` overload + matches. +- `a` on a found log is a plain `37516::`; the composite `:` on an + embedded 7517 is exactly as specified. Both parse. +- `archived` really does appear in `t` alongside cache types on live listings, and real `F` + lock-ins and `n` modifiers exist. The split parsers handle them. +- Listings carry `client`, `expiration`, NIP-32 `L`/`l`, `content-warning` and payout hints we + model nothing for; ignoring them is harmless. + +**The one real divergence: `hint`.** NIP-CC contradicts itself — the tag table says "plaintext" +and the example is plaintext, while the Clients section asks for ROT13 — and the network split +down the middle: of 34 sampled hints, **17 plaintext and 17 ROT13**, consistent within each +author but not across them. Either fixed reading spoils half the caches in the world. So +`HintObfuscation` picks per hint, showing whichever of the two rotations scores worse against +English letter frequency, and the card *toggles* rather than revealing once, so a wrong guess +costs a tap instead of the hint. + +Worth raising upstream: the spec should say which form goes on the wire. Until it does, a writer +has to pick, and this code writes ROT13 — the reference client's choice, and the one that fails +safe (a reader assuming plaintext sees noise, not the answer). + +Note the trap if anyone revisits the heuristic: counting vowels is backwards. ROT13 maps `n→a`, +`r→e`, `h→u`, `b→o`, so English ciphertext usually has *more* vowels than its plaintext. + +### Rendering, compared kind by kind + +Read against Lightning Piggy's screens (`CacheDetailSheet`, `HuntRailCard`, +`HuntPiggyDetailScreen`, `HuntRecentFindsSection`). What their UI does that ours did not: + +- **The hint shows no text until tapped** — "Stuck? Tap to reveal the hint", never the encoded + form. Adopted: a wall of ROT13 reads as corruption, and with nothing rendered before the tap + the hint heuristic can no longer spoil anything, only mislabel after an explicit ask. +- **The cache photo leads the card.** 87 `image` tags across 60 sampled listings; both their rail + card and detail screen render it. We parsed `image` and rendered nothing. Adopted (first image + only; the rest belong on a detail screen). +- **A found-log row names the cache it is about.** Ours said "Found it!" and nothing else — the + one thing a reader already assumed. Adopted, plus the log's own photo (8 of 60 carry one). The + listing was already being loaded to validate the proof, so the name is free. + +Where we deliberately differ, or lead: + +- Our feed card carries the D/T/S and modifier badges; their rail card is name + kind + distance + and puts the chips on a detail sheet. Amethyst's feed cards are richer by house style + (cf. `RoadEventCard`), so this stays. +- They read `t` as one value, so an archived cache renders "archived" *as its cache type*. Our + split parser keeps the type and adds an Archived badge. +- They parse no `n` modifiers, no `F`, no `mission` and no `verification`; there is no reference + rendering for first-to-find, art, Key Quest or verified finds, and nobody validates a 7517. + Ours is the first — worth saying out loud, because it means those four have been checked + against the spec and against real events, but not against another client's behaviour. +- Neither client renders the kind 1111 `dnf`/`maintenance` log type. They have a builder and no + reader, exactly as we do. A shared ecosystem gap rather than a divergence. +- NIP-40: they stamp `expiration` on every listing (a year by default) and drop expired caches. + Amethyst honours NIP-40 globally in `CachePruner.pruneExpiredEvents`, so no per-kind work — + though pruning is periodic rather than render-time, which is true of every kind in the app. + +### The card's visual hierarchy + +The first card was a faithful dump of the tag list: a full-width **square** map (the map slot +never overrode `LocationPreviewMap`'s `aspectRatio = 1f`) immediately followed by a 16:9 photo, +then up to **nine** chips at identical weight, and no distance. A real cache off the relays — +*Treasure Troll's Trunk*, which carries every modifier the spec defines — lit up all nine. + +Rebuilt around the three questions a reader actually has: + +- **One hero.** Photo when there is one, with the map demoted to a 76dp corner inset that still + answers "where"; the map alone at 16:9 when there is no photo. `GeocacheMap` gained an + `aspectRatio` parameter so the card, not the host, decides the shape — the same slot now serves + both the wide hero and the square inset. +- **Ratings are a line.** `Traditional · Regular · D1 · T1` in one quiet row instead of four + chips, which is what Lightning Piggy's sheet does. +- **Colour only where it is rare.** Gold for an unclaimed first-to-find, green for verified + finds, muted for claimed and archived. The gold chip is suppressed once a cache is claimed — a + prize nobody can win should not glitter. Nine chips became four. Tints are backgrounds, not + text colours: the theme's amber is unreadable as text on a light ground, `onSurface` over a + wash reads in both. +- **The name is a title** that wraps to two lines, not a one-line pill clipped over a map. +- **Distance, top right.** + +On distance: it reads `geolocationFlow().value` rather than collecting it. That flow is +`SharingStarted.WhileSubscribed`, so collecting from a feed card would switch the GPS on for +anyone who merely scrolled past a geocache — a real battery and privacy cost, paid silently, for +one line of text. Its initial value is the last cached fix, so this costs nothing and yields a +distance whenever something else (Around Me, the location picker) has already asked. The trade +is that it does not update as the reader walks; a card in a feed is not a compass, and the detail +screen is where a live fix belongs. Rounding is deliberately coarse (metres up close, one decimal +to 10km, whole km beyond) because Amethyst holds only `ACCESS_COARSE_LOCATION`. + +## 4. Security review items + +These are the parts worth a careful reviewer, not the event codecs: + +1. **Verification private keys from QR codes are attacker-controlled input.** + They arrive by scanning an object in the physical world. Treat the scanned + value as untrusted: validate it is a 32-byte secp256k1 scalar, use it for one + ephemeral signature, never persist it, never log it, and never route it + through `NostrSignerInternal` instances that outlive the call. +2. **A `7517` proves presence, nothing else.** Validation must check all four + spec steps — signature valid, signer == the listing's `verification` pubkey, + finder pubkey in the `a` tag == the 7516 author, and the naddr resolves to + *this* cache. Skipping step 2 or 3 lets anyone replay someone else's + verification into their own log. +3. **Cache locations are precise real-world locations of the user.** Publishing a + 9-character geohash is ~5m. The composer must make it obvious that this is + public, and the "log a find" flow must not attach the *finder's* location. +4. **Hints/missions are untrusted remote text** — same rendering rules as any + other user content (no auto-linkifying into a privileged surface). + +## 5. Suggested sequencing + +1. ~~Quartz protocol package + tests + the three registration files.~~ **Done** — 79 tests in + `quartz/src/commonTest/…/nipCCGeocaching/`, plus the three new rows in the + `indexable-content.golden` fixture and the `searchable-kinds.md` table. The layout follows + nip88Polls: a folder per subject, each with its event, `TagArrayExt`, `TagArrayBuilderExt` + and `tags/`. +2. ~~Read path in Amethyst.~~ **Done** — `GeocacheCard`/`GeocacheFoundLogCard` in `commonsUI` + (map-hero slot, D/T/S and modifier badges, ROT13 hint with tap-to-reveal), `Geocache.kt` + wrappers in `amethyst/` supplying the osmdroid `LocationPreviewMap`, `LocalCache` consume + + `computeReplyTo`, dispatch in `NoteCompose` and `ThreadFeedView`, the one-line + `PostsByGeohashKinds` addition, and the search window (`RenderableKinds` + a `kind:geocache` + alias). + + Two things worth knowing about how it was wired: + + - **The found-log proof badge is computed, never assumed.** A `verification` tag is a string + until it has been checked against the listing's key, the log's own author and the cache it + claims, so `RenderGeocacheFoundLog` loads and observes the listing and runs + `GeocacheVerificationValidator`. Until the listing arrives the state is `UNKNOWN` and the + card shows no badge — never an optimistic one. A verification that fails the check renders + as a warning rather than silently as "no proof". + - **"Claimed" comes off the listing's own `F` tag**, not from the cache's found logs. `F` is + authoritative once published and travels inside the event; the provisional timestamp-ordered + winner needs every verified log for the cache, so it belongs on the detail screen that + subscribes to them rather than on a feed card reading whatever replies happen to be in + memory. +3. Cache detail screen + NIP-22 logs (mostly wiring existing comment UI). This is where the + `dnf`/`note`/`maintenance` badge belongs: kind 1111 has no card of its own in `NoteCompose`, + it falls through to the generic text body, so badging a log type means touching the path every + NIP-22 comment in the app takes — worth doing once there is a cache thread to do it for. It is + also where the provisional first-to-find winner and the "multiple DNFs mean the cache is gone" + status heuristic go. +4. Write path: found logs, then DNF/note comments. +5. Verification: QR scan → ephemeral `7517` → embedded in the found log. +6. First-to-find / `F` lock-in, and the archived rendering rules. +7. Curation lists (37517) — independent of 1–6 and the easiest to defer. + +Steps 1–2 are the "make Amethyst not blind to geocaches" milestone and are worth +shipping before anything in 3+ is designed. + +## 6. Full integration design — screens, routes and entry points + +Steps 3–7 above say *what* is missing. This section says what it looks like. Every Quartz +builder needed for the write path already exists and is tested (`GeocacheListingEvent.build`, +`GeocacheFoundLogEvent.build(message, cache, verification, images)`, +`GeocacheVerificationEvent.build(finderPubKey, cache)`, `GeocacheCurationListEvent.build`, +`GeocacheLogComment`), so **none of this needs protocol work** — it is navigation, composers and +one map. Tag coverage was re-checked against the spec's tables and is complete: 14/14 on 37516, +3/3 on 7516, 1/1 on 7517, 8/8 on 37517. + +Wireframes: https://claude.ai/artifact/736F8KCTiaUkXCEpEii54C + +### 6.1 Six screens, eight routes + +| # | Screen | Route(s) | Template to copy | +|---|--------|----------|------------------| +| 1 | Geocaches hub (5 tabs) | `Route.Geocaches(initialTab: GeocacheTab? = null)` | `DiscoverScreen` (tab row + pager) | +| 2 | Cache detail | `Route.GeocacheDetail(kind, pubKeyHex, dTag)` | `calendars/detail/CalendarEventDetailScreen` | +| 3 | Log a find | `Route.LogGeocacheFind(kind, pubKeyHex, dTag)` | `composableFromBottomArgs`, NewPost chrome | +| 4 | Hide a cache | `Route.NewGeocache(draft)` / `Route.EditGeocache(address)` | `calendars/create/NewCalendarEventScreen` | +| 5 | Hunt detail | `Route.GeocacheHunt(kind, pubKeyHex, dTag)` | `SoftwareAppDetail` shape | +| 6 | Hunt composer | `Route.NewGeocacheHunt(draft)` / `Route.EditGeocacheHunt(address)` | follow-set picker | + +The address-carrying routes use the same three-field `(kind, pubKeyHex, dTag)` + `Address` +secondary-constructor shape as `Route.EditCommunity` and `Route.AwardBadge`. + +Hub tabs — one shared feed state, five views, so the map costs no extra subscription: +**Nearby** (distance-sorted) · **Map** · **Hunts** (37517) · **Finds** (my 7516s) · **Mine** +(caches I own). Needs a `GeocacheTab` enum next to `DiscoverTab` and a `dal/` filter per tab. + +### 6.2 Deliberately sheets, not screens + +- **Didn't find it** — one note + photo, publishing a kind 1111 with the `dnf` type. The same + sheet serves "add a note" and "needs maintenance"; only `GeocacheLogTypeTag` changes. This is + why no `logType` param gets bolted onto `Route.GenericCommentPost`. +- **Pin peek** on the map — raising a sheet instead of navigating keeps panning fluid. +- **Verification QR** (owner) — `QrCodeDrawer`, with the "only copy" warning. + +### 6.3 Entry points + +`NavBarItem.GEOCACHES` → `Route.Geocaches()` is the only new navigation surface, and it ships +**visible by default**. Adding the id to `NavBarCatalog` forces a drawer placement — it goes in +`DrawerFeedsItems` beside Polls, Products, Workouts and Calendars, and `DrawerSectionsTest` fails +if a catalog id appears in no section, so the repo already prevents a new destination from +silently vanishing. Hiding it is opt-out, via `DrawerSettingsScreen`. + +The bottom bar is a separate list (`DefaultBottomBarItems` is five entries: Home, Messages, +Wallet, Browser, Notifications) and is left alone — geocaching earns a drawer row, not one of +those five slots. + +Everything else is a destination change on something already tappable: a `GeocacheCard` in any feed, a "N caches +here" chip on the geohash screen, an `naddr` through `uriToRoute` (the treasures.to link path — +the interop entry that matters most), the notification feed (7516s already file under the cache +via `computeReplyTo`), the `kind:geocache` search alias, and a find count on a profile. + +**The verification secret must never reach the global QR scanner.** `NIP19QrCodeScanner` maps +scans to routes; a cache's *published* `naddr` belongs there, its verification *private key* does +not. That scan happens only inside screen 3, via `SimpleQrCodeScanner`, which returns a raw +string. + +### 6.4 What's genuinely new vs. reused + +Reused wholesale: `GeocacheCard`/`GeocacheFoundLogCard`, `LocationPreviewMap`, +`LocationPickerMap`, `GeohashLocationPickerDialog`, `SimpleQrCodeScanner`, `QrCodeDrawer`, +`FeedTopNavFilterState`, `SaveableFeedContentState`, the NIP-22 thread UI, the upload pipeline, +`GeocacheVerificationValidator`. + +Genuinely new Android UI: **a multi-marker map overlay with clustering**. Every map composable in +the repo places exactly one `Marker` (`LocationPreviewMap.kt:219`, `LocationPickerMap.kt:240` +both `removeAll { it is Marker }` first). That is the one piece with no precedent here. + +### 6.5 Rules the screens must honour + +- **Archived, or FTF locked in for someone else** → the find actions are replaced by a status + strip. NIP-CC asks for this explicitly. +- **`theme` / `map` on a 37517 are defaults, not mandates** — apply, then let the reader override. +- **`a` tag order on a hunt is meaningful** and `curatedGeocaches()` already preserves it. +- **Found logs publish to the cache's own `r` relays** (`logRelays()`) plus the user's write + relays. +- **A scanned verification key is attacker-controlled**: validate it is a 32-byte secp256k1 + scalar, sign one 7517 on `Dispatchers.Default`, drop it. Never persisted, never logged, never + handed to a signer that outlives the call. +- **The find composer must not attach the finder's location**, and must say so on screen. + +### 6.6 Build order — **built** + +All six screens, eight routes and the sheets below now exist and compile. What landed: + +| Piece | Where | +|---|---| +| 8 routes + `GeocacheTab` | `ui/navigation/routes/Routes.kt` | +| Drawer + catalog + bottom-bar category | `NavBarItem.kt`, `DrawerSections.kt` | +| Relay filter family (8 files) | `commons/.../relayClient/geocaches/` | +| Follow list, prefs, data source, 4 feed states | `AccountSettings`, `Account`, `LocalPreferences`, `RelaySubscriptionsCoordinator`, `AccountFeedContentStates` | +| 4 feed filters | `ui/.../geocaches/dal/` | +| Hub, 5 tabs, top bar, FAB, feed renderer | `ui/.../geocaches/` | +| Cache detail, log thread, owner actions, DNF sheet | `ui/.../geocaches/detail/` | +| Find composer + verification | `ui/.../geocaches/log/` | +| Cache composer | `ui/.../geocaches/create/` | +| Map tab with clustering | `ui/.../geocaches/map/` | +| Hunt detail + composer | `ui/.../geocaches/hunt/` | +| Owner revisions (archive, `F` lock-in) + 6 tests | `quartz/.../listing/GeocacheListingRevision.kt` | + +Two things behave differently from the sketch, both deliberately: + +- **Photos on a found log go through the standard upload pipeline**, not a geocaching-specific + one, so a find inherits Blossom/NIP-96 server choice and compression from the user's settings. +- **The Key Quest answer is appended to the log body** rather than given a tag. NIP-CC defines + `mission` on the listing and nothing for the response, so the only interoperable place for it + is the text every client already renders. + +### 6.6a What the first pass got wrong + +Two corrections worth keeping, because both were "done" in an earlier draft of this section and +neither was: + +- **Five of the eight entry points did not exist.** `Route.GeocacheDetail` appeared in exactly + two places in the repo: its nav registration and the hub's own feed row. Everything else — + feed taps, notification taps, `nostr:naddr` deep links, QR scans, search hits — fell through + to the bare note view. All five run through one function, `routeForInner` in `RouteMaker.kt`, + where the calendar branch three lines above says so in as many words. Two branches fixed the + lot. The `naddr` case is the one that mattered: a treasures.to link is how most people will + first meet a cache in Amethyst. +- **Curation was write-once.** "Add to a hunt" only ever created a *new* hunt, so the moment a + player wanted a second cache on a route they had already built, the app had no answer. Now a + picker sheet lists the hunts the user owns and republishes the chosen one with the cache + appended (`GeocacheCurationRevision`, built on `update {}` so foreign tags survive). + +Also added since: the "N caches here" chip on the geohash screen, and photo removal in the cache +composer, which could previously only add. + +**Deliberately not built:** a find count on someone else's profile. It would read from +`LocalCache`, which for a stranger holds ~0 of their 7516s, so it would render "0 finds" for +almost everybody. Doing it honestly needs a per-profile subscription — a profile tab like Gallery +or Zaps — which is more work than the three items above combined, for a vanity number. + +Still open, and honestly so: **none of it has been seen rendering**, and the cluster threshold +(50) is a guess that wants measuring on a mid-range device with a busy city on screen. + +### 6.6b Original build order + +1. Cache detail + log thread — gives every entry point somewhere to land. +2. Log a find, with verification — the first thing a player does, and it makes Amethyst the only + client that can *produce* a 7517 rather than only check one. +3. The hub minus the map — four feed filters and the drawer entry. +4. Hide a cache — the form plus the key ceremony; the largest single piece. +5. The map tab. +6. FTF lock-in and the archived rules. +7. Hunts — fully independent of 1–6. + +### 6.7 Decisions (settled) + +- **Nearby may turn on GPS**, behind an explicit "Use my location" the user taps. Feed cards keep + reading the cached fix and never subscribe; a distance-sorted list is worthless without a fix, + but scrolling a feed must still never start the GPS. +- **Geocaching gets a drawer row, on by default** — `DrawerFeedsItems`, hideable in + `DrawerSettingsScreen`. Not a default bottom-bar slot. +- **We write ROT13 hints.** The network is split 17/17 and the spec contradicts itself; reading + handles both, writing must pick. ROT13 is the reference client's choice and the one that fails + safe (a reader assuming plaintext sees noise, not the answer). +- **Cluster the map above roughly 50 visible markers** — a starting figure to measure on a real + device, not a guess to ship. diff --git a/amethyst/plans/README.md b/amethyst/plans/README.md index 1aebbcf8fa..ca667f7c01 100644 --- a/amethyst/plans/README.md +++ b/amethyst/plans/README.md @@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 ## Queued | Plan | Summary | | ---- | ------- | +| [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. | | [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. | | [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. | diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index ccd571f29e..9de75fd352 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -1,71 +1,245 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# ============================================================================= +# R8 configuration for the release build. # -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# Two things are balanced here. +# +# 1. Google Play measures how much of the shipped DEX R8 actually optimized +# and renamed, and warns (then restricts visibility/publishing) below 25% +# in either category. This file used to open with `-dontobfuscate` plus +# `-keepnames class ** { *; }`, and then kept all of `com.vitorpamplona.**` +# outright. That is the whole app and every library: `-dontobfuscate` +# turns renaming off globally, and `-keepnames` is shorthand for +# `-keep,allowshrinking`, which permits shrinking but neither renaming nor +# optimization. Hence 0% obfuscation / 13% optimization. +# +# 2. Anything the runtime reaches by NAME rather than by reference has to keep +# that name: JNI symbols, Jackson's reflective data binding, enum constants +# persisted into DataStore, class names written into a manifest meta-data +# value or into WorkManager's database. +# +# So every keep below is scoped to (2), and R8 gets everything else. mapping.txt +# is uploaded with each release, so stack traces stay retraceable. +# +# When adding a keep, say in a comment WHAT reads the name at runtime. A keep +# without that is usually a keep that is not needed. +# ============================================================================= -# preserve the line number information for debugging stack traces. --dontobfuscate --keepattributes LocalVariableTable --keepattributes LocalVariableTypeTable --keepattributes *Annotation* --keepattributes SourceFile --keepattributes LineNumberTable --keepattributes Signature --keepattributes Exceptions --keepattributes InnerClasses --keepattributes EnclosingMethod --keepattributes MethodParameters --keepparameternames +# ----------------------------------------------------------------------------- +# Attributes +# ----------------------------------------------------------------------------- +# These two are what make a crash report readable again. Keep them. +# +# There is no setting that gives readable stack traces *in the raw trace* once +# R8 is minifying, and that is worth being precise about, because it is the +# thing -dontobfuscate used to buy us: +# +# * R8 overwrites every class's SourceFile with the marker +# `r8-map-id-` whatever we do here. Verified by building it both +# ways: with `-renamesourcefileattribute SourceFile` all 24,440 classes +# report the literal "SourceFile"; without it they report the marker. +# There is no rule that restores the original per-class .kt name. +# * R8 renumbers lines even with LineNumberTable kept, because one +# obfuscated line now has to encode a whole INLINED frame stack. In this +# build, line 7 of one method carries three source frames: +# TextFieldCharSequence.getText():58 inlined into TextFieldState.getText() +# :146 inlined into ShortNotePostViewModel.onMessageChanged():1727. A raw +# line number is no longer a source line. +# +# That second point is a cost of OPTIMIZATION, not of renaming, and it is new +# here only because the old `-keepnames class ** { *; }` had optimization off +# program-wide — which is exactly what Play was complaining about. +# +# So the answer is retrace, not a keep rule. And retrace hands back more than +# the old raw traces did: it expands those inlined frames instead of collapsing +# them into one misleading line. See `scripts/retrace.sh` and RELEASE_OPS.md +# § 7. Two things make that painless, and both depend on this file: +# +# * `-renamesourcefileattribute` is deliberately NOT set, so the map-id +# marker survives. A pasted trace then names the exact mapping file it +# needs (the marker is the `pg_map_id` header of that mapping), so there is +# never any doubt about which release a report came from. +# * mapping.txt.gz ships as a GitHub Release asset for every build, so traces +# from F-Droid / Zapstore / Accrescent users are retraceable too — Play +# Console only auto-deobfuscates the AAB it was given. +-keepattributes SourceFile,LineNumberTable + +# Generic signatures, plus the inner/enclosing-class links that travel with them. +# +# Signature carries the generic type arguments R8 would otherwise erase. +# +# It is NOT enough to make `object : TypeReference() {}` work under full mode +# (android.enableR8.fullMode=true). Measured on device: JacksonMapper's +# built its JavaTypes through jacksonTypeRefOf() and threw +# +# IllegalArgumentException: Internal error: TypeReference constructed without +# actual type information +# +# which poisons the class -- every later use is NoClassDefFoundError, so nothing +# could be signed or sent. Keeping the anonymous subclasses did not help either +# (tried -keep,allowobfuscation and a full -keep ... { *; }). The fix was to stop +# asking Jackson to read the type back off the class: JacksonMapper now builds +# those JavaTypes with TypeFactory.constructType/constructCollectionType/ +# constructParametricType, which take the Class objects directly. +# +# Anything else that still resolves a generic type reflectively is exposed the +# same way -- notably JacksonMapper.fromJsonTo, JsonMapperNip55 (NIP-55) and +# the NIP-46 bunker path, which were not reachable in this test run. +# +# All three are ALSO in AGP's proguard-android-optimize.txt, which keeps +# AnnotationDefault, EnclosingMethod, InnerClasses, Signature and the three +# RuntimeVisible* annotation attributes. They stay spelled out here anyway: the +# duplicate is free (measured at 0 bytes) and it means a change to AGP's default +# file cannot quietly take Signature away from Jackson. +# +# Two attributes this line used to carry are gone, both measured on the arm64 +# release DEX: +# +# * `*Annotation*` — over AGP's default its only contribution was the +# RuntimeInvisible* variants, which by definition cannot be read at runtime. +# Dropping it produced a byte-identical DEX (31,390,048 either way). Nothing +# we ship reads an annotation reflectively, and the libraries that do +# (kotlinx.serialization, appfunctions, AppSearch) match on RuntimeVisible*, +# which AGP already keeps. +# * `Exceptions` — @Throws metadata for 31 methods in shipped code, read by +# Java-interop compilers and by nothing at runtime. Worth 692 bytes. +# +# For the record, since it was wrong here for a while: this line used to credit +# jackson-module-kotlin with reading @kotlin.Metadata through it. That module no +# longer ships, and the Jackson mixins it also named were deleted along with the +# NWC Jackson path. +-keepattributes Signature,InnerClasses,EnclosingMethod + +# LocalVariableTable, LocalVariableTypeTable, MethodParameters and +# -keepparameternames used to be kept here as well. They are debug metadata: +# nothing in the app reads them (jackson-module-kotlin takes parameter names +# from @kotlin.Metadata, not from MethodParameters), and they are pure DEX +# weight in a release build. -keepdirectories libs -# Keep all names --keepnames class ** { *; } +# ----------------------------------------------------------------------------- +# JNI — names that live in a .so, not in the DEX +# ----------------------------------------------------------------------------- +# proguard-android-optimize.txt already contributes +# -keepclasseswithmembernames class * { native ; } +# which pins every class that DECLARES a native method (ArtiNative, +# secp256k1's loader, …) together with those methods' names, because the +# exported symbol is Java__. What that does NOT cover is the +# traffic in the other direction: Java/Kotlin the native side looks up itself. -# Keep All enums --keep enum ** { *; } +# libarti_android.so calls back into this interface by name — +# tools/arti-build/src/lib.rs does GetMethodID("onLogLine") on it. Renaming the +# method silently kills all Tor log output. +-keep class com.vitorpamplona.amethyst.ui.tor.ArtiLogCallback { *; } -# preserve access to native classses +# secp256k1's JNI layer resolves these from native code. -keep class fr.acinq.secp256k1.** { *; } -# JNA For Libsodium --keep class com.goterl.lazysodium.** { *; } +# zxing-cpp's JNI boundary. The native library exports name-mangled symbols +# (Java_zxingcpp_BarcodeReader_readYBuffer), so R8 renaming the class or its +# external methods breaks the lookup at runtime with no build error and no +# warning -- the QR scanner simply fails to start in release builds. +# +# This arrived automatically as the io.github.zxing-cpp:android AAR's consumer +# rule. We build that library ourselves now (tools/zxing-cpp-build) and vendor +# its Kotlin half, so the rule is ours to carry. +-keep class zxingcpp.** { *; } -# libscrypt --keep class com.lambdaworks.codec.** { *; } --keep class com.lambdaworks.crypto.** { *; } --keep class com.lambdaworks.jni.** { *; } +# Nothing keeps libscrypt, NetCipher/tor-android, LazySodium or JNA any more: +# quartz replaced libsodium with a pure-Kotlin implementation (LibSodiumInstance) +# and Tor now runs through arti's own JNI layer. Their rules used to live here and +# matched zero classes in the release build — none of those artifacts appear in +# mapping.txt, usage.txt or seeds.txt, i.e. they are not on the classpath at all. +# If one ever comes back, so must its rule: JNA in particular maps types onto the +# C ABI by reflecting over their fields and method signatures at runtime. --keep class info.guardianproject.** { *; } - -# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out --keep class com.sun.jna.ToNativeConverter { *; } --keep class com.sun.jna.NativeMapped { *; } --keep class com.sun.jna.CallbackReference { *; } --keep class com.sun.jna.ptr.IntByReference { *; } --keep class com.sun.jna.NativeLong { *; } --keep class com.sun.jna.Structure { *; } --keep class com.sun.jna.Structure$* { *; } --keep class com.sun.jna.Native$ffi_callback { *; } --keep class * implements com.sun.jna.Structure$* { *; } --keep class * implements com.sun.jna.Native$* { *; } --keep class com.sun.jna.Native { - private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object); - private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object); - private static java.lang.Class nativeType(java.lang.Class); - private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object); - private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method); +# ----------------------------------------------------------------------------- +# Enum constant names +# ----------------------------------------------------------------------------- +# An enum constant's NAME is persisted data in this app. The preference stores +# write `enum.name` into DataStore and read it back with `Type.valueOf(string)` +# (see model/preferences/UISharedPreferences.kt, TorSharedPreferences.kt, +# NamecoinSharedPreferences.kt), and Jackson serialises enums by name too. +# Enum.valueOf resolves that string against the static FIELD name, so renaming +# the constants would reset every user's theme/font/Tor/connectivity setting on +# the first launch after an update. +# +# Deliberately blanket rather than a list of the enums that happen to be +# persisted today: the failure mode is silent, release-only, and one new +# `preferences[KEY] = value.name` line away. Only the field names are pinned — +# the enum classes themselves are still renamed and their methods still +# optimized. +-keepclassmembers enum * { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); } -# JSON parsing --keep class com.vitorpamplona.quartz.** { *; } --keep class com.vitorpamplona.amethyst.** { *; } +# ----------------------------------------------------------------------------- +# Jackson — reflective data binding only +# ----------------------------------------------------------------------------- +# Most of Quartz's wire format does NOT need a keep. Event, Filter, Message, +# Command, Rumor, EventTemplate, TagArray, the NIP-46 Bunker messages and the +# NIP-55 intent results all go through hand-written StdSerializer/StdDeserializer +# pairs registered on JacksonMapper / JsonMapperNip55, which read and write +# property names as string literals. Renaming their fields changes nothing on +# the wire. +# +# What remains is the code Jackson data-binds REFLECTIVELY — `treeToValue(...)` +# and `readValue()` with no custom deserializer. There the JSON property +# names come from the Kotlin constructor parameter names, so a renamed field is +# a changed wire format. -# Room generates *_Impl subclasses instantiated reflectively via no-arg constructor. -# -keepnames preserves the name but R8 still strips the unused (). --keep class * extends androidx.room.RoomDatabase { - (); -} +# NIP-47 and CLINK used to need a package keep each, because Jackson bound their +# ~106 concrete classes reflectively. Both are gone: OptimizedJsonMapper routes +# those types at the hand-written kotlinx serializers, which name every field as a +# string literal. Nothing to keep, nothing to verify. +# The on-disk stores (scheduled posts, pending PoW jobs, resource usage) used to +# need a keep each, because Jackson derived their JSON keys from the Kotlin +# constructor parameter names. They are @Serializable now: kotlinx bakes every key +# in as a string literal, so the field names can be renamed freely. The formats are +# pinned by ScheduledPostFileFormatTest and PowAndUsageFileFormatTest instead, +# which assert the bytes against what the Jackson build wrote. + +# ----------------------------------------------------------------------------- +# Names referenced from outside the DEX +# ----------------------------------------------------------------------------- +# AGP generates keeps from the merged manifest's component `android:name` +# attributes, but NOT from . The Cast framework reads +# this one out of the manifest and Class.forName()s it. +-keep class com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider { *; } + +# NOT a rule for WorkManager. It stores the worker's class name in its own +# database at enqueue time and instantiates it by name on a later process start +# — including after an app update that reshuffled the mapping — so the name does +# have to survive. But androidx.work already ships exactly that in its own +# consumer rules (`-keepnames class * extends androidx.work.ListenableWorker` +# plus a keepclassmembers for the public constructors), so a rule here was pure +# duplication. The three workers stay listed in the reflection contract, which +# now verifies the LIBRARY's rule keeps doing the job. + +# androidx.appfunctions: the KSP-generated invokers and the app_functions.xml +# the system reads are keyed off these declarations. One class plus its +# generated neighbours — cheap enough not to be worth proving unnecessary +# against a pre-stable (alpha) library. +-keep class com.vitorpamplona.amethyst.appfunctions.** { *; } + +# ----------------------------------------------------------------------------- +# Enums used as navigation-route ARGUMENTS +# ----------------------------------------------------------------------------- +# androidx.navigation's type-safe routes resolve an enum argument by its +# fully-qualified class name (NavTypeConverter.parseEnum/parseNullableEnum call +# Class.forName on the serial name). R8 renames the class, so building the nav +# graph throws and the app cannot get past login: +# +# IllegalArgumentException: Cannot find class with name +# "...routes.DiscoverTab?". Ensure that the serialName for this argument is +# the default fully qualified name. +# +# The enum FIELDS are already pinned by the blanket `-keepclassmembers enum *` +# above; that rule deliberately lets the CLASS be renamed, which is exactly what +# breaks here. Every enum used as a route argument needs its name too. +-keep class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab { *; } +-keep class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType { *; } +-keep class com.vitorpamplona.amethyst.ui.navigation.routes.GeocacheTab { *; } diff --git a/amethyst/src/androidTest/assets/qr/baseline.tsv b/amethyst/src/androidTest/assets/qr/baseline.tsv new file mode 100644 index 0000000000..258e4f2397 --- /dev/null +++ b/amethyst/src/androidTest/assets/qr/baseline.tsv @@ -0,0 +1,16 @@ +blur 3 3 +blur-heavy 0 3 +clean 3 3 +far-1.5px 0 3 +far-2px 2 3 +far-3px 3 3 +glare 3 3 +inverted 3 3 +low-contrast 3 3 +moire 3 3 +noise 2 3 +perspective 0 3 +tilt15 2 3 +tilt30 2 3 +tilt45 2 3 +very-low-contrast 0 3 diff --git a/amethyst/src/androidTest/assets/qr/blur-heavy-nevent.png b/amethyst/src/androidTest/assets/qr/blur-heavy-nevent.png new file mode 100644 index 0000000000..e97301b636 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-heavy-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/blur-heavy-nprofile.png b/amethyst/src/androidTest/assets/qr/blur-heavy-nprofile.png new file mode 100644 index 0000000000..493418b194 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-heavy-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/blur-heavy-npub.png b/amethyst/src/androidTest/assets/qr/blur-heavy-npub.png new file mode 100644 index 0000000000..93f957f537 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-heavy-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/blur-nevent.png b/amethyst/src/androidTest/assets/qr/blur-nevent.png new file mode 100644 index 0000000000..740559b130 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/blur-nprofile.png b/amethyst/src/androidTest/assets/qr/blur-nprofile.png new file mode 100644 index 0000000000..4c75240dee Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/blur-npub.png b/amethyst/src/androidTest/assets/qr/blur-npub.png new file mode 100644 index 0000000000..a948a0d920 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/blur-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/clean-nevent.png b/amethyst/src/androidTest/assets/qr/clean-nevent.png new file mode 100644 index 0000000000..a9f8e18221 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/clean-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/clean-nprofile.png b/amethyst/src/androidTest/assets/qr/clean-nprofile.png new file mode 100644 index 0000000000..e001e8123d Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/clean-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/clean-npub.png b/amethyst/src/androidTest/assets/qr/clean-npub.png new file mode 100644 index 0000000000..44f757dba1 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/clean-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/expected.tsv b/amethyst/src/androidTest/assets/qr/expected.tsv new file mode 100644 index 0000000000..733a533f41 --- /dev/null +++ b/amethyst/src/androidTest/assets/qr/expected.tsv @@ -0,0 +1,48 @@ +clean-npub.png clean nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +blur-npub.png blur nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +blur-heavy-npub.png blur-heavy nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +tilt15-npub.png tilt15 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +tilt30-npub.png tilt30 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +tilt45-npub.png tilt45 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +perspective-npub.png perspective nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +low-contrast-npub.png low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +very-low-contrast-npub.png very-low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +inverted-npub.png inverted nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +glare-npub.png glare nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +noise-npub.png noise nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +moire-npub.png moire nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +far-3px-npub.png far-3px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +far-2px-npub.png far-2px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +far-1.5px-npub.png far-1.5px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq +clean-nprofile.png clean nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +blur-nprofile.png blur nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +blur-heavy-nprofile.png blur-heavy nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +tilt15-nprofile.png tilt15 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +tilt30-nprofile.png tilt30 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +tilt45-nprofile.png tilt45 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +perspective-nprofile.png perspective nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +low-contrast-nprofile.png low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +very-low-contrast-nprofile.png very-low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +inverted-nprofile.png inverted nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +glare-nprofile.png glare nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +noise-nprofile.png noise nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +moire-nprofile.png moire nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +far-3px-nprofile.png far-3px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +far-2px-nprofile.png far-2px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +far-1.5px-nprofile.png far-1.5px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq +clean-nevent.png clean nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +blur-nevent.png blur nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +blur-heavy-nevent.png blur-heavy nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +tilt15-nevent.png tilt15 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +tilt30-nevent.png tilt30 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +tilt45-nevent.png tilt45 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +perspective-nevent.png perspective nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +low-contrast-nevent.png low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +very-low-contrast-nevent.png very-low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +inverted-nevent.png inverted nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +glare-nevent.png glare nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +noise-nevent.png noise nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +moire-nevent.png moire nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +far-3px-nevent.png far-3px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +far-2px-nevent.png far-2px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq +far-1.5px-nevent.png far-1.5px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq diff --git a/amethyst/src/androidTest/assets/qr/far-1.5px-nevent.png b/amethyst/src/androidTest/assets/qr/far-1.5px-nevent.png new file mode 100644 index 0000000000..9c5cb21fae Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-1.5px-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-1.5px-nprofile.png b/amethyst/src/androidTest/assets/qr/far-1.5px-nprofile.png new file mode 100644 index 0000000000..ea8e5b36fb Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-1.5px-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-1.5px-npub.png b/amethyst/src/androidTest/assets/qr/far-1.5px-npub.png new file mode 100644 index 0000000000..9c3a9529b0 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-1.5px-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-2px-nevent.png b/amethyst/src/androidTest/assets/qr/far-2px-nevent.png new file mode 100644 index 0000000000..adb983aff5 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-2px-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-2px-nprofile.png b/amethyst/src/androidTest/assets/qr/far-2px-nprofile.png new file mode 100644 index 0000000000..6ce86d338c Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-2px-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-2px-npub.png b/amethyst/src/androidTest/assets/qr/far-2px-npub.png new file mode 100644 index 0000000000..df60e3f43c Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-2px-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-3px-nevent.png b/amethyst/src/androidTest/assets/qr/far-3px-nevent.png new file mode 100644 index 0000000000..111342f7f1 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-3px-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-3px-nprofile.png b/amethyst/src/androidTest/assets/qr/far-3px-nprofile.png new file mode 100644 index 0000000000..94e109453c Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-3px-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/far-3px-npub.png b/amethyst/src/androidTest/assets/qr/far-3px-npub.png new file mode 100644 index 0000000000..9d6327047b Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/far-3px-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/glare-nevent.png b/amethyst/src/androidTest/assets/qr/glare-nevent.png new file mode 100644 index 0000000000..9acda03a4b Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/glare-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/glare-nprofile.png b/amethyst/src/androidTest/assets/qr/glare-nprofile.png new file mode 100644 index 0000000000..1b7a565b29 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/glare-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/glare-npub.png b/amethyst/src/androidTest/assets/qr/glare-npub.png new file mode 100644 index 0000000000..b80e94182f Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/glare-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/inverted-nevent.png b/amethyst/src/androidTest/assets/qr/inverted-nevent.png new file mode 100644 index 0000000000..4694627b92 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/inverted-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/inverted-nprofile.png b/amethyst/src/androidTest/assets/qr/inverted-nprofile.png new file mode 100644 index 0000000000..ef5ef50660 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/inverted-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/inverted-npub.png b/amethyst/src/androidTest/assets/qr/inverted-npub.png new file mode 100644 index 0000000000..9ed3a1012d Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/inverted-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/low-contrast-nevent.png b/amethyst/src/androidTest/assets/qr/low-contrast-nevent.png new file mode 100644 index 0000000000..aea754fdce Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/low-contrast-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/low-contrast-nprofile.png b/amethyst/src/androidTest/assets/qr/low-contrast-nprofile.png new file mode 100644 index 0000000000..1d46beaac4 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/low-contrast-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/low-contrast-npub.png b/amethyst/src/androidTest/assets/qr/low-contrast-npub.png new file mode 100644 index 0000000000..ea06e35e17 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/low-contrast-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/moire-nevent.png b/amethyst/src/androidTest/assets/qr/moire-nevent.png new file mode 100644 index 0000000000..f69f39c267 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/moire-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/moire-nprofile.png b/amethyst/src/androidTest/assets/qr/moire-nprofile.png new file mode 100644 index 0000000000..1e539b8189 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/moire-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/moire-npub.png b/amethyst/src/androidTest/assets/qr/moire-npub.png new file mode 100644 index 0000000000..2dd8553f4f Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/moire-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/noise-nevent.png b/amethyst/src/androidTest/assets/qr/noise-nevent.png new file mode 100644 index 0000000000..722169c289 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/noise-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/noise-nprofile.png b/amethyst/src/androidTest/assets/qr/noise-nprofile.png new file mode 100644 index 0000000000..b57e7a4aed Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/noise-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/noise-npub.png b/amethyst/src/androidTest/assets/qr/noise-npub.png new file mode 100644 index 0000000000..6ad3d5b0f8 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/noise-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/perspective-nevent.png b/amethyst/src/androidTest/assets/qr/perspective-nevent.png new file mode 100644 index 0000000000..c1f16b570e Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/perspective-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/perspective-nprofile.png b/amethyst/src/androidTest/assets/qr/perspective-nprofile.png new file mode 100644 index 0000000000..f9121f7aaa Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/perspective-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/perspective-npub.png b/amethyst/src/androidTest/assets/qr/perspective-npub.png new file mode 100644 index 0000000000..f2d3ebe03e Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/perspective-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt15-nevent.png b/amethyst/src/androidTest/assets/qr/tilt15-nevent.png new file mode 100644 index 0000000000..ad30e30592 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt15-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt15-nprofile.png b/amethyst/src/androidTest/assets/qr/tilt15-nprofile.png new file mode 100644 index 0000000000..0574a02ef6 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt15-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt15-npub.png b/amethyst/src/androidTest/assets/qr/tilt15-npub.png new file mode 100644 index 0000000000..c9c469b5dd Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt15-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt30-nevent.png b/amethyst/src/androidTest/assets/qr/tilt30-nevent.png new file mode 100644 index 0000000000..0b83d2cb94 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt30-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt30-nprofile.png b/amethyst/src/androidTest/assets/qr/tilt30-nprofile.png new file mode 100644 index 0000000000..d650c7a382 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt30-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt30-npub.png b/amethyst/src/androidTest/assets/qr/tilt30-npub.png new file mode 100644 index 0000000000..8bad92980a Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt30-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt45-nevent.png b/amethyst/src/androidTest/assets/qr/tilt45-nevent.png new file mode 100644 index 0000000000..d32fd0a077 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt45-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt45-nprofile.png b/amethyst/src/androidTest/assets/qr/tilt45-nprofile.png new file mode 100644 index 0000000000..09ca065a07 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt45-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/tilt45-npub.png b/amethyst/src/androidTest/assets/qr/tilt45-npub.png new file mode 100644 index 0000000000..73cca26568 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/tilt45-npub.png differ diff --git a/amethyst/src/androidTest/assets/qr/very-low-contrast-nevent.png b/amethyst/src/androidTest/assets/qr/very-low-contrast-nevent.png new file mode 100644 index 0000000000..9dc8fa196d Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/very-low-contrast-nevent.png differ diff --git a/amethyst/src/androidTest/assets/qr/very-low-contrast-nprofile.png b/amethyst/src/androidTest/assets/qr/very-low-contrast-nprofile.png new file mode 100644 index 0000000000..ca492a1fb2 Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/very-low-contrast-nprofile.png differ diff --git a/amethyst/src/androidTest/assets/qr/very-low-contrast-npub.png b/amethyst/src/androidTest/assets/qr/very-low-contrast-npub.png new file mode 100644 index 0000000000..ee78ae601c Binary files /dev/null and b/amethyst/src/androidTest/assets/qr/very-low-contrast-npub.png differ diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 8e8e4fcd67..2263cccba6 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -25,9 +25,9 @@ import android.graphics.Color import androidx.core.graphics.createBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.ImageDownloader diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index 9c72eead2d..d51726d393 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 39b9db024d..1a205eaf1b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -21,13 +21,12 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.core.Event @@ -163,7 +162,7 @@ class ThreadDualAxisChartAssemblerTest { fun threadOrderTest() = runBlocking { val eventArray = - JacksonMapper.mapper.readValue>(db) + Event.fromJson(header) + JacksonMapper.mapper.readValue>(db, JacksonMapper.eventListTypeInstance) + Event.fromJson(header) var counter = 0 eventArray.forEach { diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrDecodeCorpusTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrDecodeCorpusTest.kt new file mode 100644 index 0000000000..3fb2ff284d --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrDecodeCorpusTest.kt @@ -0,0 +1,129 @@ +/* + * 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.qrcode.scanner + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Runs zxing-cpp over the committed [QrCorpus] images and asserts it reads at least as many as + * ZXing-Java did, category by category. + * + * This is the gate the plan's phase 0 asks for: the justification for replacing the decoder is + * supposed to be a measurement, not an argument. The baseline it compares against was produced on + * the JVM by `QrCorpusBaselineTest` from the same images. + * + * Regenerate both with: + * ``` + * ./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' \\ + * -Pamethyst.qr.corpus.export=true + * ``` + */ +@RunWith(AndroidJUnit4::class) +class QrDecodeCorpusTest { + private val assets = InstrumentationRegistry.getInstrumentation().context.assets + private val decoder = ZxingCppBarcodeDecoder() + + @Test + fun readsEveryCleanCode() { + fixtures().filter { it.category == "clean" }.forEach { + assertTrue("clean fixture ${it.file} must decode", decode(it)) + } + } + + @Test + fun beatsTheOldDecoderInEveryCategory() { + val baseline = baseline() + assertTrue("baseline.tsv missing - regenerate the corpus", baseline.isNotEmpty()) + + val results = fixtures().groupBy { it.category }.mapValues { (_, list) -> list.count { decode(it) } } + val regressions = mutableListOf() + + val report = + buildString { + appendLine() + appendLine("category zxing-cpp ZXing-Java") + baseline.keys.sorted().forEach { category -> + val old = baseline.getValue(category) + val new = results[category] ?: 0 + appendLine(" %-20s %d/%d %d/%d".format(category, new, old.total, old.passed, old.total)) + if (new < old.passed) regressions += "$category: $new < ${old.passed}" + } + } + println(report) + + if (regressions.isNotEmpty()) { + fail("zxing-cpp read fewer codes than ZXing-Java in: ${regressions.joinToString("; ")}$report") + } + } + + private data class Fixture( + val file: String, + val category: String, + val expected: String, + ) + + private data class Baseline( + val passed: Int, + val total: Int, + ) + + private fun decode(fixture: Fixture): Boolean { + val bitmap = + assets.open("$ASSET_DIR/${fixture.file}").use { + BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }) + } ?: return false + + return try { + decoder.decode(bitmap, DecodeEffort.Thorough).any { it.text == fixture.expected } + } finally { + bitmap.recycle() + } + } + + private fun fixtures(): List = + assets.open("$ASSET_DIR/expected.tsv").bufferedReader().useLines { lines -> + lines + .filter { it.isNotBlank() } + .map { line -> + val (file, category, expected) = line.split('\t', limit = 3) + Fixture(file, category, expected) + }.toList() + } + + private fun baseline(): Map = + assets.open("$ASSET_DIR/baseline.tsv").bufferedReader().useLines { lines -> + lines.filter { it.isNotBlank() }.associate { line -> + val (category, passed, total) = line.split('\t', limit = 3) + category to Baseline(passed.toInt(), total.toInt()) + } + } + + companion object { + private const val ASSET_DIR = "qr" + } +} diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index dd2530ce41..c4304d3485 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -188,6 +188,14 @@ + + + @@ -377,6 +385,25 @@ + + + + + + + + + + + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 10b508d2be..f648f3c080 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -1348,4 +1348,6 @@ + diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 71f7fb1c3e..f3da3c094c 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2609,4 +2609,6 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect a Amethyst + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 077c3c79f3..2d5d60c64e 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2391,4 +2391,6 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect und Amethyst + diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 316262c16e..da08031d02 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -1304,4 +1304,6 @@ + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 2c0aeb5f4b..23b0fd14ee 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -106,4 +106,6 @@ + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 3527119238..ac838a9903 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -1339,4 +1339,6 @@ + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 946bf253a0..de87b0316e 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1361,4 +1361,6 @@ + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index cfc22bd566..274af176d4 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1354,4 +1354,6 @@ + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index a08d4b128f..29d94ee2fe 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1354,4 +1354,6 @@ + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index ee1f75703a..cf3e3995b4 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -1350,4 +1350,6 @@ + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 27754e3fe4..1d48d6ad81 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -1329,4 +1329,6 @@ + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 9f0cc0f74c..545e1f448e 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1281,4 +1281,6 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 0318e4963a..001bf177de 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1423,4 +1423,6 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 90dce48694..7009e03e51 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -106,8 +106,8 @@ नये सूत्र सम्वाद अधिक विकल्प - निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। - रहस्यीकृत निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। + निजी कुंचिका क्यूआर॰ चित्र अक्षरराशि दिखाएँ + रहस्यीकृत निजी कुंचिका क्यूआर॰ चित्र अक्षरराशि दिखाएँ अनुचरण करें प्रत्यानुचरण करें पारणशब्द छिपाएँ @@ -1426,7 +1426,7 @@ \n\t३॰ अमेथिस्ट के साथ जोडें। आपका धनकोष क्रमक सामान्यतः तीन विकल्प देगा। \n\t\t३॰१ स्वचालित जोड। एक घुण्डी जिससे स्वचालित रूप से खोलें तथा अमेथिस्ट से जोडें। यदि हो तो उस घुण्डी को टाँकें तथा अमेथिस्ट खोलें तथा नव्य पटल पर अभिलेखन करें। \n\t\t३॰२ मनुष्यकृत जोड। एक संयोजन वैश्विकसंसाधनपरिचायक जिसकी अनुकृति करके सीधे इस पटल पर प्रविष्ट कर सकते हैं। -\n\t\t३॰३ क्यूआर॰ क्रमचित्र। धनकोष क्रमक एक क्यूआर॰ क्रमचित्र बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा। +\n\t\t३॰३ क्यूआर॰ चित्र अक्षरराशि। धनकोष क्रमक एक क्यूआर॰ चित्र अक्षरराशि बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा। अन्धकारमय, प्रकाशवान अथवा यन्त्रव्यवस्थित प्रदर्शनशैली स्वचालित रूप से चित्र तथा जिफ॰ का अवरोहण करें @@ -1441,6 +1441,9 @@ नया चित्र नया लघु चलचित्र नया चलचित्र + क्यूआर॰ चित्र अक्षरराशि परखें + क्यूआर॰ क्रमचित्र को परखें + एक क्यूआर॰ चित्र अक्षरराशि परखें नया उद्दीप्तव्य उद्दीप्त लेख आपके विचार जोडें… @@ -2390,4 +2393,7 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> स्वास्थ्य संयोजन तथा अमेथिस्ट + + मेरा स्वास्थ्य diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 057287ec4b..bfe3d3851c 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1441,6 +1441,9 @@ Új kép Új rövid videó Új videó + QR-kód beolvasása + QR-kód beolvasása + QR-kód beolvasása Új kiemelés Kiemelt szöveg Adja hozzá a gondolatait… @@ -2390,4 +2393,7 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect és Amethyst + + Fitnesz diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index c4d8c2d138..5f40041424 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -1272,4 +1272,6 @@ Seharusnya %3$s + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 4235685088..14657b1a5e 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -1313,4 +1313,6 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index d331b35a06..afcdb71ee4 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -1306,4 +1306,6 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 9ba49413df..2b03ff48b7 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1302,4 +1302,6 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index dd45a2d395..4bbf3ab0a7 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -1371,4 +1371,6 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index d946265b92..f3c36ae52f 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2297,4 +2297,6 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 59acce21ce..53cc769694 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1567,6 +1567,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Nowe zdjęcie Nowy filmik Nowe wideo + Zeskanuj QR kod + Skanuj QR + Zeskanuj QR kod Nowe wyróżnienie Wyróżniony tekst Dodaj swoje przemyślenia… @@ -2608,4 +2611,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect i Amethyst + + Moja kondycja diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2dde5acce8..061ddb0b3a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2389,4 +2389,6 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect e Amethyst + diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index a5ec0d9bcc..278fda2e5c 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1316,4 +1316,6 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 7623077a1f..fdca0ce35a 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -1389,4 +1389,6 @@ + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 275b77b36a..7714d49441 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1384,4 +1384,6 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index ad8489e5e1..1ee945173d 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2533,4 +2533,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 83c61da299..c06f35d302 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -1359,4 +1359,6 @@ + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 29bb166f4c..438ce567e2 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2389,4 +2389,6 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect och Amethyst + diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index a9fad01a27..d5fb3accd8 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -1337,4 +1337,6 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 0397bcc831..1987974119 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -1323,4 +1323,6 @@ + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index cbb2b4c1d1..ba46c2c444 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -1175,4 +1175,6 @@ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index ed79f95c32..f1ab223acc 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -1320,4 +1320,6 @@ + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 02b5d8813c..7480369920 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -1384,4 +1384,6 @@ + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index aa6090f8e9..4ae535415c 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -1339,4 +1339,6 @@ + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 82e55f65bc..bdbc3647b6 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -1290,4 +1290,6 @@ + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 2c8beb4150..bc0f2e2454 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1830,4 +1830,6 @@ + diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 6f23be117d..dc88d50a3b 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -1324,4 +1324,6 @@ + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 5ac2435775..40448a41a2 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1324,4 +1324,6 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 4bb6cb6493..3c13261a60 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -1300,4 +1300,6 @@ + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a696142a3f..e6d0b967c3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -123,6 +123,8 @@ Follow back Hide Password Invalid key + Amethyst for Android can\'t sign in with a bunker:// address. It hands these out so other apps can ask it to sign, but it cannot sign in with one. Use an external signer app, or Amethyst Desktop. + That\'s an app asking to connect to your signer, not a way to sign in. Sign in first, then open it from the NIP-46 signer screen. Invalid key: %1$s Password is required Key is required @@ -1675,6 +1677,9 @@ New Picture New Short New Video + Scan QR code + Scan QR + Scan a QR code New Highlight Highlighted text Add your thoughts… @@ -1821,6 +1826,15 @@ Distinct from the "shorts" string, which labels the vertical/short-video-only feed. --> Media Calendars + Geocaches + Cache hunts + Cache + Hunt + Log a find + Hide a cache + Edit cache + New hunt + Edit hunt Calendar lists Chess Wallet @@ -2776,4 +2790,8 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect and Amethyst + + + My Fitness diff --git a/amethyst/src/main/res/xml/shortcuts.xml b/amethyst/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000000..a969608257 --- /dev/null +++ b/amethyst/src/main/res/xml/shortcuts.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 08a2da1826..a0dd8d1bf9 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent @@ -1907,13 +1908,13 @@ class AmethystAppFunctions { "Active Amethyst account is read-only (npub login). Sign in with a private key or NIP-46 bunker to publish.", ) } - // NostrSignerExternal lives in quartz/androidMain and isn't visible - // to commonMain — but we're already in android-app code, so the - // class is on the classpath. Reflective name-check keeps the - // dependency edge clean and avoids hard-coupling the bridge to - // the NIP-55 implementation class. - val klass = signer::class.qualifiedName - if (klass == "com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal") { + // NostrSignerExternal lives in quartz/androidMain, which is on the + // classpath here because this is android-app code. This used to compare + // `signer::class.qualifiedName` against the fully-qualified name to + // avoid the import; R8 renames the class in release builds, so that + // comparison silently stopped matching and the external-signer branch + // never ran. A type check has no such failure mode. + if (signer is NostrSignerExternal) { throw AppFunctionNotSupportedException( "Amethyst is configured to use an external NIP-55 signer (Amber). " + "Write actions from Gemini aren't supported with this signer yet — " + diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LeakedCoroutineReporter.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LeakedCoroutineReporter.kt new file mode 100644 index 0000000000..1ad851a3ca --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LeakedCoroutineReporter.kt @@ -0,0 +1,69 @@ +/* + * 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 + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Prints every coroutine failure that reaches the JVM-wide handler during a unit-test run, + * together with the thread and coroutine context it came from. + * + * Registered through `META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler`, so + * `kotlinx.coroutines` hands it any exception no `CoroutineExceptionHandler` in the failing + * coroutine's own context claimed — i.e. exactly the ones leaked by a scope built as + * `CoroutineScope(Dispatchers.IO + SupervisorJob())` and never cancelled. + * + * Why this is worth a file: every test class in this module shares one JVM, and + * `kotlinx-coroutines-test` installs a global handler of its own that *collects* such + * exceptions. One arriving while no test is running is replayed into the next `runTest` to + * start — which fails as `UncaughtExceptionsBeforeTest` naming a test that had nothing to do + * with it, and passes when run alone. Without this reporter the only record of the real + * culprit is a suppressed exception buried in one report; with it, the leak is printed at the + * moment it happens, under the class that was actually running. + * + * Deliberately only reports: it does not signal the exception as handled, so + * `kotlinx-coroutines-test` still surfaces it and no genuine failure is swallowed. + * + * `ServiceLoader` order decides whether this runs before or after that collector, which only + * matters while a test is running — there the collector claims the exception and fails that very + * test with its own stack trace, so nothing is lost either way. Between tests the collector + * claims nothing, which is precisely the case this reporter exists for. + */ +class LeakedCoroutineReporter : + AbstractCoroutineContextElement(CoroutineExceptionHandler), + CoroutineExceptionHandler { + override fun handleException( + context: CoroutineContext, + exception: Throwable, + ) { + System.err.println( + "LEAKED COROUTINE EXCEPTION — a coroutine failed outside any test's scope.\n" + + " thread: ${Thread.currentThread().name}\n" + + " context: $context\n" + + " The test class printed alongside this is the one that was running, NOT " + + "necessarily the one that started the coroutine. Whichever test built the " + + "scope in the stack trace below must cancel it before it finishes.", + ) + exception.printStackTrace() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 3d484262cc..f126658623 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Dao import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.model.LocalCache.getOrCreateAddressableNoteInternal +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.getOrCreateAddressableNoteInternal import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip19Bech32.entities.NNote diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarAppointmentCacheIdentityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarAppointmentCacheIdentityTest.kt new file mode 100644 index 0000000000..4558af59bf --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarAppointmentCacheIdentityTest.kt @@ -0,0 +1,175 @@ +/* + * 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.calendar + +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * A 31923 appointment lands in `LocalCache` as TWO objects: the canonical `AddressableNote` under + * its address, and a version note under the event id. Only the first is handed to feeds when the + * event arrives, and only the first survives the cache sweep that runs on every app switch — so a + * feed must query the addressables, which is what [com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter] + * now does. Scanning `LocalCache.notes` instead gave the calendar views a second identity for the + * same appointment and made them flicker events in and out around every trip to the background. + * + * `LocalCache` is a process-wide object and JUnit's method order is hash-based, so every method + * here uses its own author key and asserts only over that key's address range. + */ +class CalendarAppointmentCacheIdentityTest { + private fun appointment( + id: String, + pubKey: String, + dTag: String, + ) = CalendarTimeSlotEvent( + id = id, + pubKey = pubKey, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("title", "Standup"), + arrayOf("start", "1800000000"), + ), + content = "", + sig = "sig", + ) + + @Test + fun theQueryTheFeedUsesReturnsTheSameObjectTheLiveUpdateDelivers() { + val pubKey = "a1".repeat(32) + val event = appointment("a3".repeat(32), pubKey, "standup") + LocalCache.justConsumeMyOwnEvent(event) + + // What LocalCache hands a feed on arrival (refreshNewNoteObservers(replaceableNote)). + val delivered = LocalCache.getAddressableNoteIfExists(event.address()) + assertNotNull("the appointment did not reach the addressable cache", delivered) + + // What the feed's full rebuild now scans. + val scanned = + LocalCache.addressables.filterIntoSet(CalendarTimeSlotEvent.KIND, pubKey) { _, it -> + it.event is CalendarTimeSlotEvent + } + assertEquals(1, scanned.size) + assertSame("the rebuild must return the object the live path delivers", delivered, scanned.first()) + } + + @Test + fun theCanonicalNoteIsKeyedByItsAddress_notByTheEventId() { + val pubKey = "b1".repeat(32) + val event = appointment("b3".repeat(32), pubKey, "retro") + LocalCache.justConsumeMyOwnEvent(event) + + val canonical = LocalCache.getAddressableNoteIfExists(event.address())!! + + // The version note under `event.id` is a different object with a different idHex. Both + // carry the same appointment, and a feed that mixed the two showed it twice — `distinctBy` + // and the LazyColumn keys both go by idHex. (The version note is not asserted on here: it + // is weakly held and moves all its references to the canonical note on arrival, so it can + // be collected at any moment — which is the other half of why a feed must not rely on it.) + assertEquals(event.address().toValue(), canonical.idHex) + assertNotEquals("the two identities for one appointment must not be confused", event.id, canonical.idHex) + } + + @Test + fun theAppointmentOutlivesTheVersionNoteBeingSwept() { + val pubKey = "c1".repeat(32) + val event = appointment("c3".repeat(32), pubKey, "1-1") + LocalCache.justConsumeMyOwnEvent(event) + + // Held for the length of the test on purpose: `addressables` is a weak cache too, so + // without a reference the appointment can be collected out from under the assertions + // under GC pressure (it is exactly the feed's own list that holds the working set alive + // in the app). The difference the fix rests on is not weak-vs-strong, it is that this + // note is the one everything links to while the version note has its references moved + // away on arrival and is swept on every app switch. + @Suppress("UNUSED_VARIABLE") + val keepAlive = LocalCache.getAddressableNoteIfExists(event.address()) + + // CachePruner.cleanMemory()/prunePastVersionsOfReplaceables() drop version notes on every + // app switch (MemoryTrimmingService tier 1); `notes` holds them weakly anyway. + LocalCache.notes.remove(event.id) + + assertNull("the version note is gone, as it is after a trip to the background", LocalCache.notes.get(event.id)) + assertEquals( + "the appointment must still be there for the feed to find", + event.id, + LocalCache + .addressables + .filterIntoSet(CalendarTimeSlotEvent.KIND, pubKey) { _, it -> it.event is CalendarTimeSlotEvent } + .firstOrNull() + ?.event + ?.id, + ) + } + + @Test + fun theFeedReturnsTheCanonicalNote_notTheVersionNote() { + val pubKey = "d1".repeat(32) + val event = appointment("d3".repeat(32), pubKey, "planning") + LocalCache.justConsumeMyOwnEvent(event) + + // Taken before the query, and held: see the note in the sweep test above. + val delivered = LocalCache.getAddressableNoteIfExists(event.address()) + + val mine = + CalendarAppointmentsFeedFilter(seeEverythingAccount()) + .feed() + .filter { it.event?.pubKey == pubKey } + + assertEquals("the appointment must be in the feed exactly once", 1, mine.size) + assertSame( + "the feed must hand back the same object live updates deliver, or the two paths " + + "fight over which copy of the appointment is on screen", + delivered, + mine.first(), + ) + } + + @Test + fun theFeedStillFindsTheAppointmentAfterTheVersionNoteIsSwept() { + val pubKey = "e1".repeat(32) + val event = appointment("e3".repeat(32), pubKey, "review") + LocalCache.justConsumeMyOwnEvent(event) + + // Held for the length of the test: see the note in the sweep test above. + @Suppress("UNUSED_VARIABLE") + val keepAlive = LocalCache.getAddressableNoteIfExists(event.address()) + + // Every app switch runs the tier-1 sweep; the version note does not survive it. + LocalCache.notes.remove(event.id) + + val mine = + CalendarAppointmentsFeedFilter(seeEverythingAccount()) + .feed() + .filter { it.event?.pubKey == pubKey } + + assertEquals("the appointment vanished from the feed after the sweep", 1, mine.size) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt index 74347b6d61..99f3195d38 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.calendar -import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarTestAccount.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarTestAccount.kt new file mode 100644 index 0000000000..365bc21e12 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarTestAccount.kt @@ -0,0 +1,61 @@ +/* + * 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.calendar + +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavFilter +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * An account that sees everything. The appointments feed reads exactly two things off an + * [Account] — the calendars top-nav filter and the hidden-user sets — so this is the whole + * surface a calendar test needs. + */ +fun seeEverythingAccount(): Account { + val hiddenUsers = + mockk().also { + every { it.flow } returns + MutableStateFlow( + LiveHiddenUsers( + showSensitiveContent = true, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + ), + ) + } + + val global = + GlobalTopNavFilter( + outboxRelays = MutableStateFlow(emptySet()), + proxyRelays = MutableStateFlow(emptySet()), + relayFeeds = MutableStateFlow(emptySet()), + ) + + return mockk().also { + every { it.liveCalendarsFollowLists } returns MutableStateFlow(global) + every { it.hiddenUsers } returns hiddenUsers + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt new file mode 100644 index 0000000000..1a7920efdc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt @@ -0,0 +1,261 @@ +/* + * 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.calendar + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * The model's cache-backed flows: the calendar picker's list, and the membership filter whose + * "not loaded yet" answer used to be an empty set — which reads as "match nothing" and blanked + * every lens until the kind-31924 arrived. + * + * Real dispatchers throughout, no virtual time. The flows seed on [Dispatchers.Default] (the scan + * walks the whole cache and must stay off the UI thread), so their emissions land on real threads; + * a test-scheduler Main would sit there un-driven while the test blocked waiting for them, and + * every await would expire on the virtual clock first. Nothing here is driven by delays, so + * virtual time buys nothing. + * + * Main is [Dispatchers.Unconfined] rather than [Dispatchers.Default] so that cancelling the + * model's scope finishes synchronously on the cancelling thread. Cancellation is otherwise + * asynchronous, and a continuation that resumed after `resetMain()` threw "Main dispatcher had + * failed to initialize" onto a background thread — which `runTest` then reported against whatever + * unrelated test started next. + * + * `LocalCache` is a process-wide object and JUnit's method order is hash-based, so every method + * uses its own author key and asserts only over that key's events. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarsViewModelFlowTest { + // viewModelScope is Dispatchers.Main, and every flow is a WhileSubscribed stateIn, so nothing + // runs until something subscribes. + @Before + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun calendar( + id: String, + pubKey: String, + dTag: String?, + title: String, + members: List
= emptyList(), + ) = CalendarEvent( + id = id, + pubKey = pubKey, + createdAt = 1_700_000_000L, + tags = + ( + listOfNotNull(dTag?.let { arrayOf("d", it) }, arrayOf("title", title)) + + members.map { arrayOf("a", it.toValue()) } + ).toTypedArray(), + content = "", + sig = "sig", + ) + + private fun appointment( + id: String, + pubKey: String, + dTag: String, + ) = CalendarTimeSlotEvent( + id = id, + pubKey = pubKey, + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("d", dTag), arrayOf("title", "Standup"), arrayOf("start", "1800000000")), + content = "", + sig = "sig", + ) + + /** + * Consumes [calendars] and hands back the notes they landed in, so the caller can hold them + * for the length of the test: `LocalCache.addressables` is a weak cache, and with nothing + * holding a reference a calendar can be collected out from under the assertions. + */ + private fun consume(vararg calendars: CalendarEvent): List { + calendars.forEach { LocalCache.justConsumeMyOwnEvent(it) } + return calendars.map { LocalCache.getAddressableNoteIfExists(it.address()) } + } + + /** + * Runs [block] against a model bound to [pubKey], on real threads, and tears everything down. + * + * The model comes out of a real [ViewModelStore] so that clearing the store cancels its + * `viewModelScope` — the same thing the back stack does when the screen goes. Leaving it + * running outlived `resetMain()`, and the collectors then threw "Main dispatcher had failed + * to initialize" onto a background thread, which `runTest` reports against whichever test + * happens to start next. + */ + private fun withModel( + pubKey: String, + block: suspend (CalendarsViewModel) -> Unit, + ) = runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val store = ViewModelStore() + try { + val feed = FeedContentState(CalendarAppointmentsFeedFilter(seeEverythingAccount()), scope, LocalCache) + val model = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java] + model.init(pubKey, feed) + block(model) + } finally { + store.clear() + scope.cancel() + } + } + + /** Subscribes until [predicate] holds, or fails. Subscribing is what starts the flow. */ + private suspend fun StateFlow.await( + what: String, + predicate: (T) -> Boolean, + ): T = + withTimeoutOrNull(AWAIT_MS) { first(predicate) } + ?: throw AssertionError("$what: gave up, last value was $value") + + /** Subscribes for a window and asserts nothing matching [predicate] ever shows up. */ + private suspend fun StateFlow.never( + what: String, + predicate: (T) -> Boolean, + ) { + val seen = withTimeoutOrNull(SETTLE_MS) { first(predicate) } + if (seen != null) throw AssertionError("$what: saw $seen") + } + + @Test + fun theCalendarPickerListsThisAccountsCalendars() { + val mine = "f1".repeat(32) + val someoneElse = "f2".repeat(32) + val held = + consume( + calendar("f3".repeat(32), mine, "work", "Work"), + calendar("f4".repeat(32), mine, "gigs", "Also mine"), + calendar("f5".repeat(32), someoneElse, "theirs", "Not mine"), + ) + + withModel(mine) { model -> + val listed = model.ownCalendars.await("the picker never loaded") { it.size >= 2 } + + // Sorted by title, case-insensitive, and the other author's calendar is not here. + assertEquals(listOf("Also mine", "Work"), listed.map { it.title() }) + } + + assertEquals("the calendars must stay reachable for the whole test", 3, held.size) + } + + @Test + fun nothingIsFilteredUntilTheUserPicksACalendar() = + withModel("e1".repeat(32)) { model -> + model.filterAddresses.never("\"All\" must not narrow anything") { it != null } + } + + @Test + fun aCalendarThatHasNotLoadedYetDoesNotFilterEverythingOut() = + withModel("e2".repeat(32)) { model -> + model.selectCalendar("a-calendar-that-is-not-in-the-cache") + + // The regression: this used to answer with an empty set, which `applyCalendarFilter` + // reads as "match nothing" — every lens went blank. + model.filterAddresses.never("an unresolved calendar must not blank the screen") { it != null } + } + + @Test + fun pickingACalendarResolvesItsMembers() { + val mine = "e3".repeat(32) + val member = Address(CalendarTimeSlotEvent.KIND, mine, "standup") + LocalCache.justConsumeMyOwnEvent(appointment("e4".repeat(32), mine, "standup")) + val held = consume(calendar("e5".repeat(32), mine, "work", "Work", listOf(member))) + + withModel(mine) { model -> + model.selectCalendar("work") + + assertEquals(setOf(member), model.filterAddresses.await("never resolved") { it != null }) + } + + assertEquals(1, held.size) + } + + @Test + fun pickingAnEmptyCalendarDoesFilterEverythingOut() { + val mine = "e6".repeat(32) + val held = consume(calendar("e7".repeat(32), mine, "empty", "Empty")) + + withModel(mine) { model -> + model.selectCalendar("empty") + + // A calendar that really lists nothing is the one case where an empty set is right. + assertEquals(emptySet
(), model.filterAddresses.await("never resolved") { it != null }) + } + + assertEquals(1, held.size) + } + + @Test + fun aCalendarWithNoDTagCanStillBeFilteredBy() { + val mine = "e8".repeat(32) + val member = Address(CalendarTimeSlotEvent.KIND, mine, "standup") + // Addressed as `31924::` — legal, and the picker offers it, so picking it has to + // narrow the feed. Resolving through a `#d: [""]` filter could not: the event carries no + // literal d tag for the matcher to find, which is why this resolves off the picker's list. + val held = consume(calendar("e9".repeat(32), mine, null, "Untagged", listOf(member))) + + withModel(mine) { model -> + model.selectCalendar("") + + assertEquals(setOf(member), model.filterAddresses.await("never resolved") { it != null }) + } + + assertEquals(1, held.size) + } + + companion object { + /** Real milliseconds — every await crosses [Dispatchers.Default]. */ + private const val AWAIT_MS = 5_000L + + /** How long "it never happens" waits before believing it. */ + private const val SETTLE_MS = 500L + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt new file mode 100644 index 0000000000..770e0add94 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt @@ -0,0 +1,151 @@ +/* + * 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.calendar + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewMode +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.startOfWeek +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +/** + * The paging arithmetic the calendar lenses share, now that it lives on the screen's ViewModel + * instead of being spelled out in each view's click handlers. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarsViewModelTest { + // The model's derived flows are `stateIn(viewModelScope, …)`, and stateIn launches its + // sharing coroutine as soon as the property initializer runs — so simply CONSTRUCTING the + // model touches Dispatchers.Main. Without these, that launch fails on a plain JVM test and + // the failure lands on a background thread, where `runTest` reports it against whichever + // test happens to start next. Clearing the store afterwards cancels the scope, exactly as + // the back stack does when the screen goes. + private var store = ViewModelStore() + + @Before + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + store = ViewModelStore() + } + + @After + fun tearDown() { + store.clear() + Dispatchers.resetMain() + } + + private fun newModel(): CalendarsViewModel = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java] + + @Test + fun aFreshScreenOpensOnToday() { + val today = LocalDate.now() + val model = newModel() + + assertEquals(CalendarsViewMode.FEED, model.viewMode) + assertNull(model.filterDTag.value) + assertEquals(YearMonth.from(today), model.visibleMonth) + assertEquals(today, model.visibleDate) + assertEquals(startOfWeek(today), model.weekStart) + assertEquals(0, model.selectedDayIndex) + assertNull(model.selectedDayKey) + } + + @Test + fun pagingTheMonthDropsTheDaySelectedInTheOldOne() { + val model = newModel() + model.selectedDayKey = LocalDate.of(2025, 1, 15).toEpochDay() + + model.showMonth(model.visibleMonth.plusMonths(1)) + + assertNull("a day picked in January cannot stay selected in February", model.selectedDayKey) + } + + @Test + fun pagingTheWeekMovesSevenDaysAndReturnsTheStripToTheFirstDay() { + val model = newModel() + model.showWeekOf(LocalDate.of(2025, 1, 15)) // a Wednesday + model.selectedDayIndex = 4 + + val before = model.weekStart + model.shiftWeeks(1) + + assertEquals(before.plusDays(7), model.weekStart) + assertEquals(0, model.selectedDayIndex) + } + + @Test + fun showWeekOfSnapsToTheSundayOnOrBeforeTheDate() { + val model = newModel() + + model.showWeekOf(LocalDate.of(2025, 1, 15)) // Wednesday + assertEquals(LocalDate.of(2025, 1, 12), model.weekStart) + assertEquals(DayOfWeek.SUNDAY, model.weekStart.dayOfWeek) + + model.showWeekOf(LocalDate.of(2025, 1, 12)) // already a Sunday: stays put + assertEquals(LocalDate.of(2025, 1, 12), model.weekStart) + } + + @Test + fun theStripsSelectedDateIsTheWeekStartPlusItsIndex() { + val model = newModel() + model.showWeekOf(LocalDate.of(2025, 1, 15)) + model.selectedDayIndex = 3 + + assertEquals(LocalDate.of(2025, 1, 15), model.selectedWeekDate) + } + + @Test + fun pagingTheDayCrossesDaylightSavingWithoutSlipping() { + // US spring-forward lands on 2025-03-09; stepping by milliseconds used to land an hour + // short and repeat a day. LocalDate arithmetic ignores zones, so a step is always a day. + val model = newModel() + model.visibleEpochDay = LocalDate.of(2025, 3, 8).toEpochDay() + + model.shiftDays(1) + assertEquals(LocalDate.of(2025, 3, 9), model.visibleDate) + + model.shiftDays(1) + assertEquals(LocalDate.of(2025, 3, 10), model.visibleDate) + + model.shiftDays(-2) + assertEquals(LocalDate.of(2025, 3, 8), model.visibleDate) + } + + @Test + fun everyLensKeepsItsOwnScrollPosition() { + val model = newModel() + val states = listOf(model.feedListState, model.monthListState, model.weekListState, model.dayListState) + + assertEquals("the lenses must not share one scroll offset", 4, states.distinct().size) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpTagAssemblyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpTagAssemblyTest.kt new file mode 100644 index 0000000000..5045edf354 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpTagAssemblyTest.kt @@ -0,0 +1,96 @@ +/* + * 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.calendar + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks in the tag shape [com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow] assembles. + * + * The `p` count is the load-bearing assertion. Kind 31925 is in `NOTIFICATION_KINDS`, and + * `NotificationFeedFilter.tagsAnEventByUser` returns true for it (a `BaseAddressableEvent` falls + * through every `BaseNoteEvent` branch), so every pubkey tagged here gets a notification row for + * this RSVP. Tagging the appointment's other invitees would therefore hand each of them one row + * per co-invitee per answer change, and buys no routing: `EventBroadcaster` already reaches them + * by following the a-tag into the appointment, whose own p tags it reads as pubkey hints. + */ +class RsvpTagAssemblyTest { + private val host = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val apptId = "43575072239da152afe3d7b5c70ed2beb48db2b10e60c60da45229c09c877d2a" + private val relay = "wss://relay.damus.io/" + + private fun buildRsvp(): Array> { + val target = Address(31923, host, "party") + val hint = RelayUrlNormalizer.normalizeOrNull(relay) + + return CalendarRSVPEvent + .build( + calendarEventAddress = ATag(target, hint), + status = RSVPStatusTag.STATUS.ACCEPTED, + calendarEventId = ETag(apptId, hint, host), + calendarEventAuthor = PTag(host), + dTag = "rsvp-d", + ).tags + } + + @Test + fun tagsTheAppointmentCoordinateAndThePinnedRevision() { + val tags = buildRsvp() + + // The `a` tag follows the host's later edits; the `e` tag pins the revision answered. + assertEquals("31923:$host:party", tags.single { it[0] == "a" }[1]) + assertEquals(apptId, tags.single { it[0] == "e" }[1]) + assertEquals("rsvp-d", tags.single { it[0] == "d" }[1]) + assertEquals("accepted", tags.single { it[0] == "status" }[1]) + } + + @Test + fun tagsExactlyOnePubkeyAndItIsTheHost() { + val pTags = buildRsvp().filter { it[0] == "p" } + + assertEquals(listOf(host), pTags.map { it[1] }) + } + + @Test + fun theHostRemainsTheFirstPubkeySoCalendarEventAuthorResolves() { + val tags = buildRsvp() + val event = + CalendarRSVPEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1700000000, + tags = tags, + content = "", + sig = "00".repeat(64), + ) + + assertEquals(host, event.calendarEventAuthor()?.pubKey) + assertEquals(apptId, event.calendarEventId()?.eventId) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt new file mode 100644 index 0000000000..815b6e933b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [addressedAuthors] is what lets [EventBroadcaster] reach an addressed author's inbox relays + * without a cache hit. The rest of the a-tag branch is nested inside + * `getAddressableNoteIfExists(addressId)`, so before this existed an RSVP answering an + * appointment this device had never cached went only to the sender's own outbox — the host it + * was replying to never received it. + */ +class AddressedAuthorRoutingTest { + private val host = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val other = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + private val relay = "wss://relay.damus.io/" + + private fun rsvp(vararg tags: Array) = + CalendarRSVPEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + private fun calendar(vararg tags: Array) = + CalendarEvent( + id = "22".repeat(32), + pubKey = host, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + @Test + fun `an RSVP addresses the appointment's host`() { + val event = rsvp(arrayOf("a", "31923:$host:party", relay), arrayOf("status", "accepted")) + + assertEquals(setOf(host), addressedAuthors(event)) + } + + @Test + fun `the host is found without a relay hint on the tag`() { + // The hint is optional; the coordinate alone must still identify who to deliver to. + val event = rsvp(arrayOf("a", "31923:$host:party"), arrayOf("status", "declined")) + + assertEquals(setOf(host), addressedAuthors(event)) + } + + @Test + fun `a day-slot appointment resolves the same way`() { + val event = rsvp(arrayOf("a", "31922:$host:all-day"), arrayOf("status", "tentative")) + + assertEquals(setOf(host), addressedAuthors(event)) + } + + @Test + fun `a calendar addresses every appointment author it lists`() { + val event = + calendar( + arrayOf("d", "my-calendar"), + arrayOf("a", "31923:$host:party", relay), + arrayOf("a", "31922:$other:standup"), + ) + + assertEquals(setOf(host, other), addressedAuthors(event)) + } + + @Test + fun `repeated authors collapse to one delivery target`() { + // A calendar usually lists many appointments by the same host; their inbox is one target. + val event = + calendar( + arrayOf("d", "my-calendar"), + arrayOf("a", "31923:$host:party"), + arrayOf("a", "31923:$host:standup"), + arrayOf("a", "31922:$host:all-day"), + ) + + assertEquals(setOf(host), addressedAuthors(event)) + } + + @Test + fun `a malformed coordinate is dropped rather than throwing`() { + val event = + rsvp( + arrayOf("a", "not-a-coordinate"), + arrayOf("a", "31923:$host:party"), + arrayOf("status", "accepted"), + ) + + assertEquals(setOf(host), addressedAuthors(event)) + } + + @Test + fun `an event with no a tags addresses nobody`() { + assertTrue(addressedAuthors(rsvp(arrayOf("status", "accepted"))).isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AppSpecificDataRepublishTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AppSpecificDataRepublishTest.kt index 95854ab122..94d1225985 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AppSpecificDataRepublishTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AppSpecificDataRepublishTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import android.os.Looper +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip01Core.core.nextCreatedAtToSupersede import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzDmNotificationResolutionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzDmNotificationResolutionTest.kt index 5e588e814d..1ede333c8f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzDmNotificationResolutionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzDmNotificationResolutionTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import android.os.Looper import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 1621905d52..30766d0f2c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import android.os.Looper import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index 87e693af34..cb11cc64d2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent @@ -158,9 +160,7 @@ class DvmHeartbeatTest { sig = "cc".repeat(64), ) - LocalCache.justConsume(appDef("dvm-x"), null, true) - LocalCache.justConsume(nonDiscoveryApp, null, true) - val consumed = + val secondSubscriptionApp = AppDefinitionEvent( id = "c2".repeat(32), pubKey = appDefPubKey, @@ -169,7 +169,18 @@ class DvmHeartbeatTest { content = """{"name":"Paid2","subscription":true}""", sig = "cc".repeat(64), ) - LocalCache.justConsume(consumed, null, true) + + // `addressables` holds notes by WeakReference, so a GC between consuming these and + // scanning for them clears the lot and the scan returns nothing — which is exactly how + // this failed on CI, where the heap is tighter than a dev box's. A fixture has to hold + // what it expects to find, the same discipline LargeCacheAddressableFilterTest spells + // out; the forced GC below is what keeps that honest rather than assumed. + val held = + listOf(alive, appDef("dvm-x"), subscriptionApp, nonDiscoveryApp, secondSubscriptionApp) + .onEach { LocalCache.justConsume(it, null, true) } + .map { LocalCache.getOrCreateNote(it) } + + System.gc() val scanned = LocalCache.cachedDvmAnnouncements() @@ -178,5 +189,6 @@ class DvmHeartbeatTest { assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" }) assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned) assertTrue("capped", scanned.size <= 100) + assertEquals("the fixture notes must stay reachable for the whole test", 5, held.size) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt index 012478cb99..609857aaf5 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.HomeFeedType +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt index 865f6c1457..6bf410abc4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlinx.serialization.json.Json diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt index 8eabafc9e1..5d64a594db 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import org.junit.Assert.assertEquals diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt index 733bbd5149..ffa297752b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -45,7 +46,20 @@ import java.util.concurrent.atomic.AtomicInteger class NwcInfoCacheTest { private var clock = 1_000L + + /** + * Unconfined so a caller runs inline up to its first real suspension, but [NwcInfoCache] still + * hands its fetches to [Dispatchers.IO] — so this scope owns real background work and must not + * outlive the test. A fetch still running afterwards has no + * [kotlinx.coroutines.CoroutineExceptionHandler] in context, and anything it throws lands on + * the JVM-wide handler, where `runTest` reports it against whichever unrelated test class + * starts next. + */ private val scope = CoroutineScope(Dispatchers.Unconfined) + + @After + fun tearDown() = scope.cancel() + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! private fun uri(pubkey: String) = Nip47WalletConnect.Nip47URINorm(pubkey, relay, "secret") diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt index 43838456fc..e3ae4ba5a2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.model.nip51Lists import android.os.Looper +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -36,6 +36,7 @@ import io.mockk.unmockkStatic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -56,6 +57,16 @@ import org.junit.Test * [LocalCache]. */ class BlockedRelayListAddRelayTest { + /** + * Handed to every [Fixture] so [tearDown] can cancel it. [BlockedRelayListState] starts an + * eager `stateIn` and a forever `collect` on whatever scope it is handed, so a scope left alive + * here outlives the test: its coroutines keep decrypting on [Dispatchers.IO] with no + * [kotlinx.coroutines.CoroutineExceptionHandler] in context, and anything they throw reaches + * the JVM-wide handler — where `runTest` reports it against whichever *unrelated* test class + * happens to start next, as `UncaughtExceptionsBeforeTest`. + */ + private val stateScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com") private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com") @@ -70,10 +81,15 @@ class BlockedRelayListAddRelayTest { @After fun tearDown() { + // Before unmocking: a collector still running after Looper goes back to its + // unmocked stub reads `null == null` as "the main thread" and throws. + stateScope.cancel() unmockkStatic(Looper::class) } - private class Fixture { + private class Fixture( + scope: CoroutineScope, + ) { val keyPair = KeyPair() val signer = NostrSignerInternal(keyPair) val decryptionCache = BlockedRelayListDecryptionCache(signer) @@ -88,7 +104,7 @@ class BlockedRelayListAddRelayTest { signer = signer, cache = LocalCache, decryptionCache = decryptionCache, - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + scope = scope, settings = settings, ) @@ -101,7 +117,7 @@ class BlockedRelayListAddRelayTest { @Test fun blockingTheFirstRelayCreatesTheList() = runTest { - val f = Fixture() + val f = Fixture(stateScope) val event = f.state.addRelay(paid) @@ -113,7 +129,7 @@ class BlockedRelayListAddRelayTest { @Test fun blockingASecondRelayKeepsTheFirst() = runTest { - val f = Fixture() + val f = Fixture(stateScope) f.land(f.state.addRelay(paid)) val event = f.state.addRelay(alsoPaid) @@ -124,7 +140,7 @@ class BlockedRelayListAddRelayTest { @Test fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() = runTest { - val f = Fixture() + val f = Fixture(stateScope) f.land(f.state.addRelay(paid)) val event = f.state.addRelay(paid) @@ -140,7 +156,7 @@ class BlockedRelayListAddRelayTest { @Test fun blockedRelaysStayInEncryptedPrivateTags() = runTest { - val f = Fixture() + val f = Fixture(stateScope) f.land(f.state.addRelay(paid)) val event = f.state.addRelay(alsoPaid) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt index 0e8918b99f..a8872f4ad3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -108,7 +108,7 @@ class FavoriteAlgoFeedTopNavFilterTest { // passing a LocalCache is only needed because the method demands it; // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. - val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + val set = filter.startValue(com.vitorpamplona.amethyst.commons.model.cache.LocalCache) assertTrue(set.requestIds.isEmpty()) } @@ -124,7 +124,7 @@ class FavoriteAlgoFeedTopNavFilterTest { requestId = "9".repeat(64), ) - val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + val set = filter.startValue(com.vitorpamplona.amethyst.commons.model.cache.LocalCache) assertTrue(set.requestIds == setOf("9".repeat(64))) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt index a71ab04f88..c8d48f2d61 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt @@ -43,6 +43,7 @@ class ShareIntentRoutingTest { assertEquals(ShareTarget.PICTURE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsPictureAlias")) assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias")) assertEquals(ShareTarget.VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsVideoAlias")) + assertEquals(ShareTarget.SCAN_QR, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ScanQrCodeAlias")) } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareTargetManifestTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareTargetManifestTest.kt index a3e816ea30..2b5438b183 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareTargetManifestTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareTargetManifestTest.kt @@ -73,6 +73,7 @@ class ShareTargetManifestTest { ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME, ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME, ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME, + ShareIntentRouting.SCAN_QR_ALIAS_SIMPLE_NAME, ) expected.forEach { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/crashreports/ReportAssemblerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/crashreports/ReportAssemblerTest.kt index f618092db0..46720c57ac 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/crashreports/ReportAssemblerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/crashreports/ReportAssemblerTest.kt @@ -35,7 +35,7 @@ class ReportAssemblerTest { val report = ReportAssembler().buildReport(e, "main") assertTrue(report.length < 2_000) - assertTrue(report.startsWith("IllegalArgumentException: ")) + assertTrue(report.startsWith("java.lang.IllegalArgumentException: ")) assertTrue(report.contains("Navigation destination that matches route")) assertTrue(report.trimEnd().endsWith("```")) } @@ -48,7 +48,21 @@ class ReportAssemblerTest { val report = ReportAssembler().buildReport(e, "main") assertTrue(report.contains("java.lang.RuntimeException: boom")) - assertTrue(report.contains("com.example.Foo.bar(Foo.kt:42)")) + // The "at " prefix is load-bearing: retrace only rewrites frames it recognises. + assertTrue(report.contains(" at com.example.Foo.bar(Foo.kt:42)")) assertTrue(report.contains("java.lang.IllegalStateException: root cause")) } + + @Test + fun headlineNamesTheExceptionClassInFullSoItCanBeRetraced() { + // The headline is the line a maintainer skims and dedups on. Release builds are + // obfuscated, and `retrace` can only restore a class name it can resolve — a bare + // simple name has no package, so the headline has to carry the fully qualified one. + val e = IllegalStateException("boom") + e.stackTrace = arrayOf(StackTraceElement("com.example.Foo", "bar", "Foo.kt", 42)) + + val report = ReportAssembler().buildReport(e, "main") + + assertTrue(report.startsWith("java.lang.IllegalStateException: ")) + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt index 7b2783a490..ba5ce493c0 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.os.Looper -import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt new file mode 100644 index 0000000000..dbea72be3d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt @@ -0,0 +1,135 @@ +/* + * 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.service.pow + +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The PoW-queue and resource-usage files survived the move off Jackson. + * + * Both hold state an update must not lose: `pending_pow_jobs.json` is posts the user + * already hit send on that are still mining, and the usage file backs the + * high-consumption alert. Each literal below is the exact string the Jackson build + * wrote for the object beside it, captured before the switch. + */ +class PowAndUsageFileFormatTest { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val powSample = + PowJobsFile( + version = 1, + jobs = + listOf( + PersistedPoWJob( + id = "j1", + accountPubkey = "pk1", + kind = 1, + difficulty = 21, + templateJson = """{"t":1}""", + replayType = "broadcast", + relayUrls = listOf("wss://a"), + extraEventsJson = emptyList(), + publishAtSec = null, + recipientPubkeys = listOf("r1"), + wrapExpirationDelta = null, + createdAtSec = 999L, + ), + ), + ) + + private val usageSample = + ResourceUsageStore.UsageFile( + version = 1, + days = mapOf("20260919" to mapOf("relay.a" to 12L, "relay.b" to 34L)), + lastAlertAtSec = 555L, + alertsOptOut = true, + ) + + @Test + fun powJobsWriteTheBytesJacksonWrote() { + assertEquals(POW_JACKSON_OUTPUT, json.encodeToString(powSample)) + } + + @Test + fun powJobsFromTheJacksonBuildStillLoad() { + val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) + + assertEquals(1, loaded.jobs.size) + val job = loaded.jobs.first() + assertEquals("j1", job.id) + assertEquals(21, job.difficulty) + assertEquals("broadcast", job.replayType) + assertEquals(listOf("r1"), job.recipientPubkeys) + assertEquals(999L, job.createdAtSec) + assertEquals(null, job.publishAtSec) + } + + @Test + fun usageWritesTheBytesJacksonWrote() { + assertEquals(USAGE_JACKSON_OUTPUT, json.encodeToString(usageSample)) + } + + @Test + fun usageFromTheJacksonBuildStillLoads() { + val loaded = json.decodeFromString(USAGE_JACKSON_OUTPUT) + + assertEquals(mapOf("relay.a" to 12L, "relay.b" to 34L), loaded.days["20260919"]) + assertEquals(555L, loaded.lastAlertAtSec) + assertTrue(loaded.alertsOptOut) + } + + @Test + fun bothReadersTolerateKeysFromANewerBuild() { + val pow = POW_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":[1]""") + val usage = USAGE_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":{"a":1}""") + + assertEquals( + "j1", + json + .decodeFromString(pow) + .jobs + .first() + .id, + ) + assertEquals(555L, json.decodeFromString(usage).lastAlertAtSec) + } + + companion object { + private const val POW_JACKSON_OUTPUT = + """{"version":1,"jobs":[{"id":"j1","accountPubkey":"pk1","kind":1,"difficulty":21,""" + + """"templateJson":"{\"t\":1}","replayType":"broadcast","relayUrls":["wss://a"],""" + + """"extraEventsJson":[],"publishAtSec":null,"recipientPubkeys":["r1"],""" + + """"wrapExpirationDelta":null,"createdAtSec":999}]}""" + + private const val USAGE_JACKSON_OUTPUT = + """{"version":1,"days":{"20260919":{"relay.a":12,"relay.b":34}},""" + + """"lastAlertAtSec":555,"alertsOptOut":true}""" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt index 7ac2c9c86d..1e30d86b88 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Address import io.mockk.every import io.mockk.mockk diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt index f8ea473932..bc639cabb2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.workouts.health +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -225,6 +226,49 @@ class WorkoutMergerTest { assertEquals(1800, merged.durationSeconds) } + @Test + fun skeletonsWithoutMetricsGroupExactlyAsTheFullyLoadedOnesDo() { + // The progressive read merges twice: once over session skeletons, to get something on + // screen, and again once each session's metrics have been aggregated. Grouping keys on + // type, start and duration only, so both passes must produce the same shape — otherwise + // the dashboard would reshuffle its workout list when the metrics land. + val loaded = + listOf( + workout("a", startTimeEpochSeconds = 0, durationSeconds = 600, distanceMeters = 2_000.0, calories = 150, avgHeartRate = 140, steps = 1_800), + workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600, distanceMeters = 2_100.0, calories = 160, avgHeartRate = 150, steps = 1_900), + workout("c", exercise = ExerciseType.CYCLING, startTimeEpochSeconds = 20_000, durationSeconds = 3600, distanceMeters = 25_000.0), + ) + val skeletons = + loaded.map { + it.copy(distanceMeters = null, calories = null, avgHeartRate = null, maxHeartRate = null, steps = null, elevationGainMeters = null) + } + + val early = WorkoutMerger.mergeCloseWorkouts(skeletons) + val late = WorkoutMerger.mergeCloseWorkouts(loaded) + + assertEquals(late.map { it.id }, early.map { it.id }) + assertEquals(late.map { it.sessionCount }, early.map { it.sessionCount }) + assertEquals(late.map { it.startTimeEpochSeconds }, early.map { it.startTimeEpochSeconds }) + assertEquals(late.map { it.durationSeconds }, early.map { it.durationSeconds }) + } + + @Test + fun aggregatingPerSessionBeforeMergingIsWhatSumsASplitEffort() { + // Why the progressive read still aggregates each raw session and merges afterwards, + // rather than aggregating the merged span once: the merged workout's metrics are the + // sum of its members', and the span between them is not part of the effort. + val first = workout("a", startTimeEpochSeconds = 0, durationSeconds = 600, distanceMeters = 2_000.0, calories = 150, steps = 1_800) + val second = workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600, distanceMeters = 2_100.0, calories = 160, steps = 1_900) + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single() + + assertEquals(4_100.0, merged.distanceMeters!!, 0.001) + assertEquals(310, merged.calories) + assertEquals(3_700, merged.steps) + // The 10-minute break between them is excluded: duration is the sum, not end minus start. + assertEquals(1200, merged.durationSeconds) + } + @Test fun chainMergesEvenWhenAdjacentGapsAreShortButEndsAreFarApart() { // 45-min sessions each starting 50 min apart: consecutive gaps are 5 min, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt index 309e8f338f..95b20c73c2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt @@ -22,10 +22,12 @@ package com.vitorpamplona.amethyst.ui import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import java.net.URLEncoder class UriToRouteTest { private val account = mockk() @@ -58,6 +60,92 @@ class UriToRouteTest { assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account)) } + // The QR scanner shows a "can't open this" sheet for anything uriToRoute returns null for, so + // what does and does not route here decides what that sheet ever has to explain. + + @Test + fun rawWalletConnectUrisRouteWithoutTheDlnwcWrapper() { + // Not just the `dlnwc?value=` deep-link form: a wallet's QR code holds the bare URI, and + // the fallback at the end of uriToRoute is what catches it. + assertEquals( + Route.WalletAddNwc(NWC_URI), + uriToRoute(NWC_URI, account), + ) + } + + @Test + fun everyWalletConnectSchemeSpellingRoutes() { + assertEquals( + Route.WalletAddNwc(NWC_URI_NO_PLUS), + uriToRoute(NWC_URI_NO_PLUS, account), + ) + } + + @Test + fun walletConnectDeepLinksStillUnwrapTheValueParameter() { + // The value has to be percent-encoded, as a real deep link's would be: left raw, its own + // `&secret=` reads as a parameter of the OUTER uri and the value comes back truncated. + val encoded = URLEncoder.encode(NWC_URI, Charsets.UTF_8.name()) + + assertEquals( + Route.WalletAddNwc(NWC_URI), + uriToRoute("dlnwc?value=$encoded", account), + ) + } + + @Test + fun walletConnectDeepLinksRouteInEverySpellingIsWalletConnectRouteAccepts() { + // isWalletConnectRoute() accepts three spellings, so all three have to survive the parse + // behind it. They did not: `java.net.URI` calls a scheme followed by anything but `/` an + // *opaque* uri and reports it as having no query at all, so the one-colon form lost its + // `value=` and came back to the user as "that uri was invalid" -- while the `//` spelling + // of the very same link worked. Only the bare form was covered here before. + val encoded = URLEncoder.encode(NWC_URI, Charsets.UTF_8.name()) + + listOf( + "dlnwc?value=$encoded", + "amethyst+walletconnect:dlnwc?value=$encoded", + "amethyst+walletconnect://dlnwc?value=$encoded", + ).forEach { deepLink -> + assertEquals(deepLink, Route.WalletAddNwc(NWC_URI), uriToRoute(deepLink, account)) + } + } + + @Test + fun theLauncherShortcutOpensTheScannerDirectly() { + // res/xml/shortcuts.xml fires `amethyst:scanqr`. If this stops resolving, long-pressing the + // app icon silently lands on the home feed instead of the camera. + val signed = mockk() + every { signed.signer } returns mockk { every { pubKey } returns PUBKEY_HEX } + + val expected = Route.QRDisplay(PUBKEY_HEX, startScanning = true) + // The shortcut fires our own scheme; the other two stay accepted so a shortcut pinned by + // an older build keeps working. + assertEquals(expected, uriToRoute("amethyst:scanqr", signed)) + assertEquals(expected, uriToRoute("nostr:scanqr", signed)) + assertEquals(expected, uriToRoute("scanqr", signed)) + } + + @Test + fun theShortcutRouteIsNotConfusedWithOtherNostrUris() { + assertNull(uriToRoute("nostr:scanqrcode", account)) + } + + @Test + fun bunkerUrisDoNotRouteAnywhere() { + // Amethyst *publishes* bunker:// addresses (it is the remote signer); it has no screen that + // consumes one. The NIP-46 signer screen pairs nostrconnect:// offers only. Until that + // changes, a scanned bunker:// belongs in the scanner's explanation sheet, and the sheet + // must not tell the user to take it somewhere that cannot accept it. + assertNull(uriToRoute("bunker://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=abc", account)) + } + + companion object { + private const val PUBKEY_HEX = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private const val NWC_URI = "nostr+walletconnect://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=$PUBKEY_HEX" + private const val NWC_URI_NO_PLUS = "nostrwalletconnect://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=$PUBKEY_HEX" + } + @Test fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() { val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/IsSameRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/IsSameRouteTest.kt new file mode 100644 index 0000000000..89a49f2365 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/IsSameRouteTest.kt @@ -0,0 +1,58 @@ +/* + * 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.navigation + +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `isSameRoute` is what stops a deep link from stacking a second copy of the screen already on + * top. The QR launcher shortcut routes to `QRDisplay(startScanning = true)`, which is an action + * ("open the camera"), not a place: if the user closed the scanner and is still sitting on that + * entry, the shortcut must fire again rather than be swallowed as a duplicate. + */ +class IsSameRouteTest { + private val me = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + @Test + fun scanShortcutOnTopOfItselfIsNotADuplicate() { + val scan = Route.QRDisplay(me, startScanning = true) + assertFalse(isSameRoute(scan, Route.QRDisplay(me, startScanning = true))) + } + + @Test + fun scanShortcutOnTopOfTheCodeScreenIsNotADuplicate() { + assertFalse(isSameRoute(Route.QRDisplay(me), Route.QRDisplay(me, startScanning = true))) + } + + @Test + fun showingTheSameCodeTwiceIsStillADuplicate() { + assertTrue(isSameRoute(Route.QRDisplay(me), Route.QRDisplay(me))) + } + + @Test + fun ordinaryRoutesStillDedupe() { + assertTrue(isSameRoute(Route.Profile(me), Route.Profile(me))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt new file mode 100644 index 0000000000..c5cac66bbb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt @@ -0,0 +1,116 @@ +/* + * 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.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.navOptions +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A nav-bar tap must land on the tab itself, never on whatever the user had pushed on top of one. + * + * The phone bottom bar gets this for free — it hides itself off tab roots, so it can only ever be + * tapped from one. The large-screen navigation rail stays on screen the whole time, which is where + * the gap showed: tapping Home from a thread three screens deep came back to that thread instead of + * the feed. + * + * Two rules keep that from happening, one per test below: + * + * 1. Pushes above a tab root are dropped, unsaved, on the way out — anything saved is eligible to be + * replayed by a later `restoreState`. + * 2. Home is never restored onto. `popUpTo(Home) { inclusive = false; saveState = true }` files the + * popped entries under the popUpTo target's own destination id as well as the popped tab's — see + * the `if (!inclusive)` branch of `NavControllerImpl.executePopOperations` — so + * `navigate(Home) { restoreState = true }` handed Home back the stack the user had left behind in + * some *other* tab. Every other tab restores as before; that is what keeps its ViewModelStore. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NavBottomBarStackTest { + /** A back-stack entry that is, or isn't, a tab root as far as [isBottomNavRoot] can tell. */ + private fun entry(isTabRoot: Boolean): NavBackStackEntry = + mockk(relaxed = true) { + every { savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) } returns isTabRoot + } + + /** + * Taps [route] on a controller whose back stack holds none of the nav-bar destinations, and + * returns the options the resulting navigate was built with. + */ + private fun TestScope.optionsForTapping(route: Route): NavOptions { + val options = slot Unit>() + val controller = + mockk(relaxed = true) { + every { currentBackStackEntry } returns entry(isTabRoot = true) + every { navigate(route, capture(options)) } returns Unit + } + + Nav(controller, this).navBottomBar(route) + advanceUntilIdle() + + return navOptions(options.captured) + } + + @Test + fun dropsWhatTheUserPushedOnTopOfTheTabBeforeSwitchingTabs() = + runTest { + // Home > Note > Profile: two pushes sitting on a tab root. + val controller = + mockk(relaxed = true) { + every { currentBackStackEntry } returnsMany + listOf(entry(isTabRoot = false), entry(isTabRoot = false), entry(isTabRoot = true)) + every { popBackStack() } returns true + } + + Nav(controller, this).navBottomBar(Route.Message) + advanceUntilIdle() + + // Exactly the two pushes and no more: the tab root itself stays, so the navigate below + // saves a tab root rather than a branch that could be replayed later. + verify(exactly = 2) { controller.popBackStack() } + } + + @Test + fun neverRestoresASavedStackOntoHome() = + runTest { + assertFalse(optionsForTapping(Route.Home).shouldRestoreState()) + } + + @Test + fun restoresTheSavedStackOfEveryOtherTab() = + runTest { + assertTrue(optionsForTapping(Route.Message).shouldRestoreState()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpus.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpus.kt new file mode 100644 index 0000000000..a20f15de17 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpus.kt @@ -0,0 +1,378 @@ +/* + * 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.qrcode.scanner + +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.google.zxing.qrcode.encoder.Encoder +import java.awt.Color +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.awt.image.ConvolveOp +import java.awt.image.Kernel +import java.awt.image.RescaleOp +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.random.Random + +/** + * Deterministic corpus of degraded QR codes, for measuring how well a decoder actually reads the + * codes people point phones at. + * + * Why it lives in the JVM test source set: it is pure `java.awt`, no Android, so the corpus can be + * generated and the *old* decoder (ZXing-Java) measured on any machine, with no device or + * emulator. The new decoder is native and Android-only, so it is measured by the instrumented + * `QrDecodeCorpusTest` against the exact same images, exported from here. + * + * Everything is seeded, so two runs produce byte-identical images and the two measurements are + * comparable. + */ +object QrCorpus { + /** A throwaway pubkey. Never a real key — these images get committed. */ + private const val PUBKEY = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + /** + * The payload lengths that matter, shortest to longest. Length drives the QR version, which + * drives module size at a fixed physical size — and small modules, far more than error + * correction, are what defeats a camera at arm's length. + */ + private val PAYLOADS = + listOf( + "npub" to "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq", + "nprofile" to "nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq", + "nevent" to "nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq", + ) + + /** One generated image plus what it should decode to and which hazard it represents. */ + data class Fixture( + val name: String, + val category: String, + val expected: String, + val image: BufferedImage, + ) + + /** + * Every fixture, in a stable order. + * + * Categories are the *reasons* scans fail in the field, not arbitrary transforms: a code seen + * out of focus, off-axis, on a dim or glossy surface, photographed off another screen, or + * simply too far away to resolve. + */ + fun all(): List { + val fixtures = mutableListOf() + + PAYLOADS.forEach { (label, payload) -> + val (base, modules) = renderWithModuleCount(payload, moduleSize = MODULE_PX) + + fun add( + category: String, + image: BufferedImage, + ) = fixtures.add(Fixture("$category-$label", category, payload, image)) + + add("clean", base) + // A quarter-module and a half-module of blur. Both are expressed in modules so that + // changing MODULE_PX cannot quietly re-tune the corpus's difficulty -- and they are + // chosen to straddle the old decoder's limit, because a category it fails outright + // can only ever show an improvement, never catch a regression. + add("blur", blur(base, radius = MODULE_PX / 4)) + add("blur-heavy", blur(base, radius = MODULE_PX / 2)) + add("tilt15", rotate(base, degrees = 15.0)) + add("tilt30", rotate(base, degrees = 30.0)) + add("tilt45", rotate(base, degrees = 45.0)) + add("perspective", perspective(base, strength = 0.28)) + add("low-contrast", contrast(base, scale = 0.30f)) + add("very-low-contrast", contrast(base, scale = 0.12f)) + add("inverted", invert(base)) + add("glare", glare(base)) + add("noise", noise(base, sigma = 46.0)) + add("moire", moire(base)) + // Sized by pixels-per-module, not absolute pixels: that ratio is what decides + // whether a code is resolvable at all, and it is the whole reason a long payload is + // harder to scan than a short one at the same physical size. + add("far-3px", shrinkInFrame(base, modules, pxPerModule = 3.0)) + add("far-2px", shrinkInFrame(base, modules, pxPerModule = 2.0)) + add("far-1.5px", shrinkInFrame(base, modules, pxPerModule = 1.5)) + } + + return fixtures + } + + /** Renders [payload] exactly the way `QrCodeDrawer` does: ECC level Q, 4-module quiet zone. */ + fun render( + payload: String, + moduleSize: Int, + ): BufferedImage = renderWithModuleCount(payload, moduleSize).first + + /** As [render], plus the symbol's module count — what pixels-per-module is measured against. */ + fun renderWithModuleCount( + payload: String, + moduleSize: Int, + ): Pair { + val code = + Encoder.encode( + payload, + ErrorCorrectionLevel.Q, + mapOf( + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, + ), + ) + val matrix = code.matrix!! + val quiet = 4 + val side = (matrix.width + quiet * 2) * moduleSize + + val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB) + val g = image.createGraphics() + g.color = Color.WHITE + g.fillRect(0, 0, side, side) + g.color = Color.BLACK + for (y in 0 until matrix.height) { + for (x in 0 until matrix.width) { + if (matrix[x, y] == 1.toByte()) { + g.fillRect((x + quiet) * moduleSize, (y + quiet) * moduleSize, moduleSize, moduleSize) + } + } + } + g.dispose() + return image to matrix.width + } + + // ------------------------------------------------------------------ + // degradations + // ------------------------------------------------------------------ + + /** Out of focus — the single most common reason a frame fails to decode. */ + private fun blur( + source: BufferedImage, + radius: Int, + ): BufferedImage { + val size = radius * 2 + 1 + val weight = 1f / (size * size) + val kernel = Kernel(size, size, FloatArray(size * size) { weight }) + val padded = pad(source, radius) + val out = BufferedImage(padded.width, padded.height, BufferedImage.TYPE_INT_RGB) + ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null).filter(padded, out) + return out + } + + /** Held at an angle. */ + private fun rotate( + source: BufferedImage, + degrees: Double, + ): BufferedImage { + val radians = Math.toRadians(degrees) + val cos = abs(cos(radians)) + val sin = abs(sin(radians)) + val w = (source.width * cos + source.height * sin).roundToInt() + val h = (source.width * sin + source.height * cos).roundToInt() + + val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB) + val g = out.createGraphics() + g.color = Color.WHITE + g.fillRect(0, 0, w, h) + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR, + ) + g.translate(w / 2.0, h / 2.0) + g.rotate(radians) + g.translate(-source.width / 2.0, -source.height / 2.0) + g.drawImage(source, 0, 0, null) + g.dispose() + return out + } + + /** + * Seen off-axis — a code on a wall photographed from the side. A true projective warp, which + * [java.awt.geom.AffineTransform] cannot express, so it is mapped by hand. + */ + private fun perspective( + source: BufferedImage, + strength: Double, + ): BufferedImage { + val w = source.width + val h = source.height + val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB) + + for (y in 0 until h) { + // Rows further "away" sample a narrower slice, which is what makes modules shrink + // toward one edge exactly as a real off-axis photo does. + val t = y.toDouble() / (h - 1) + val squeeze = 1.0 - strength * (1.0 - t) + for (x in 0 until w) { + val centered = x - w / 2.0 + val sourceX = (centered / squeeze + w / 2.0).roundToInt() + val rgb = + if (sourceX in 0 until w) source.getRGB(sourceX, y) else WHITE_RGB + out.setRGB(x, y, rgb) + } + } + return out + } + + /** A dim screen, or e-ink, or a washed-out print: black and white move toward each other. */ + private fun contrast( + source: BufferedImage, + scale: Float, + ): BufferedImage { + val offset = 255f * (1f - scale) / 2f + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + RescaleOp(scale, offset, null).filter(source, out) + return out + } + + /** Light-on-dark, as a dark-mode client or an inverted print produces. */ + private fun invert(source: BufferedImage): BufferedImage { + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + for (y in 0 until source.height) { + for (x in 0 until source.width) { + out.setRGB(x, y, source.getRGB(x, y).inv() and 0xFFFFFF) + } + } + return out + } + + /** A highlight burning out one corner — glass, gloss, or a ceiling light. */ + private fun glare(source: BufferedImage): BufferedImage { + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + val cx = source.width * 0.68 + val cy = source.height * 0.30 + val radius = min(source.width, source.height) * 0.30 + + for (y in 0 until source.height) { + for (x in 0 until source.width) { + val distance = hypot(x - cx, y - cy) + val lift = if (distance >= radius) 0.0 else (1.0 - distance / radius) * 235.0 + out.setRGB(x, y, liftPixel(source.getRGB(x, y), lift)) + } + } + return out + } + + /** Sensor noise in poor light. */ + private fun noise( + source: BufferedImage, + sigma: Double, + ): BufferedImage { + val random = Random(SEED) + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + for (y in 0 until source.height) { + for (x in 0 until source.width) { + val delta = gaussian(random) * sigma + out.setRGB(x, y, liftPixel(source.getRGB(x, y), delta)) + } + } + return out + } + + /** Photographed off another screen: a faint scanline beat over the modules. */ + private fun moire(source: BufferedImage): BufferedImage { + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + for (y in 0 until source.height) { + val band = sin(y * 0.9) * 34.0 + for (x in 0 until source.width) { + out.setRGB(x, y, liftPixel(source.getRGB(x, y), band)) + } + } + return out + } + + /** + * Too far away: the symbol is scaled until each module is [pxPerModule] pixels across, + * then centred in a full-size frame. + * + * This is the category the old scanner could do nothing about, because it had no zoom — and + * the one auto-zoom exists for. + */ + private fun shrinkInFrame( + source: BufferedImage, + modules: Int, + pxPerModule: Double, + ): BufferedImage { + val targetPx = (modules * pxPerModule).roundToInt() + val frame = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + val g = frame.createGraphics() + g.color = Color.WHITE + g.fillRect(0, 0, frame.width, frame.height) + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR, + ) + val x = (frame.width - targetPx) / 2 + val y = (frame.height - targetPx) / 2 + g.drawImage(source, x, y, targetPx, targetPx, null) + g.dispose() + return frame + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + /** + * Pixels per module in the source renders. + * + * Four is ample to decode from (the hard fixtures shrink from here) and keeps the corpus that + * has to live in the repository to a few hundred kilobytes rather than a few megabytes. + */ + private const val MODULE_PX = 4 + + private const val SEED = 20260915L + private const val WHITE_RGB = 0xFFFFFF + + private fun pad( + source: BufferedImage, + margin: Int, + ): BufferedImage { + val out = BufferedImage(source.width + margin * 2, source.height + margin * 2, BufferedImage.TYPE_INT_RGB) + val g = out.createGraphics() + g.color = Color.WHITE + g.fillRect(0, 0, out.width, out.height) + g.drawImage(source, margin, margin, null) + g.dispose() + return out + } + + private fun liftPixel( + rgb: Int, + delta: Double, + ): Int { + fun channel(shift: Int): Int { + val value = (rgb shr shift) and 0xFF + return max(0, min(255, (value + delta).roundToInt())) + } + return (channel(16) shl 16) or (channel(8) shl 8) or channel(0) + } + + /** Box-Muller, so the noise is normally distributed rather than uniform. */ + private fun gaussian(random: Random): Double { + val u1 = random.nextDouble().coerceAtLeast(1e-12) + val u2 = random.nextDouble() + return sqrt(-2.0 * ln(u1)) * cos(2.0 * Math.PI * u2) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpusBaselineTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpusBaselineTest.kt new file mode 100644 index 0000000000..9b53c8db0d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrCorpusBaselineTest.kt @@ -0,0 +1,199 @@ +/* + * 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.qrcode.scanner + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.LuminanceSource +import com.google.zxing.MultiFormatReader +import com.google.zxing.common.HybridBinarizer +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +/** + * Measures ZXing-Java — the decoder the old zxing-android-embedded scanner used — against + * [QrCorpus], and records the result as the baseline the new decoder has to beat. + * + * This is the half of the plan's phase 0 that needs no device. The other half + * (`QrDecodeCorpusTest`, instrumented) runs zxing-cpp over the exported images and asserts it + * does at least as well, category by category. + * + * **What this is and is not.** It measures *decoder quality on a still image*, and it is + * deliberately generous to the old decoder: it decodes the full uncropped image and retries + * inverted, where the shipped scanner cropped to a viewfinder rect and alternated inversion + * across frames. So a win here is a floor on the real-world improvement, not the whole of it. + */ +class QrCorpusBaselineTest { + @Test + fun cleanCodesAllDecode() { + // The one hard assertion. If a pristine, generously-sized code fails, the corpus itself + // is broken and every other number in this file is meaningless. + val clean = QrCorpus.all().filter { it.category == "clean" } + assertEquals("corpus should contain one clean fixture per payload", 3, clean.size) + clean.forEach { + assertEquals("clean fixture ${it.name} must decode", it.expected, decode(it.image)) + } + } + + @Test + fun reportsTheBaselinePerCategory() { + val byCategory = QrCorpus.all().groupBy { it.category } + + val rows = + byCategory.map { (category, fixtures) -> + val passed = fixtures.count { decode(it.image) == it.expected } + Row(category, passed, fixtures.size) + } + + println(render(rows)) + + // Recorded, not asserted: these numbers are the yardstick, and pinning them here would + // just mean a ZXing bump breaks the build instead of informing it. + if (System.getProperty(EXPORT_PROPERTY) == "true") { + writeBaseline(rows) + } + } + + /** + * Writes the corpus and its baseline into the instrumented test's assets. + * + * Opt-in via `-Pamethyst.qr.corpus.export=true` so an ordinary test run never dirties the + * working tree. The generator is deterministic, so re-exporting an unchanged corpus is a + * no-op. It is a Gradle property rather than a plain `-D` because the test runs in a forked + * JVM that does not inherit the Gradle JVM's system properties. + */ + @Test + fun exportsTheCorpusForTheInstrumentedTest() { + assumeTrue("pass -Pamethyst.qr.corpus.export=true to regenerate the corpus", System.getProperty(EXPORT_PROPERTY) == "true") + + val dir = File(ASSET_DIR) + dir.mkdirs() + + QrCorpus.all().forEach { fixture -> + // 8-bit grey, not RGB: every fixture is greyscale in content, and storing three + // identical channels tripled the size of a corpus that has to live in the repo. + ImageIO.write(toGrayscale(fixture.image), "png", File(dir, "${fixture.name}.png")) + } + + File(dir, "expected.tsv").writeText( + QrCorpus.all().joinToString("\n", postfix = "\n") { "${it.name}.png\t${it.category}\t${it.expected}" }, + ) + } + + private fun toGrayscale(source: BufferedImage): BufferedImage { + val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_BYTE_GRAY) + val g = out.createGraphics() + g.drawImage(source, 0, 0, null) + g.dispose() + return out + } + + private data class Row( + val category: String, + val passed: Int, + val total: Int, + ) + + private fun render(rows: List): String = + buildString { + appendLine() + appendLine("ZXing-Java (the old scanner's decoder) over QrCorpus:") + rows.sortedBy { it.category }.forEach { + appendLine(" %-20s %d/%d".format(it.category, it.passed, it.total)) + } + val passed = rows.sumOf { it.passed } + val total = rows.sumOf { it.total } + appendLine(" %-20s %d/%d".format("TOTAL", passed, total)) + } + + private fun writeBaseline(rows: List) { + File(ASSET_DIR).mkdirs() + File(ASSET_DIR, BASELINE_FILE).writeText( + rows.sortedBy { it.category }.joinToString("\n", postfix = "\n") { "${it.category}\t${it.passed}\t${it.total}" }, + ) + } + + /** ZXing-Java, full frame, with an inverted retry — a deliberately generous baseline. */ + private fun decode(image: BufferedImage): String? { + val source = GrayLuminanceSource(image) + return tryDecode(source) ?: tryDecode(source.invert()) + } + + private fun tryDecode(source: LuminanceSource): String? = + try { + MultiFormatReader() + .apply { setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) } + .decodeWithState(BinaryBitmap(HybridBinarizer(source))) + .text + } catch (e: Exception) { + null + } + + /** + * A [LuminanceSource] over a [BufferedImage]. + * + * Hand-rolled rather than pulling in `com.google.zxing:javase` for twenty lines — a new + * dependency, even a permissive one, is not worth it here. + */ + private class GrayLuminanceSource( + image: BufferedImage, + ) : LuminanceSource(image.width, image.height) { + private val luminances = + ByteArray(image.width * image.height).also { out -> + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val rgb = image.getRGB(x, y) + val r = (rgb shr 16) and 0xFF + val g = (rgb shr 8) and 0xFF + val b = rgb and 0xFF + // ITU-R BT.601 in integer arithmetic, matching ZXing's own conversion. + out[y * image.width + x] = ((r * 306 + g * 601 + b * 117) shr 10).toByte() + } + } + } + + override fun getRow( + y: Int, + row: ByteArray?, + ): ByteArray { + val out = if (row != null && row.size >= width) row else ByteArray(width) + System.arraycopy(luminances, y * width, out, 0, width) + return out + } + + override fun getMatrix(): ByteArray = luminances + + override fun isRotateSupported(): Boolean = false + + override fun isCropSupported(): Boolean = false + } + + companion object { + private const val EXPORT_PROPERTY = "amethyst.qr.corpus.export" + private const val ASSET_DIR = "src/androidTest/assets/qr" + private const val BASELINE_FILE = "baseline.tsv" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerStateTest.kt new file mode 100644 index 0000000000..dd8ac61c84 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerStateTest.kt @@ -0,0 +1,147 @@ +/* + * 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.qrcode.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class QrScannerStateTest { + private val frame = ScanFrame(720, 1280) + + private fun result( + text: String, + sequenceId: String? = null, + sequenceIndex: Int = -1, + sequenceSize: Int = -1, + ) = ScanResult( + text = text, + bounds = null, + sequenceId = sequenceId, + sequenceIndex = sequenceIndex, + sequenceSize = sequenceSize, + ) + + private fun scan( + vararg results: ScanResult, + brightness: Float = 1f, + ) = FrameScan(results.toList(), frame, brightness) + + // ---- the tap path ---- + + @Test + fun `a tapped candidate is accepted even right after the same code was submitted`() { + val state = QrScannerState() + + // Alone in frame, so it is auto-accepted. + assertEquals("npub1aaa", state.onFrame(scan(result("npub1aaa")), 1_000L)) + + // The caller could not use it; the user dismisses the sheet. + state.onRejected(classified()) + state.dismissRejection(1_100L) + + // A second code enters the frame, so nothing is auto-accepted any more... + assertNull(state.onFrame(scan(result("npub1aaa"), result("npub1bbb")), 1_200L)) + + // ...and the user taps the first one deliberately, inside the dedupe window. + assertEquals("npub1aaa", state.onCandidateTapped(result("npub1aaa"), 1_300L)) + } + + // ---- multi-part sequences ---- + + @Test + fun `a half-captured sequence is dropped once an unrelated code is being scanned`() { + val state = QrScannerState() + + assertNull(state.onFrame(scan(result("part0", "seq", 0, 3)), 1_000L)) + assertEquals(1 to 3, state.sequenceProgress) + + // The user gives up and scans an ordinary code instead, for well past the timeout. + var now = 2_000L + repeat(5) { + state.onFrame(scan(result("npub1zzz")), now) + now += StructuredAppendAccumulator.DEFAULT_TIMEOUT_MS / 2 + } + + assertNull("the abandoned sequence hint is still on screen", state.sequenceProgress) + } + + @Test + fun `a part claiming an index outside its own size never joins into the payload`() { + val state = QrScannerState() + + // Two parts arrive for a 2-part sequence, but the second claims index 7. The count is + // satisfied while index 1 is still missing, and splicing "a" with a part that does not + // belong at that position would hand the caller a corrupt payload. + assertNull(state.onFrame(scan(result("a", "seq", 0, 2)), 1_000L)) + assertNull(state.onFrame(scan(result("b", "seq", 7, 2)), 1_100L)) + + // The genuine part 1 completes it, and the stray index is not spliced in. + assertEquals("ab!", state.onFrame(scan(result("b!", "seq", 1, 2)), 1_200L)) + } + + // ---- the rules that already work, pinned so they keep working ---- + + @Test + fun `one code alone in frame is accepted, and not again while it is held there`() { + val state = QrScannerState() + + assertEquals("npub1aaa", state.onFrame(scan(result("npub1aaa")), 1_000L)) + assertNull(state.onFrame(scan(result("npub1aaa")), 1_100L)) + assertNull(state.onFrame(scan(result("npub1aaa")), 1_000L + QrScannerState.DEDUPE_MS - 1)) + assertEquals("npub1aaa", state.onFrame(scan(result("npub1aaa")), 1_000L + QrScannerState.DEDUPE_MS)) + } + + @Test + fun `several codes in frame are drawn but none is chosen`() { + val state = QrScannerState() + + assertNull(state.onFrame(scan(result("npub1aaa"), result("npub1bbb")), 1_000L)) + assertEquals(2, state.candidates.size) + } + + @Test + fun `nothing is decided while the cannot-open sheet is up`() { + val state = QrScannerState() + + state.onRejected(classified()) + assertNull(state.onFrame(scan(result("npub1bbb")), 1_000L)) + assertTrue(state.candidates.isEmpty()) + } + + @Test + fun `the torch is offered only after the scene has been dark for a while`() { + val state = QrScannerState() + + state.onFrame(scan(brightness = 0.05f), 1_000L) + assertFalse(state.isDark) + + state.onFrame(scan(brightness = 0.05f), 1_000L + QrScannerState.DARK_DWELL_MS) + assertTrue(state.isDark) + + state.onFrame(scan(brightness = 0.9f), 2_500L) + assertFalse(state.isDark) + } + + private fun classified() = classifyScannedPayload("not something this screen takes") +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt new file mode 100644 index 0000000000..b9fa0f3bd1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt @@ -0,0 +1,181 @@ +/* + * 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.qrcode.scanner + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScannedPayloadTest { + private val pubkeyHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val npub = NPub.create(pubkeyHex) + + @Test + fun `npub with and without the nostr prefix both classify as nostr`() { + assertTrue(classifyScannedPayload(npub) is ScannedPayload.Nostr) + assertTrue(classifyScannedPayload("nostr:$npub") is ScannedPayload.Nostr) + assertTrue(classifyScannedPayload(" $npub ") is ScannedPayload.Nostr) + } + + @Test + fun `a bare hex pubkey is re-encoded rather than rejected`() { + val payload = classifyScannedPayload(pubkeyHex) + + assertTrue(payload is ScannedPayload.HexPubKey) + assertEquals(npub, (payload as ScannedPayload.HexPubKey).npub) + } + + @Test + fun `uppercase hex is accepted and normalises to the same npub`() { + val payload = classifyScannedPayload(pubkeyHex.uppercase()) + + assertTrue(payload is ScannedPayload.HexPubKey) + assertEquals(npub, (payload as ScannedPayload.HexPubKey).npub) + } + + @Test + fun `hex of the wrong length is not a pubkey`() { + assertTrue(classifyScannedPayload(pubkeyHex.drop(1)) is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload(pubkeyHex + "ab") is ScannedPayload.Unknown) + } + + @Test + fun `signer and wallet schemes win over the nip19 scan`() { + assertTrue(classifyScannedPayload("bunker://$pubkeyHex?relay=wss%3A%2F%2Frelay.example") is ScannedPayload.Bunker) + assertTrue(classifyScannedPayload("nostrconnect://$pubkeyHex?relay=wss://r.example&secret=abc") is ScannedPayload.NostrConnect) + assertTrue(classifyScannedPayload("nostr+walletconnect://$pubkeyHex?relay=wss://r.example&secret=abc") is ScannedPayload.WalletConnect) + assertTrue(classifyScannedPayload("nostrwalletconnect://$pubkeyHex?secret=abc") is ScannedPayload.WalletConnect) + } + + @Test + fun `scheme matching ignores case`() { + assertTrue(classifyScannedPayload("BUNKER://$pubkeyHex") is ScannedPayload.Bunker) + assertTrue(classifyScannedPayload("Nostr+WalletConnect://$pubkeyHex") is ScannedPayload.WalletConnect) + } + + @Test + fun `lightning invoices and lnurls are recognised`() { + assertTrue(classifyScannedPayload("lnbc1u1pjxyz") is ScannedPayload.Lightning) + assertTrue(classifyScannedPayload("lightning:lnbc1u1pjxyz") is ScannedPayload.Lightning) + assertTrue(classifyScannedPayload("LNURL1DP68GURN8GHJ7") is ScannedPayload.Lightning) + } + + @Test + fun `web links are recognised and carry their url`() { + val payload = classifyScannedPayload("https://example.com/some/page") + + assertTrue(payload is ScannedPayload.Web) + assertEquals("https://example.com/some/page", (payload as ScannedPayload.Web).url) + } + + @Test + fun `an njump link resolves as the entity in its path, not as a web page`() { + // The NIP-19 scan runs before the http check, and matches anywhere in the string. That is + // deliberate: a scanned njump/nostr.at link should open the profile in the app rather + // than a web page whose only job is to redirect back into one. + assertTrue(classifyScannedPayload("https://njump.to/$npub") is ScannedPayload.Nostr) + } + + @Test + fun `plain text and blanks fall through to unknown`() { + assertTrue(classifyScannedPayload("WIFI:S:cafe;T:WPA;P:hunter2;;") is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload("") is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload(" ") is ScannedPayload.Unknown) + } + + @Test + fun `everything carrying key material or a pairing secret is marked secret`() { + val nsec = classifyScannedPayload(NSEC_FIXTURE) + assertTrue(nsec is ScannedPayload.Nostr) + assertTrue((nsec as ScannedPayload.Nostr).entity is NSec) + assertTrue(nsec.containsSecret) + + assertTrue(classifyScannedPayload("bunker://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("nostrconnect://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("nostr+walletconnect://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("cashuBo2Ftd").containsSecret) + } + + @Test + fun `an ncryptsec is secret even though nothing can parse it`() { + // Nip19Parser's regex lists ncryptsec1 but parseComponents has no branch for it, so it + // returns null and the payload lands in Unknown. Unknown is not secret, which put an + // encrypted private key on screen with a Copy button -- the exact thing containsSecret + // exists to prevent. Classification must not depend on whether we can decode the thing. + val payload = classifyScannedPayload(NCRYPTSEC_FIXTURE) + + assertTrue("an encrypted private key must never be echoed", payload.containsSecret) + } + + @Test + fun `key material is secret whatever case it arrives in`() { + assertTrue(classifyScannedPayload(NCRYPTSEC_FIXTURE.uppercase()).containsSecret) + assertTrue(classifyScannedPayload("nostr:$NCRYPTSEC_FIXTURE").containsSecret) + } + + @Test + fun `public payloads are not marked secret`() { + assertFalse(classifyScannedPayload(npub).containsSecret) + assertFalse(classifyScannedPayload(pubkeyHex).containsSecret) + assertFalse(classifyScannedPayload("https://example.com").containsSecret) + assertFalse(classifyScannedPayload("lnbc1u1pjxyz").containsSecret) + assertFalse(classifyScannedPayload("just some text").containsSecret) + } + + @Test + fun `an nsec is secret in every shape a QR code can carry it`() { + // Bech32 is case-insensitive, and a QR encoder that wants alphanumeric mode -- half the + // bytes of byte mode, so a noticeably smaller and easier-to-scan code -- must uppercase + // its payload. An uppercased nsec is therefore not a corner case; it is what a + // size-conscious generator produces. + assertTrue("an uppercase nsec must never be echoed", classifyScannedPayload(NSEC_FIXTURE.uppercase()).containsSecret) + assertTrue(classifyScannedPayload("nostr:$NSEC_FIXTURE").containsSecret) + assertTrue(classifyScannedPayload("NOSTR:${NSEC_FIXTURE.uppercase()}").containsSecret) + } + + @Test + fun `an nsec too damaged to parse is still secret`() { + // Same principle the ncryptsec branch already follows: whether a payload is dangerous to + // put on screen cannot depend on whether we happen to be able to read it. A half-copied + // key pasted from the clipboard, or one transcribed with a typo into a QR generator, + // still shows most of its characters. + assertTrue(classifyScannedPayload(NSEC_FIXTURE.dropLast(4)).containsSecret) + assertTrue(classifyScannedPayload(NSEC_FIXTURE.dropLast(1) + "q").containsSecret) + } + + @Test + fun `raw is always the trimmed input`() { + assertEquals(npub, classifyScannedPayload("\n $npub \t").raw) + } + + companion object { + /** A throwaway key, generated here so no real secret ever reaches a source file. */ + private val NSEC_FIXTURE = ByteArray(32) { (it + 1).toByte() }.toNsec() + + /** Shape only — nothing here can decrypt it, and classification must not need to. */ + private const val NCRYPTSEC_FIXTURE = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt new file mode 100644 index 0000000000..e20b9106b4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt @@ -0,0 +1,113 @@ +/* + * 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.qrcode.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StructuredAppendAccumulatorTest { + private fun part( + text: String, + index: Int, + size: Int, + id: String = "seq", + ) = ScanResult(text = text, bounds = null, sequenceId = id, sequenceIndex = index, sequenceSize = size) + + @Test + fun `parts in order join once the last one lands`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("abc", 0, 3), 0)) + assertNull(acc.add(part("def", 1, 3), 100)) + assertEquals("abcdefghi", acc.add(part("ghi", 2, 3), 200)) + } + + @Test + fun `parts out of order still join in index order`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("ghi", 2, 3), 0)) + assertNull(acc.add(part("abc", 0, 3), 10)) + assertEquals("abcdefghi", acc.add(part("def", 1, 3), 20)) + } + + @Test + fun `a repeated part does not complete the sequence`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("abc", 0, 3), 0)) + assertNull(acc.add(part("abc", 0, 3), 10)) + assertNull(acc.add(part("abc", 0, 3), 20)) + assertEquals(1, acc.captured) + } + + @Test + fun `progress reports captured against total`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("a", 0, 3), 0) + assertEquals(1, acc.captured) + assertEquals(3, acc.total) + + acc.add(part("b", 1, 3), 10) + assertEquals(2, acc.captured) + } + + @Test + fun `a different sequence id restarts rather than splicing`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("abc", 0, 2, id = "one"), 0) + assertNull(acc.add(part("xyz", 0, 2, id = "two"), 10)) + assertEquals(1, acc.captured) + assertEquals("xyzuvw", acc.add(part("uvw", 1, 2, id = "two"), 20)) + } + + @Test + fun `a part arriving after the timeout restarts the sequence`() { + val acc = StructuredAppendAccumulator(timeoutMs = 1_000) + + acc.add(part("abc", 0, 2), 0) + // The second part shows up two seconds late: treat it as the start of a new attempt + // rather than half of an abandoned one. + assertNull(acc.add(part("def", 1, 2), 3_000)) + assertEquals(1, acc.captured) + } + + @Test + fun `a result that is not part of a sequence is ignored`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(ScanResult("plain", bounds = null), 0)) + assertEquals(0, acc.captured) + } + + @Test + fun `completing a sequence clears the accumulator for the next one`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("a", 0, 2), 0) + assertEquals("ab", acc.add(part("b", 1, 2), 10)) + assertEquals(0, acc.captured) + assertEquals(0, acc.total) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt index 8d9fcf289e..f3b35e345c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts -import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.toNewWorkoutRoute import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/HealthConnectContributionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/HealthConnectContributionTest.kt new file mode 100644 index 0000000000..636ac5c3ad --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/HealthConnectContributionTest.kt @@ -0,0 +1,144 @@ +/* + * 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.workouts.fitness + +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rule that decides which stages of a progressive Health Connect read are allowed to reach + * the My Fitness dashboard. + * + * The screen re-reads on every resume, and a read's first stage carries no metrics. Applying it + * unconditionally would be a downgrade for anyone coming back to a dashboard that was already + * complete. + */ +class HealthConnectContributionTest { + private fun workout( + id: String, + distanceMeters: Double? = null, + ) = DetectedWorkout( + id = id, + exercise = ExerciseType.RUNNING, + title = null, + startTimeEpochSeconds = 1_700_000_000, + durationSeconds = 1800, + distanceMeters = distanceMeters, + calories = null, + avgHeartRate = null, + maxHeartRate = null, + steps = null, + elevationGainMeters = null, + source = "Samsung Health", + ) + + private fun stageOne(vararg workouts: DetectedWorkout) = HealthConnectManager.WorkoutRead(workouts.toList(), metricsPending = true) + + private fun stageTwo(vararg workouts: DetectedWorkout) = HealthConnectManager.WorkoutRead(workouts.toList(), metricsPending = false) + + @Test + fun `the first load shows stage one, which is the whole point of reading in stages`() { + val skeleton = workout("a") + + val after = HealthConnectContribution().after(stageOne(skeleton)) + + assertEquals(listOf(skeleton), after.workouts) + assertTrue(after.metricsPending) + assertFalse(after.sessionsPending) + } + + @Test + fun `stage two replaces stage one and clears the pending flag`() { + val loaded = workout("a", distanceMeters = 10_000.0) + + val after = HealthConnectContribution().after(stageOne(workout("a"))).after(stageTwo(loaded)) + + assertEquals(listOf(loaded), after.workouts) + assertFalse(after.metricsPending) + } + + @Test + fun `a resume does not strip the metrics off an already-complete dashboard`() { + val loaded = workout("a", distanceMeters = 10_000.0) + val complete = HealthConnectContribution(workouts = listOf(loaded)) + + // What refresh() does on resume, then the new read's stage 1 arriving. + val rereading = complete.copy(sessionsPending = true) + val after = rereading.after(stageOne(workout("a"))) + + assertEquals(listOf(loaded), after.workouts) + // Nothing on screen has changed, so nothing should be annotated as loading either. + assertFalse(after.metricsPending) + assertFalse(after.sessionsPending) + } + + @Test + fun `the resumed read still lands, atomically, when its metrics arrive`() { + val old = workout("a", distanceMeters = 10_000.0) + val fresh = workout("b", distanceMeters = 12_000.0) + + val after = + HealthConnectContribution(workouts = listOf(old)) + .copy(sessionsPending = true) + .after(stageOne(workout("a"), workout("b"))) + .after(stageTwo(old, fresh)) + + assertEquals(listOf(old, fresh), after.workouts) + assertFalse(after.metricsPending) + } + + @Test + fun `an empty stage two is applied even when workouts are on screen, so revoked data clears`() { + val complete = HealthConnectContribution(workouts = listOf(workout("a", distanceMeters = 10_000.0))) + + // Not a downgrade to decline — a completed read that found nothing is the truth. + val after = complete.after(stageTwo()) + + assertTrue(after.workouts.isEmpty()) + assertFalse(after.metricsPending) + } + + @Test + fun `a read that finds nothing clears the sessions-pending gate`() { + // Health Connect unavailable, the read failing, and a device with no mappable sessions + // all emit one empty non-pending result. Any of them leaving sessionsPending set would + // strand an empty dashboard on its spinner forever. + val after = HealthConnectContribution(sessionsPending = true).after(stageTwo()) + + assertTrue(after.workouts.isEmpty()) + assertFalse(after.sessionsPending) + assertFalse(after.metricsPending) + } + + @Test + fun `declining a stage keeps the same workout list instance rather than rebuilding it`() { + val workouts = listOf(workout("a", distanceMeters = 10_000.0)) + val complete = HealthConnectContribution(workouts = workouts, sessionsPending = true) + + assertSame(workouts, complete.after(stageOne(workout("a"))).workouts) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModelKeyKindTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModelKeyKindTest.kt new file mode 100644 index 0000000000..839d80dc38 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModelKeyKindTest.kt @@ -0,0 +1,81 @@ +/* + * 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.login + +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Amethyst for Android is the bunker, never the bunker's client. + * + * `AccountSettings.isWriteable()` is `privKey != null || externalSignerPackageName != null`: the + * only remote signing it can persist is an external signer app. A `bunker://` address pasted into + * the key field is therefore not a key it failed to read, it is a sign-in method it does not have + * — but it used to fall through to the hex branch and come back as + * "Invalid key: Could not sign in: Invalid hex bunker://…", which reads as "you mistyped it" for a + * string the user pasted correctly. + */ +class LoginViewModelKeyKindTest { + private fun viewModelWithKey(text: String) = + LoginViewModel().apply { + acceptedTerms = true + key = TextFieldValue(text) + } + + private fun errorResIdOf(model: LoginViewModel) = (model.errorManager.error as? LoginErrorManager.SingleErrorMsg)?.errorResId + + @Test + fun aBunkerAddressIsRefusedAsAnUnsupportedMethodNotAsABadKey() { + val model = viewModelWithKey("bunker://${"a".repeat(64)}?relay=wss%3A%2F%2Fr.example&secret=s1") + + assertFalse(model.checkCanLogin()) + assertEquals(R.string.login_bunker_not_supported, errorResIdOf(model)) + } + + /** Same defect, same field: a connect offer is not a sign-in method either. */ + @Test + fun aNostrConnectOfferIsRefusedWithItsOwnExplanation() { + val model = viewModelWithKey("nostrconnect://${"b".repeat(64)}?relay=wss%3A%2F%2Fr.example&secret=s1") + + assertFalse(model.checkCanLogin()) + assertEquals(R.string.login_nostrconnect_not_supported, errorResIdOf(model)) + } + + /** Pasted from a chat app, which loves to add a trailing space. */ + @Test + fun surroundingWhitespaceDoesNotHideTheExplanation() { + val model = viewModelWithKey(" bunker://${"a".repeat(64)}?secret=s1 ") + + assertFalse(model.checkCanLogin()) + assertEquals(R.string.login_bunker_not_supported, errorResIdOf(model)) + } + + /** An npub is still a key, so the guard must not swallow the ordinary path. */ + @Test + fun anOrdinaryKeyStillPassesTheCheck() { + val model = viewModelWithKey("npub1${"q".repeat(58)}") + + assertEquals(true, model.checkCanLogin()) + } +} diff --git a/amethyst/src/test/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler b/amethyst/src/test/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler new file mode 100644 index 0000000000..724951f161 --- /dev/null +++ b/amethyst/src/test/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler @@ -0,0 +1 @@ +com.vitorpamplona.amethyst.LeakedCoroutineReporter diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt index 1da2be3a7e..5f8faca04f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.benchmark -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -35,8 +34,9 @@ open class BaseLargeCacheBenchmark { // This file includes duplicates val fullDBInputStream = javaClass.classLoader!!.getResourceAsStream("nostr_vitor_startup_data.json.gz") - return JacksonMapper.mapper.readValue>( + return JacksonMapper.mapper.readValue( GZIPInputStream(fullDBInputStream), + JacksonMapper.eventListTypeInstance, ) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index d9138f5dce..08f47f60cd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -43,8 +42,9 @@ open class BaseCacheBenchmark { // This file includes duplicates val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json.gz") - return JacksonMapper.mapper.readValue>( + return JacksonMapper.mapper.readValue( GZIPInputStream(fullDBInputStream), + JacksonMapper.eventListTypeInstance, ) } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt index 1eec88137d..884e353321 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher import org.junit.Rule @@ -54,7 +54,7 @@ class EventCmdSerializerBenchmark { @Test fun jsonStringEncoderJackson() { - val jsonMapper = jacksonObjectMapper() + val jsonMapper = ObjectMapper() benchmarkRule.measureRepeated { jsonMapper.writeValueAsString(specialEncoders) } @@ -62,7 +62,7 @@ class EventCmdSerializerBenchmark { @Test fun jsonStringEncoderSha256Jackson() { - val jsonMapper = jacksonObjectMapper() + val jsonMapper = ObjectMapper() benchmarkRule.measureRepeated { val digest = Sha256Hasher() digest.hash(jsonMapper.writeValueAsString(specialEncoders).toByteArray()) diff --git a/build.gradle.kts b/build.gradle.kts index d3ea321ee8..5604756c04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,6 +74,11 @@ allprojects { spotless { kotlin { target("src/**/*.kt") + // Third-party sources vendored verbatim keep their own license header and + // formatting. Stamping our MIT header onto someone else's Apache-2.0 file + // would misstate its provenance, and reformatting it would make the next + // re-vendor a merge conflict instead of a copy. + targetExclude("src/main/java/zxingcpp/**/*.kt") ktlint("1.7.1") licenseHeaderFile( @@ -91,6 +96,36 @@ allprojects { subprojects { afterEvaluate { + // The Kotlin Multiplatform plugin never registers a plain `test` task: it creates one + // task per target (jvmTest, androidUnitTest, linuxX64Test, ...) plus the `allTests` + // aggregate. A root `./gradlew test` therefore runs `test` only in the Java/Android + // modules that own one and *silently* skips every KMP module - Gradle only errors when + // no project at all has the task, and four of them do, so it exits 0 looking healthy. + // That hid ~8k tests (quartz, commons, commonsUI, quic, nestsClient, marmotQuic), which + // is most of this repo's suite. Register the alias so the documented command means what + // it says. + // + // It maps to jvmTest, not allTests, on purpose: allTests also drags in androidUnitTest + // and the native targets, half of which cannot run on a given host (macosArm64Test on + // Linux) and all of which change what `test` costs. jvmTest is exactly what the + // pre-push hook and CI already run for these modules, so the alias matches the coverage + // they expect rather than inventing a third definition of "the tests". + // + // The flip side: a KMP module's non-JVM targets stay outside `test`. :quartz's + // androidHostTest source set (~3.9k tests) is the big one - nothing runs it today and it + // is red on main, so aliasing onto allTests would have turned `test` red for everyone + // rather than fixing anything. Tracked in CLAUDE.md's Build Commands section. + if (plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") && + tasks.findByName("test") == null && + tasks.findByName("jvmTest") != null + ) { + tasks.register("test") { + group = "verification" + description = "Runs the JVM unit tests for this Kotlin Multiplatform module." + dependsOn("jvmTest") + } + } + try { tasks.named("preBuild") { dependsOn("spotlessApply") diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb index a585805cf8..c304905683 100644 --- a/cli/packaging/homebrew/amy.rb +++ b/cli/packaging/homebrew/amy.rb @@ -38,8 +38,8 @@ class Amy < Formula desc "Nostr client from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/amy-1.15.2-jvm.tar.gz" - sha256 "db354a7e9994f0f15b9a9c55596fec0e3d3bc4294a81455d6f35ad5ccefb4191" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.16.0/amy-1.16.0-jvm.tar.gz" + sha256 "ecf95078146669c50e5fae179e8cc40ed4a387df299027f6b39faf95b3ed3f79" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index bda85c11a5..00aaa43c71 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -75,7 +75,7 @@ in `commonsUI`, under the same package. ### Domain models & data | Package | UI? | Purpose | |----------------|-----|---------| -| `model` | no¹ | Core domain types (`Note`, `User`, `Channel`), thread assembly (`ThreadAssembler`, `ThreadLevelCalculator`, `ReplyContext`, `replyingDirectlyTo`), and per-NIP event model extensions in `model/nipNN…` subpackages. `model/cache` holds the in-memory event-store interfaces + `UserMetadataCache`. `model/account`, `model/observables`. The largest package; keep it organized by NIP. | +| `model` | no¹ | Core domain types (`Note`, `User`, `Channel`), thread assembly (`ThreadAssembler`, `ThreadLevelCalculator`, `ReplyContext`, `replyingDirectlyTo`), and per-NIP event model extensions in `model/nipNN…` subpackages. `model/cache` holds the in-memory event store: the `ICacheProvider` / `ILocalCache` ports and `UserMetadataCache` in commonMain, and the concrete `LocalCache` (plus `AntiSpamFilter`, `CachePruner`, `CacheSearch` and the `LocalCacheHost` app-shell port) in jvmAndroid. `model/account`, `model/observables`. The largest package; keep it organized by NIP. | | `defaults` | no | Static bootstrap data (default relays, channels). | ¹ `model` uses only the `@Stable`/`@Immutable` runtime annotations — CLI-safe. @@ -253,6 +253,22 @@ These are intentionally *documented*, not silently tolerated. Fix opportunistica namespaces expected to grow; do not fold them into `util` just for size. - **`onchain`** (on-chain zap splitting) is `quartz`-adjacent but un-numbered; leave readable unless a clear NIP number lands. +- **`model/cache/EventCache` is `jvmAndroid`, not `commonMain`.** The NIP-95 + `java.io.File` spill is the obvious blocker but not the binding one: the + cache's own storage, `LargeSoftCache`, is `jvmAndroid` + (`WeakReference` + `ConcurrentSkipListMap`), as are the two + `*ListMatchingFilter` observables, `MintDirectoryIndex` and + `NwcPaymentTracker`. Promoting the cache means promoting those first. + The binding constraint is `LargeSoftCache`'s **sorted** store: it backs the + ranged `forEach(from, to, …)` that all of `LargeSoftCacheAddressExt` uses to + scan one kind's slice of the `Address` key space, and a hash map turns each + of those into a full scan. `quartz/linuxTest/LargeCacheRangeFallbackTest` + documents the matching invariant from the other side — the native range + overloads fall back to full scans, and that is deemed safe precisely + *because* the range callers live in the JVM-only `LargeSoftCache`. Moving + this needs a sorted KMP store first, not just the okio blob sink and the + `java.util.SortedSet` signature change. See the audit in + `commons/plans/2026-08-30-commons-migration-sweep.md`. --- diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index efbf6a8f94..20982ddae2 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -776,10 +776,9 @@ Options, for the maintainer to pick: `NappletIdentityWatch`); `ScheduledPostStore` Jackson+`java.io.File` → kotlinx-serialization+okio (frees `ScheduledPostWorkGate`). `LargeSoftCache` stays parked (needs a WeakReference expect/actual). -- **Wave 2: LocalCache move-group** (~200 files behind it) — **blocked on a - design decision**: `CachePruner`/`CacheSearch` call - `Account.isFollowing(...)`; either `IAccount` grows it (DesktopIAccount - must implement) or they take a narrower interface (recommended). +- **Wave 2: LocalCache move-group** — **part A landed (2026-09-19)**, see the + section below. Part B (repoint `desktopApp`, delete `DesktopLocalCache.kt`) + is the remaining half. - **Wave 3: strings bridge** — **BUILT (2026-09-01)**. The pieces: - `commons/ui/StringRes.kt` (commonMain): `stringRes(StringResource)`, formatted + plural variants, `loadStringRes`/`loadPluralStringRes` for @@ -826,3 +825,480 @@ Options, for the maintainer to pick: gradle re-extract. - stdlib atomics have no `incrementAndFetch()` here — use `addAndFetch(1)`; `withLock {}` can't assign outer `val`s — restructure to lambda-return. + + +### Wave 2 part A landed — `LocalCache` is in `commons` (2026-09-19) + +`LocalCache` (4,021 lines) and its move-group now live in +`commons/src/jvmAndroid/…/commons/model/`. `desktopApp` is untouched and still +runs `DesktopLocalCache`; repointing it and deleting that 1,177-line fork is +part B. + +**Where things went** + +| From `amethyst/…/model/` | To `commons/…/commons/model/` | +|---|---| +| `LocalCache.kt` | `cache/LocalCache.kt` | +| `AntiSpamFilter.kt` | `cache/AntiSpamFilter.kt` | +| `CachePruner.kt` | `cache/CachePruner.kt` | +| `CacheSearch.kt` | `cache/CacheSearch.kt` | +| `DvmHeartbeatRegistry.kt` | `nip90DVMs/DvmHeartbeatRegistry.kt` | +| `nipBCOnchainZaps/OnchainZapResolver.kt` | `nipBCOnchainZaps/OnchainZapResolver.kt` | + +All in `jvmAndroid`: `LocalCache` spills NIP-95 blobs through `java.io.File`, +and the other five either name `LocalCache` or use `java.util.concurrent`. + +**The seam: `LocalCacheHost`** + +A new port in `commons/…/model/cache/LocalCacheHost.kt` collects everything the +cache needed from `Amethyst.instance` and friends — `scope`, `isDebug`, +`nip95BlobDir`, `relayStats`, `relaySelfPubKey(relay)`, `assertNotMainThread()`. +Every member has a default that is the honest answer for a host that supplies +none of them, so the cache runs shell-less (Desktop, tests). `LocalCache.appHost` +is the install point; Android installs `AmethystLocalCacheHost` from `AppModules` +next to `val cache`, before any event is consumed. Named `appHost`, not `host`, +because `host` is already a domain word in that file (NIP-29 host relay, +gift-wrap host). + +The whole seam cut is 101 changed lines in a 4,021-line file: + +| Was | Now | +|---|---| +| `Amethyst.instance.nip11Cache.getFromCache(relay).self` | `appHost.relaySelfPubKey(relay)` | +| `Amethyst.instance.applicationIOScope.launch` (×2) | `appHost.scope.launch` | +| `Amethyst.instance.nip95cache` | `appHost.nip95BlobDir` | +| `isDebug` (×2) | `appHost.isDebug` | +| `checkNotInMainThread()` | `appHost.assertNotMainThread()` | +| `ui.note.dateFormatter` | `commons.util.dateFormatter` (the twin already existed) | + +`nip95BlobDir` is nullable, so the `FileStorageEvent` path had to grow one +branch: with nowhere to spill to, the note keeps the event whole instead of +loading a content-stripped copy whose bytes are on no disk. Android always +supplies the directory, so its behaviour is unchanged. + +**The design question the plan flagged, resolved** + +`CachePruner`/`CacheSearch` took `Account`. It turned out `IAccount` did not +need to grow anything and Desktop needed no change: + +- `CacheSearch.findUsersStartingWith(…, forAccount: IAccount?)` — `isHidden` + and `hiddenWordsCase` are already on `IAccount`, and `isFollowing(user)` is + by definition `user.pubkeyHex in followingKeySet()`, which is too. Hoisting + the set out of the per-candidate lambda also stops rebuilding it per result. +- `CachePruner.pruneHiddenMessages(account: IAccount)` — `Channel.pruneHiddenMessages` + already took `IAccount`. +- `CachePruner.pruneHiddenEvents(hidden: LiveHiddenUsers)` — takes the mute list + by value, the idiom `ICacheProvider.findNotesMatching` already documents. + `IAccount` exposes hidden users only as hash codes, which would prune authors + the reader never muted. + +**Other pieces** + +- `ILocalCache` was declared inline in `LocalCache.kt` and used nowhere else; + hoisted to `commons/…/model/cache/ILocalCache.kt` (commonMain) as the + write-side port next to the read-side `ICacheProvider`. +- `AntiSpamFilter`: `android.util.LruCache` → `androidx.collection.LruCache`, + already the commons idiom. Its `get` is Kotlin-nullable where the platform + class returned a platform type, so the two duplicate checks now read each + entry once into a local and smart-cast. It takes its host as `() -> LocalCacheHost` + rather than a value, because the cache that owns it is built before the shell + installs one. +- `njumpLink` moved from `ui/note/NoteQuickActionMenu.kt` to + `commons/…/util/ExternalLinks.kt` (AntiSpamFilter logs one); the app's four + call sites import it from there. +- `OnchainZapResolver.onchainTipHeightFlow` dropped its + `runCatching { Amethyst.instance }` fallback — `cache.appHost.scope` always + answers. + +**Left in `amethyst` on purpose:** `MiniFhir.kt`, which the original move-group +listed. It is clean and movable, but `LocalCache` never referenced it (the hit +was the phrase "Resource-usage ledger" in a comment), so it unlocks nothing here +and would only widen the diff. `OnchainWalletState` likewise stays — `Account` +and `RailCapability` use it, `OnchainZapResolver` does not. + +**Verified:** `:commons:compileKotlinJvm`, `:amethyst:compileFdroidDebugKotlin`, +`:desktopApp:compileKotlin`, `:cli:compileKotlin`, `:commons:verifyKmpPurity`, +`:commons:jvmTest`, `:cli:test`, `:amethyst:testPlayDebugUnitTest`, +`spotlessApply`. + + +### Wave 2 part B — compatibility analysis and the road to deleting `DesktopLocalCache` (2026-09-19) + +Part A moved the cache. Part B is retiring the Desktop fork. The naive framing +("repoint ~70 consumers and delete 1,177 lines") is wrong; what follows is the +measured picture. + +#### What is already compatible + +- **`consume()` coverage is a strict superset.** All 22 kinds Desktop routes + are handled; the 9 without a dedicated `EventCache` overload + (`TextNoteEvent`, `ContactListEvent`, `CommentEvent`, + `AdvertisedRelayListEvent`, `BlossomServersEvent`, `BookmarkListEvent`, + `OldBookmarkListEvent`, `ChatMessageRelayListEvent`, `FollowListEvent`) fall + into the generic replaceable/addressable group. +- **13 of 13 core read methods match** by name and signature. +- **Feed retention is already aligned** — both platforms hold feed content + strongly in the shared `FeedContentState`. +- **The event stream is separable.** Desktop's `newEventBundles` is driven by + `DesktopRelaySubscriptionsCoordinator`, not by the cache, so Desktop can keep + owning `DesktopCacheEventStream` and does not inherit `LocalCacheFlow`'s + 1 s `BundledInsert` window. + +#### Resolved during part B + +- **`notesByAuthor`** — a strong `ConcurrentHashMap>` + of every note ever consumed, which defeated the `LargeSoftCache` + (`WeakReference`, despite the name) and made Desktop retain every note for + the life of the process. It also drove kind-0 metadata invalidation, which + Android has no counterpart for. Removed: every display-data site now observes + the author's `User` metadata flow through `Event.rememberDisplayData` (which + already existed, documented for this, with zero call sites). +- **`object` vs `class`** — `EventCache` is now the class, `LocalCache` the + process-wide `object` over it. Android's ~350 static call sites were untouched. +- **`findUsersStartingWith(prefix, limit)`** — `EventCache` now overrides the + port method instead of inheriting its `emptyList()` default. + +#### Still to do, and the shape it should take + +`DesktopLocalCache` should become a **facade over an owned `EventCache`**, not +a deletion: ~500 lines of genuinely Desktop-specific state survive, and the +~700 lines of `consumeXxx` routing go. + +1. **Storage unification first** (safe, mechanical): drop Desktop's `users` / + `notes` / `addressableNotes` / `liveChatChannels` and delegate to the owned + `EventCache`. Desktop's own `consumeXxx` methods keep working because they + go through `getOrCreateNote` / `getOrCreateAddressableNote`. Note the key + type widens (`addressableNotes` → `addressables`); + the one external reader (`DesktopRelaySubscriptionsCoordinator:599`) ignores + the key. +2. **Then swap routing kind by kind**, each step green against the 62 tests in + the 8 `desktop/cache` test classes, ending at + `cache.checkDeletionAndConsume(event, relay, true)`. +3. **Desktop-only state stays**, moved behind the facade: `localRelayStore` + write-through, `followedUsers`, `accountPubkey`, `contactListEvents`, + `metadataVersion`, `followPackVersion` / `liveActivityVersion`, the + snapshots, `cachedAdvertisedRelayList`, follower/following counts, + `onProfileMetadataConsumed`, `appScope`, `eventStream`. The mutations are + concentrated in five places (metadata, contact list, follow pack, live + activity, `clear`) and re-derive cleanly from the event after consume. +4. **Two Desktop-shaped `consume` overloads do not generalize** — the NIP-47 + `LnZapPaymentRequestEvent` one takes a `zappedNote` and an `onResponse` + callback, and the response one drives `paymentTracker` + `appScope`. Android + reaches the same tracker through account state. Keep them Desktop-side. +5. **`clear()`** has only 2 production call sites (`Main.kt`, logout/account + switch) plus 2 in tests. `EventCache` deliberately has no `clear()`: + `DeletionIndex`, `FilterIndex`, `HintIndexer` and `NwcPaymentTracker` have + no way to reset, so one would be a half-truth. Construct a fresh + `EventCache` instead — which is what making it a class bought. + +**Known behaviour changes to watch for when this lands:** kind-0 currently +returns `false` from Desktop's `route()` and is therefore never written through +to `LocalRelayStore`; `EventCache` returns `true` when the metadata updated, so +profiles would start persisting. And notes outside a loaded feed become +GC-eligible on Desktop, as they already are on Android. + +**The test suite is not sufficient for this step.** 62 tests cover the consume +path well, but nothing covers a stale render. Run the desktop app against a +real relay before calling part B done. + + +### Can `EventCache` go to `commonMain`? — audit (2026-09-21) + +Short answer: yes in principle, but it is a **port, not a move**, and the +earlier note in this file ("needs an okio sink … nothing else in the +move-group blocks iOS") was wrong. The file sink is the visible blocker; the +binding one is that the cache's own storage is `jvmAndroid`. + +`commons` really does build `iosArm64`/`iosSimulatorArm64`, so this is a real +constraint rather than a hypothetical one. + +**What must move with it** (`EventCache` cannot go alone): + +| Dependency | Why it is `jvmAndroid` | +|---|---| +| `LargeSoftCache` — `users`, `notes`, `addressables` | `java.lang.ref.WeakReference`, `ConcurrentSkipListMap`, `BiConsumer` | +| `EventListMatchingFilter`, `NoteListMatchingFilter` | `SortedSet`, `ConcurrentHashMap`, `ConcurrentSkipListSet` | +| `MintDirectoryIndex` | `ConcurrentHashMap` | +| `NwcPaymentTracker` | `ConcurrentHashMap`, `AtomicInteger` | +| `DvmHeartbeatRegistry`, `OnchainZapResolver` | `ConcurrentHashMap` | +| `LocalCacheHost` | `java.io.File` | + +**Most of it already has an answer in the tree:** + +- `commons.util.WeakReference` is already an `expect`/`actual`, and its KDoc + already anticipates `kotlin.native.ref.WeakReference` for iOS. +- quartz `commonMain` already ships `ConcurrentMap`, `ConcurrentSet`, + `ConcurrentHashCache` and `LargeCache` — so every `ConcurrentHashMap` / + `ConcurrentSkipListSet` above is a swap, not a design problem. +- ~~`LargeSoftCache`'s skip-list ordering is not load-bearing~~ — **wrong, + corrected 2026-09-21.** It is load-bearing, and it is the thing that stops + this migration. `LargeSoftCache` implements `CacheOperations` (which is + itself `quartz/jvmAndroid`, not commonMain) for its ranged + `forEach(from, to, …)`, backed by `ConcurrentSkipListMap.subMap`. The whole + of `LargeSoftCacheAddressExt` is built on it: `filter(kindStart(kind), + kindEnd(kind), …)` walks only one kind's slice of the `Address` key space, + and `filterIntoSet` is called 28 times. On a hash map every one of those + becomes a full scan of all addressables. +- `AtomicInteger` → `kotlin.concurrent.atomics`; `BiConsumer` → a function + type; the `dateFormatter` call is one log line and can go. +- `androidx.collection.LruCache` in `AntiSpamFilter` is already KMP — + `commonMain` uses it in `blurhash/CosineCache` and `relays/EOSE`. + +**The two pieces of genuinely new work:** + +1. **NIP-95 blob sink.** `EventCache.consume(FileStorageEvent)` writes through + `java.io.File`/`FileOutputStream`. Needs okio or an `expect` sink behind + `LocalCacheHost.nip95BlobDir`. +2. **`java.util.SortedSet` in the public API.** `EventCache.filter(Filter)` + returns one, and both `*ListMatchingFilter` observables take + `(Filter) -> SortedSet`. Kotlin has no common `SortedSet`, so this is + a signature change rippling into `CacheSearch`, both observables and + `LocalCacheSearchParityTest`. + +**The invariant this would break.** `quartz/linuxTest/LargeCacheRangeFallbackTest` +pins the native `LargeCache` range overloads to a deliberate full-scan +fallback, and says why that is acceptable: *"the range overloads have no +callers outside the JVM-only `LargeSoftCache`"*. Promoting `LargeSoftCache` to +`commonMain` makes that statement false. Any move has to either give iOS a +genuinely sorted store or accept — explicitly, not silently — that addressable +lookups there are full scans. + +**Revised cost.** This is not the swap described above. It needs either an +`expect`/`actual` `LargeSoftCache` with a hand-written sorted iOS actual (the +`LargeCache` apple actual, for comparison, is 432 lines), or a KMP +sorted-concurrent map that does not exist in the tree or the stdlib — which +would want a `Comparable` bound on `Address` and would benefit quartz too. +Neither is a step; both are projects. + +**Recommended order** (bottom-up; each step is independently shippable), *if +the ordering question above is answered first*: + +1. `LargeSoftCache` → `commonMain` on the existing `WeakReference` expect plus + quartz's `ConcurrentMap`. This is the keystone — everything else is behind it. +2. The `ConcurrentHashMap`/`ConcurrentSkipListSet` holders, mechanically. +3. `SortedSet` out of `EventCache.filter`'s signature. +4. The NIP-95 sink. +5. `EventCache` + `LocalCacheHost` themselves. + +**Not on part B's critical path.** Desktop is JVM, so retiring +`DesktopLocalCache` needs none of this. The payoff here is an iOS front end +later, not anything queued now. + +### Step 1 shipped: `LargeSoftCache` is now `expect`/`actual` (iOS unimplemented) + +Decision on the ordering question above: **`expect`/`actual`, with the iOS +actual deliberately missing.** Not a hash-map actual, and not a full sorted +concurrent map yet — the third option, which keeps the invariant honest at the +cost of iOS not being able to construct the cache. + +- `model/cache/LargeSoftCache.kt` → `commonMain`, as + `expect class LargeSoftCache() : ICacheOperations`. + Like quartz's `LargeCache`, the expect has to redeclare every member it + inherits from the interface — an expect class is not abstract, so the + inherited abstracts are its own to declare. +- `LargeSoftCache.jvmAndroid.kt` is today's implementation unchanged in + behaviour: `ConcurrentSkipListMap>` behind + `CacheOperations`. The collector defaults and both `forEach(BiConsumer)` + overloads stay JVM-side; only `size()` needed an `actual override`, the rest + actualize through inheritance. `java.lang.ref.WeakReference` became the + commons `WeakReference` expect — the same type on JVM, it is an `actual + typealias`. +- `LargeSoftCache.ios.kt` **throws on every member.** Kotlin/Native has no + sorted concurrent map and no weak-valued one; a `HashMap` actual would + compile and then be wrong in exactly the way `LargeCacheRangeFallbackTest` + warns about — every bounded scan silently walking every entry. The stub + keeps `:commons` compiling for iOS and fails loudly if anything there tries + to construct a cache. Replacing it is now a self-contained task with a + written-down contract (key-sorted, weak values) rather than a blocker on the + rest of the migration. +- `LargeSoftCacheAddressExt.kt` moved to `commonMain` with it — its ranged + `filter`/`filterIntoSet`/`mapNotNullIntoSet` calls are all `ICacheOperations` + members, so they need no platform code. + +Verified: `:commons:compileCommonMainKotlinMetadata`, +`:commons:compileIosMainKotlinMetadata` (this runs on Linux and *does* enforce +expect/actual matching — checked by breaking a member name on purpose and +watching it fail), `:commons:verifyKmpPurity`, `:commons:jvmTest`, +`:desktopApp:test`, `:cli:test`, `:amethyst:compileFdroidDebugKotlin`, +`LargeCacheAddressableFilterTest`, `spotlessCheck`. + +Steps 2–5 are unchanged and still ahead; the iOS actual is now their peer, not +their gate. + +### Step 2 shipped: the `ConcurrentHashMap` holders + +Three of the six holders in the audit table above are now `commonMain`, each a +straight swap onto a KMP primitive that already existed: + +- `MintDirectoryIndex` — `ConcurrentHashMap` → quartz + `ConcurrentMap`. `counts.merge(key, 1, Int::plus)` becomes + `merge(key, 1) { old, new -> old + new }`, which is atomic on every target, + so a count still cannot be lost to a racing `add`. `suggest()` now ranks over + `snapshot()` — a copy the comparator cannot see shift underneath it. +- `DvmHeartbeatRegistry` — `ConcurrentMap.getOrPut` is the same atomic + get-or-create the `ConcurrentHashMap` version relied on, so two threads + racing the first beat for an address still agree on one `MutableStateFlow`. +- `NwcPaymentTracker` — `ConcurrentMap` plus `kotlin.concurrent.atomics.AtomicInt` + for `spoofAttempts` (`incrementAndGet()` → `fetchAndIncrement()`, `get()` → + `load()`, under `@OptIn(ExperimentalAtomicApi::class)` as quartz already does + in `BleChunkAssembler` and `BanStore`). `containsKey` has no `ConcurrentMap` + equivalent and became a null check on `get`, which is the same question for a + map that never stores nulls. Its test moved to `commonTest`, so the tracker's + 4 race cases now also run on iOS rather than JVM only. + +`OnchainZapResolver` cannot move yet — it reads `EventCache` — but its two +`ConcurrentHashMap.newKeySet()` in-flight gates are now the commons +`ConcurrentSet`, which is exactly `newKeySet()` on JVM/Android. That leaves it +with no `java.*` import, so it travels with `EventCache` in step 5 for free. + +`MintDirectoryIndexTest` stayed in `jvmTest`: it is written against +`org.junit.Assert`, and converting it to `kotlin.test` is unrelated churn. + +Remaining in the table: `EventListMatchingFilter` / `NoteListMatchingFilter`, +which also carry `SortedSet` — they belong to step 3, not this one. + +Verified: `:commons:compileCommonMainKotlinMetadata`, +`:commons:compileIosMainKotlinMetadata`, `:commons:jvmTest` (incl. the moved +`NwcPaymentTrackerTest`, 4 tests), `:desktopApp:test`, `:cli:test`, +`:amethyst:compileFdroidDebugKotlin`, `DvmHeartbeatTest`, `spotlessCheck`. + +### Steps 3–5 shipped: the cache group is `commonMain` + +`commons/src/jvmAndroid/…/model/cache/` now holds exactly one file, the +`LargeSoftCache` actual. Everything else — `EventCache` (4k lines), +`LocalCache`, `LocalCacheHost`, `AntiSpamFilter`, `CachePruner`, `CacheSearch`, +`OnchainZapResolver` — is shared. + +**The NIP-95 sink.** `nip95BlobDir: File?` became `nip95Blobs: Nip95BlobStore?`, +because a directory is not what the cache needs — somewhere to put bytes and a +way to ask whether they are there already is. `FileSystemNip95BlobStore` is the +okio-backed one, and okio was already a commons dependency. Its constructor +takes a plain path string so Android needs no okio of its own, and +`platformFileSystem` is a two-line expect/actual because okio declares +`FileSystem.SYSTEM` per platform. + +Two bugs surfaced while rewriting that block. `decode()` returns `ByteArray?` +and went straight into `FileOutputStream.write` — a platform type, so a null +would have thrown past the `IOException` catch. And the note's copy dropped its +content whenever a directory existed, *including* right after a failed write, +losing the only copy of those bytes; content is now dropped only when the blob +really is stored. + +**`SortedSet`.** `EventCache.filter` returns a `List` in the comparator's +order, newest first. That order is load-bearing (the napplet gateway answers +REQs from it). The comparator also defines uniqueness by reference, so the +sorted set was collapsing the duplicate references a filter with a repeated +kind produces; `toSet()` before sorting keeps exactly that, since `Note` +declares no `equals()`. + +**The observables.** `NoteListMatchingFilter` / `EventListMatchingFilter` are +now `expect`/`actual` with today's implementation untouched as the jvmAndroid +actual and a throwing iOS stub. Their concurrency — every sorted-set write +inside that key's `ConcurrentHashMap.compute` critical section — is not +reassemblable from quartz's KMP primitives: `getOrPut` covers `new()`, but +`remove()` needs the sorted-set removal *inside* the section, or a concurrent +re-add inserts a comparator-equal entry that the later removal takes out +instead, dropping the note for good. A copy-on-write `compute` cannot stand in +either, since its CAS retry may run a side-effecting lambda twice. + +**What the move itself turned up**, none of it visible to an import grep: +`@Synchronized` / `@Volatile` / `synchronized {}` (→ `KmpLock`, +`kotlin.concurrent.Volatile`), `System.nanoTime` (→ `TimeSource.Monotonic`), +`HashMap.merge` (→ a local `mergeMax`), `Dispatchers.IO` (just needs +`import kotlinx.coroutines.IO`), the one `dateFormatter` log line (now logs the +raw `created_at`), and `LnurlEndpointCache` — a quartz **jvmAndroid** singleton, +reached through a new `LocalCacheHost.lnurlEndpoint` port whose default `null` +means "cold cache", which is what a miss already meant. + +**Still true, and worth repeating:** commonMain is not iOS support. iOS now +compiles the cache, and the cache's store and its two observables throw there. +The remaining gap is three named files, not a module boundary. + +Verified: `:commons:compileCommonMainKotlinMetadata`, +`:commons:compileIosMainKotlinMetadata`, `:commons:verifyKmpPurity`, +`:commons:jvmTest`, `:desktopApp:test`, `:cli:test`, +`:amethyst:compileFdroidDebugKotlin`, `:amethyst:testPlayDebugUnitTest`, +`spotlessCheck`. + +## Step 8 — measuring the copy-on-write observables (the "is it actually faster?" question) + +The rewrite of `NoteListMatchingFilter` / `EventListMatchingFilter` from +`ConcurrentSkipListSet` + `ConcurrentHashMap` to copy-on-write over an +`AtomicReference` was made for **portability** — `java.util.concurrent` has no +KMP equivalent, and that is what kept these two in `jvmAndroid` with an iOS +stub. That argument says nothing about speed, and these sit on a hot path: +every consumed event is offered to each observer whose filter could match it, +from each relay's socket coroutine. A few hundred events a second across a +dozen relays reaches this code thousands of times a second. + +So it was measured rather than argued. `ObserverListBenchmark` +(`commons/src/jvmTest/.../prodbench/`) keeps the skip-list implementation +verbatim as the baseline and as a **differential oracle**: `bothImplementations +Agree` asserts the two emit identical lists for identical input at limits +`null` / 50 / 400, which is the property the rewrite had to preserve. It runs +in ~7s and asserts only on correctness, never on wall time. + +**What the first run found:** the copy-on-write version was *slower* on the +limited-filter insert path and **4.9x slower on 8 concurrent threads**, with +zero thread scaling. The cause was not the design but one line — +`State.plus` published `ids + added` and, over the limit, `ids + added - +dropped`. Each operator allocates a full copy of the set, so the eviction path +rebuilt the membership set **twice per insert**. Replacing both with a single +`HashSet` copy (sized up front, mutated, then published) is the whole of the +fix. + +**After that fix**, against the implementation it replaced: + +| shape | result | +|---|---| +| re-deliver an already listed note (steady state once a screen is warm) | **3.3–4.1x faster** | +| insert into a populated *unlimited* list, n = 100 / 1000 | **1.3–1.5x faster**, widening with n | +| cold fill, n = 5000 | **1.8x faster** | +| concurrent inserts into the same observer, 1 thread | parity | +| concurrent inserts into the same observer, 4 / 8 threads | **2.1–2.7x slower** | + +The last row is the real cost and is documented on both classes rather than +buried here: threads serialize on one reference and a lost CAS discards its +copy, where the skip list striped across keys and scaled with thread count. Two +things bound it. The benchmark's threads do nothing but insert, while real +ingest spends most of its per-event budget on signature verification and +parsing before reaching an observer, so the contention window is a fraction of +the measured one. And nearly every production observer registers with **no +`limit`** — grep the `observeNotes` / `observeEvents` call sites — which is the +shape copy-on-write wins. + +Worth revisiting if a profile ever disagrees. The lever would be the emit, not +the lock: both implementations already materialize the whole list on every +write, so an observer that only ever appends is paying O(n) to tell the UI +about one new row. + +**Also worth recording:** "lock-free" was never the differentiator between the +two. The skip-list version was lock-free too — `ConcurrentHashMap` stripes per +key, so `compute` holds one bin, not a monitor. The choice was portability and +speed, not locking. + +Verified: `:commons:jvmTest`, `:commons:compileIosMainKotlinMetadata`, +`:commons:spotlessApply`. + +## Step 9 — the weak note cache has no strong referent in tests + +`compose-ui-test` went red on `DesktopCachePipelineTest`: +`FollowingFeedFilter only includes notes from followed users`, `expected:<1> but +was:<0>`, and it would not reproduce on a dev machine. + +The cause is this branch, indirectly. `DesktopLocalCache` kept a +`notesByAuthor: ConcurrentHashMap>` index for metadata +invalidation — an unbounded strong map holding every note the Desktop app ever +saw, which quietly defeated the point of storing them in a `LargeSoftCache`. +Removing it was right (the Android cache never had one). But it was also the +only thing keeping the test fixtures alive: every test consumes events and then +queries the cache for the notes they produced, with nothing in between holding a +reference. A GC landing in that window empties the cache. + +It reproduces deterministically with two `System.gc()` calls before the query, +and it is not specific to that one test — all 46 consume sites have the shape, +so CI's tighter heap just picked the victim. The fix routes every consume +through an `ingest` helper that pins what the cache built for the lifetime of +the test instance, the way a screen holds the notes it is showing in the app, +and keeps the forced GC in the test that failed so the contract is asserted +rather than left to the heap. diff --git a/commons/proguard-rules.pro b/commons/proguard-rules.pro deleted file mode 100644 index 0bf7b32616..0000000000 --- a/commons/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --keep class com.vitorpamplona.quartz.** { *; } --keep class com.vitorpamplona.amethyst.** { *; } \ No newline at end of file diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt similarity index 87% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt index 6d61a538b0..dd6b872d6e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.cashu -import java.util.concurrent.ConcurrentHashMap +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap /** * In-memory directory of Cashu mint URLs the app has seen on the network, @@ -43,14 +43,15 @@ import java.util.concurrent.ConcurrentHashMap * `https://` scheme are dropped — autocomplete on garbage tags would * surface noise. * - * Thread-safe via `ConcurrentHashMap`; safe to call from any dispatcher. + * Thread-safe via [ConcurrentMap], whose `merge` is atomic on every target, so a + * count cannot be lost to a racing [add]; safe to call from any dispatcher. */ class MintDirectoryIndex { - private val counts = ConcurrentHashMap() + private val counts = ConcurrentMap() fun add(rawUrl: String) { val key = normalize(rawUrl) ?: return - counts.merge(key, 1, Int::plus) + counts.merge(key, 1) { old, new -> old + new } } fun addAll(rawUrls: Iterable) = rawUrls.forEach(::add) @@ -69,7 +70,11 @@ class MintDirectoryIndex { val ranking = compareByDescending> { it.value } .thenBy { it.key } - return counts.entries + // snapshot() instead of a live view: the ranking sorts the whole entry set, + // and a copy cannot shift under the comparator while it does. + return counts + .snapshot() + .entries .asSequence() .filter { needle.isEmpty() || it.key.contains(needle) } .sortedWith(ranking) @@ -79,7 +84,7 @@ class MintDirectoryIndex { } /** Total number of unique mint URLs in the index. */ - fun size(): Int = counts.size + fun size(): Int = counts.size() /** Clears the index — for tests; production should let it accumulate. */ fun clear() = counts.clear() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index feb37f0cde..0991204ea0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -186,7 +186,7 @@ class NostrSignerPermissionLedger( * delete afterwards, in the same risk class as the original kind 1/6/7 set (notes, reposts, * reactions, pictures, videos, voice, public/live/relay chat, threads, polls, comments, * highlights, code snippets, file metadata, reports, torrents, long-form articles, wiki, status). - * Some are *addressable* (long-form 30023, wiki 30818, legacy video 34235/34236): re-signing + * Some are *addressable* (long-form 30023, wiki 30818, video 34235/34236): re-signing * with the same `d` tag replaces the app's own prior version at that address — an accepted * trade-off, since an app that can already post arbitrary notes could do equal reputational harm. * The set also includes two harmless non-content signatures: @@ -247,8 +247,8 @@ class NostrSignerPermissionLedger( LongTextNoteEvent.KIND, // 30023 — NIP-23 long-form articles (addressable content) StatusEvent.KIND, // 30315 — ephemeral user status / presence WikiNoteEvent.KIND, // 30818 — NIP-54 wiki articles (addressable content) - VideoHorizontalEvent.KIND, // 34235 — legacy addressable horizontal video (NIP-71) - VideoVerticalEvent.KIND, // 34236 — legacy addressable vertical video (NIP-71) + VideoHorizontalEvent.KIND, // 34235 — addressable horizontal video (NIP-71) + VideoVerticalEvent.KIND, // 34236 — addressable vertical video (NIP-71) PublicationIndexEvent.KIND, // 30040 — NKBIP-01 publication index (addressable content) LearningResourceEvent.KIND, // 30142 — learning resources (addressable content) BlossomPieceIndexEvent.KIND, // 32176 — Blossom piece indexes (addressable content) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index c7e11dd6d3..3dde3a6d1e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent import com.vitorpamplona.quartz.utils.BigDecimal import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.anyAsync @@ -346,7 +347,14 @@ open class Note( fun relayUrlsForReactions(): List { val authorRelay = author?.inboxRelays() ?: emptyList() - return authorRelay + relays + // A NIP-CC cache names where its finds belong, and that is exactly where Amethyst writes + // them (LogGeocacheFindViewModel sends to the same list). Reading back from anywhere else + // makes the write and the read land on disjoint relay sets: a cache whose log relays are + // neither the owner's inbox nor wherever we happened to see the listing reports "No one + // has logged this cache yet" while its logs sit precisely where the listing said to look. + val declaredLogRelays = (event as? GeocacheListingEvent)?.logRelays() ?: emptyList() + + return authorRelay + relays + declaredLogRelays } fun relayHintUrl(): NormalizedRelayUrl? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/AntiSpamFilter.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/AntiSpamFilter.kt index 752492a5c3..545e73cd6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/AntiSpamFilter.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache -import android.util.LruCache -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.ui.note.njumpLink +import androidx.collection.LruCache +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.njumpLink +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -42,7 +43,13 @@ data class Spammer( fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5 } -class AntiSpamFilter { +class AntiSpamFilter( + /** + * Where duplicate reports go. Read on each hit rather than captured, because the cache that + * owns this filter is built long before the app shell installs its [LocalCacheHost]. + */ + private val host: () -> LocalCacheHost = { LocalCacheHost }, +) { val recentEventIds = LruCache(2000) val recentAddressables = LruCache(2000) val spamMessages = LruCache(1000) @@ -78,14 +85,14 @@ class AntiSpamFilter { val address = event.address() // normal event + // existingAddress may be null if the first duplicate was evicted from the LRU cache + // while the spammer record still matches this hash. + val existingAddress = recentAddressables[hash] + val knownSpammer = spamMessages[hash] if ( - (recentAddressables[hash] != null && recentAddressables[hash] != address) || - (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address)) + (existingAddress != null && existingAddress != address) || + (knownSpammer != null && !knownSpammer.duplicatedEventAddresses.contains(address)) ) { - // may be null if the first duplicate was evicted from the LRU cache - // while the spammer record still matches this hash. - val existingAddress = recentAddressables[hash] - val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay)) val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2 @@ -101,9 +108,10 @@ class AntiSpamFilter { val spammer = logOffender(hash, event) if (spammer.shouldHide() && relay != null) { - Amethyst.instance.relayStats - .get(relay) - .newSpam(link1, link2) + host() + .relayStats + ?.get(relay) + ?.newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) @@ -115,9 +123,10 @@ class AntiSpamFilter { } else { // normal event val existingEvent = recentEventIds[hash] + val knownSpammer = spamMessages[hash] if ( (existingEvent != null && existingEvent != event.id) || - (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id)) + (knownSpammer != null && !knownSpammer.duplicatedEventIds.contains(event.id)) ) { val link2 = njumpLink(NEvent.create(event.id, null, null, relay)) // existingEvent may be null if the first duplicate was evicted from the @@ -136,9 +145,10 @@ class AntiSpamFilter { val spammer = logOffender(hash, event) if (spammer.shouldHide() && relay != null) { - Amethyst.instance.relayStats - .get(relay) - .newSpam(link1, link2) + host() + .relayStats + ?.get(relay) + ?.newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) @@ -152,39 +162,43 @@ class AntiSpamFilter { return false } - @Synchronized + // Was @Synchronized, which is a JVM-only annotation: same mutual exclusion, spelled + // for every target. Only this method touches spamMessages for a given hash. + private val offenderLock = KmpLock() + private fun logOffender( hashCode: Int, event: Event, - ): Spammer { - val spammer = spamMessages.get(hashCode) + ): Spammer = + offenderLock.withLock { + val spammer = spamMessages.get(hashCode) - return if (spammer == null) { - val newSpammer = - if (event is AddressableEvent) { - Spammer( - pubkeyHex = event.pubKey, - duplicatedEventIds = setOf(), - duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()), - ) - } else { - Spammer( - pubkeyHex = event.pubKey, - duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id), - duplicatedEventAddresses = setOf(), - ) - } - spamMessages.put(hashCode, newSpammer) - newSpammer - } else { - if (event is AddressableEvent) { - spammer.duplicatedEventAddresses += event.address() + if (spammer == null) { + val newSpammer = + if (event is AddressableEvent) { + Spammer( + pubkeyHex = event.pubKey, + duplicatedEventIds = setOf(), + duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()), + ) + } else { + Spammer( + pubkeyHex = event.pubKey, + duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id), + duplicatedEventAddresses = setOf(), + ) + } + spamMessages.put(hashCode, newSpammer) + newSpammer } else { - spammer.duplicatedEventIds += event.id + if (event is AddressableEvent) { + spammer.duplicatedEventAddresses += event.address() + } else { + spammer.duplicatedEventIds += event.id + } + spammer } - spammer } - } val flowSpam = MutableStateFlow(AntiSpamState(this)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt index 40679c575e..8f26268712 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -62,7 +62,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * `MemoryTrimmingService`. */ class CachePruner( - private val cache: LocalCache, + private val cache: EventCache, ) { fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" } @@ -85,7 +85,7 @@ class CachePruner( private fun pruneHiddenMessagesChannel( channel: Channel, - account: Account, + account: IAccount, ) { val toBeRemoved = channel.pruneHiddenMessages(account) @@ -106,7 +106,7 @@ class CachePruner( } } - fun pruneHiddenMessages(account: Account) { + fun pruneHiddenMessages(account: IAccount) { cache.ephemeralChannels.forEach { _, channel -> pruneHiddenMessagesChannel(channel, account) } @@ -162,7 +162,7 @@ class CachePruner( } fun pruneOldMessages() { - checkNotInMainThread() + cache.appHost.assertNotMainThread() cache.ephemeralChannels.forEach { _, channel -> pruneOldMessagesChannel(channel) @@ -214,12 +214,12 @@ class CachePruner( is BaseDMGroupEvent -> if (giftWrapFloor != null) { val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt - if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.mergeMax(it, outerUntil) } } is PrivateDmEvent -> { val until = ev.createdAt - if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } - if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.mergeMax(it, until) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.mergeMax(it, until) } } } @@ -310,7 +310,7 @@ class CachePruner( } fun pruneRepliesAndReactions(accounts: Set) { - checkNotInMainThread() + cache.appHost.assertNotMainThread() val toBeRemoved = cache.notes.filter { _, note -> @@ -448,7 +448,7 @@ class CachePruner( } fun pruneExpiredEvents() { - checkNotInMainThread() + cache.appHost.assertNotMainThread() val now = TimeUtils.now() val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true } @@ -473,13 +473,20 @@ class CachePruner( } } - fun pruneHiddenEvents(account: Account) { - checkNotInMainThread() + /** + * Drops every event authored by a muted or blocked user. + * + * Takes the mute list by value rather than the account that holds it: the pubkeys are all + * this needs, and [IAccount] exposes hidden users only as hash codes, which would match + * authors the reader never muted. + */ + fun pruneHiddenEvents(hidden: LiveHiddenUsers) { + cache.appHost.assertNotMainThread() val childrenToBeRemoved = mutableListOf() val toBeRemoved = - account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> + hidden.hiddenUsers.flatMap { userHex -> (cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() } @@ -493,3 +500,15 @@ class CachePruner( println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") } } + +/** + * `Map.merge(key, value, ::maxOf)` spelled for every target — the JVM's own merge is not in the + * common stdlib. Keeps the highest value seen for [relay]. + */ +private fun MutableMap.mergeMax( + relay: NormalizedRelayUrl, + value: Long, +) { + val existing = this[relay] + this[relay] = if (existing == null) value else maxOf(existing, value) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CacheSearch.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CacheSearch.kt index 0c6a81c41d..6abcdaefd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CacheSearch.kt @@ -18,17 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache +import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.search.RenderableKinds -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.tagValueContains import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -53,15 +52,24 @@ import kotlinx.coroutines.CancellationException * tested against a populated cache. */ class CacheSearch( - private val cache: LocalCache, + private val cache: EventCache, ) { + /** + * Users whose name, display name, NIP-05 or lightning address matches [username], best first. + * + * [forAccount] is the reader: when given, their muted users and muted words are filtered out + * and the people they follow sort first. `null` searches everything and ranks on the name + * match alone — what a caller with no account in hand (a mention autocomplete, a spotlight) + * wants. [limit] caps the result *after* ranking, so the cap never costs the best matches. + */ fun findUsersStartingWith( username: String, - forAccount: Account?, + forAccount: IAccount?, + limit: Int? = null, ): List { if (username.isBlank()) return emptyList() - checkNotInMainThread() + cache.appHost.assertNotMainThread() val key = decodePublicKeyAsHexOrNull(username) @@ -89,24 +97,28 @@ class CacheSearch( user.pubkeyHex.startsWith(username, true) || user.pubkeyNpub().startsWith(username, true) ) && - (forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) + (forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenWordsCase))) } } - val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true } + val following = forAccount?.followingKeySet() ?: emptySet() + val findsFollowing = finds.associateWith { it.pubkeyHex in following } val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true } val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true } val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() } - return finds.sortedWith( - compareBy( - { findsFollowing[it] == false }, - { anyNameStartsWith[it] == false }, - { anyAddressStartsWith[it] == false }, - { displayNames[it] }, - { it.pubkeyHex }, - ), - ) + val ranked = + finds.sortedWith( + compareBy( + { findsFollowing[it] == false }, + { anyNameStartsWith[it] == false }, + { anyAddressStartsWith[it] == false }, + { displayNames[it] }, + { it.pubkeyHex }, + ), + ) + + return if (limit != null) ranked.take(limit) else ranked } /** @@ -152,7 +164,7 @@ class CacheSearch( filters: List, hidden: LiveHiddenUsers, ): List { - checkNotInMainThread() + cache.appHost.assertNotMainThread() if (filters.isEmpty()) return emptyList() @@ -189,7 +201,7 @@ class CacheSearch( text: String, hidden: LiveHiddenUsers, ): List { - checkNotInMainThread() + cache.appHost.assertNotMainThread() if (text.isBlank()) return emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index ebf639ff2b..eedda6510a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -20,10 +20,9 @@ */ @file:Suppress("DEPRECATION") -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel @@ -40,9 +39,6 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache -import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel @@ -51,6 +47,8 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy +import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry +import com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -60,11 +58,6 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.model.redirectStrayRelayGroupContent import com.vitorpamplona.amethyst.commons.service.BundledInsert import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker -import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.amethyst.model.LocalCache.observeEvents -import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent @@ -326,7 +319,6 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnZapReceiptValidator -import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent @@ -407,6 +399,10 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent @@ -419,6 +415,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -428,22 +425,32 @@ import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.util.SortedSet +import kotlin.concurrent.Volatile +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.TimeSource -interface ILocalCache { - fun markAsSeen( - eventId: String, - relay: NormalizedRelayUrl, - ) { - // Default no-op; implementations may override to track seen events per relay - } -} +/** + * The in-memory event store: every `Note`, `User` and `Channel` the app has consumed, plus the + * indexes over them. + * + * A class rather than a singleton so a front end can own its cache and a test can have one to + * itself. Android uses the process-wide [LocalCache] instance; Desktop and tests build their own. + */ +open class EventCache : + ILocalCache, + ICacheProvider, + Dao { + /** + * What this cache needs from the application shell around it: a scope to verify on, the + * NIP-95 blob directory, the relay identity and stats sinks, and the main-thread assertion. + * Defaults to [LocalCacheHost.Default], which supplies none of them; Android installs its + * own during app startup. + */ + @Volatile + var appHost: LocalCacheHost = LocalCacheHost -object LocalCache : ILocalCache, ICacheProvider, Dao { - val antiSpam = AntiSpamFilter() + val antiSpam = AntiSpamFilter { appHost } val users = LargeSoftCache() val notes = LargeSoftCache() @@ -508,8 +515,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { */ val mintDirectory = MintDirectoryIndex() - @Volatile private var mintDirectoryBackfilled = false - private val mintDirectoryBackfillLock = Any() + @OptIn(ExperimentalAtomicApi::class) + private val mintDirectoryBackfilled = AtomicBoolean(false) /** * Sweeps `notes` + `addressables` for any NIP-87 / NIP-61 event the @@ -523,15 +530,17 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { * scan failure is swallowed so the index stays usable even if the * cache is in an unexpected state. */ + @OptIn(ExperimentalAtomicApi::class) fun ensureMintDirectoryBackfilled() { - if (mintDirectoryBackfilled) return - synchronized(mintDirectoryBackfillLock) { - if (mintDirectoryBackfilled) return - runCatching { - notes.forEach { _, note -> note.event?.let(::updateMintIndex) } - addressables.forEach { _, note -> note.event?.let(::updateMintIndex) } - } - mintDirectoryBackfilled = true + // Claim the sweep with a CAS rather than a lock: exactly one caller gets true and does + // the work. A second caller returns immediately instead of blocking for the length of a + // full cache scan — it may see a partially filled index, which is the same thing it sees + // before any backfill runs, and one relay round-trip later it does not. + if (!mintDirectoryBackfilled.compareAndSet(false, true)) return + + runCatching { + notes.forEach { _, note -> note.event?.let(::updateMintIndex) } + addressables.forEach { _, note -> note.event?.let(::updateMintIndex) } } } @@ -569,6 +578,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { hidden: LiveHiddenUsers, ) = search.findNotesStartingWith(text, hidden) + override fun findUsersStartingWith( + prefix: String, + limit: Int, + ) = search.findUsersStartingWith(prefix, null, limit) + override fun findPublicChatChannelsStartingWith(text: String) = search.findPublicChatChannelsStartingWith(text) override fun findEphemeralChatChannelsStartingWith(text: String) = search.findEphemeralChatChannelsStartingWith(text) @@ -584,7 +598,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - fun filter(filter: Filter): SortedSet = filter(filter) { true } + fun filter(filter: Filter): List = filter(filter) { true } /** * Every note matching [filter]'s NIP-01 fields that also satisfies [predicate]. @@ -597,7 +611,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun filter( filter: Filter, predicate: (Note) -> Boolean, - ): SortedSet { + ): List { val byKinds = filter.kinds?.filter { it.isAddressable() || it.isReplaceable() } val addressableMatches = @@ -636,20 +650,26 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - val all = (addressableMatches + noteMatches).toSortedSet(CreatedAtIdHexComparator) + // toSet() before sorting: a filter that repeats a kind scans that kind twice, and the + // SortedSet this used to return collapsed the repeats. Note declares no equals(), so a + // plain Set is the same reference-identity de-duplication that comparator gave. + // + // The order is the comparator's, newest first, and it is load-bearing: the napplet + // gateway answers REQs out of this, and NIP-01 has relays return events newest first. + val all = (addressableMatches + noteMatches).toSet().sortedWith(CreatedAtIdHexComparator) val limit = filter.limit ?: return all // Sorted first, then cut. Both halves arrive in hash-walk order, so taking before sorting // dropped whichever matches the walk happened to reach last — the newest ones as often as // not — and a query with 200 addressable matches never showed a single regular note. if (all.size <= limit) return all - return all.asSequence().take(limit).toCollection(sortedSetOf(CreatedAtIdHexComparator)) + return all.take(limit) } fun observeNotes(filter: Filter): Flow> = callbackFlow { val newFilter = - NoteListMatchingFilter(filter, this@LocalCache::filter) { + NoteListMatchingFilter(filter, this@EventCache::filter) { trySend(it) } @@ -665,7 +685,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun observeEvents(filter: Filter): Flow> = callbackFlow { val cachedFilter = - EventListMatchingFilter(filter, this@LocalCache::filter) { + EventListMatchingFilter(filter, this@EventCache::filter) { trySend(it) } @@ -859,8 +879,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return null } - override fun getEventStream(): com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream = - object : com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream { + override fun getEventStream(): ICacheEventStream = + object : ICacheEventStream { override val newEventBundles = live.newEventBundles override val deletedEventBundles = live.deletedEventBundles } @@ -1349,6 +1369,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } + is GeocacheFoundLogEvent -> { + // Files the log under the cache's addressable note, so a cache's replies hold its + // found logs next to the NIP-22 comments that carry its did-not-finds and + // maintenance notes. Both halves of a cache's history then arrive through the one + // collection the thread view already reads. + listOfNotNull(event.geocache()?.let { getOrCreateAddressableNote(it) }) + } + is StreamMessageV2Event -> { // A Buzz thread reply links to the message it answers (its `reply`-marked e-tag) so it // lands in that message's replies (the minichat). A plain message / non-reply has no marker. @@ -2135,10 +2163,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { event: Event, relay: NormalizedRelayUrl, ): Boolean { - val self = - Amethyst.instance.nip11Cache - .getFromCache(relay) - .self ?: return true + val self = appHost.relaySelfPubKey(relay) ?: return true return event.pubKey == self } @@ -2670,7 +2695,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // stays in cache as a visible artifact but contributes 0 to zap totals. val recipientLnurl = recipientLnurl(event) val recipientLnurlpUrl = recipientLnurl?.let { LnurlForm.toUrl(it) } - val cachedInfo = recipientLnurlpUrl?.let { LnurlEndpointCache.get(it) } + val cachedInfo = recipientLnurlpUrl?.let { appHost.lnurlEndpoint(it) } val author = getOrCreateUser(event.pubKey) val repliesTo = computeReplyTo(event) @@ -2716,7 +2741,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachZapToLiveActivityChannel(event, note, relay) refreshNewNoteObservers(note) - Amethyst.instance.applicationIOScope.launch { + appHost.scope.launch { try { val info = resolver.resolve(recipientLnurlpUrl) if (info == null) { @@ -3078,25 +3103,31 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { note.addRelay(relay) } + // A host with nowhere to spill keeps the bytes in memory below instead. + val blobs = appHost.nip95Blobs + + // Whether the bytes are in the store now. The note's copy may only drop its content + // once this is true — a store that refused the write is not somewhere to drop it. + var spilled = blobs != null && blobs.exists(event.id) + val isVerified = - try { - val cachePath = Amethyst.instance.nip95cache - cachePath.mkdirs() - val file = File(cachePath, event.id) - if (!file.exists() && (wasVerified || justVerify(event))) { - FileOutputStream(file).use { stream -> - stream.write(event.decode()) - } + if (blobs == null || spilled) { + wasVerified + } else if (wasVerified || justVerify(event)) { + // decode() is null when the content is not the base64 the event claims, so + // there is nothing to spill and nothing this call can vouch for. + val bytes = event.decode() + if (bytes != null && blobs.store(event.id, bytes)) { + spilled = true Log.i( "FileStorageEvent", - "NIP95 File received from $relay and saved to disk as $file", + "NIP95 File received from $relay and stored as ${event.id}", ) true } else { wasVerified } - } catch (e: IOException) { - Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) + } else { wasVerified } @@ -3104,11 +3135,17 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { if (note.event != null) return false if (isVerified || justVerify(event)) { - // this is an invalid event. But we don't need to keep the data in memory. - val eventNoData = - FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) + // The blob is in the store, so the copy in the note drops its content. If it is + // not — no store, a refused write, undecodable content — the note is the only + // thing holding those bytes, so the event stays whole. + val stored = + if (spilled) { + FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) + } else { + event + } - note.loadEvent(eventNoData, author, emptyList()) + note.loadEvent(stored, author, emptyList()) refreshNewNoteObservers(note) @@ -3228,7 +3265,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } - Amethyst.instance.applicationIOScope.launch { + appHost.scope.launch { responseCallback(event) } @@ -3309,14 +3346,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null fun justVerify(event: Event): Boolean { - checkNotInMainThread() + appHost.assertNotMainThread() val meter = verifyMeter if (meter == null) return justVerifyInner(event) - val start = System.nanoTime() + val start = TimeSource.Monotonic.markNow() val valid = justVerifyInner(event) - meter(System.nanoTime() - start, valid) + meter(start.elapsedNow().inWholeNanoseconds, valid) return valid } @@ -3326,7 +3363,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { event.checkSignature() } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("Event Verification Failed") { "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}" } + Log.w("Event Verification Failed") { "Kind: ${event.kind} created at ${event.createdAt} with message ${e.message}" } } false } else { @@ -3356,7 +3393,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { deletionIndex.hasBeenDeletedBy(event)?.let { deletionEvent -> getNoteIfExists(deletionEvent.id)?.let { note -> if (!note.hasRelay(relay.url)) { - if (isDebug) { + if (appHost.isDebug) { Log.d("LocalCache") { "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}" } } relay.sendIfConnected(EventCmd(deletionEvent)) @@ -3373,7 +3410,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { getAddressableNoteIfExists(event.address())?.let { note -> note.event?.let { existingEvent -> if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url) && !deletionIndex.hasBeenDeleted(event) && !event.isExpired()) { - if (isDebug) { + if (appHost.isDebug) { Log.d("LocalCache") { "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}" } } @@ -3789,6 +3826,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is ExternalIdentitiesEvent, is FileServersEvent, is FollowListEvent, + is GeocacheListingEvent, + is GeocacheCurationListEvent, is GeohashListEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, @@ -3930,6 +3969,13 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is RequestToVanishEvent, is CodeSnippetEvent, is ZapPollEvent, + is GeocacheFoundLogEvent, + // A standalone kind 7517. NIP-CC lets a verification be published on its own as well + // as embedded in the log, so one can arrive; storing it keeps it out of the + // "Event Not Supported" log. It is proof material, not content — no feed asks for + // this kind, and nothing trusts one until GeocacheVerificationValidator has checked + // it against a listing. + is GeocacheVerificationEvent, is RoadEventReportEvent, is RoadEventConfirmationEvent, is SealedRumorEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ILocalCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ILocalCache.kt new file mode 100644 index 0000000000..615148ad29 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ILocalCache.kt @@ -0,0 +1,43 @@ +/* + * 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.cache + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The write-side port of the event cache: what a relay connection needs to call when a relay + * confirms it holds an event, without naming the concrete cache. + * + * Kept separate from [ICacheProvider], which is the read side the model layer uses. + */ +interface ILocalCache { + /** + * Records that [relay] is known to hold [eventId] — because it served it, or acknowledged + * it with an `OK`. + * + * Default no-op so a cache that keeps no per-relay provenance need not pretend to. + */ + fun markAsSeen( + eventId: String, + relay: NormalizedRelayUrl, + ) { + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt new file mode 100644 index 0000000000..fd60ec9a03 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cache + +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.quartz.utils.cache.CacheCollectors +import com.vitorpamplona.quartz.utils.cache.ICacheBiConsumer +import com.vitorpamplona.quartz.utils.cache.ICacheOperations + +/** + * A **sorted**, thread-safe cache whose values are held by [WeakReference], so an + * entry no other part of the app references is free to be collected. + * + * Two properties are load-bearing and any `actual` must preserve both: + * + * 1. **Weak values.** The cache alone never keeps an object alive. Callers that + * need a value to survive must hold it themselves — see [getOrCreate], which + * returns the live instance and re-creates it after a collection. + * 2. **Key ordering.** Iteration follows the natural order of [K], and the + * ranged `from`/`to` operations inherited from [ICacheOperations] (the + * `filter(from, to, …)` family behind `LargeSoftCacheAddressExt.kt`) must scan + * only that sub-range. Backing this with a hash map would still *compile*, + * but would silently turn every bounded scan — the kind lookups the feeds + * run constantly — into a full scan of every entry. + * + * The JVM/Android `actual` is a `ConcurrentSkipListMap` of weak references, + * which gives both for free. iOS has neither a sorted concurrent map nor a + * weak-valued one in its standard library, so its actual is a stub that throws + * — see `LargeSoftCache.ios.kt`. + */ +expect class LargeSoftCache() : ICacheOperations { + fun keys(): Set + + fun get(key: K): V? + + fun remove(key: K) + + /** Removes [key] only while it still maps to [value]; returns true if it did. */ + fun removeIf( + key: K, + value: WeakReference, + ): Boolean + + fun isEmpty(): Boolean + + fun clear() + + fun containsKey(key: K): Boolean + + /** Puts [value] under [key], held weakly. */ + fun put( + key: K, + value: V, + ) + + /** + * Returns the live value for [key], building and caching a new one when the + * key is absent or its referent has already been collected. + */ + fun getOrCreate( + key: K, + builder: (key: K) -> V, + ): V + + /** Drops every entry whose referent has already been collected. */ + fun cleanUp() + + // Read-side operations inherited from ICacheOperations. An expect class has + // to declare the abstract members it inherits, same as quartz's LargeCache. + override fun size(): Int + + override fun forEach(consumer: ICacheBiConsumer): Unit + + override fun filter(consumer: CacheCollectors.BiFilter): List + + override fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set + + override fun map(consumer: CacheCollectors.BiNotNullMapper): List + + override fun mapNotNull(consumer: CacheCollectors.BiMapper): List + + override fun mapNotNullIntoSet(consumer: CacheCollectors.BiMapper): Set + + override fun mapFlatten(consumer: CacheCollectors.BiMapper?>): List + + override fun mapFlattenIntoSet(consumer: CacheCollectors.BiMapper?>): Set + + override fun maxOrNullOf( + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? + + override fun sumOf(consumer: CacheCollectors.BiSumOf): Int + + override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong): Long + + override fun groupBy(consumer: CacheCollectors.BiNotNullMapper): Map> + + override fun countByGroup(consumer: CacheCollectors.BiNotNullMapper): Map + + override fun sumByGroup( + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map + + override fun count(consumer: CacheCollectors.BiFilter): Int + + override fun associate(transform: (K, V) -> Pair): Map + + override fun associateWith(transform: (K, V) -> U?): Map + + override fun filter( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): List + + override fun filterIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Set + + override fun map( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): List + + override fun mapNotNull( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): List + + override fun mapNotNullIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): Set + + override fun mapFlatten( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): List + + override fun mapFlattenIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): Set + + override fun maxOrNullOf( + from: K, + to: K, + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? + + override fun sumOf( + from: K, + to: K, + consumer: CacheCollectors.BiSumOf, + ): Int + + override fun sumOfLong( + from: K, + to: K, + consumer: CacheCollectors.BiSumOfLong, + ): Long + + override fun groupBy( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map> + + override fun countByGroup( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map + + override fun sumByGroup( + from: K, + to: K, + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map + + override fun count( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Int + + override fun associate( + from: K, + to: K, + transform: (K, V) -> Pair, + ): Map + + override fun associateWith( + from: K, + to: K, + transform: (K, V) -> U?, + ): Map + + override fun joinToString( + separator: CharSequence, + prefix: CharSequence, + postfix: CharSequence, + limit: Int, + truncated: CharSequence, + transform: ((K, V) -> CharSequence)?, + ): String +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt similarity index 98% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt index 052642f80f..5ba77446a2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCacheAddressExt.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.model.cache import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.cache.CacheCollectors diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCache.kt similarity index 53% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCache.kt index 17f4b976aa..44eb87c74b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCache.kt @@ -18,25 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.jackson - -import com.fasterxml.jackson.annotation.JsonInclude +package com.vitorpamplona.amethyst.commons.model.cache /** - * Applied to a reflectively-serialized DTO so Jackson OMITS a null field instead - * of writing it. + * The process-wide [EventCache]. * - * Optional protocol fields are absent, not null. A peer is free to type one - * strictly: sending `"from": null` for an absent `from` earned - * `Invalid list_transactions params: from must be an integer` from a NIP-47 - * wallet, and the request failed. + * Android reaches the cache statically from ~350 call sites, so the singleton keeps its own name + * and they need no receiver. Note that Android runs this Application class in **two** processes + * (`main` and `:napplet`) and an `object` is one copy per process — the populated cache lives only + * in `main`, and nothing reachable from the sandbox should touch this. * - * A MIXIN rather than an annotation on the class, because these DTOs live in - * `commonMain` and Jackson annotations are JVM-only. Class-level rather than the - * mapper-wide `setSerializationInclusion`, which in Jackson 2.x also suppresses - * null MAP ENTRIES — and [com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement] - * deliberately keeps those, so a global setting would close one backend - * divergence by opening another. + * Front ends that own their cache — Desktop, and every test that wants isolation — construct an + * [EventCache] instead. */ -@JsonInclude(JsonInclude.Include.NON_NULL) -abstract class OmitNullsMixin +object LocalCache : EventCache() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCacheHost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCacheHost.kt new file mode 100644 index 0000000000..12d33d9fe5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LocalCacheHost.kt @@ -0,0 +1,98 @@ +/* + * 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.cache + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob + +/** + * The handful of things the event cache needs from the application shell around it. + * + * The cache itself is a plain in-memory store and can run without any of them, so every + * member here has a default that is the honest answer for a host that does not provide it + * — an own scope, nowhere to spill NIP-95 blobs, no relay identity, no stats sink. A front end + * installs its own via [EventCache.appHost]; Android's `Amethyst` does so at startup, + * Desktop and unit tests keep the defaults. + * + * This exists so [LocalCache] can live in `commons` without naming `Amethyst.instance`, + * `BuildConfig` or `android.os.Looper`. + * + * **Android's `:napplet` process must never reach the cache.** Now that [LocalCache] lives + * in `commons`, `:nappletHost` can see it, where before it could not compile a reference at + * all. A stray one there would no longer fail fast on an unset `Amethyst.instance` — it + * would quietly build a second, empty cache in that process, with its own IO scope nothing + * cancels. Processes share no memory: the populated cache exists only in `main`. + */ +interface LocalCacheHost { + /** Scope for the cache's own background work: NIP-BC chain lookups and LNURL fetches. */ + val scope: CoroutineScope + + /** + * Whether this is a debug build. Gates the two very chatty per-event log lines that + * dump full event JSON while back-filling a relay with a deletion or a newer version. + */ + val isDebug: Boolean + + /** + * Where NIP-95 `FileStorageEvent` blobs are spilled, so their bytes can leave memory once + * they are stored. `null` keeps the event in memory and skips the spill. + */ + val nip95Blobs: Nip95BlobStore? + + /** Per-relay counters the anti-spam filter reports duplicate events to. */ + val relayStats: RelayStats? + + /** + * The relay's own pubkey, as its NIP-11 document advertises it (`self`), used to accept + * relay-signed NIP-29 group metadata. `null` means unknown, which accepts the event. + */ + fun relaySelfPubKey(relay: NormalizedRelayUrl): HexKey? = null + + /** + * LNURL-pay endpoint metadata the shell has already resolved for [lnurlpUrl], used to + * validate a zap receipt's signer against the provider's `nostrPubkey` (NIP-57 Appendix F). + * + * `null` is "not cached", which is what a cache miss already means here: the receipt takes + * the slower resolver path instead. A host that caches nothing therefore behaves like a + * cold cache, not like a broken one. + */ + fun lnurlEndpoint(lnurlpUrl: String): LnurlEndpointInfo? = null + + /** + * Throws if called on the platform's main thread. Signature verification and the cache + * sweeps are far too slow to run there. A no-op by default and on release builds. + */ + fun assertNotMainThread() {} + + /** The neutral host: an own IO scope and nothing else. */ + companion object Default : LocalCacheHost { + override val scope: CoroutineScope by lazy { CoroutineScope(Dispatchers.IO + SupervisorJob()) } + override val isDebug = false + override val nip95Blobs: Nip95BlobStore? = null + override val relayStats: RelayStats? = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/Nip95BlobStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/Nip95BlobStore.kt new file mode 100644 index 0000000000..e35c28513f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/Nip95BlobStore.kt @@ -0,0 +1,75 @@ +/* + * 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.cache + +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.Log +import okio.FileSystem +import okio.IOException +import okio.Path +import okio.Path.Companion.toPath + +/** + * Where a NIP-95 blob's bytes go so they can leave memory. + * + * The cache does not need a directory, it needs somewhere to put bytes and a way to ask whether + * they are already there — which is all this is. A front end that has nowhere to spill to simply + * supplies none, and the event keeps its content in memory. + */ +interface Nip95BlobStore { + /** Whether a blob is already stored under [id]. */ + fun exists(id: String): Boolean + + /** Stores [bytes] under [id]. Returns false when the write failed. */ + fun store( + id: String, + bytes: ByteArray, + ): Boolean +} + +/** + * The ordinary [Nip95BlobStore]: one file per event id under [directory]. + * + * okio rather than `java.io.File` because this sits behind `LocalCacheHost`, which the event + * cache reads on every NIP-95 event, and the cache is shared code. + */ +class FileSystemNip95BlobStore( + private val directory: Path, + private val fileSystem: FileSystem = platformFileSystem, +) : Nip95BlobStore { + /** Takes the platform's own path string, so callers need no okio of their own. */ + constructor(directoryPath: String) : this(directoryPath.toPath()) + + override fun exists(id: String): Boolean = fileSystem.exists(directory / id) + + override fun store( + id: String, + bytes: ByteArray, + ): Boolean = + try { + fileSystem.createDirectories(directory) + fileSystem.write(directory / id) { write(bytes) } + true + } catch (e: IOException) { + Log.e("Nip95BlobStore", "Could not store the NIP-95 blob $id", e) + false + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip90DVMs/DvmHeartbeatRegistry.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip90DVMs/DvmHeartbeatRegistry.kt index 5f7ec90245..2e8e9f30b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip90DVMs/DvmHeartbeatRegistry.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.nip90DVMs import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import java.util.concurrent.ConcurrentHashMap /** * Strong, process-wide record of the latest heartbeat per DVM announcement address @@ -40,7 +40,7 @@ import java.util.concurrent.ConcurrentHashMap * One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat". */ object DvmHeartbeatRegistry { - private val latestBeatCreatedAt = ConcurrentHashMap>() + private val latestBeatCreatedAt = ConcurrentMap>() private fun flowFor(address: Address): MutableStateFlow = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipBCOnchainZaps/OnchainZapResolver.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipBCOnchainZaps/OnchainZapResolver.kt index b22c441921..c2176518f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipBCOnchainZaps/OnchainZapResolver.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nipBCOnchainZaps +package com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus -import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.EventCache +import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap @@ -33,17 +33,14 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit -import java.util.concurrent.ConcurrentHashMap /** * Coordinates NIP-BC on-chain zap verification on top of the chain backend. @@ -62,7 +59,7 @@ import java.util.concurrent.ConcurrentHashMap * across cache eviction and doesn't accumulate here. */ class OnchainZapResolver( - private val cache: LocalCache, + private val cache: EventCache, ) { /** * Caps the parallelism of [reverifyOnchainZapsForNote] so a thread with many @@ -74,10 +71,10 @@ class OnchainZapResolver( /** * In-flight set of event ids currently being verified. Prevents two `consume()` * calls (or a `consume` plus a reverify) from issuing parallel Esplora fetches - * for the same event. `ConcurrentHashMap.newKeySet` gives lock-free atomic `add` + * for the same event. [ConcurrentSet] gives lock-free atomic `add` * returning `true` only for the inserting caller. */ - private val verifyingEventIds: MutableSet = ConcurrentHashMap.newKeySet() + private val verifyingEventIds = ConcurrentSet() /** * In-flight set of note id strings currently being reverified. Lets the on-chain @@ -86,7 +83,7 @@ class OnchainZapResolver( * backstop; this one short-circuits earlier and avoids creating async coroutines * that would just no-op. */ - private val reverifyingNoteIds: MutableSet = ConcurrentHashMap.newKeySet() + private val reverifyingNoteIds = ConcurrentSet() /** * Shared poller for the current bitcoin chain tip height. Each gallery that @@ -94,16 +91,11 @@ class OnchainZapResolver( * entries whenever the tip advances. Lazy + `WhileSubscribed` so the HTTP call * only fires when at least one UI surface needs it. * - * Lazy initialization is required because [Amethyst.instance] may not exist - * when [OnchainZapResolver] is first constructed. Falls back to a constant - * null-emitting [StateFlow] if the application scope isn't available yet - * (e.g. unit tests, ContentProvider invocations), so the lazy field doesn't - * permanently fail with `UninitializedPropertyAccessException`. + * Lazy so the scope is read from the cache's host after the app shell has installed + * it, rather than at construction time — the cache is a singleton and is built first. */ val onchainTipHeightFlow: StateFlow by lazy { - val scope = - runCatching { Amethyst.instance.applicationIOScope }.getOrNull() - ?: return@lazy MutableStateFlow(null).asStateFlow() + val scope = cache.appHost.scope flow { while (true) { @@ -150,7 +142,7 @@ class OnchainZapResolver( val verifier = OnchainZapVerifier(backend) - Amethyst.instance.applicationIOScope.launch { + cache.appHost.scope.launch { try { verifyAndUpgradeOnchainZap(event, source, repliesTo, verifier) } finally { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt new file mode 100644 index 0000000000..a445830f69 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt @@ -0,0 +1,212 @@ +/* + * 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(ExperimentalAtomicApi::class) + +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.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Creates a list of events (regular and addressable), sorted by created_at, that is updated + * every time a new matching event is received — INCLUDING newer versions of addressables, whose + * refreshed content re-emits (the entry keeps its captured position; consumers that care about + * order re-sort downstream). + * + * There is exactly one [Note] instance per id/address (the cache owns their creation), so + * uniqueness is a non-issue in principle — except a note's sort key is mutable: a newer + * replaceable event swaps the event on the SAME [AddressableNote] instance, changing its + * created_at in place. Anything ordered on that live value cannot survive it. So the sort key is + * snapshotted into an immutable [Entry] when the note first enters and never read live again. + * + * **Lock-free, by copy-on-write.** Observer callbacks fire concurrently from several consume + * threads (relay ingest + UI-side justConsume) and this observer is used everywhere, so it takes + * no lock: the whole list lives in one immutable [State] behind an [AtomicReference], and every + * mutation builds the next state and publishes it with a compare-and-set, retrying if another + * thread won the race. Same shape quartz uses for its native `ConcurrentMap`. + * + * Two things fall out of that, both previously paid for by hand: + * - a reader never sees a torn list, so the emitted snapshot needs no de-duplication pass — + * one entry per idHex holds by construction, since [State.ids] is what gates insertion; + * - the emitted list IS the published state, so emitting costs one map rather than a walk of a + * concurrent structure plus a `HashSet` to strip the duplicates that walk could surface. + * + * The copy is O(n) per write, which is what the previous per-emit snapshot already cost. + * + * **Measured**, against the `ConcurrentSkipListSet` implementation this replaced — see + * `ObserverListBenchmark`, which keeps that implementation as the baseline and as a + * differential oracle: + * - re-delivery of an already listed note (the steady state once a screen is warm): 3.3–4.1x + * faster, because it is a load plus a set lookup where the skip list took a `compute`; + * - insert into a populated unlimited list (what nearly every observer registers): 1.3–1.5x + * faster at n = 100–1000, widening with n; + * - concurrent inserts into the SAME observer: at parity on one thread, 2.1–2.7x slower on + * 4–8. That is the honest cost of this design: threads serialize on one reference and a + * lost CAS throws away its copy, where the skip list striped across keys and scaled. It is + * measured with threads doing nothing but inserting; real ingest spends most of its + * per-event budget verifying signatures and parsing before it reaches an observer, so the + * contention window is a fraction of that. Worth revisiting if a profile ever says + * otherwise. + * + * **Garbage**, measured the same way, since on a phone young-gen churn is paid in jank rather + * than in CPU: re-delivery allocates **nothing at all** (a load and a set lookup — where the + * skip list's `compute` allocated a capturing lambda and a boxed `Ref.BooleanRef`, 56 bytes on + * every one of the commonest call in the app), and a single-threaded insert allocates 1.2x less + * than the skip list did. Under contention it inverts for the same reason the timing does — a + * lost CAS discards the copy it just built — reaching 2.0x more on 4 threads and 2.4x on 8. + * + * What neither implementation is, is cheap per insert: both materialize the whole list on every + * write, so an observer holding a thousand notes allocates ~78 KB each time one arrives. That is + * unchanged by this rewrite and is where the next win is, if one is wanted — not in the locking. + */ +class EventListMatchingFilter( + private val filter: Filter, + private val atOnce: (filter: Filter) -> List, + private val update: (List) -> Unit, +) : Observable { + /** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */ + private class Entry( + val note: Note, + val createdAt: Long, + val id: HexKey, + ) + + /** One immutable version of the list. [ids] is the membership gate, keyed by the stable idHex. */ + private inner class State( + val entries: List, + val ids: Set, + ) { + @Suppress("UNCHECKED_CAST") + fun events(): List = entries.mapNotNull { it.note.event as? T } + } + + // created_at descending, id ascending as a stable tiebreak. Both fields are + // immutable snapshots, so an Entry never moves once inserted. + private val order = + Comparator { a, b -> + val byCreatedAt = b.createdAt.compareTo(a.createdAt) + if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id) + } + + private val state = AtomicReference(State(emptyList(), emptySet())) + + private fun entryFor(note: Note): Entry { + // A null event (unresolved note) sorts last, matching CreatedAtIdHexComparator. + val event = note.event + return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex) + } + + private fun State.plus( + entry: Entry, + limit: Int?, + ): State { + val found = entries.binarySearch(entry, order) + val at = if (found < 0) -found - 1 else found + + val grown = ArrayList(entries.size + 1) + grown.addAll(entries.subList(0, at)) + grown.add(entry) + grown.addAll(entries.subList(at, entries.size)) + + // One copy of the membership set per write. `ids + added` and `ids + added - dropped` + // read well but allocate one full set per operator, so the limit path paid for two; + // measured, that was the whole of this implementation's deficit against the skip list + // on a limited filter. + val nextIds = HashSet(((entries.size + 2) / 0.75f).toInt() + 1) + nextIds.addAll(ids) + nextIds.add(entry.note.idHex) + + if (limit == null || grown.size <= limit) return State(grown, nextIds) + + // Over the limit: drop the oldest, which sorts last. That can be the entry just + // inserted, and then it is simply not listed — as the previous pollLast() did. + val dropped = grown.removeAt(grown.size - 1) + nextIds.remove(dropped.note.idHex) + return State(grown, nextIds) + } + + private fun State.minus(idHex: HexKey): State = State(entries.filterNot { it.note.idHex == idHex }, ids - idHex) + + override fun new( + event: Event, + note: Note, + ) { + if (event is AddressableEvent && note !is AddressableNote) { + // The "version" note (a regular note holding an addressable event) is never + // stored — the AddressableNote is. Re-emit if that addressable is already + // listed, so consumers pick up the refreshed content read live off the note. + val current = state.load() + if (event.address().toValue() in current.ids) update(current.events()) + return + } + + if (!filter.match(event)) return + + val limit = filter.limit + while (true) { + val current = state.load() + // Already listed: the entry keeps its captured position — re-sorting it would + // mean a remove plus an add, and two entries for one note would transiently + // coexist and read the same live event twice. Re-emit so consumers see the + // refreshed content. + if (note.idHex in current.ids) { + update(current.events()) + return + } + + val next = current.plus(entryFor(note), limit) + if (state.compareAndSet(current, next)) { + update(next.events()) + return + } + } + } + + override fun remove(note: Note) { + while (true) { + val current = state.load() + if (note.idHex !in current.ids) return + + val next = current.minus(note.idHex) + if (state.compareAndSet(current, next)) { + update(next.events()) + return + } + } + } + + fun init() { + // The cache query behind [atOnce] has already applied the filter's limit, so this + // inserts what it is given, exactly as the previous implementation did. + var fresh = State(emptyList(), emptySet()) + atOnce(filter).forEach { note -> + if (note.idHex !in fresh.ids) fresh = fresh.plus(entryFor(note), null) + } + state.store(fresh) + update(fresh.events()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt new file mode 100644 index 0000000000..0fc40405be --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt @@ -0,0 +1,203 @@ +/* + * 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(ExperimentalAtomicApi::class) + +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.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Creates a list of notes (regular and addressable), sorted by created_at like a + * relay, that only grows when a new note appears. + * + * New versions of addressables do not update the list. + * + * There is exactly one [Note] instance per id/address (the cache owns their creation), so + * uniqueness is a non-issue in principle — except a note's sort key is mutable: a newer + * replaceable event swaps the event on the SAME [AddressableNote] instance, changing its + * created_at in place. Anything ordered on that live value cannot survive it. So the sort key is + * snapshotted into an immutable [Entry] when the note first enters and never read live again. + * + * **Lock-free, by copy-on-write.** Observer callbacks fire concurrently from several consume + * threads (relay ingest + UI-side justConsume) and this observer is used everywhere, so it takes + * no lock: the whole list lives in one immutable [State] behind an [AtomicReference], and every + * mutation builds the next state and publishes it with a compare-and-set, retrying if another + * thread won the race. Same shape quartz uses for its native `ConcurrentMap`. + * + * Two things fall out of that, both previously paid for by hand: + * - a reader never sees a torn list, so the emitted snapshot needs no de-duplication pass — + * one entry per idHex holds by construction, since [State.ids] is what gates insertion; + * - the emitted list IS the published state, so emitting costs one map rather than a walk of a + * concurrent structure plus a `HashSet` to strip the duplicates that walk could surface. + * + * The copy is O(n) per write, which is what the previous per-emit snapshot already cost. + * + * **Measured**, against the `ConcurrentSkipListSet` implementation this replaced — see + * `ObserverListBenchmark`, which keeps that implementation as the baseline and as a + * differential oracle: + * - re-delivery of an already listed note (the steady state once a screen is warm): 3.3–4.1x + * faster, because it is a load plus a set lookup where the skip list took a `compute`; + * - insert into a populated unlimited list (what nearly every observer registers): 1.3–1.5x + * faster at n = 100–1000, widening with n; + * - concurrent inserts into the SAME observer: at parity on one thread, 2.1–2.7x slower on + * 4–8. That is the honest cost of this design: threads serialize on one reference and a + * lost CAS throws away its copy, where the skip list striped across keys and scaled. It is + * measured with threads doing nothing but inserting; real ingest spends most of its + * per-event budget verifying signatures and parsing before it reaches an observer, so the + * contention window is a fraction of that. Worth revisiting if a profile ever says + * otherwise. + * + * **Garbage**, measured the same way, since on a phone young-gen churn is paid in jank rather + * than in CPU: re-delivery allocates **nothing at all** (a load and a set lookup — where the + * skip list's `compute` allocated a capturing lambda and a boxed `Ref.BooleanRef`, 56 bytes on + * every one of the commonest call in the app), and a single-threaded insert allocates 1.2x less + * than the skip list did. Under contention it inverts for the same reason the timing does — a + * lost CAS discards the copy it just built — reaching 2.0x more on 4 threads and 2.4x on 8. + * + * What neither implementation is, is cheap per insert: both materialize the whole list on every + * write, so an observer holding a thousand notes allocates ~78 KB each time one arrives. That is + * unchanged by this rewrite and is where the next win is, if one is wanted — not in the locking. + */ +class NoteListMatchingFilter( + private val filter: Filter, + private val atOnce: (filter: Filter) -> List, + private val update: (List) -> Unit, +) : Observable { + /** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */ + private class Entry( + val note: Note, + val createdAt: Long, + val id: HexKey, + ) + + /** One immutable version of the list. [ids] is the membership gate, keyed by the stable idHex. */ + private class State( + val entries: List, + val ids: Set, + ) { + fun notes(): List = entries.map { it.note } + + companion object { + val EMPTY = State(emptyList(), emptySet()) + } + } + + // created_at descending, id ascending as a stable tiebreak. Both fields are + // immutable snapshots, so an Entry never moves once inserted. + private val order = + Comparator { a, b -> + val byCreatedAt = b.createdAt.compareTo(a.createdAt) + if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id) + } + + private val state = AtomicReference(State.EMPTY) + + private fun entryFor(note: Note): Entry { + // A null event (unresolved note) sorts last, matching CreatedAtIdHexComparator. + val event = note.event + return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex) + } + + private fun State.plus( + entry: Entry, + limit: Int?, + ): State { + val found = entries.binarySearch(entry, order) + val at = if (found < 0) -found - 1 else found + + val grown = ArrayList(entries.size + 1) + grown.addAll(entries.subList(0, at)) + grown.add(entry) + grown.addAll(entries.subList(at, entries.size)) + + // One copy of the membership set per write. `ids + added` and `ids + added - dropped` + // read well but allocate one full set per operator, so the limit path paid for two; + // measured, that was the whole of this implementation's deficit against the skip list + // on a limited filter. + val nextIds = HashSet(((entries.size + 2) / 0.75f).toInt() + 1) + nextIds.addAll(ids) + nextIds.add(entry.note.idHex) + + if (limit == null || grown.size <= limit) return State(grown, nextIds) + + // Over the limit: drop the oldest, which sorts last. That can be the entry just + // inserted, and then it is simply not listed — as the previous pollLast() did. + val dropped = grown.removeAt(grown.size - 1) + nextIds.remove(dropped.note.idHex) + return State(grown, nextIds) + } + + private fun State.minus(idHex: HexKey): State = State(entries.filterNot { it.note.idHex == idHex }, ids - idHex) + + override fun new( + event: Event, + note: Note, + ) { + if (event is AddressableEvent && note !is AddressableNote) return + + if (!filter.match(event)) return + + val limit = filter.limit + while (true) { + val current = state.load() + // Already listed: a newer version of an addressable keeps its entry, and its + // position with it, so the list only ever grows. + if (note.idHex in current.ids) return + + val next = current.plus(entryFor(note), limit) + if (state.compareAndSet(current, next)) { + update(next.notes()) + return + } + } + } + + override fun remove(note: Note) { + while (true) { + val current = state.load() + if (note.idHex !in current.ids) return + + val next = current.minus(note.idHex) + if (state.compareAndSet(current, next)) { + update(next.notes()) + return + } + } + } + + fun init() { + // The cache query behind [atOnce] has already applied the filter's limit, so this + // inserts what it is given, exactly as the previous implementation did. + var fresh = State.EMPTY + atOnce(filter).forEach { note -> + if (note.idHex !in fresh.ids) fresh = fresh.plus(entryFor(note), null) + } + state.store(fresh) + update(fresh.notes()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 133741660c..e747afff09 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsToAddressesKinds1 = @@ -55,6 +56,11 @@ val RepliesAndReactionsToAddressesKinds1 = CommentEvent.KIND, AttestationEvent.KIND, BadgeAwardEvent.KIND, + // A NIP-CC found log points at the cache it claims with a lowercase `a`, which is how + // every client writing them today does it. Without this the cache screen asks for + // engagement on the listing and never for its finds, so a cache with logs sitting on a + // relay we are already connected to still reads "No one has logged this cache yet". + GeocacheFoundLogEvent.KIND, ) val PostsAndChatMessagesToAddresses = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAllCommunities.kt new file mode 100644 index 0000000000..d7eb49c874 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAllCommunities.kt @@ -0,0 +1,149 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +val GeocachesFromCommunityKinds = GeocacheFeedKinds + +val GeocachesFromCommunityKindsStr = GeocacheFeedKinds.map { it.toString() } + +fun filterGeocachesFromAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to GeocachesFromCommunityKindsStr, + ), + limit = communityList.size * 20, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, + tags = mapOf("a" to communityList), + kinds = GeocachesFromCommunityKinds, + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterGeocachesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterGeocachesFromAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} + +fun filterGeocachesFromCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to GeocachesFromCommunityKindsStr, + ), + limit = 100, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = GeocachesFromCommunityKinds, + limit = 100, + since = since, + ), + ), + ) +} + +fun filterGeocachesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterGeocachesFromCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAuthors.kt new file mode 100644 index 0000000000..b4e5c5ec7d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByAuthors.kt @@ -0,0 +1,93 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterGeocachesByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, + authors = authorList, + kinds = GeocacheFeedKinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterGeocachesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterGeocachesByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterGeocachesByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterGeocachesByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByFollows.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByFollows.kt new file mode 100644 index 0000000000..d7b53358b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByFollows.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterGeocachesByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterGeocachesByAuthors(relay, it, since) + }, + ).flatten() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByGeohashes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByGeohashes.kt new file mode 100644 index 0000000000..291b22222b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByGeohashes.kt @@ -0,0 +1,71 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterGeocachesByGeohashes( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.TAG_FEED, + kinds = GeocacheFeedKinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterGeocachesByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterGeocachesByGeohashes( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByHashtag.kt new file mode 100644 index 0000000000..4d3b2de8f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesByHashtag.kt @@ -0,0 +1,68 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterGeocachesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.TAG_FEED, + kinds = GeocacheFeedKinds, + tags = mapOf("t" to hashtags.toList()), + limit = 200, + since = since, + ), + ), + ) + +fun filterGeocachesByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterGeocachesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesGlobal.kt new file mode 100644 index 0000000000..1fe046b59f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/FilterGeocachesGlobal.kt @@ -0,0 +1,50 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterGeocachesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, + kinds = GeocacheFeedKinds, + limit = 200, + since = since, + ), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocacheFeedKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocacheFeedKinds.kt new file mode 100644 index 0000000000..fa48c9deff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocacheFeedKinds.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.geocaches + +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +/** + * Every kind the geocaching hub renders, fetched in one subscription. + * + * The listings are the feed; the found logs come along because the hub's "Finds" tab and every + * cache card's found/unfound state are read off them, and a second REQ for them would double the + * round trips for data the same relay is already serving. The curation lists ride along for the + * "Hunts" tab for the same reason. + * + * [GeocacheListingEvent.LEGACY_KIND] is included because NIP-CC references 37515 as a + * predecessor and real listings on that kind exist on the network. + */ +val GeocacheFeedKinds = + listOf( + GeocacheListingEvent.KIND, + GeocacheListingEvent.LEGACY_KIND, + GeocacheFoundLogEvent.KIND, + GeocacheCurationListEvent.KIND, + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocachesFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocachesFilterAssembler.kt new file mode 100644 index 0000000000..88c4e4e53c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/GeocachesFilterAssembler.kt @@ -0,0 +1,30 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.SingleTopNavFeedSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class GeocachesFilterAssembler( + client: INostrClient, +) : TopNavFeedFilterAssembler({ keys -> listOf(SingleTopNavFeedSubAssembler(client, keys, ::makeGeocachesFilter)) }) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/SubAssemblyHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/SubAssemblyHelper.kt new file mode 100644 index 0000000000..87ff8a275f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geocaches/SubAssemblyHelper.kt @@ -0,0 +1,59 @@ +/* + * 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.relayClient.geocaches + +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByAllCommunities +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByAuthors +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByCommunity +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByFollows +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByGeohashes +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByHashtag +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesByMutedAuthors +import com.vitorpamplona.amethyst.commons.relayClient.geocaches.filterGeocachesGlobal +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeGeocachesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterGeocachesByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterGeocachesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterGeocachesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterGeocachesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterGeocachesByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterGeocachesByGeohashes(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterGeocachesByMutedAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterGeocachesByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + }.scopedTo(feedSettings) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt index f0cc4fb533..102d73791a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -62,6 +63,7 @@ val PostsByGeohashKinds = WikiNoteEvent.KIND, RoadEventReportEvent.KIND, RoadEventConfirmationEvent.KIND, + GeocacheListingEvent.KIND, ) fun filterPostsByGeohash( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt index f366992b85..90bdc83585 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt @@ -35,6 +35,11 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -52,6 +57,11 @@ val HomePostsByGeohashKinds = VoiceEvent.KIND, AttestationEvent.KIND, InteractiveStoryPrologueEvent.KIND, + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) fun filterHomePostsByGeohashes( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt index a6a97fa7f7..6983457805 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt @@ -38,6 +38,11 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -57,6 +62,11 @@ val HomePostsBuHashtagsKinds = VoiceEvent.KIND, AttestationEvent.KIND, InteractiveStoryPrologueEvent.KIND, + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) fun filterHomePostsByHashtags( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt index 4b8a2250bb..096432af97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt @@ -32,6 +32,11 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -49,21 +54,15 @@ val HomePostsFromCommunityKinds = CommunityPostApprovalEvent.KIND, CommentEvent.KIND, InteractiveStoryPrologueEvent.KIND, + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) -val HomePostsFromCommunityKindsStr = - listOf( - TextNoteEvent.KIND.toString(), - LongTextNoteEvent.KIND.toString(), - ClassifiedsEvent.KIND.toString(), - HighlightEvent.KIND.toString(), - PollEvent.KIND.toString(), - WikiNoteEvent.KIND.toString(), - NipTextEvent.KIND.toString(), - CommunityPostApprovalEvent.KIND.toString(), - CommentEvent.KIND.toString(), - InteractiveStoryPrologueEvent.KIND.toString(), - ) +/** The same set, as the `k` tag values a NIP-72 post approval carries. */ +val HomePostsFromCommunityKindsStr = HomePostsFromCommunityKinds.map { it.toString() } fun filterHomePostsFromCommunity( relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index 23227231ad..db874ab832 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.user.watchers import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast @@ -90,27 +91,33 @@ fun filterUserMetadataForKey( } } - return perRelayUsers - .map { (relay, users) -> - val firstTimers = mutableSetOf() - val updates = mutableSetOf() + val filters = mutableListOf() - var minimumTime: Long = Long.MAX_VALUE + perRelayUsers.forEach { (relay, users) -> + val firstTimers = mutableSetOf() + val updates = mutableSetOf() - users.forEach { user -> - val time = since.since(user)?.get(relay)?.time - if (time == null || user.metadataOrNull()?.flow?.value == null) { - firstTimers.add(user.pubkeyHex) - } else { - updates.add(user.pubkeyHex) - if (time < minimumTime) { - minimumTime = time - } + var minimumTime: Long = Long.MAX_VALUE + + users.forEach { user -> + val time = since.since(user)?.get(relay)?.time + if (time == null || user.metadataOrNull()?.flow?.value == null) { + firstTimers.add(user.pubkeyHex) + } else { + updates.add(user.pubkeyHex) + if (time < minimumTime) { + minimumTime = time } } + } - listOfNotNull( - if (firstTimers.isNotEmpty()) { + // Chunked for the same reason as FilterMissingEvents and FeedMetadataCoordinator: + // a relay that clamps `limit` answers only part of an oversized batch, silently. + // One screen can ask for far more than a hundred authors at once — a thread renders + // every reply row in one pass, and each row subscribes its own author. + if (firstTimers.isNotEmpty()) { + forEachChunk(firstTimers.sorted()) { chunk -> + filters.add( RelayBasedFilter( relay = relay, filter = @@ -118,13 +125,18 @@ fun filterUserMetadataForKey( purpose = SubPurpose.PROFILE_METADATA, accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, - authors = firstTimers.sorted(), + authors = chunk, ), - ) - } else { - null - }, - if (updates.isNotEmpty()) { + ), + ) + } + } + + if (updates.isNotEmpty()) { + // Every chunk keeps the group's earliest EOSE: splitting the authors must not + // narrow the window any of them is asked about. + forEachChunk(updates.sorted()) { chunk -> + filters.add( RelayBasedFilter( relay = relay, filter = @@ -132,13 +144,14 @@ fun filterUserMetadataForKey( purpose = SubPurpose.PROFILE_METADATA, accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, - authors = updates.sorted(), + authors = chunk, since = minimumTime, ), - ) - } else { - null - }, - ) - }.flatten() + ), + ) + } + } + } + + return filters } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt index 5f37378765..04562ff4cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts +import kotlinx.serialization.Serializable + +@Serializable enum class ScheduledPostStatus { PENDING, PUBLISHING, @@ -28,6 +31,7 @@ enum class ScheduledPostStatus { CANCELLED, } +@Serializable data class ScheduledPost( val id: String, val accountPubkey: String, @@ -44,6 +48,7 @@ data class ScheduledPost( val terminatedAtSec: Long? = null, ) +@Serializable data class ScheduledPostFile( val version: Int = 1, val posts: List = emptyList(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt index f69a83fe60..6a61907b8c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent @@ -102,6 +103,7 @@ object KindRegistry { "community" to listOf(CommunityDefinitionEvent.KIND), "wiki" to listOf(WikiNoteEvent.KIND), "classified" to listOf(ClassifiedsEvent.KIND), + "geocache" to listOf(GeocacheListingEvent.KIND), "highlight" to listOf(HighlightEvent.KIND), "poll" to listOf(PollEvent.KIND), // The kinds the app's own feeds are windows onto, so a screen that seeds its search diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt index 9db05afda8..98542803e5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt @@ -79,6 +79,8 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -212,6 +214,9 @@ object RenderableKinds { NamedSiteEvent.KIND, RootNappletEvent.KIND, NamedNappletEvent.KIND, + // geocaches + GeocacheListingEvent.KIND, + GeocacheFoundLogEvent.KIND, // everything else with a card BadgeDefinitionEvent.KIND, EmojiPackEvent.KIND, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt similarity index 91% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt index c4ec34a70d..94ba2218d7 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.commons.service.nwc import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.fetchAndIncrement /** * Tracks pending NIP-47 (Nostr Wallet Connect) payment requests awaiting responses. @@ -45,6 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger * the request on the relay could otherwise forge a response with their own * keypair and trick the client into displaying attacker-controlled data. */ +@OptIn(ExperimentalAtomicApi::class) class NwcPaymentTracker { /** * Data for a pending payment request. @@ -63,10 +66,10 @@ class NwcPaymentTracker { // right `e` tag but the wrong author. Surfaced to the UI when a // request times out so an actual attack can be distinguished from // an unresponsive wallet. - val spoofAttempts = AtomicInteger(0) + val spoofAttempts = AtomicInt(0) } - private val awaitingRequests = ConcurrentHashMap(10) + private val awaitingRequests = ConcurrentMap() /** * Registers a pending payment request. @@ -118,7 +121,7 @@ class NwcPaymentTracker { if (requestId == null) return MatchResult.NoMatch val pending = awaitingRequests[requestId] ?: return MatchResult.NoMatch if (pending.expectedServicePubkey != responseAuthor) { - pending.spoofAttempts.incrementAndGet() + pending.spoofAttempts.fetchAndIncrement() return MatchResult.WrongAuthor(pending.expectedServicePubkey, responseAuthor) } // Author matches — atomically remove and return. @@ -132,13 +135,13 @@ class NwcPaymentTracker { */ fun spoofAttemptsFor(requestId: HexKey?): Int { if (requestId == null) return 0 - return awaitingRequests[requestId]?.spoofAttempts?.get() ?: 0 + return awaitingRequests[requestId]?.spoofAttempts?.load() ?: 0 } /** * Checks if there's a pending request for the given ID. */ - fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests.containsKey(requestId) + fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests[requestId] != null /** * Manually removes a pending request (e.g., on timeout). @@ -153,5 +156,5 @@ class NwcPaymentTracker { /** * Returns count of pending requests (for debugging/monitoring). */ - fun pendingCount(): Int = awaitingRequests.size + fun pendingCount(): Int = awaitingRequests.size() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt index 1409f4768b..dddb512664 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.pow +import kotlinx.serialization.Serializable + /** * Durable record of a template mining job so a post survives process death: * everything needed to re-mine and re-send with no lambda captured — the @@ -31,6 +33,7 @@ package com.vitorpamplona.amethyst.commons.service.pow * outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE] * signs and parks the event in the scheduled-post store for [publishAtSec]. */ +@Serializable data class PersistedPoWJob( val id: String, val accountPubkey: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ExternalLinks.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ExternalLinks.kt new file mode 100644 index 0000000000..5166685d9c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ExternalLinks.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +/** + * An `https://njump.to/…` link for a NIP-19 bech32 entity, for handing a nostr event to + * something that does not speak nostr — a share sheet, a browser, or a log line meant to be + * opened by a human. + */ +fun njumpLink(nip19BechAddress: String) = "https://njump.to/$nip19BechAddress" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.kt new file mode 100644 index 0000000000..e45d6e58ee --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import okio.FileSystem + +/** + * The real filesystem for this target. + * + * okio declares `FileSystem.SYSTEM` per platform rather than in common — there is no such thing + * on JS — so shared code that wants the ordinary filesystem has to come through here. + */ +expect val platformFileSystem: FileSystem diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddressesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddressesTest.kt new file mode 100644 index 0000000000..3510308d05 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddressesTest.kt @@ -0,0 +1,107 @@ +/* + * 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.relayClient.event.watchers + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FilterRepliesAndReactionsToAddressesTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://one.example")!! + private val authorKey = "b".repeat(64) + + private fun addressable(address: Address) = AddressableNote(address).apply { addRelaySync(relay) } + + private fun listingDeclaringLogRelay( + address: Address, + logRelay: NormalizedRelayUrl, + ) = addressable(address).apply { + event = + GeocacheListingEvent( + id = "c".repeat(64), + pubKey = authorKey, + createdAt = 1L, + tags = arrayOf(arrayOf("d", address.dTag), arrayOf(RelayTag.TAG_NAME, logRelay.url)), + content = "", + sig = "d".repeat(64), + ) + } + + /** + * A NIP-CC found log is the cache's history. It hangs off the listing by a lowercase `a`, the + * same tag the engagement watcher already asks about, but it is its own kind — so without + * 7516 in the kind list the cache screen asks for every reaction to a cache and never for its + * finds, and a cache with logs on a relay we are connected to still reads as unfound. + */ + @Test + fun asksForFoundLogsOfACache() { + val cache = addressable(Address(GeocacheListingEvent.KIND, authorKey, "a-cache")) + + val filters = filterRepliesAndReactionsToAddresses(listOf(cache), null)!! + + val askingByAddress = filters.filter { it.filter.tags?.containsKey("a") == true } + + assertTrue( + askingByAddress.any { GeocacheFoundLogEvent.KIND in (it.filter.kinds ?: emptyList()) }, + "no filter asks for kind ${GeocacheFoundLogEvent.KIND} on the cache's address", + ) + } + + /** + * NIP-CC lets a cache name the relays its finds belong on, and Amethyst honours that when it + * publishes one. Asking for them anywhere else splits the write from the read: the logs land + * exactly where the listing said, and the cache screen — looking only at the owner's inbox and + * at wherever it happened to see the listing — reports the cache as never found. + */ + @Test + fun asksTheRelaysTheCacheNamesForItsLogs() { + val logRelay = RelayUrlNormalizer.normalizeOrNull("wss://logs.example")!! + val cache = listingDeclaringLogRelay(Address(GeocacheListingEvent.KIND, authorKey, "a-cache"), logRelay) + + val filters = filterRepliesAndReactionsToAddresses(listOf(cache), null)!! + + assertTrue( + filters.any { it.relay == logRelay && GeocacheFoundLogEvent.KIND in (it.filter.kinds ?: emptyList()) }, + "no filter asks ${logRelay.url} -- the relay the listing names -- for the cache's logs", + ) + } + + /** The declared relays are added to the usual ones, not swapped in for them. */ + @Test + fun stillAsksTheRelayTheListingCameFrom() { + val logRelay = RelayUrlNormalizer.normalizeOrNull("wss://logs.example")!! + val cache = listingDeclaringLogRelay(Address(GeocacheListingEvent.KIND, authorKey, "a-cache"), logRelay) + + val filters = filterRepliesAndReactionsToAddresses(listOf(cache), null)!! + + assertEquals( + setOf(relay, logRelay), + filters.map { it.relay }.toSet(), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt new file mode 100644 index 0000000000..c51e367053 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.home + +import com.vitorpamplona.amethyst.commons.relayClient.home.nip01Core.HomePostsBuHashtagsKinds +import com.vitorpamplona.amethyst.commons.relayClient.home.nip01Core.HomePostsByGeohashKinds +import com.vitorpamplona.amethyst.commons.relayClient.home.nip65Follows.HomePostsNewThreadKinds1 +import com.vitorpamplona.amethyst.commons.relayClient.home.nip72Communities.HomePostsFromCommunityKinds +import com.vitorpamplona.amethyst.commons.relayClient.home.nip72Communities.HomePostsFromCommunityKindsStr +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The "All Follows" home feed fans out to four REQ builders: authors, hashtags, geohashes and + * communities. The home DAL renders pictures and every NIP-71 video kind from any of those legs, + * so a leg that omits a media kind silently hides content the feed would happily show — a + * kind-22 short tagged `#bitcoin` never arriving while a kind-1 with the same tag does. + */ +class HomeMediaKindCoverageTest { + private val mediaKinds = + listOf( + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, + ) + + private fun assertCarriesMedia( + legName: String, + kinds: List, + ) = mediaKinds.forEach { + assertTrue(it in kinds, "$legName must request kind $it") + } + + @Test + fun everyHomeLegRequestsPicturesAndAllVideoKinds() { + assertCarriesMedia("authors", HomePostsNewThreadKinds1) + assertCarriesMedia("hashtags", HomePostsBuHashtagsKinds) + assertCarriesMedia("geohashes", HomePostsByGeohashKinds) + assertCarriesMedia("communities", HomePostsFromCommunityKinds) + } + + @Test + fun communityApprovalKTagsMirrorTheCommunityKinds() { + assertEquals(HomePostsFromCommunityKinds.map { it.toString() }, HomePostsFromCommunityKindsStr) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt index 2f071224dc..6b5ac8fa96 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt @@ -145,7 +145,9 @@ class KindRegistryTest { // a word. The list is here so adding a kind to RenderableKinds is a decision about // whether it earns a name, rather than silence. assertEquals( - listOf(24, 54, 1018, 1111, 1337, 1808, 10001, 10003, 30000, 30001, 30053, 30296, 30297, 30817, 31337), + // 7516 is a found log: read inside a cache's thread, where `kind:geocache` already + // leads, rather than looked up on its own. + listOf(24, 54, 1018, 1111, 1337, 1808, 7516, 10001, 10003, 30000, 30001, 30053, 30296, 30297, 30817, 31337), RenderableKinds.ALL.filter { KindRegistry.nameFor(it) == null }.sorted(), ) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKindsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKindsTest.kt index d2f810eb9e..bb50faace3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKindsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKindsTest.kt @@ -167,6 +167,9 @@ class RenderableKindsTest { 31925, 33401, 31990, + // geocache curation lists: the caches themselves are in the window, but a list of + // them has no card yet, so a result would be a blank row + 37517, ).sorted(), omitted.sorted(), "the set of searchable kinds not shown in search changed", diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTrackerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTrackerTest.kt similarity index 100% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTrackerTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTrackerTest.kt diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.ios.kt new file mode 100644 index 0000000000..679c955c54 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.ios.kt @@ -0,0 +1,237 @@ +/* + * 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.cache + +import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.quartz.utils.cache.CacheCollectors +import com.vitorpamplona.quartz.utils.cache.ICacheBiConsumer +import com.vitorpamplona.quartz.utils.cache.ICacheOperations + +/** + * **iOS has no implementation of this cache yet.** Every member throws. + * + * The expect declaration needs a store that is sorted by key *and* holds its + * values weakly, and Kotlin/Native ships neither: `kotlin.collections` has no + * concurrent sorted map, and `kotlin.native.ref.WeakReference` (what + * [WeakReference] maps to here) covers only the reference side. Writing a + * plain `HashMap` actual would compile and then be wrong at runtime in a way + * no test would catch — the ranged scans behind `LargeSoftCacheAddressExt.kt` + * would silently walk every entry, and nothing would ever be evicted. + * + * This stub exists so `:commons` keeps compiling for iOS while the cache moves + * into commonMain. Nothing on iOS may construct it until a real actual lands: + * a sorted concurrent map (a skip list or a lock-striped sorted structure) + * whose values are weak references. + */ +actual class LargeSoftCache : ICacheOperations { + actual fun keys(): Set = notYetImplemented() + + actual fun get(key: K): V? = notYetImplemented() + + actual fun remove(key: K) { + notYetImplemented() + } + + actual fun removeIf( + key: K, + value: WeakReference, + ): Boolean = notYetImplemented() + + actual fun isEmpty(): Boolean = notYetImplemented() + + actual fun clear() { + notYetImplemented() + } + + actual fun containsKey(key: K): Boolean = notYetImplemented() + + actual fun put( + key: K, + value: V, + ) { + notYetImplemented() + } + + actual fun getOrCreate( + key: K, + builder: (key: K) -> V, + ): V = notYetImplemented() + + actual fun cleanUp() { + notYetImplemented() + } + + actual override fun size(): Int = notYetImplemented() + + actual override fun forEach(consumer: ICacheBiConsumer) { + notYetImplemented() + } + + actual override fun filter(consumer: CacheCollectors.BiFilter): List = notYetImplemented() + + actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = notYetImplemented() + + actual override fun map(consumer: CacheCollectors.BiNotNullMapper): List = notYetImplemented() + + actual override fun mapNotNull(consumer: CacheCollectors.BiMapper): List = notYetImplemented() + + actual override fun mapNotNullIntoSet(consumer: CacheCollectors.BiMapper): Set = notYetImplemented() + + actual override fun mapFlatten(consumer: CacheCollectors.BiMapper?>): List = notYetImplemented() + + actual override fun mapFlattenIntoSet(consumer: CacheCollectors.BiMapper?>): Set = notYetImplemented() + + actual override fun maxOrNullOf( + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? = notYetImplemented() + + actual override fun sumOf(consumer: CacheCollectors.BiSumOf): Int = notYetImplemented() + + actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong): Long = notYetImplemented() + + actual override fun groupBy(consumer: CacheCollectors.BiNotNullMapper): Map> = notYetImplemented() + + actual override fun countByGroup(consumer: CacheCollectors.BiNotNullMapper): Map = notYetImplemented() + + actual override fun sumByGroup( + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map = notYetImplemented() + + actual override fun count(consumer: CacheCollectors.BiFilter): Int = notYetImplemented() + + actual override fun associate(transform: (K, V) -> Pair): Map = notYetImplemented() + + actual override fun associateWith(transform: (K, V) -> U?): Map = notYetImplemented() + + actual override fun filter( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): List = notYetImplemented() + + actual override fun filterIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Set = notYetImplemented() + + actual override fun map( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): List = notYetImplemented() + + actual override fun mapNotNull( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): List = notYetImplemented() + + actual override fun mapNotNullIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): Set = notYetImplemented() + + actual override fun mapFlatten( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): List = notYetImplemented() + + actual override fun mapFlattenIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): Set = notYetImplemented() + + actual override fun maxOrNullOf( + from: K, + to: K, + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? = notYetImplemented() + + actual override fun sumOf( + from: K, + to: K, + consumer: CacheCollectors.BiSumOf, + ): Int = notYetImplemented() + + actual override fun sumOfLong( + from: K, + to: K, + consumer: CacheCollectors.BiSumOfLong, + ): Long = notYetImplemented() + + actual override fun groupBy( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map> = notYetImplemented() + + actual override fun countByGroup( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map = notYetImplemented() + + actual override fun sumByGroup( + from: K, + to: K, + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map = notYetImplemented() + + actual override fun count( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Int = notYetImplemented() + + actual override fun associate( + from: K, + to: K, + transform: (K, V) -> Pair, + ): Map = notYetImplemented() + + actual override fun associateWith( + from: K, + to: K, + transform: (K, V) -> U?, + ): Map = notYetImplemented() + + actual override fun joinToString( + separator: CharSequence, + prefix: CharSequence, + postfix: CharSequence, + limit: Int, + truncated: CharSequence, + transform: ((K, V) -> CharSequence)?, + ): String = notYetImplemented() +} + +private fun notYetImplemented(): Nothing = + throw NotImplementedError( + "LargeSoftCache has no iOS actual yet: it needs a key-sorted concurrent map with weak values.", + ) diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.ios.kt similarity index 79% rename from quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt rename to commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.ios.kt index fe331449b9..2ca484b1cd 100644 --- a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.ios.kt @@ -18,13 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz +package com.vitorpamplona.amethyst.commons.util -actual class TestResourceLoader { - actual fun loadString(file: String): String = - this@TestResourceLoader - .javaClass.classLoader!! - .getResourceAsStream(file) - .bufferedReader() - .use { it.readText() } -} +import okio.FileSystem + +actual val platformFileSystem: FileSystem = FileSystem.SYSTEM diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt index f8f8637e55..f06a970de3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt @@ -18,13 +18,23 @@ * 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.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +/** Where a workout in the training log came from. */ +enum class WorkoutOrigin { + /** Recorded by a watch or fitness app and read from Health Connect. */ + HEALTH_CONNECT, + + /** A kind 1301 the user published themselves — typed into Amethyst, or posted from another client. */ + PUBLISHED, +} + /** - * A finished workout read from Health Connect and mapped to the fields Amethyst + * A finished workout in the user's training log: read from Health Connect, or + * recovered from a kind 1301 they published. Carries the fields Amethyst * can publish as a NIP-101e kind 1301 event. Platform-neutral and free of any * Health Connect types so it can feed the navigation route and the suggestion * UI directly. @@ -49,10 +59,43 @@ data class DetectedWorkout( val elevationGainMeters: Double?, /** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */ val source: String, + /** + * Which store this came from. Defaults to Health Connect because that is where the type + * originated and where every constructor but the published-event mapper still builds from. + */ + val origin: WorkoutOrigin = WorkoutOrigin.HEALTH_CONNECT, + /** + * Whether a kind 1301 for this workout already exists. + * + * Not the same question as [origin]: a workout read from Health Connect is published the + * moment the user shares it, and [TrainingLog.merge] keeps the richer Health Connect copy + * rather than the published one — so the survivor is still [WorkoutOrigin.HEALTH_CONNECT] + * while a kind 1301 for it is already out there. Anything offering to publish a workout + * must read this, not the origin, or it offers to post the same effort twice. + */ + val alreadyPublished: Boolean = false, /** * How many Health Connect sessions this workout represents. 1 for a raw * session; higher when [WorkoutMerger] combined several close-by same-type * sessions (e.g. a long run split around breaks) into a single suggestion. */ val sessionCount: Int = 1, -) +) { + /** + * Whether this carries any metric beyond its duration. + * + * False means one of two things, and the difference matters to whoever is holding two copies + * of the same effort: the activity genuinely has none to record (a gym session has no + * distance and often no calories), or they are simply not known yet — a Health Connect + * session read before its per-session aggregations have come back. Either way a copy that + * has some is the better one to keep, which is what [TrainingLog.merge] uses this for. + */ + val hasAnyMetric: Boolean + get() = + distanceMeters != null || + calories != null || + avgHeartRate != null || + maxHeartRate != null || + steps != null || + elevationGainMeters != null +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt new file mode 100644 index 0000000000..ee4061bb85 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt @@ -0,0 +1,141 @@ +/* + * 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.fitness + +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import java.util.Collections +import java.util.IdentityHashMap +import kotlin.math.abs + +/** + * The user's training log: everything they have done, from both places Amethyst can learn about + * it. + * + * Health Connect knows what their watch recorded. It does not know about a workout they typed + * into Amethyst by hand, or one they posted from another NIP-101e client, and it cannot see + * further back than 30 days. Their own kind 1301 events know all of those and none of the + * device detail. Neither source alone is the truth, so the dashboard reads both. + * + * The practical consequence is that My Fitness works with no health permissions at all — a user + * who never connects Health Connect still gets a summary of what they have logged. + */ +object TrainingLog { + /** + * How far apart two records of the same activity can start and still be judged the same + * workout. Generous on purpose: a published event often carries only the minute the user + * typed, or the time the post went out, while Health Connect has the exact second. + */ + const val DEDUPE_TOLERANCE_SECONDS = 900L + + /** + * Combines both sources into one log, newest first. + * + * Where the same workout appears in both — the usual case once a user shares one that came + * from their watch — the Health Connect copy normally wins: it carries the metrics the + * published event may have dropped (heart rate, steps, climb), and its start time is the + * recorded one rather than a publish timestamp. + * + * That preference is only justified while the Health Connect copy actually has those metrics. + * It can arrive without them — its per-session aggregations are read separately and may not + * have come back yet, or may have failed — and then it is strictly worse than the published + * event it would displace. So a copy carrying no metric at all yields to a matching one that + * does, rather than evicting it and taking numbers off the screen. See + * [DetectedWorkout.hasAnyMetric]. + * + * The winner keeps the loser's one piece of information: that a kind 1301 for this workout + * exists. Dropping the published copy would otherwise lose that fact, and the survivor — + * still flagged as Health Connect data — would read as never posted. See + * [DetectedWorkout.alreadyPublished]. + */ + fun merge( + healthConnect: List, + published: List, + ): List { + val merged = ArrayList(healthConnect.size + published.size) + // The published copies that won their tie and are therefore already in [merged]. + // Identity, not equality: two published workouts can be equal in every field, and one + // winning must not silently exclude the other from the pass below. + val kept = Collections.newSetFromMap(IdentityHashMap()) + + healthConnect.forEach { recorded -> + val match = published.firstOrNull { candidate -> recorded.isProbablySameWorkoutAs(candidate) } + + when { + match == null -> merged.add(recorded) + recorded.hasAnyMetric || !match.hasAnyMetric -> merged.add(recorded.copy(alreadyPublished = true)) + else -> { + // The published event is flagged as published by construction, so no copy. + merged.add(match) + kept.add(match) + } + } + } + + // A published workout that any Health Connect copy matched is already represented by + // whichever of the two won — including the ones just added above. + published.forEach { candidate -> + if (candidate !in kept && healthConnect.none { it.isProbablySameWorkoutAs(candidate) }) merged.add(candidate) + } + + return merged.sortedByDescending { it.startTimeEpochSeconds } + } + + private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean = + exercise == other.exercise && + abs(startTimeEpochSeconds - other.startTimeEpochSeconds) <= DEDUPE_TOLERANCE_SECONDS +} + +/** + * Reads a published workout back into the training log, or null when it carries nothing a + * summary can use — no recognisable activity, or no duration. + * + * Start time prefers the explicit `start` tag and falls back to the event's own `created_at`. + * That fallback is the publish time, not the workout time: a manual entry has no better signal, + * and for day-level bucketing (streaks, active days, the week split) it is close enough, since + * people post a workout the same day they do it. + */ +fun WorkoutRecordEvent.toDetectedWorkout(): DetectedWorkout? { + val activity = activityType() ?: return null + + // effectiveDurationSeconds, not durationSeconds: a POWR-dialect record carries `start`/`end` + // and no `duration` tag, so the raw accessor returns null and the workout would vanish from + // the log entirely. WorkoutDisplay already reads it through the same helper. + val duration = effectiveDurationSeconds() ?: return null + if (duration <= 0) return null + + return DetectedWorkout( + id = id, + exercise = activity, + title = title()?.takeIf { it.isNotBlank() }, + startTimeEpochSeconds = workoutStartTime() ?: workoutStart() ?: createdAt, + durationSeconds = duration, + distanceMeters = distance()?.toMeters()?.takeIf { it > 0 }, + calories = calories()?.takeIf { it > 0 }, + avgHeartRate = avgHeartRate()?.takeIf { it > 0 }, + maxHeartRate = maxHeartRate()?.takeIf { it > 0 }, + steps = steps()?.takeIf { it > 0 }, + elevationGainMeters = elevationGain()?.toMeters()?.takeIf { it > 0 }, + source = workoutSource() ?: "", + origin = WorkoutOrigin.PUBLISHED, + // It came back from a relay, so by definition it is already out there. + alreadyPublished = true, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt new file mode 100644 index 0000000000..88437973b6 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt @@ -0,0 +1,265 @@ +/* + * 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.fitness + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlin.math.roundToInt + +/** + * Turns a flat list of [DetectedWorkout] into the figures the My Fitness screen shows the + * user about their own training: period totals, week-over-week movement, a per-activity + * breakdown, bests, and how consistently they have been showing up. + * + * Pure and platform-free on purpose — every number the screen displays is derived here, so + * the arithmetic can be tested without Health Connect, a device, or Compose. + * + * Everything is scoped to [WINDOW_DAYS]. Health Connect only serves the most recent 30 days + * unless an app also holds `READ_HEALTH_DATA_HISTORY`, which Amethyst deliberately does not + * request; four whole weeks is the largest honest window inside that limit, and it makes the + * "this week vs last week" comparison land inside it too. + */ +object WorkoutStats { + /** Four whole weeks — see the class note on the 30-day Health Connect limit. */ + const val WINDOW_DAYS = 28L + + /** Days in the recent-comparison period. */ + const val WEEK_DAYS = 7L + + /** + * Totals over some set of workouts. Sums are plain sums; [avgHeartRate] is + * duration-weighted so a two-hour ride outweighs a ten-minute walk. + */ + @Immutable + data class Totals( + val workoutCount: Int = 0, + val durationSeconds: Long = 0, + val distanceMeters: Double = 0.0, + val calories: Int = 0, + val steps: Int = 0, + val elevationGainMeters: Double = 0.0, + val avgHeartRate: Int? = null, + val maxHeartRate: Int? = null, + ) { + val isEmpty: Boolean get() = workoutCount == 0 + } + + /** One activity's slice of the window, e.g. "Running: 5 workouts, 42 km". */ + @Immutable + data class ActivityTotals( + val exercise: ExerciseType, + val totals: Totals, + ) + + /** A single best effort in the window, with the workout that set it. */ + @Immutable + data class Best( + val kind: BestKind, + val workout: DetectedWorkout, + ) + + enum class BestKind { LONGEST_DISTANCE, LONGEST_DURATION, BIGGEST_CLIMB, MOST_STEPS, HIGHEST_HEART_RATE } + + /** Everything the My Fitness screen renders. */ + @Immutable + data class Report( + val windowTotals: Totals, + val thisWeek: Totals, + val previousWeek: Totals, + val weeklyAverage: Totals, + val byActivity: List, + val bests: List, + val activeDays: Int, + val currentStreakDays: Int, + val workouts: List, + ) { + val isEmpty: Boolean get() = windowTotals.isEmpty + } + + /** + * Builds the report from [workouts] — which the caller should already have limited to + * [WINDOW_DAYS] — as of [now]. [zone] decides day boundaries for the streak and + * active-day counts, so a workout at 23:30 counts for that day, not the next. + */ + fun report( + workouts: List, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), + ): Report { + val windowStart = now.epochSecond - WINDOW_DAYS * SECONDS_PER_DAY + val weekStart = now.epochSecond - WEEK_DAYS * SECONDS_PER_DAY + val previousWeekStart = weekStart - WEEK_DAYS * SECONDS_PER_DAY + + val inWindow = + workouts + .filter { it.startTimeEpochSeconds >= windowStart } + .sortedByDescending { it.startTimeEpochSeconds } + + val thisWeek = inWindow.filter { it.startTimeEpochSeconds >= weekStart } + val previousWeek = inWindow.filter { it.startTimeEpochSeconds in previousWeekStart until weekStart } + + val windowTotals = total(inWindow) + + return Report( + windowTotals = windowTotals, + thisWeek = total(thisWeek), + previousWeek = total(previousWeek), + weeklyAverage = perWeek(windowTotals), + byActivity = byActivity(inWindow), + bests = bests(inWindow), + activeDays = activeDays(inWindow, zone).size, + currentStreakDays = streak(inWindow, now, zone), + workouts = inWindow, + ) + } + + private const val SECONDS_PER_DAY = 86_400L + + /** Sums a set of workouts. Absent metrics contribute nothing rather than zero-ing the total. */ + fun total(workouts: List): Totals { + if (workouts.isEmpty()) return Totals() + + val withHeartRate = workouts.filter { it.avgHeartRate != null } + val heartRateWeight = withHeartRate.sumOf { it.durationSeconds } + + return Totals( + workoutCount = workouts.size, + durationSeconds = workouts.sumOf { it.durationSeconds }, + distanceMeters = workouts.sumOf { it.distanceMeters ?: 0.0 }, + calories = workouts.sumOf { it.calories ?: 0 }, + steps = workouts.sumOf { it.steps ?: 0 }, + elevationGainMeters = workouts.sumOf { it.elevationGainMeters ?: 0.0 }, + avgHeartRate = + when { + withHeartRate.isEmpty() -> null + heartRateWeight > 0 -> + withHeartRate + .sumOf { it.avgHeartRate!!.toDouble() * it.durationSeconds } + .div(heartRateWeight) + .roundToInt() + else -> withHeartRate.map { it.avgHeartRate!! }.average().roundToInt() + }, + maxHeartRate = workouts.mapNotNull { it.maxHeartRate }.maxOrNull(), + ) + } + + /** The window's totals expressed per week, for "your weekly average" lines. */ + private fun perWeek(totals: Totals): Totals { + val weeks = WINDOW_DAYS.toDouble() / WEEK_DAYS + return Totals( + workoutCount = (totals.workoutCount / weeks).roundToInt(), + durationSeconds = (totals.durationSeconds / weeks).toLong(), + distanceMeters = totals.distanceMeters / weeks, + calories = (totals.calories / weeks).roundToInt(), + steps = (totals.steps / weeks).roundToInt(), + elevationGainMeters = totals.elevationGainMeters / weeks, + // Averages don't divide: the mean heart rate of a week is the mean of the window. + avgHeartRate = totals.avgHeartRate, + maxHeartRate = totals.maxHeartRate, + ) + } + + /** Per-activity totals, busiest first (by time spent, then by count). */ + private fun byActivity(workouts: List): List = + workouts + .groupBy { it.exercise } + .map { (exercise, list) -> ActivityTotals(exercise, total(list)) } + .sortedWith( + compareByDescending { it.totals.durationSeconds } + .thenByDescending { it.totals.workoutCount }, + ) + + /** + * The standout efforts of the window. A best is only reported when the metric is present + * and positive, so a user whose watch records no elevation never sees an empty "biggest + * climb" card. + */ + private fun bests(workouts: List): List = + listOfNotNull( + workouts + .filter { (it.distanceMeters ?: 0.0) > 0 } + .maxByOrNull { it.distanceMeters!! } + ?.let { Best(BestKind.LONGEST_DISTANCE, it) }, + workouts + .filter { it.durationSeconds > 0 } + .maxByOrNull { it.durationSeconds } + ?.let { Best(BestKind.LONGEST_DURATION, it) }, + workouts + .filter { (it.elevationGainMeters ?: 0.0) > 0 } + .maxByOrNull { it.elevationGainMeters!! } + ?.let { Best(BestKind.BIGGEST_CLIMB, it) }, + workouts + .filter { (it.steps ?: 0) > 0 } + .maxByOrNull { it.steps!! } + ?.let { Best(BestKind.MOST_STEPS, it) }, + workouts + .filter { (it.maxHeartRate ?: 0) > 0 } + .maxByOrNull { it.maxHeartRate!! } + ?.let { Best(BestKind.HIGHEST_HEART_RATE, it) }, + ) + + /** The distinct local dates on which the user trained. */ + private fun activeDays( + workouts: List, + zone: ZoneId, + ): Set = workouts.mapTo(HashSet()) { it.localDate(zone) } + + /** + * Consecutive days up to today on which the user trained. A rest day today does not break + * a streak that is otherwise alive — the count then runs back from yesterday — so the + * number only resets once a whole day has genuinely been missed. + */ + private fun streak( + workouts: List, + now: Instant, + zone: ZoneId, + ): Int { + val days = activeDays(workouts, zone) + if (days.isEmpty()) return 0 + + val today = now.atZone(zone).toLocalDate() + var cursor = if (today in days) today else today.minusDays(1) + + var streak = 0 + while (cursor in days) { + streak++ + cursor = cursor.minusDays(1) + } + return streak + } + + private fun DetectedWorkout.localDate(zone: ZoneId): LocalDate = Instant.ofEpochSecond(startTimeEpochSeconds).atZone(zone).toLocalDate() + + /** + * Percentage change from [previous] to [current], or null when there is no previous value + * to compare against (an arrow up from zero says nothing). + */ + fun percentChange( + current: Double, + previous: Double, + ): Int? { + if (previous <= 0.0) return null + return (((current - previous) / previous) * 100).roundToInt() + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.jvmAndroid.kt similarity index 82% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.jvmAndroid.kt index 71bd3ba9c5..1c11cff060 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.jvmAndroid.kt @@ -20,17 +20,22 @@ */ package com.vitorpamplona.amethyst.commons.model.cache +import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.quartz.utils.cache.CacheOperations -import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentSkipListMap import java.util.function.BiConsumer -class LargeSoftCache : CacheOperations { +/** + * A `ConcurrentSkipListMap` gives the two properties the expect declares in one + * structure: keys stay in natural order (so `subMap` serves the ranged scans) + * and reads are lock-free. + */ +actual class LargeSoftCache : CacheOperations { private val cache = ConcurrentSkipListMap>() - fun keys() = cache.keys + actual fun keys(): Set = cache.keys - fun get(key: K): V? { + actual fun get(key: K): V? { val softRef = cache.get(key) ?: return null val value = softRef.get() @@ -42,20 +47,24 @@ class LargeSoftCache : CacheOperations { } } - fun remove(key: K) = cache.remove(key) + actual fun remove(key: K) { + cache.remove(key) + } - fun removeIf( + actual fun removeIf( key: K, value: WeakReference, - ) = cache.remove(key, value) + ): Boolean = cache.remove(key, value) - override fun size() = cache.size + actual override fun size() = cache.size - fun isEmpty() = cache.isEmpty() + actual fun isEmpty(): Boolean = cache.isEmpty() - fun clear() = cache.clear() + actual fun clear() { + cache.clear() + } - fun containsKey(key: K) = cache.containsKey(key) + actual fun containsKey(key: K): Boolean = cache.containsKey(key) /** * Puts an object into the cache with a specified key. @@ -64,7 +73,7 @@ class LargeSoftCache : CacheOperations { * @param key The key to associate with the object. * @param value The object to cache. */ - fun put( + actual fun put( key: K, value: V, ) { @@ -80,7 +89,7 @@ class LargeSoftCache : CacheOperations { * @param key The key of the object to retrieve. * @return The cached object, or null if it's no longer available. */ - fun getOrCreate( + actual fun getOrCreate( key: K, builder: (key: K) -> V, ): V { @@ -107,7 +116,7 @@ class LargeSoftCache : CacheOperations { * Proactively cleans up the cache by removing entries whose weakly referenced * objects have been garbage collected. Single-pass iterator for efficiency. */ - fun cleanUp() { + actual fun cleanUp() { val iter = cache.entries.iterator() while (iter.hasNext()) { val entry = iter.next() @@ -131,7 +140,7 @@ class LargeSoftCache : CacheOperations { .forEach(BiConsumerWrapper(this, consumer)) } - class BiConsumerWrapper( + private class BiConsumerWrapper( val cache: LargeSoftCache, val inner: BiConsumer, ) : BiConsumer> { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt deleted file mode 100644 index c6373a1267..0000000000 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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.observables - -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import java.util.SortedSet -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentSkipListSet - -/** - * Creates a list of events (regular and addressable), sorted by created_at, that - * is updated every time a new matching event is received — INCLUDING newer - * versions of addressables, whose refreshed content re-emits and re-sorts. - * - * Like [NoteListMatchingFilter], this cannot store mutable [Note]s in a set - * ordered on their live created_at: a newer replaceable event mutates the SAME - * [AddressableNote] instance in place, which strands its node and lets the same - * instance be inserted twice — the emitted list would then carry the same event - * twice. So the sort key is snapshotted into an immutable [Entry], [sorted] is - * ordered on that snapshot, and [byId] is the membership source of truth keyed - * by the stable idHex (the address for addressables, the event id otherwise). - * - * Unlike [NoteListMatchingFilter], an addressable update is NOT ignored: the - * list re-emits so consumers pick up the refreshed [Event] (read live off the - * note). The entry's captured position is kept — re-sorting an updated entry - * would mean a remove+add on [sorted], and two entries with different captured - * keys for the same note would then transiently coexist and both read the same - * live event, duplicating it. Keeping the position matches the original's only - * non-corrupting behavior (it never reliably re-sorted either); consumers that - * care about order re-sort downstream. - * - * Observer callbacks fire concurrently from several consume threads (relay - * ingest + UI-side justConsume), so it stays lock-free: [sorted] is only ever - * written for a key INSIDE that key's `compute` critical section, and only while - * the key is absent. ConcurrentHashMap stripes per key, so same-idHex ops - * serialize while different keys run in parallel. An entry is added only while - * its key is absent from [byId], and every path that frees a key removes its - * entry from [sorted] first. - * - * That keeps [sorted] converged to one entry per idHex, but a - * ConcurrentSkipListSet iterator is only weakly consistent: under concurrent - * add/remove churn a single traversal can momentarily surface a key twice - * (lazy-deleted node not yet unlinked while its replacement is inserted). The - * emitted list must never carry a duplicate id — a LazyColumn keyed on it would - * crash — so [snapshot] deduplicates by the stable idHex as it materializes. - */ -class EventListMatchingFilter( - private val filter: Filter, - private val atOnce: (filter: Filter) -> SortedSet, - private val update: (List) -> Unit, -) : Observable { - /** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */ - private class Entry( - val note: Note, - val createdAt: Long, - val id: HexKey, - ) - - // created_at descending, id ascending as a stable tiebreak. Both fields are - // immutable snapshots, so an Entry never moves once inserted. - private val order = - Comparator { a, b -> - val byCreatedAt = b.createdAt.compareTo(a.createdAt) - if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id) - } - - private val sorted = ConcurrentSkipListSet(order) - private val byId = ConcurrentHashMap() - - private fun entryFor(note: Note): Entry { - val event = note.event - return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex) - } - - @Suppress("UNCHECKED_CAST") - override fun new( - event: Event, - note: Note, - ) { - if (event is AddressableEvent && note !is AddressableNote) { - // The "version" note (a regular note holding an addressable event) is - // never stored — the AddressableNote is. Re-emit if that addressable - // is already listed so consumers pick up the refreshed content. - if (byId.containsKey(event.address().toValue())) update(snapshot()) - return - } - - if (!filter.match(event)) return - - // Add to [sorted] atomically with claiming the idHex slot, only when the - // key is absent. An update keeps its entry (and position) — the re-emit - // below reflects the refreshed event read live off the note. - var added = false - byId.compute(note.idHex) { _, existing -> - existing ?: entryFor(note).also { - sorted.add(it) - added = true - } - } - - if (added) { - val limit = filter.limit - if (limit != null && sorted.size > limit) { - // Drop the oldest (sorts last under [order]). - sorted.pollLast()?.let { byId.remove(it.note.idHex, it) } - } - } - - // Always re-emit on a match: a first insert grows the list, an update - // refreshes the event content the snapshot reads off the note. - update(snapshot()) - } - - @Suppress("UNCHECKED_CAST") - override fun remove(note: Note) { - var removed = false - byId.compute(note.idHex) { _, existing -> - if (existing != null) { - sorted.remove(existing) - removed = true - } - null - } - if (removed) update(snapshot()) - } - - @Suppress("UNCHECKED_CAST") - fun init() { - sorted.clear() - byId.clear() - atOnce(filter).forEach { note -> - byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } } - } - update(snapshot()) - } - - @Suppress("UNCHECKED_CAST") - private fun snapshot(): List { - // Dedup by the stable idHex: the weakly-consistent iterator can transiently - // surface a key twice under concurrent churn. Both would read the same live - // event off the same note, so keeping the first (newest position) is correct. - val seen = HashSet() - return sorted.mapNotNull { e -> if (seen.add(e.note.idHex)) e.note.event as? T else null } - } -} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt deleted file mode 100644 index d2962053fd..0000000000 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * 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.observables - -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import java.util.SortedSet -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentSkipListSet - -/** - * Creates a list of notes (regular and addressable), sorted by created_at like a - * relay, that only grows when a new note appears. - * - * New versions of addressables do not update the list. - * - * There is exactly one [Note] instance per id/address (LocalCache owns their - * creation), so uniqueness is a non-issue in principle — except a note's sort - * key is mutable: a newer replaceable event swaps the event on the SAME - * [AddressableNote] instance, changing its created_at in place. A sorted set - * ordered on that live value cannot survive it — the moved node is no longer on - * the search path of add()/remove(), so the same instance gets inserted twice - * and the emitted list carries a duplicate idHex, crashing any LazyColumn keyed - * on it. - * - * So the sort key is snapshotted into an immutable [Entry] when the note first - * enters and never read live again; [sorted] is ordered on that snapshot - * (stable) and [byId] is the membership source of truth, keyed by the stable - * idHex. - * - * Observer callbacks fire concurrently from several consume threads (relay - * ingest + UI-side justConsume), and this observer is used everywhere, so it - * stays lock-free: [byId] is a ConcurrentHashMap and every write to [sorted] for - * a given idHex happens INSIDE that key's `compute` critical section. - * ConcurrentHashMap stripes per key, so same-idHex ops serialize while different - * keys run fully in parallel. An entry is added to [sorted] only while its key - * is absent from [byId], and every path that makes a key absent removes its - * entry from [sorted] first, keeping [sorted] converged to one entry per idHex. - * - * That convergence isn't enough on its own: a ConcurrentSkipListSet iterator is - * only weakly consistent, so under concurrent add/remove churn a single - * traversal can momentarily surface a key twice (a lazy-deleted node not yet - * unlinked while its replacement is inserted). The emitted list must never carry - * a duplicate idHex — the LazyColumn keyed on it would crash — so [snapshot] - * deduplicates by idHex as it materializes. - */ -class NoteListMatchingFilter( - private val filter: Filter, - private val atOnce: (filter: Filter) -> SortedSet, - private val update: (List) -> Unit, -) : Observable { - /** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */ - private class Entry( - val note: Note, - val createdAt: Long, - val id: HexKey, - ) - - // created_at descending, id ascending as a stable tiebreak. Both fields are - // immutable snapshots, so an Entry never moves once inserted. - private val order = - Comparator { a, b -> - val byCreatedAt = b.createdAt.compareTo(a.createdAt) - if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id) - } - - private val sorted = ConcurrentSkipListSet(order) - private val byId = ConcurrentHashMap() - - private fun entryFor(note: Note): Entry { - // A null event (unresolved note) sorts last, matching CreatedAtIdHexComparator. - val event = note.event - return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex) - } - - override fun new( - event: Event, - note: Note, - ) { - if (event is AddressableEvent && note !is AddressableNote) return - - if (!filter.match(event)) return - - // Add to [sorted] atomically with claiming the idHex slot. New versions - // of an already listed note return the existing entry untouched. - var added = false - byId.compute(note.idHex) { _, existing -> - existing ?: entryFor(note).also { - sorted.add(it) - added = true - } - } - if (!added) return - - val limit = filter.limit - if (limit != null && sorted.size > limit) { - // Drop the oldest (sorts last under [order]). - sorted.pollLast()?.let { byId.remove(it.note.idHex, it) } - } - - update(snapshot()) - } - - override fun remove(note: Note) { - // Remove from [sorted] atomically with releasing the idHex slot. - var removed = false - byId.compute(note.idHex) { _, existing -> - if (existing != null) { - sorted.remove(existing) - removed = true - } - null - } - if (removed) update(snapshot()) - } - - fun init() { - sorted.clear() - byId.clear() - atOnce(filter).forEach { note -> - byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } } - } - update(snapshot()) - } - - private fun snapshot(): List { - // Dedup by idHex: the weakly-consistent iterator can transiently surface a - // key twice under concurrent churn. Keeping the first (newest position) is - // correct — both nodes point at the same note. - val seen = HashSet() - return sorted.mapNotNull { e -> e.note.takeIf { seen.add(it.idHex) } } - } -} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index 539f759fce..d19e8e7a6c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -20,15 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission @@ -37,9 +35,18 @@ class ScheduledPostStore( private val storageFile: File, private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, ) { - private val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + /** + * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx + * omits a value equal to its default, which would silently drop `"version":1` + * from every file this build rewrites. `ignoreUnknownKeys` matches the + * FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer + * build still loads here. + */ + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } private val mutex = Mutex() private var loaded = false @@ -330,7 +337,7 @@ class ScheduledPostStore( val fromDisk = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile).posts.toMutableList() + json.decodeFromString(storageFile.readText()).posts.toMutableList() } else { // File vanished — treat as no external state; keep current in-memory. return @@ -348,7 +355,7 @@ class ScheduledPostStore( posts = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile).posts.toMutableList() + json.decodeFromString(storageFile.readText()).posts.toMutableList() } else { mutableListOf() } @@ -408,7 +415,7 @@ class ScheduledPostStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) + tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) // Restrict to owner-only BEFORE the rename so the store is never briefly // world-readable. It holds pre-signed events + the account's pubkey, which // must not leak to other local users on a shared machine. diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt index 9161df6764..5acca96399 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.lnurl -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -48,7 +48,7 @@ import kotlin.coroutines.cancellation.CancellationException class LightningAddressResolver( private val httpClient: OkHttpClient, ) { - private val mapper = jacksonObjectMapper() + private val mapper = ObjectMapper() /** * Result of resolving a lightning address to a BOLT11 invoice. diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt index 2fbdf41481..dd162f881c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.lnurl -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver @@ -47,7 +47,7 @@ import kotlin.coroutines.cancellation.CancellationException class OkHttpLnurlEndpointResolver( private val okHttpClient: (String) -> OkHttpClient, ) : LnurlEndpointResolver { - private val mapper = jacksonObjectMapper() + private val mapper = ObjectMapper() override suspend fun resolve(lnurlpUrl: String): LnurlEndpointInfo? = LnurlEndpointCache.getOrFetch(lnurlpUrl, ::fetch) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.jvmAndroid.kt new file mode 100644 index 0000000000..2ca484b1cd --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/SystemFileSystem.jvmAndroid.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import okio.FileSystem + +actual val platformFileSystem: FileSystem = FileSystem.SYSTEM diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 926135932f..c817d7f6ef 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -441,7 +441,7 @@ actual class SecureKeyStorage private actual constructor() { } if (removedFromVault) { - if (contents!!.isEmpty()) { + if (contents.isEmpty()) { try { keyring().deletePassword(SERVICE_NAME, vaultAlias) } catch (_: PasswordAccessException) { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PartialMetricsReportTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PartialMetricsReportTest.kt new file mode 100644 index 0000000000..8cbedd1529 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PartialMetricsReportTest.kt @@ -0,0 +1,281 @@ +/* + * 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.fitness + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * The My Fitness dashboard renders before Health Connect's per-session metrics have arrived — + * each of those costs its own IPC, so waiting for all of them is what made the screen sit on a + * spinner. These tests pin what that first pass is allowed to look like. + * + * The contract has two halves, and the screen depends on both: + * - everything not derived from a metric must already be final, so no visible number *changes* + * when the metrics land — it only gains cells; + * - a metric that is not known yet must read as absent, never as zero, so the screen hides its + * cell instead of claiming the user ran 0 km. + */ +class PartialMetricsReportTest { + private val zone: ZoneId = ZoneId.of("UTC") + + /** Noon UTC so a test never straddles a day boundary by accident. */ + private val now: Instant = ZonedDateTime.of(2026, 3, 15, 12, 0, 0, 0, zone).toInstant() + + private var nextId = 0 + + private fun workout( + daysAgo: Long, + exercise: ExerciseType = ExerciseType.RUNNING, + durationSeconds: Long = 1800, + distanceMeters: Double? = null, + calories: Int? = null, + avgHeartRate: Int? = null, + maxHeartRate: Int? = null, + steps: Int? = null, + elevationGainMeters: Double? = null, + ) = DetectedWorkout( + id = "w${nextId++}", + exercise = exercise, + title = null, + startTimeEpochSeconds = now.epochSecond - daysAgo * 86_400L, + durationSeconds = durationSeconds, + distanceMeters = distanceMeters, + calories = calories, + avgHeartRate = avgHeartRate, + maxHeartRate = maxHeartRate, + steps = steps, + elevationGainMeters = elevationGainMeters, + source = "Samsung Health", + ) + + /** What the first pass has: activity, when, how long. Nothing that needs an aggregate call. */ + private fun DetectedWorkout.withoutMetrics() = + copy( + distanceMeters = null, + calories = null, + avgHeartRate = null, + maxHeartRate = null, + steps = null, + elevationGainMeters = null, + ) + + private val fullyLoaded = + listOf( + workout(daysAgo = 0, durationSeconds = 3600, distanceMeters = 10_000.0, calories = 700, avgHeartRate = 150, maxHeartRate = 175, steps = 9_000, elevationGainMeters = 120.0), + workout(daysAgo = 1, durationSeconds = 1800, distanceMeters = 5_000.0, calories = 320, avgHeartRate = 140, maxHeartRate = 160, steps = 4_500), + workout(daysAgo = 2, exercise = ExerciseType.CYCLING, durationSeconds = 5400, distanceMeters = 40_000.0, calories = 900, avgHeartRate = 130, maxHeartRate = 155, elevationGainMeters = 400.0), + workout(daysAgo = 9, exercise = ExerciseType.STRENGTH, durationSeconds = 2700, calories = 250), + ) + + private val partial = fullyLoaded.map { it.withoutMetrics() } + + @Test + fun `counts, time and consistency are already final before the metrics arrive`() { + val early = WorkoutStats.report(partial, now, zone) + val late = WorkoutStats.report(fullyLoaded, now, zone) + + assertEquals(late.windowTotals.workoutCount, early.windowTotals.workoutCount) + assertEquals(late.windowTotals.durationSeconds, early.windowTotals.durationSeconds) + assertEquals(late.thisWeek.workoutCount, early.thisWeek.workoutCount) + assertEquals(late.thisWeek.durationSeconds, early.thisWeek.durationSeconds) + assertEquals(late.previousWeek.workoutCount, early.previousWeek.workoutCount) + assertEquals(late.weeklyAverage.durationSeconds, early.weeklyAverage.durationSeconds) + assertEquals(late.activeDays, early.activeDays) + assertEquals(late.currentStreakDays, early.currentStreakDays) + } + + @Test + fun `the activity split is already final, in the same order`() { + val early = WorkoutStats.report(partial, now, zone) + val late = WorkoutStats.report(fullyLoaded, now, zone) + + assertEquals(late.byActivity.map { it.exercise }, early.byActivity.map { it.exercise }) + assertEquals( + late.byActivity.map { it.totals.workoutCount }, + early.byActivity.map { it.totals.workoutCount }, + ) + assertEquals( + late.byActivity.map { it.totals.durationSeconds }, + early.byActivity.map { it.totals.durationSeconds }, + ) + } + + @Test + fun `an unknown metric reads as absent, so the screen hides its cell instead of showing zero`() { + val early = WorkoutStats.report(partial, now, zone) + + // The screen gates these cells on `> 0` / a non-null, so absent is what keeps them hidden. + assertEquals(0.0, early.windowTotals.distanceMeters, 0.0) + assertEquals(0, early.windowTotals.calories) + assertEquals(0, early.windowTotals.steps) + assertEquals(0.0, early.windowTotals.elevationGainMeters, 0.0) + assertNull(early.windowTotals.avgHeartRate) + assertNull(early.windowTotals.maxHeartRate) + } + + @Test + fun `only the duration best is offered before the metrics arrive`() { + val early = WorkoutStats.report(partial, now, zone) + val late = WorkoutStats.report(fullyLoaded, now, zone) + + // A best whose metric is unknown is withheld rather than awarded to whichever workout + // happens to have a null — the cycling ride below is the real longest distance. + assertEquals(listOf(WorkoutStats.BestKind.LONGEST_DURATION), early.bests.map { it.kind }) + + assertTrue(late.bests.map { it.kind }.containsAll(early.bests.map { it.kind })) + assertEquals( + late.bests + .first { it.kind == WorkoutStats.BestKind.LONGEST_DURATION } + .workout.id, + early.bests + .first { it.kind == WorkoutStats.BestKind.LONGEST_DURATION } + .workout.id, + ) + } + + @Test + fun `a partial pass with workouts in it is not mistaken for an empty log`() { + val early = WorkoutStats.report(partial, now, zone) + + // isEmpty drives the "nothing logged yet" copy and the connect prompt. A user with four + // workouts whose metrics are still loading must not see either. + assertFalse(early.isEmpty) + assertEquals(fullyLoaded.size, early.workouts.size) + } + + @Test + fun `merging both sources still dedupes when the health connect copy has no metrics yet`() { + val published = + fullyLoaded.take(1).map { + it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true) + } + + // Dedupe keys on activity and start time, neither of which is a metric, so the first pass + // must already collapse the pair rather than double-count it and then halve the count. + val merged = TrainingLog.merge(partial, published) + + assertEquals(partial.size, merged.size) + assertEquals(1, merged.count { it.alreadyPublished }) + } + + @Test + fun `a metric-less health connect copy yields to the published one that still has its numbers`() { + val published = + fullyLoaded.take(1).map { + it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true) + } + + // Health Connect normally wins the tie because it carries more. During stage 1 it carries + // less, and evicting the published copy then would take 10 km off a dashboard that was + // already showing it — down to zero, and back up a second later. + val merged = TrainingLog.merge(partial, published) + val survivor = merged.first { it.startTimeEpochSeconds == published.single().startTimeEpochSeconds } + + assertEquals(10_000.0, survivor.distanceMeters!!, 0.001) + assertEquals(700, survivor.calories) + assertTrue(survivor.alreadyPublished) + } + + @Test + fun `the whole window keeps its published totals through the partial pass`() { + val published = + fullyLoaded.map { + it.copy(id = "published-${'$'}{it.id}", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true) + } + + // The user has published every one of these. Stage 1 must not make their distance and + // calorie totals dip to zero on the way to showing the same numbers again. + val beforeAnyHealthConnect = WorkoutStats.report(TrainingLog.merge(emptyList(), published), now, zone) + val duringStageOne = WorkoutStats.report(TrainingLog.merge(partial, published), now, zone) + + assertEquals(beforeAnyHealthConnect.windowTotals.distanceMeters, duringStageOne.windowTotals.distanceMeters, 0.001) + assertEquals(beforeAnyHealthConnect.windowTotals.calories, duringStageOne.windowTotals.calories) + assertEquals(beforeAnyHealthConnect.windowTotals.steps, duringStageOne.windowTotals.steps) + assertEquals(beforeAnyHealthConnect.windowTotals.maxHeartRate, duringStageOne.windowTotals.maxHeartRate) + assertEquals(beforeAnyHealthConnect.bests.map { it.kind }.toSet(), duringStageOne.bests.map { it.kind }.toSet()) + } + + @Test + fun `a loaded health connect copy still wins over the published one, as before`() { + // The yielding rule is narrow: it must not invert the normal preference, or a shared + // workout would lose the device detail the published event dropped. + val published = + fullyLoaded.take(1).map { + it.copy( + id = "published", + origin = WorkoutOrigin.PUBLISHED, + alreadyPublished = true, + steps = null, + elevationGainMeters = null, + ) + } + + val merged = TrainingLog.merge(fullyLoaded, published) + val survivor = merged.first { it.startTimeEpochSeconds == published.single().startTimeEpochSeconds } + + assertEquals(WorkoutOrigin.HEALTH_CONNECT, survivor.origin) + assertEquals(9_000, survivor.steps) + assertTrue(survivor.alreadyPublished) + } + + @Test + fun `one health connect session still absorbs every published copy it matches`() { + // Pre-existing dedupe behaviour, kept: the yielding rule changes which copy survives a + // tie, never how many survive. Two published events inside the 15-minute tolerance and + // one recorded session is still one workout, not two. + val recorded = listOf(workout(daysAgo = 1, durationSeconds = 1800, distanceMeters = 5_000.0)) + val start = recorded.single().startTimeEpochSeconds + val published = + listOf( + recorded.single().copy(id = "p1", startTimeEpochSeconds = start, origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true), + recorded.single().copy(id = "p2", startTimeEpochSeconds = start + 300, origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true), + ) + + assertEquals(1, TrainingLog.merge(recorded, published).size) + // And the same when the recorded copy is the metric-less one that yields. + assertEquals(1, TrainingLog.merge(recorded.map { it.withoutMetrics() }, published).size) + } + + @Test + fun `two copies that both lack metrics keep the health connect one`() { + // A gym session genuinely has no distance or steps on either side. Nothing to prefer, so + // the normal rule stands and the pair still collapses to one entry. + val strengthOnly = listOf(workout(daysAgo = 3, exercise = ExerciseType.STRENGTH, durationSeconds = 2700)) + val published = + strengthOnly.map { + it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true) + } + + val merged = TrainingLog.merge(strengthOnly, published) + + assertEquals(1, merged.size) + assertEquals(WorkoutOrigin.HEALTH_CONNECT, merged.single().origin) + assertTrue(merged.single().alreadyPublished) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt new file mode 100644 index 0000000000..3664f09399 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt @@ -0,0 +1,167 @@ +/* + * 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.fitness + +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.CaloriesTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutEndTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PublishedWorkoutMappingTest { + private val createdAt = 1_800_000_000L + + private fun event(vararg tags: Array) = + WorkoutRecordEvent( + id = "eventid", + pubKey = "pubkey", + createdAt = createdAt, + tags = arrayOf(*tags), + content = "", + sig = "sig", + ) + + @Test + fun `a full workout round-trips into the training log`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.RUNNING), + TitleTag.assemble("Morning Run"), + DurationTag.assemble(1800), + DistanceTag.assemble(5.0, DistanceTag.KILOMETERS), + CaloriesTag.assemble(380), + AvgHeartRateTag.assemble(150), + StepsTag.assemble(6000), + WorkoutStartTimeTag.assemble(createdAt - 3600), + ).toDetectedWorkout() + + assertNotNull(workout) + assertEquals(ExerciseType.RUNNING, workout!!.exercise) + assertEquals("Morning Run", workout.title) + assertEquals(1800, workout.durationSeconds) + assertEquals(5000.0, workout.distanceMeters!!, 0.001) + assertEquals(380, workout.calories) + assertEquals(150, workout.avgHeartRate) + assertEquals(6000, workout.steps) + assertEquals(createdAt - 3600, workout.startTimeEpochSeconds) + assertEquals(WorkoutOrigin.PUBLISHED, workout.origin) + // Recovered from a relay, so the dashboard must not offer to publish it again. + assertTrue(workout.alreadyPublished) + } + + /** Miles on the wire must land as metres in the log, or every total is wrong by 1.6x. */ + @Test + fun `an imperial distance is converted to metres`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.RUNNING), + DurationTag.assemble(1800), + DistanceTag.assemble(3.0, DistanceTag.MILES), + ).toDetectedWorkout() + + assertEquals(4828.032, workout!!.distanceMeters!!, 0.001) + } + + @Test + fun `an elevation in feet is converted to metres`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.HIKING), + DurationTag.assemble(3600), + ElevationGainTag.assemble(1000.0, "ft"), + ).toDetectedWorkout() + + assertEquals(304.8, workout!!.elevationGainMeters!!, 0.01) + } + + /** + * A workout typed in by hand carries no start time. Falling back to created_at keeps it in + * the right day bucket for streaks and the week split. + */ + @Test + fun `a manual entry without a start time falls back to the publish time`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.YOGA), + DurationTag.assemble(600), + ).toDetectedWorkout() + + assertEquals(createdAt, workout!!.startTimeEpochSeconds) + } + + @Test + fun `an event with no recognisable activity is skipped`() { + assertNull(event(DurationTag.assemble(1800)).toDetectedWorkout()) + } + + /** + * POWR-dialect records carry `start`/`end` and no `duration` tag. Reading the raw duration + * accessor drops them from the log entirely. + */ + @Test + fun `a POWR record with start and end but no duration tag is kept`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.CYCLING), + WorkoutStartTag.assemble(createdAt - 3600), + WorkoutEndTag.assemble(createdAt - 387), + ).toDetectedWorkout() + + assertNotNull(workout) + assertEquals(3213, workout!!.durationSeconds) + assertEquals(createdAt - 3600, workout.startTimeEpochSeconds) + } + + @Test + fun `an event with no duration is skipped`() { + assertNull(event(ExerciseTag.assemble(ExerciseType.RUNNING)).toDetectedWorkout()) + } + + /** Zeroes on the wire are absences, not measurements — they must not drag totals or bests. */ + @Test + fun `zero metrics are treated as absent`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.STRENGTH), + DurationTag.assemble(2400), + CaloriesTag.assemble(0), + StepsTag.assemble(0), + AvgHeartRateTag.assemble(0), + ).toDetectedWorkout() + + assertNull(workout!!.calories) + assertNull(workout.steps) + assertNull(workout.avgHeartRate) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt new file mode 100644 index 0000000000..bdfb0f8e1c --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt @@ -0,0 +1,201 @@ +/* + * 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.fitness + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrainingLogTest { + private val noon = 1_800_000_000L + + private fun workout( + id: String, + origin: WorkoutOrigin, + startTimeEpochSeconds: Long, + exercise: ExerciseType = ExerciseType.RUNNING, + distanceMeters: Double? = null, + avgHeartRate: Int? = null, + ) = DetectedWorkout( + id = id, + exercise = exercise, + title = null, + startTimeEpochSeconds = startTimeEpochSeconds, + durationSeconds = 1800, + distanceMeters = distanceMeters, + calories = null, + avgHeartRate = avgHeartRate, + maxHeartRate = null, + steps = null, + elevationGainMeters = null, + source = "test", + origin = origin, + // Matches the real mappers: anything recovered from a relay is by definition published. + alreadyPublished = origin == WorkoutOrigin.PUBLISHED, + ) + + @Test + fun `with no health connect the log is just what the user published`() { + val published = listOf(workout("a", WorkoutOrigin.PUBLISHED, noon)) + + val merged = TrainingLog.merge(emptyList(), published) + + assertEquals(1, merged.size) + assertEquals("a", merged[0].id) + } + + @Test + fun `with no published workouts the log is just health connect`() { + val hc = listOf(workout("a", WorkoutOrigin.HEALTH_CONNECT, noon)) + + assertEquals(1, TrainingLog.merge(hc, emptyList()).size) + } + + /** The common case: the user shared a workout their watch recorded. It must not count twice. */ + @Test + fun `a published copy of a health connect workout is dropped`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, avgHeartRate = 150)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(1, merged.size) + assertEquals("hc", merged[0].id) + // The Health Connect copy wins because it carries the metrics the post dropped. + assertEquals(150, merged[0].avgHeartRate) + } + + /** + * The survivor is Health Connect data, so its origin cannot answer "has this been posted?". + * Without the flag the dashboard offers to share it again and the user posts a second + * kind 1301 for the same effort. + */ + @Test + fun `the surviving copy remembers that the workout was already published`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(1, merged.size) + assertEquals(WorkoutOrigin.HEALTH_CONNECT, merged[0].origin) + assertTrue(merged[0].alreadyPublished) + } + + @Test + fun `a health connect workout that was never shared is not marked published`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(2, merged.size) + assertFalse(merged.first { it.id == "hc" }.alreadyPublished) + assertTrue(merged.first { it.id == "pub" }.alreadyPublished) + } + + /** Only the matching session is flagged; an unrelated one in the same log is left alone. */ + @Test + fun `flagging one workout does not flag the rest of the log`() { + val hc = + listOf( + workout("shared", WorkoutOrigin.HEALTH_CONNECT, noon), + workout("private", WorkoutOrigin.HEALTH_CONNECT, noon - 86_400), + ) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(2, merged.size) + assertTrue(merged.first { it.id == "shared" }.alreadyPublished) + assertFalse(merged.first { it.id == "private" }.alreadyPublished) + } + + @Test + fun `a published workout just outside the tolerance is kept as its own`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1)) + + assertEquals(2, TrainingLog.merge(hc, published).size) + } + + @Test + fun `the tolerance boundary itself counts as the same workout`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS)) + + assertEquals(1, TrainingLog.merge(hc, published).size) + } + + /** Deduping on time alone would swallow a genuine second session. */ + @Test + fun `a different activity at the same time is not a duplicate`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, exercise = ExerciseType.RUNNING)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon, exercise = ExerciseType.STRENGTH)) + + assertEquals(2, TrainingLog.merge(hc, published).size) + } + + @Test + fun `dedupe also applies when the published copy is slightly earlier`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon - 300)) + + assertEquals(1, TrainingLog.merge(hc, published).size) + } + + @Test + fun `the merged log is ordered newest first`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon - 10_000)) + val published = + listOf( + workout("older", WorkoutOrigin.PUBLISHED, noon - 50_000), + workout("newest", WorkoutOrigin.PUBLISHED, noon), + ) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(listOf("newest", "hc", "older"), merged.map { it.id }) + } + + @Test + fun `one health connect workout does not swallow several unrelated published ones`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = + listOf( + workout("dup", WorkoutOrigin.PUBLISHED, noon + 30), + workout("yesterday", WorkoutOrigin.PUBLISHED, noon - 86_400), + workout("tomorrow", WorkoutOrigin.PUBLISHED, noon + 86_400), + ) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(3, merged.size) + assertTrue(merged.none { it.id == "dup" }) + } + + @Test + fun `an empty log merges to empty`() { + assertTrue(TrainingLog.merge(emptyList(), emptyList()).isEmpty()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt new file mode 100644 index 0000000000..25c8ec021c --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt @@ -0,0 +1,249 @@ +/* + * 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.fitness + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +class WorkoutStatsTest { + private val zone: ZoneId = ZoneId.of("UTC") + + /** Noon UTC so a test never straddles a day boundary by accident. */ + private val now: Instant = ZonedDateTime.of(2026, 3, 15, 12, 0, 0, 0, zone).toInstant() + + private var nextId = 0 + + private fun workout( + daysAgo: Long, + exercise: ExerciseType = ExerciseType.RUNNING, + durationSeconds: Long = 1800, + distanceMeters: Double? = null, + calories: Int? = null, + avgHeartRate: Int? = null, + maxHeartRate: Int? = null, + steps: Int? = null, + elevationGainMeters: Double? = null, + ) = DetectedWorkout( + id = "w${nextId++}", + exercise = exercise, + title = null, + startTimeEpochSeconds = now.epochSecond - daysAgo * 86_400L, + durationSeconds = durationSeconds, + distanceMeters = distanceMeters, + calories = calories, + avgHeartRate = avgHeartRate, + maxHeartRate = maxHeartRate, + steps = steps, + elevationGainMeters = elevationGainMeters, + source = "Samsung Health", + ) + + @Test + fun `an empty history reports empty rather than zeroes everywhere`() { + val report = WorkoutStats.report(emptyList(), now, zone) + + assertTrue(report.isEmpty) + assertEquals(0, report.activeDays) + assertEquals(0, report.currentStreakDays) + assertTrue(report.bests.isEmpty()) + } + + @Test + fun `workouts older than the window are excluded`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 3), workout(daysAgo = 40, distanceMeters = 99_000.0)), + now, + zone, + ) + + assertEquals(1, report.windowTotals.workoutCount) + assertEquals(0.0, report.windowTotals.distanceMeters, 0.001) + } + + @Test + fun `this week and last week are split at the seven day boundary`() { + val report = + WorkoutStats.report( + listOf( + workout(daysAgo = 1, distanceMeters = 5000.0), + workout(daysAgo = 6, distanceMeters = 3000.0), + workout(daysAgo = 8, distanceMeters = 10_000.0), + workout(daysAgo = 20, distanceMeters = 1000.0), + ), + now, + zone, + ) + + assertEquals(2, report.thisWeek.workoutCount) + assertEquals(8000.0, report.thisWeek.distanceMeters, 0.001) + assertEquals(1, report.previousWeek.workoutCount) + assertEquals(10_000.0, report.previousWeek.distanceMeters, 0.001) + assertEquals(4, report.windowTotals.workoutCount) + } + + /** A missing metric must not be counted as a zero that drags an average down. */ + @Test + fun `absent metrics contribute nothing to totals`() { + val report = + WorkoutStats.report( + listOf( + workout(daysAgo = 1, distanceMeters = 5000.0, calories = 300, steps = 6000), + workout(daysAgo = 2), + ), + now, + zone, + ) + + assertEquals(5000.0, report.windowTotals.distanceMeters, 0.001) + assertEquals(300, report.windowTotals.calories) + assertEquals(6000, report.windowTotals.steps) + assertNull(report.windowTotals.avgHeartRate) + } + + @Test + fun `average heart rate is duration weighted across the window`() { + val report = + WorkoutStats.report( + listOf( + workout(daysAgo = 1, durationSeconds = 3600, avgHeartRate = 160), + workout(daysAgo = 2, durationSeconds = 600, avgHeartRate = 100), + ), + now, + zone, + ) + + // (160*3600 + 100*600) / 4200 = 151.4 -> 151 + assertEquals(151, report.windowTotals.avgHeartRate) + } + + @Test + fun `the activity breakdown is ordered by time spent`() { + val report = + WorkoutStats.report( + listOf( + workout(daysAgo = 1, exercise = ExerciseType.RUNNING, durationSeconds = 600), + workout(daysAgo = 2, exercise = ExerciseType.CYCLING, durationSeconds = 7200), + workout(daysAgo = 3, exercise = ExerciseType.RUNNING, durationSeconds = 600), + ), + now, + zone, + ) + + assertEquals(ExerciseType.CYCLING, report.byActivity[0].exercise) + assertEquals(1, report.byActivity[0].totals.workoutCount) + assertEquals(ExerciseType.RUNNING, report.byActivity[1].exercise) + assertEquals(2, report.byActivity[1].totals.workoutCount) + } + + @Test + fun `a best is only reported for metrics that are actually present`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 1, distanceMeters = 5000.0)), + now, + zone, + ) + + val kinds = report.bests.map { it.kind } + assertTrue(WorkoutStats.BestKind.LONGEST_DISTANCE in kinds) + assertTrue(WorkoutStats.BestKind.LONGEST_DURATION in kinds) + assertTrue(WorkoutStats.BestKind.BIGGEST_CLIMB !in kinds) + assertTrue(WorkoutStats.BestKind.MOST_STEPS !in kinds) + assertTrue(WorkoutStats.BestKind.HIGHEST_HEART_RATE !in kinds) + } + + @Test + fun `two workouts on the same day count as one active day`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 1), workout(daysAgo = 1), workout(daysAgo = 3)), + now, + zone, + ) + + assertEquals(2, report.activeDays) + } + + @Test + fun `the streak counts consecutive days back from today`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 0), workout(daysAgo = 1), workout(daysAgo = 2), workout(daysAgo = 5)), + now, + zone, + ) + + assertEquals(3, report.currentStreakDays) + } + + /** Resting today shouldn't wipe out a streak that is still alive. */ + @Test + fun `a rest day today keeps yesterday's streak alive`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 1), workout(daysAgo = 2)), + now, + zone, + ) + + assertEquals(2, report.currentStreakDays) + } + + @Test + fun `missing a whole day resets the streak`() { + val report = + WorkoutStats.report( + listOf(workout(daysAgo = 2), workout(daysAgo = 3)), + now, + zone, + ) + + assertEquals(0, report.currentStreakDays) + } + + @Test + fun `the weekly average divides the four week window by four`() { + val report = + WorkoutStats.report( + List(8) { workout(daysAgo = (it * 3).toLong(), distanceMeters = 5000.0) }, + now, + zone, + ) + + assertEquals(8, report.windowTotals.workoutCount) + assertEquals(2, report.weeklyAverage.workoutCount) + assertEquals(10_000.0, report.weeklyAverage.distanceMeters, 0.001) + } + + @Test + fun `percent change is null when there is nothing to compare against`() { + assertNull(WorkoutStats.percentChange(current = 10.0, previous = 0.0)) + assertEquals(50, WorkoutStats.percentChange(current = 15.0, previous = 10.0)) + assertEquals(-25, WorkoutStats.percentChange(current = 7.5, previous = 10.0)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilterTest.kt index b28699e362..6e46771881 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilterTest.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.EventFactory -import java.util.TreeSet import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread @@ -65,7 +64,7 @@ class EventListMatchingFilterTest { sink: (List) -> Unit, ) = EventListMatchingFilter( filter = withFilter, - atOnce = { TreeSet(CreatedAtIdHexComparator) }, + atOnce = { emptyList() }, update = sink, ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilterTest.kt index a77efc8318..ba366ecb62 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilterTest.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.EventFactory -import java.util.TreeSet import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread @@ -67,7 +66,7 @@ class NoteListMatchingFilterTest { sink: (List) -> Unit, ) = NoteListMatchingFilter( filter = withFilter, - atOnce = { TreeSet(CreatedAtIdHexComparator) }, + atOnce = { emptyList() }, update = sink, ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/ObserverListBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/ObserverListBenchmark.kt new file mode 100644 index 0000000000..08cdd7f567 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/ObserverListBenchmark.kt @@ -0,0 +1,547 @@ +/* + * 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.prodbench + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.observables.NoteListMatchingFilter +import com.vitorpamplona.amethyst.commons.model.observables.Observable +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.EventFactory +import java.lang.management.ManagementFactory +import java.util.Random +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentSkipListSet +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Measures [NoteListMatchingFilter], the list every `observeNotes` / `observeEvents` screen + * sits behind, against the `ConcurrentSkipListSet` implementation it replaced. + * + * It is on a hot path: every consumed event is offered to each observer whose filter could + * match it, from each relay's socket coroutine, so a few hundred events a second across a + * dozen relays reach this code thousands of times a second. The rewrite to copy-on-write was + * made to get the class into `commonMain` (`java.util.concurrent` has no KMP equivalent); + * that is a portability argument, and this file is the performance one, which is a separate + * question and has to be answered with numbers rather than asymptotics. + * + * [SkipListReference] is the previous implementation, kept verbatim as the baseline. It also + * serves as a differential oracle: [bothImplementationsAgree] asserts the two produce identical + * output for identical input, which is the property the rewrite actually had to preserve. + * + * Deterministic and offline. Prints ns/op; asserts only on correctness, never on wall time, + * since CI machines vary. + */ +class ObserverListBenchmark { + private val author = "d0d0a746b44c9de8422165aef520b1fe041eedf5794f7592505477eeac122c18" + + /** Touches the emitted list so neither implementation's O(n) materialization is dead code. */ + private val blackhole = AtomicLong() + + private fun sink(list: List) { + blackhole.addAndGet(list.size.toLong() + if (list.isEmpty()) 0 else list[0].idHex.length.toLong()) + } + + private fun event( + i: Int, + createdAt: Long, + ): Event = + EventFactory.create( + id = "%064x".format(i), + pubKey = author, + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "n", + sig = "00".repeat(64), + ) + + /** + * createdAt is deliberately NOT monotonic in i: a real feed arrives out of order, so the + * insert lands mid-list rather than always at the head, which is the cheapest case for + * both implementations and would flatter whichever one is worse. + */ + private fun fixtures(n: Int): List> { + val rnd = Random(42) + return (0 until n).map { i -> + val e = event(i, 1_700_000_000L + rnd.nextInt(n * 4)) + val note = Note(e.id) + note.event = e + e to note + } + } + + private fun cow(f: Filter) = NoteListMatchingFilter(f, { emptyList() }, ::sink) + + private fun skipList(f: Filter) = SkipListReference(f, ::sink) + + // ---------------------------------------------------------------------- harness + + private fun bench( + label: String, + reps: Int = 5, + warmups: Int = 2, + body: () -> Int, + ): Double { + repeat(warmups) { body() } + val times = ArrayList(reps) + repeat(reps) { + val start = System.nanoTime() + val ops = body() + times.add((System.nanoTime() - start).toDouble() / ops) + } + times.sort() + val median = times[times.size / 2] + println(" %-44s %9.0f ns/op %11.0f ops/s".format(label, median, 1_000_000_000.0 / median)) + return median + } + + private fun compare( + name: String, + cowNs: Double, + skipNs: Double, + ) { + val ratio = cowNs / skipNs + val verdict = + when { + ratio < 0.95 -> "copy-on-write faster x%.2f".format(1 / ratio) + ratio > 1.05 -> "copy-on-write slower x%.2f".format(ratio) + else -> "parity" + } + println(" -> $name: $verdict\n") + } + + // ------------------------------------------------------------------ correctness + + @Test + fun bothImplementationsAgree() { + val fx = fixtures(500) + for (limit in listOf(null, 50, 400)) { + val f = Filter(kinds = listOf(TextNoteEvent.KIND), limit = limit) + + var fromCow: List = emptyList() + val a = NoteListMatchingFilter(f, { emptyList() }, { fromCow = it }) + var fromSkipList: List = emptyList() + val b = SkipListReference(f) { fromSkipList = it } + + fx.forEach { (e, note) -> a.new(e, note) } + fx.forEach { (e, note) -> b.new(e, note) } + + assertEquals( + fromSkipList.map { it.idHex }, + fromCow.map { it.idHex }, + "copy-on-write must emit exactly what the skip list emitted (limit=$limit)", + ) + assertEquals(fromCow.size, fromCow.map { it.idHex }.toSet().size, "no duplicate keys") + + // Re-delivering an already listed note must be a no-op in both. + fx.take(50).forEach { (e, note) -> a.new(e, note) } + fx.take(50).forEach { (e, note) -> b.new(e, note) } + assertEquals(fromSkipList.map { it.idHex }, fromCow.map { it.idHex }, "re-delivery must not change either list") + } + } + + // ------------------------------------------------------------------- serial load + + @Test + fun insertIntoAPopulatedList() { + // No limit: that is what nearly every production observer passes, so the list grows + // to whatever the cache holds for that kind. + println("\n== insert into a list already holding n (seed excluded from timing, no limit) ==") + for (n in listOf(100, 1000)) { + val seed = fixtures(n) + val arrivals = fixtures(n * 2).drop(n).map { (e, _) -> e } + val f = Filter(kinds = listOf(TextNoteEvent.KIND)) + + fun run(make: () -> Observable): () -> Int = + { + val subject = make() + seed.forEach { (e, note) -> subject.new(e, note) } + val notes = + arrivals.map { e -> + val note = Note(e.id) + note.event = e + e to note + } + val t0 = System.nanoTime() + notes.forEach { (e, note) -> subject.new(e, note) } + elapsed.addAndGet(System.nanoTime() - t0) + arrivals.size + } + + val c = benchInner("copy-on-write insert @$n", run { cow(f) }) + val s = benchInner("skip list insert @$n", run { skipList(f) }) + compare("insert @n=$n", c, s) + } + } + + private val elapsed = AtomicLong() + + /** Reports only the span the body accumulated into [elapsed], excluding its setup. */ + private fun benchInner( + label: String, + body: () -> Int, + reps: Int = 5, + warmups: Int = 2, + ): Double { + repeat(warmups) { + elapsed.set(0) + body() + } + val times = ArrayList(reps) + repeat(reps) { + elapsed.set(0) + val ops = body() + times.add(elapsed.get().toDouble() / ops) + } + times.sort() + val median = times[times.size / 2] + println(" %-44s %9.0f ns/op %11.0f ops/s".format(label, median, 1_000_000_000.0 / median)) + return median + } + + @Test + fun reDeliveryOfAnAlreadyListedNote() { + // The dominant steady-state call once a screen is warm: a newer version of an + // addressable, or the same note arriving from a second relay. + println("\n== re-delivery of a note already in the list ==") + for (n in listOf(100, 1000)) { + val fx = fixtures(n) + val f = Filter(kinds = listOf(TextNoteEvent.KIND), limit = n) + val reps = 100_000 + + val a = cow(f).also { s -> fx.forEach { (e, note) -> s.new(e, note) } } + val b = skipList(f).also { s -> fx.forEach { (e, note) -> s.new(e, note) } } + + val c = + bench("copy-on-write re-deliver @$n") { + for (i in 0 until reps) { + val (e, note) = fx[i % n] + a.new(e, note) + } + reps + } + val s = + bench("skip list re-deliver @$n") { + for (i in 0 until reps) { + val (e, note) = fx[i % n] + b.new(e, note) + } + reps + } + compare("re-deliver n=$n", c, s) + } + } + + // ------------------------------------------------------------ allocation / GC + + /** + * Bytes allocated per operation, which is the GC-pressure question: a phone pays for young-gen + * churn in jank, not just in CPU. Measured with HotSpot's per-thread allocation counter, so it + * is exact rather than inferred from heap deltas, and single-threaded so the counter is this + * work and nothing else. + */ + private fun allocatedBytes(): Long { + val bean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + return bean.getThreadAllocatedBytes(Thread.currentThread().id) + } + + private fun measureAllocation( + label: String, + warmups: Int = 2, + body: () -> Int, + ): Double { + repeat(warmups) { body() } + val before = allocatedBytes() + val ops = body() + val bytes = (allocatedBytes() - before).toDouble() / ops + println(" %-44s %12.0f bytes/op".format(label, bytes)) + return bytes + } + + private fun compareBytes( + name: String, + cowBytes: Double, + skipBytes: Double, + ) { + val ratio = cowBytes / skipBytes + val verdict = + when { + ratio < 0.95 -> "copy-on-write allocates %.2fx LESS".format(1 / ratio) + ratio > 1.05 -> "copy-on-write allocates %.2fx MORE".format(ratio) + else -> "parity" + } + println(" -> $name: $verdict\n") + } + + @Test + fun allocationPerOperation() { + println("\n== bytes allocated per operation (GC pressure) ==") + + // (a) The re-delivery path: the dominant steady-state call once a screen is warm. + for (n in listOf(100, 1000)) { + val fx = fixtures(n) + val f = Filter(kinds = listOf(TextNoteEvent.KIND), limit = n) + val a = cow(f).also { s -> fx.forEach { (e, note) -> s.new(e, note) } } + val b = skipList(f).also { s -> fx.forEach { (e, note) -> s.new(e, note) } } + val reps = 50_000 + + val c = + measureAllocation("copy-on-write re-deliver @$n") { + for (i in 0 until reps) { + val (e, note) = fx[i % n] + a.new(e, note) + } + reps + } + val sk = + measureAllocation("skip list re-deliver @$n") { + for (i in 0 until reps) { + val (e, note) = fx[i % n] + b.new(e, note) + } + reps + } + compareBytes("re-deliver n=$n", c, sk) + } + + // (b) The insert path, where copy-on-write copies the list and the skip list does not. + for (n in listOf(100, 1000)) { + val seed = fixtures(n) + val arrivals = fixtures(n * 2).drop(n).map { (e, _) -> e } + val f = Filter(kinds = listOf(TextNoteEvent.KIND)) + + fun run(make: () -> Observable): () -> Int = + { + val subject = make() + seed.forEach { (e, note) -> subject.new(e, note) } + val notes = + arrivals.map { e -> + val note = Note(e.id) + note.event = e + e to note + } + val before = allocatedBytes() + notes.forEach { (e, note) -> subject.new(e, note) } + alloc.addAndGet(allocatedBytes() - before) + arrivals.size + } + + fun measureInner( + label: String, + body: () -> Int, + ): Double { + repeat(2) { + alloc.set(0) + body() + } + alloc.set(0) + val ops = body() + val bytes = alloc.get().toDouble() / ops + println(" %-44s %12.0f bytes/op".format(label, bytes)) + return bytes + } + + val c = measureInner("copy-on-write insert @$n", run { cow(f) }) + val sk = measureInner("skip list insert @$n", run { skipList(f) }) + compareBytes("insert @n=$n", c, sk) + } + println(" blackhole=${blackhole.get()}") + } + + private val alloc = AtomicLong() + + /** + * The one place copy-on-write could lose on allocation: a lost CAS throws away the copy it + * just built and retries, so contention multiplies the bytes per insert. The skip list has no + * such amplification. Summed across the worker threads, since the counter is per-thread. + */ + @Test + fun allocationUnderContention() { + println("\n== bytes allocated per insert under contention (CAS retry amplification) ==") + val ops = 4_000 + val fx = fixtures(ops) + val f = Filter(kinds = listOf(TextNoteEvent.KIND), limit = 1000) + + fun run( + threads: Int, + subject: Observable, + ): Double { + val bean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + val total = AtomicLong() + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + repeat(threads) { t -> + thread { + start.await() + val before = bean.getThreadAllocatedBytes(Thread.currentThread().id) + var i = t + while (i < ops) { + val (e, note) = fx[i % fx.size] + subject.new(e, note) + i += threads + } + total.addAndGet(bean.getThreadAllocatedBytes(Thread.currentThread().id) - before) + done.countDown() + } + } + start.countDown() + done.await() + return total.get().toDouble() / ops + } + + for (threads in listOf(1, 4, 8)) { + run(threads, cow(f)) + run(threads, skipList(f)) + val c = run(threads, cow(f)) + val sk = run(threads, skipList(f)) + println(" %-44s %12.0f bytes/op".format("copy-on-write insert x$threads", c)) + println(" %-44s %12.0f bytes/op".format("skip list insert x$threads", sk)) + compareBytes("insert x$threads", c, sk) + } + println(" blackhole=${blackhole.get()}") + } + + // --------------------------------------------------------------- concurrent load + + private fun concurrently( + threads: Int, + ops: Int, + subject: Observable, + fx: List>, + ): Int { + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + repeat(threads) { t -> + thread { + start.await() + var i = t + while (i < ops) { + val (e, note) = fx[i % fx.size] + subject.new(e, note) + i += threads + } + done.countDown() + } + } + start.countDown() + done.await() + return ops + } + + @Test + fun concurrentInsertsFromSeveralRelayThreads() { + // Worst case on purpose: these threads do nothing but insert. Real ingest spends most + // of its per-event budget on signature verification and parsing before reaching an + // observer, so actual contention on one filter is a fraction of this. + println("\n== concurrent inserts, threads doing nothing but inserting (worst case) ==") + val ops = 4_000 + val fx = fixtures(ops) + val f = Filter(kinds = listOf(TextNoteEvent.KIND), limit = 1000) + for (threads in listOf(1, 4, 8)) { + val c = bench("copy-on-write concurrent x$threads", reps = 3, warmups = 1) { concurrently(threads, ops, cow(f), fx) } + val s = bench("skip list concurrent x$threads", reps = 3, warmups = 1) { concurrently(threads, ops, skipList(f), fx) } + compare("concurrent x$threads", c, s) + } + println(" blackhole=${blackhole.get()}") + } + + /** + * [NoteListMatchingFilter] as it stood before the copy-on-write rewrite, verbatim apart from + * dropping the unused `atOnce`/`init` pair. Kept as the baseline these numbers are measured + * against, and as the oracle in [bothImplementationsAgree]. + * + * It is lock-free too -- `byId` is a ConcurrentHashMap, striped per key, and every write to + * `sorted` for an idHex happens inside that key's `compute` section -- which is why the + * comparison is about speed and portability, not about locking. + */ + private class SkipListReference( + private val filter: Filter, + private val update: (List) -> Unit, + ) : Observable { + private class Entry( + val note: Note, + val createdAt: Long, + val id: HexKey, + ) + + private val order = + Comparator { a, b -> + val byCreatedAt = b.createdAt.compareTo(a.createdAt) + if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id) + } + + private val sorted = ConcurrentSkipListSet(order) + private val byId = ConcurrentHashMap() + + private fun entryFor(note: Note) = Entry(note, note.createdAt() ?: Long.MIN_VALUE, note.event?.id ?: note.idHex) + + override fun new( + event: Event, + note: Note, + ) { + if (event is AddressableEvent && note !is AddressableNote) return + if (!filter.match(event)) return + + var added = false + byId.compute(note.idHex) { _, existing -> + existing ?: entryFor(note).also { + sorted.add(it) + added = true + } + } + if (!added) return + + val limit = filter.limit + if (limit != null && sorted.size > limit) { + sorted.pollLast()?.let { byId.remove(it.note.idHex, it) } + } + + update(snapshot()) + } + + override fun remove(note: Note) { + var removed = false + byId.compute(note.idHex) { _, existing -> + if (existing != null) { + sorted.remove(existing) + removed = true + } + null + } + if (removed) update(snapshot()) + } + + /** The skip list's iterator is only weakly consistent, so this had to strip duplicates. */ + private fun snapshot(): List { + val seen = HashSet() + return sorted.mapNotNull { e -> e.note.takeIf { seen.add(it.idHex) } } + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt new file mode 100644 index 0000000000..7745905273 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt @@ -0,0 +1,117 @@ +/* + * 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.scheduledposts + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The scheduled-post file survived the move off Jackson. + * + * This file is a user's queued posts: pre-signed events waiting for their publish + * time. If the format shifted when the serializer changed, an existing install + * would silently drop everything it had queued on the first launch after the + * update — no crash, no error, just an empty queue. + * + * [JACKSON_OUTPUT] is the exact string the Jackson build wrote for [sample], + * captured from it before the switch. The assertions are that the kotlinx reader + * loads it and the kotlinx writer reproduces it byte for byte — the second half + * matters because it is what a downgrade, or an older `amy` build sharing the same + * file, has to keep reading. + */ +class ScheduledPostFileFormatTest { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val sample = + ScheduledPostFile( + version = 1, + posts = + listOf( + ScheduledPost( + id = "id1", + accountPubkey = "pk1", + signedEventJson = """{"k":1}""", + relayUrls = listOf("wss://a", "wss://b"), + extraEventsJson = listOf("e1"), + publishAtSec = 111L, + createdAtSec = 222L, + status = ScheduledPostStatus.PENDING, + lastAttemptAtSec = null, + attemptCount = 0, + lastError = null, + terminatedAtSec = 333L, + ), + ), + ) + + @Test + fun writesTheBytesJacksonWrote() { + assertEquals(JACKSON_OUTPUT, json.encodeToString(sample)) + } + + @Test + fun readsAFileWrittenByTheJacksonBuild() { + val loaded = json.decodeFromString(JACKSON_OUTPUT) + + assertEquals(1, loaded.version) + assertEquals(1, loaded.posts.size) + val post = loaded.posts.first() + assertEquals("id1", post.id) + assertEquals("""{"k":1}""", post.signedEventJson) + assertEquals(listOf("wss://a", "wss://b"), post.relayUrls) + assertEquals(ScheduledPostStatus.PENDING, post.status) + assertEquals(333L, post.terminatedAtSec) + } + + @Test + fun aFileFromANewerBuildStillLoads() { + // Forward compatibility: the reader must not choke on a key it does not know, + // which is what FAIL_ON_UNKNOWN_PROPERTIES=false used to buy. + val withExtras = + JACKSON_OUTPUT + .replace("""{"version":1""", """{"version":1,"somethingNew":{"a":1}""") + .replace(""""id":"id1"""", """"id":"id1","perPostFutureField":true""") + + val loaded = json.decodeFromString(withExtras) + + assertEquals("id1", loaded.posts.first().id) + } + + @Test + fun versionIsNotDroppedJustBecauseItEqualsItsDefault() { + // kotlinx omits a value equal to its default unless encodeDefaults is set. + // Losing "version" would make the file unreadable to anything that checks it. + assertEquals(true, json.encodeToString(sample).startsWith("""{"version":1,""")) + } + + companion object { + private const val JACKSON_OUTPUT = + """{"version":1,"posts":[{"id":"id1","accountPubkey":"pk1","signedEventJson":"{\"k\":1}",""" + + """"relayUrls":["wss://a","wss://b"],"extraEventsJson":["e1"],"publishAtSec":111,""" + + """"createdAtSec":222,"status":"PENDING","lastAttemptAtSec":null,"attemptCount":0,""" + + """"lastError":null,"terminatedAtSec":333}]}""" + } +} diff --git a/commonsUI/proguard-rules.pro b/commonsUI/proguard-rules.pro deleted file mode 100644 index 0bf7b32616..0000000000 --- a/commonsUI/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --keep class com.vitorpamplona.quartz.** { *; } --keep class com.vitorpamplona.amethyst.** { *; } \ No newline at end of file diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index ee75754b15..56d4724a42 100644 Binary files a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index 429023b1b5..5a78b0c742 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -50,6 +50,7 @@ كاميرا سرعة ازدحام مروري حدث على الطريق + فتح قراءة كتابة @@ -444,6 +445,8 @@ ربط %1$s كم من Health Connect + لم يتم العثور على nSites بعد. إعادة التحميل يحتاج المتصفح المدمج إلى Android 11 أو أحدث. @@ -766,6 +769,9 @@ جارٍ الاتصال… جارٍ الاتصال… انتهت المكالمة + تجاهل تبديل الكاميرا مكالمة صوتية diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index e8404bbf42..8350369fdc 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -46,6 +46,7 @@ স্পিড ক্যামেরা যানজট রাস্তার ঘটনা + খুলুন পড়ুন লিখুন @@ -433,6 +434,8 @@ সংযোগ করুন %1$s কিমি Health Connect থেকে + এখনো কোনো nSite পাওয়া যায়নি। পুনরায় লোড করুন ইন-অ্যাপ ব্রাউজারের জন্য Android 11 বা নতুন সংস্করণ প্রয়োজন। @@ -750,6 +753,9 @@ কল করা হচ্ছে… সংযুক্ত হচ্ছে… কল শেষ হয়েছে + বাতিল করুন ক্যামেরা পরিবর্তন করুন ভয়েস কল diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index c1061e454e..4a37728eff 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -173,6 +173,7 @@ Rychlostní radar Dopravní zácpa Silniční událost + Zapy na toto se rozdělují mezi: %1$d%% Value-for-Value @@ -674,6 +675,8 @@ Nic neopustí váš telefon, dokud sami neklepnete na návrh a příspěvek nezveřejníte. Amethyst nemá žádný server: příspěvek jde na relaye Nostr, které jste nastavili. Celá funkce je volitelná. Vypnete ji v Nastavení → Nastavení editoru, nebo kdykoli odeberete oprávnění v Health Connect — zbytek Amethystu funguje dál. Přečíst si celé zásady ochrany soukromí + Zatím nenalezeny žádné nSites. Obnovit Integrovaný prohlížeč vyžaduje Android 11 nebo novější. @@ -1245,6 +1248,9 @@ Volání\u2026 Připojování\u2026 Hovor ukončen + Zavřít Přepnout kameru Hlasový hovor diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index 2366af052a..6a9ae6c681 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -165,6 +165,7 @@ Blitzer Stau Straßenereignis + Zaps hierauf werden aufgeteilt zwischen: %1$d%% Value-for-Value @@ -664,6 +665,8 @@ Nichts verlässt dein Telefon, bis du auf einen Vorschlag tippst und den Beitrag selbst veröffentlichst. Amethyst hat keinen Server: Der Beitrag geht an die Nostr-Relays, die du eingerichtet hast. Die gesamte Funktion ist optional. Schalte sie unter Einstellungen → Editor-Einstellungen aus oder entziehe die Berechtigungen jederzeit in Health Connect — der Rest von Amethyst funktioniert weiter. Vollständige Datenschutzerklärung lesen + Noch keine nSites gefunden. Neu laden Der In-App-Browser benötigt Android 11 oder neuer. @@ -1235,6 +1238,9 @@ Anruf\u2026 Verbindung wird hergestellt\u2026 Anruf beendet + Verwerfen Kamera wechseln Sprachanruf diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index f57f47cf95..48e9fc105e 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -46,6 +46,7 @@ Κάμερα ταχύτητας Κυκλοφοριακή συμφόρηση Οδικό συμβάν + Άνοιγμα Ανάγνωση Εγγραφή @@ -431,6 +432,8 @@ Αφήστε το Amethyst να διαβάσει ολοκληρωμένες προπονήσεις από το Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) και να προτείνει μια ανάρτηση. Σύνδεση Από το Health Connect + Δεν βρέθηκαν nSites ακόμα. Ανανέωση Ο ενσωματωμένος περιηγητής απαιτεί Android 11 ή νεότερο. @@ -750,6 +753,9 @@ Κλήση… Σύνδεση… Η κλήση τελείωσε + Παράβλεψη Εναλλαγή κάμερας Φωνητική κλήση diff --git a/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml b/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml index eb6246dff7..d1c1c4d417 100644 --- a/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml @@ -13,7 +13,13 @@ + Profile Image + + diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index 2e9a5ce140..b25fb090ea 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -46,6 +46,7 @@ Rapideca fotilo Trafika ŝtopado Voja evento + Malfermi Legi Skribi @@ -434,6 +435,8 @@ Lasu Amethyst legi finitajn ekzercadojn el Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) kaj sugesti afiŝon. Konekti El Health Connect + Ankoraŭ neniuj nSite-oj trovitaj. Reŝargi La en-aplikaĵa retumilo bezonas Android 11 aŭ pli novan. @@ -754,6 +757,9 @@ Alvokante… Konektante… Alvoko finiĝis + Malakcepti Ŝanĝi fotilon Voĉa alvoko diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index f3d1771d9c..bea6583cb9 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -46,6 +46,7 @@ Radar de velocidad Atasco de tráfico Incidencia vial + Abrir Leer Escribir @@ -429,6 +430,8 @@ Deja que Amethyst lea los entrenamientos terminados de Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) y sugiera una publicación. Conectar Desde Health Connect + No se han encontrado nSites todavía. Recargar El navegador integrado requiere Android 11 o superior. @@ -748,6 +751,9 @@ Llamando… Conectando… Llamada finalizada + Descartar Cambiar cámara Llamada de voz diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index 514b50419b..18e2199102 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -46,6 +46,7 @@ Radar de velocidad Atasco de tráfico Incidencia vial + Abrir Leer Escribir @@ -429,6 +430,8 @@ Deja que Amethyst lea los entrenamientos terminados de Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) y sugiera una publicación. Conectar Desde Health Connect + No se han encontrado nSites todavía. Recargar El navegador integrado requiere Android 11 o superior. @@ -748,6 +751,9 @@ Llamando… Conectando… Llamada finalizada + Descartar Cambiar cámara Llamada de voz diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index 57fbaab136..a9df09cd56 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -46,6 +46,7 @@ Radar de velocidad Atasco de tráfico Incidencia vial + Abrir Leer Escribir @@ -429,6 +430,8 @@ Deja que Amethyst lea los entrenamientos terminados de Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) y sugiera una publicación. Conectar Desde Health Connect + No se han encontrado nSites todavía. Recargar El navegador integrado requiere Android 11 o superior. @@ -748,6 +751,9 @@ Llamando… Conectando… Llamada finalizada + Descartar Cambiar cámara Llamada de voz diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index 46593a3525..7378c6e0d5 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -46,6 +46,7 @@ دوربین سرعت‌سنج ترافیک رویداد جاده‌ای + باز خواندن نوشتن @@ -435,6 +436,8 @@ اتصال %1$s کیلومتر از Health Connect + هنوز هیچ nSite ای یافت نشد. بارگذاری مجدد مرورگر درون‌برنامه‌ای به Android 11 یا جدیدتر نیاز دارد. @@ -755,6 +758,9 @@ در حال تماس… در حال اتصال… تماس پایان یافت + بستن تعویض دوربین تماس صوتی diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index 66d1e7b1e0..94b09423ca 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -46,6 +46,7 @@ Nopeusvalvontakamera Ruuhka Tietapahtuma + Avaa Lue Kirjoita @@ -428,6 +429,8 @@ Anna Amethystin lukea valmiit harjoitukset Health Connectista (Samsung Health, Google Fit, Fitbit, Garmin…) ja ehdottaa julkaisua. Yhdistä Health Connectista + nSitejä ei löydy vielä. Lataa uudelleen Sovelluksen sisäinen selain vaatii Android 11 tai uudemman. @@ -745,6 +748,9 @@ Soitetaan… Yhdistetään… Puhelu päättyi + Ohita Vaihda kameraa Äänipuhelu diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 649a217a62..1ac0464be5 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -46,6 +46,7 @@ Radar Embouteillage Événement routier + Ouvrir Lire Écrire @@ -418,6 +419,8 @@ Laissez Amethyst lire les entraînements terminés depuis Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) et suggérer une publication. Connecter Depuis Health Connect + Aucun nSite trouvé pour l'instant. Recharger Le navigateur intégré nécessite Android 11 ou une version plus récente. @@ -729,6 +732,9 @@ Appel\u2026 Connexion en cours\u2026 Appel terminé + Ignorer Basculer de caméra Appel vocal diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index abfc22a20d..7ab3ad2374 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -46,6 +46,7 @@ Radar Embouteillage Événement routier + Les zaps sont divisés entre : Valeur-pour-valeur Ouvrir @@ -424,6 +425,8 @@ Laissez Amethyst lire les entraînements terminés depuis Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) et suggérer une publication. Connecter Depuis Health Connect + Aucun nSite trouvé pour l'instant. Recharger Le navigateur intégré nécessite Android 11 ou une version plus récente. @@ -811,6 +814,9 @@ Appel\u2026 Connexion en cours\u2026 Appel terminé + Ignorer Basculer de caméra Appel vocal diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index a5d584bfc5..566f6152f2 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -165,6 +165,7 @@ गति चित्रग्राहक यातायात अचलता मार्ग घटना + इसको ज्साप इनमें विभाजित : %1$d प्रतिशतप्रतिशत मूल्य के लिए मूल्य @@ -186,7 +187,37 @@ %1$d घटनाएँ परिचय ध्वजचित्र - क्यूआर॰ क्रमचित्र के अभिमुख करें + क्यूआर॰ चित्र अक्षरराशि के अभिमुख करें + अमेथिस्ट को चित्रग्राहक चाहिए क्यूआर॰ चित्र अक्षरराशि परखने के लिए। कुछ भी अभिलेखित अथवा आरोहित नहीं। चित्र का विस्तारण यन्त्र पर किया जाएगा तथा हटा दिया जाएगा। + चित्रग्राहक अनुमति + चित्रग्राहक अभिगमन निष्क्रिय कर दिया गया अमेथिस्ट के लिए। आप इसे पुनःसक्रिय कर सकते हैं आण्ड्रोइड स्थापना विकल्पों में। + स्थापना विकल्प खोलें + क्यूआर॰ विस्तारक इस यन्त्र पर आरम्भ नहीं किया जा सका। तो भी आप अक्षरराशि को लेख के रूप में चिपका सकते हैं। + प्रकाशस्रोत सक्रिय करें + प्रकाशस्रोत निष्क्रिय करें + अन्धकारमय है। प्रकाशस्रोत सक्रिय करके प्रयास करें + चित्र परखें + चिपकाएँ + उस चित्र में से कोई क्यूआर॰ चित्र अक्षरराशि प्राप्त नहीं + टाँकाफलक में कुछ भी नहीं परखने के लिए + अक्षरराशि का चयन करें + %2$s में से %1$s भाग प्राप्त। आगे बढें + विस्तारण पुनःस्थापना + अमेथिस्ट इस अक्षरराशि को नहीं खोल सकता + इस अक्षरराशि में एक निजी कुंचिका अथवा जोडनेवाला रहस्य है। इसलिए इसकी विषयवस्तु यहाँ दिखाया नहीं जाता। अनुकूल पटल का उपयोग करें। + यह एक निजी कुंचिका है। प्रवेशांकन पटल पर चिपकाएँ। + यह एक धनकोष संयोजन है। इसे जोडें धनकोष स्थापना विकल्पों में। + यह एक दूरस्थ हस्ताक्षरकर्ता पता है। अमेथिस्ट इसे देता है अन्य क्रमकों को जिससे वे अमेथिस्ट से हस्ताक्षर अनुरोध कर सकते हैं। अमेथिस्ट इससे हस्ताक्षर करके प्रवेशांकन नहीं कर सकता। + यह एक क्रमक है जो आपके हस्ताक्षरकर्ता से जुडने का अनुरोध कर रहा है। इसे निप॰छियालीस हस्ताक्षरकर्ता पटल से खोलें। + यह एक लैटनिंग चालान है। + यह एक काशयू अक्षरराशि है। + यह एक जाल योजक है। + यह साधारण लेख है। नोस्टर सम्बन्धित अक्षरराशि नहीं। + यह नोस्टर सम्बन्धित अक्षरराशि है। पर इस पटल द्वारा नहीं खोला जा सकता। + योजक खोलें + अनुकृति + अनुकृत + पुनःपरखें क्यूआर॰ क्रमचित्र दिखाएँ परिचय चित्र आपका परिचय चित्र @@ -494,8 +525,8 @@ किसी भी संचारयन्त्र चित्रग्राहक के साथ परखें नोस्टर क्रमक के साथ परखें चित्र - क्यूआर॰ चित्र जिसमें इस टीका का एक जाल योजक समाविष्ट है - क्यूआर॰ चित्र जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है + क्यूआर॰ चित्र अक्षरराशि जिसमें इस टीका का एक जाल योजक समाविष्ट है + क्यूआर॰ चित्र अक्षरराशि जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है अंगुलचित्र छिपाया गया संवेदनशिल विषयवस्तु के कारण लेखक सूचक लेख की अनुकृति करें @@ -631,7 +662,7 @@ गति ऊँचाई वृद्धि ऊँचाई घटाव - उष्ममान + उष्ममानइकाई पद हृदय गति उच्चतम हृदय गति @@ -656,7 +687,7 @@ अमेथिस्ट क्या पढता है और क्यों व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। तथा पत्र का शीर्षक तथा कालावधि। दूरी। आप कितने दूर गए। धवन चालन चलन तरण की दूरी के रूप में दिखाया जाएगा। - सक्रिय तथा कुल उष्ममान। ऊर्जा जिसका व्यय व्यायाम से किया गया। सक्रिय उष्ममान उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममान विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। + सक्रिय तथा कुल उष्ममानइकाई। ऊर्जा जिसका व्यय व्यायाम से किया गया। सक्रिय उष्ममानइकाई उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममानइकाई विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। मानक परिमाण कि श्रम कितना कठिन था। पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। ऊँचाई वृद्धि। आप कितना ऊपर चढे। जो कि पृथकता है एक समतल चालन का एक पर्वतीय चालन से। @@ -666,6 +697,48 @@ आपके संचारयन्त्र से तब तक कुछ नहीं जाएगा जब तक आप एक सुझाव नहीं दबाते तथा पत्र का प्रकाशन आप स्वयम नहीं करते। अमेथिस्ट का कोई सेवासंगणक नहीं। पत्र उन नोस्टर पुनःप्रसारकों को जाएगा जिनको आपने समाकृत कर रखे हैं। यह सम्पूर्ण सुविधा विकल्पात्मक है। इसे निष्क्रिय करें स्थापना विकल्पों में सम्पादन पटल में। अथवा स्वास्थ्य संयोजन में अनुमतियाँ का निराकरण करें कभी भी। अमेथिस्ट के शेष भाग कार्य करते रहेंगे। सम्पूर्ण गोपनीयता नीति पढें + मेरा स्वास्थ्य + पिछले चार सप्ताह + दूरी उष्ममानइकाई तथा हृदयस्पन्दन गति का आवहन… + आपके अभ्यास के अन्तिम चार पूर्ण सप्ताह। व्यायाम जो आपने प्रकाशित किए। तथा जो भी स्वास्थ्यसंयोजन इस संचारयन्त्र पर अभिलेखन करता है। अमेथिस्ट इसका साराम्श कर निकालेगा यन्त्र पर तथा कुछ भी प्रकाशित नहीं करेगा। + इस सप्ताह + पिछले सप्ताह की तुलना में + साप्ताहिक मध्यमान + व्यायाम + सक्रिय दिन + लगातार इतने दिन + समय + + %1$dघं॰ %2$dमि॰ + %1$dमि॰ + %1$dसे॰ + दूरी + उष्ममानइकाई + पद + आरोहण + मध्य॰ हृ॰गति + उच्च॰ हृ॰गति + गतिविधि अनुसार + सर्वोत्तम प्रयास + दीर्घतम दूरी + दीर्घतम व्यायाम + बृहत्तम आरोहण + अधिकतम पद + उच्चतम हृदयस्पन्दन गति + निकटकाल व्यायाम + पिछले चार सप्ताह में कुछ भी अभिलेखित नहीं। व्यायाम जो आप प्रकाशित करते हैं तथा जो भी आपकी घडी अभिलेखित करती है सब यहाँ दिखेंगे। + व्यायाम के अभिलेख + स्वास्थ्यसंयोजन से जोड बनाएँ आपके साप्ताहिक संकलनों को देखने के लिए। तथा सप्ताहोत्तर सप्ताह प्रवृत्ति। तथा सर्वोत्तम प्रयासों को। तथा निरन्तार अभ्यास दिनों को। सब आपके संचारयन्त्र पर रहेगा। + स्वास्थ्यसंयोजन से जोड बनाएँ आपकी घडी द्वारा अभिलेखित व्यायामों को जोडने के लिए। हृदयस्पन्दन गति तथा पद तथा आरोहण युक्त। + संयोजन + व्यायाम बाँटें + स्पन्दनप्रतिमिनुट + सहस्रउष्ममानइकाई + सहस्रमीटर + मील + मीटर + पाद कोई नोस्टरस्थान प्राप्त नहीं अब तक। पुनःआवहन क्रमकाभ्यन्तर जालवीक्षक के लिए आण्ड्रोइड ग्यारह अथवा नवीनतर आवश्यक। @@ -1237,6 +1310,11 @@ आह्वान चालू\u2026 संयोजन बन रहा है\u2026 आह्वान समाप्त + + %1$02dमि॰%2$02dसे॰ + %1$dघं॰%2$02dमि॰%3$02dसे॰ हटाएँ अन्य चित्रग्राहक ध्‍वनि आह्वान @@ -1625,7 +1703,7 @@ यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या तो भी आरोहण करें। तो भी आरोहण करें पाण्डुलिपि सम्पादन - क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें + क्यूआर॰ चित्र अक्षरराशि के साथ प्रवेशांकन करें भेजें प्राप्त करें लेनदेन @@ -1643,6 +1721,7 @@ इस छलनी के अनुकूल कोई लेनदेन नहीं आवहन चालू… प्राप्त + ज्साप किया गया टीका खोलें भेजा गया नवीकरण सभी @@ -2086,8 +2165,8 @@ समापन दिनांक तथा समय चुनें %1$s में समाप्त समापन समय - क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ - क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ + क्यूआर॰ चित्र अक्षरराशि के रूप में एनपुब॰ को दिखाएँ + क्यूआर॰ चित्र अक्षरराशि के रूप में एनप्रोफैल॰ को दिखाएँ आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे। ये अच्छे विकल्प हैं :\n - auth.nostr1.com (शुल्करहित)\n - inbox.nostr.wine (सशुल्क)\n - relay.0xchat.com (शुल्करहित) @@ -2197,7 +2276,7 @@ भेजें उपयोगकर्ता नाम को ध्वनि के रूप में चलाएँ घुण्डी - क्यूआर॰ क्रमचित्र परखें + क्यूआर॰ चित्र अक्षरराशि परखें दिनांक का चयन करें पाण्डुलिपि पुरस्कार diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 6f63c998ab..ca302802b1 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -4,7 +4,7 @@ Üdvözöljük az Amethystben Jelentkezzen be Nostr a-fiókjába Asztali Nostr-kliens - Jelentkezzen be Nostr-a kulcsával + Jelentkezzen be a Nostr-a kulcsával nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz Bejelentkezés kulccsal Bejelentkezés @@ -21,7 +21,7 @@ Nyilvános kulcs (megosztható): Titkos kulcs (SOHA ne ossza meg!): Elmentettem a kulcsaimat, folytatás - Egy biztonságos helyre mentettem a kulcsaimat + Biztonságos helyre mentettem a kulcsaimat Titkosított másolása (ajánlott) Jelszó a titkosított biztonsági mentéshez %1$d. lépés / %2$d @@ -165,6 +165,7 @@ Sebességmérő kamera Forgalmi dugó Útesemény + Az erre küldött Zapek megoszlanak a következők között: %1$d%% Értéket az értékért @@ -187,6 +188,36 @@ Profilplakát Fókuszálás a QR-kódra + Az Amethystnek hozzáférésre van szüksége a kamerához a QR-kódok beolvasásához. Semmi sem kerül rögzítésre vagy feltöltésre, a képkockák a készüléken dekódolódnak, majd törlődnek. + Kamera engedélyezése + Az Amethystnek nincs engedélye hozzáférni a kamerához. Az Android beállításaiban adhat engedélyt a hozzáféréshez. + Beállítások megnyitása + A QR-kód-olvasó nem indult el ezen az eszközön. A kódot azonban szövegként beillesztheti. + Vaku bekapcsolása + Vaku kikapcsolása + Sötét van, kapcsolja be a vakut + Kép beolvasása + Beillesztés + Nem található QR-kód a képen + A vágólapon nincs semmi a beolvasáshoz + Érintse meg a beolvasandó kódot + %1$s darabot sikerült beolvasni a(z) %2$s darab közül, folytatás + Nagyítás visszaállítása + Az Amethyst nem tudja megnyitni ezt a kódot + Ez a kód egy titkos kulcsot vagy párosítási titkot tartalmaz, ezért a tartalma itt nem jelenik meg. Használja azt a képernyőt, ahol meg kell adni. + Ez egy privát kulcs. Ezt inkább illessze be a bejelentkezési képernyőn. + Ez egy pénztárca-kapcsolat. A pénztárca beállításaiból adhatja hozzá. + Ez egy távoli aláírói cím. Az Amethyst osztja ki ezeket, hogy más alkalmazások aláírásra kérhessék, ezzel a címmel azonban nem lehet bejelentkezni. + Ez egy olyan alkalmazás, amely csatlakozást kér az aláírási eszközéhez. Nyissa meg a NIP-46 aláírási eszköz képernyőjéről. + Ez egy Lightning-számla. + Ez egy Cashu-token. + Ez egy webhivatkozás. + Ez egy egyszerű szöveg, nem Nostr-kód. + Ez egy Nostr-kód, de ez a képernyő nem tudja megnyitni. + Hivatkozás megnyitása + Másolás + Másolva + Beolvasás újra QR-kód megjelenítése Profilkép Saját profilkép @@ -667,6 +698,47 @@ Semmi sem hagyja el az Ön eszközét, amíg rá nem koppint egy javaslatra, és saját maga közzé nem teszi a bejegyzést. Az Amethyst nem használ kiszolgálót: a bejegyzés az Ön által beállított Nostr-átjátszókra kerül. Egyetlen funkció sem kötelező. Bármikor kikapcsolhatja a Beállítások → Írás menüpontban, vagy visszavonhatja a Health Connectben megadott engedélyeket; az Amethyst többi része továbbra is működni fog. Olvassa el a teljes adatvédelmi nyilatkozatot + Fitnesz + Elmúlt 4 hét + Az edzés utolsó négy teljes hete: a közzétett edzések, valamint minden egyéb adat, amelyet a Health Connect rögzít ezen az eszközön. Az Amethyst az eszközön állítja össze az összefoglalót, és abból semmit sem tesz közzé. + Ezen a héten + elmúlt héten + Heti átlag + Edzések + Aktív napok + Napi sorozat + Idő + + %1$dó %2$dp + %1$dp + %1$dmp + Távolság + Kalória + Lépések + Mászás + Átl. szívritmus + Max. szívritmus + Tevékenység szerint + Legjobb igyekezet + Leghosszabb távolság + Leghosszabb edzés + Legmagasabb mászás + Legtöbb lépés + Legmagasabb pulzusszám + Legutóbbi edzések + Az elmúlt 4 hétben nem volt rögzítve semmi. A közzétett edzések, valamint az órá által rögzített adatok itt jelennek meg. + Edzések nyomon követése + Kapcsolja össze a Health Connecttel, hogy megtekintse a heti összesített adatait, a hétről hétre változó tendenciákat, a legjobb teljesítményeit és az edzéssorozatát. Minden adat az eszközön marad. + Kapcsolja össze a Health Connect alkalmazással, hogy hozzáadhassa az órája által rögzített edzéseket, a pulzusszámmal, a lépések számával és a megmászott magassággal együtt. + Kapcsolódás + Edzés megosztása + bpm + kcal + km + mi + m + láb Még nem találhatók nOldalak. Újratöltés Az alkalmazáson belüli böngészőhöz Android 11 vagy újabb verzió szükséges. @@ -846,7 +918,7 @@ Most beszéljen Mikrofon megnyitva Befejezi ezt a hangszobát? - Ön a házigazda. A szoba bezárása mindenkit le fog választani. Válassza a "Csak elhagyás" lehetőséget, ha később vissza szeretne térni — a szoba 8 óra inaktivitás után automatikusan bezár. + Ön a házigazda. A szoba bezárása mindenkit le fog választani. Válassza a „Csak elhagyás” lehetőséget, ha később vissza szeretne térni — a szoba 8 óra inaktivitás után automatikusan bezáródik. Szoba bezárása Csak elhagyás ÉLŐ @@ -1238,6 +1310,11 @@ Hívás\u2026 Kapcsolódás\u2026 A hívás befejeződött + + %1$02d:%2$02d + %1$d:%2$02d:%3$02d Eltüntetés Kameraváltás Hanghívás @@ -1644,6 +1721,7 @@ Nincs olyan olyan tranzakció, amely megfelelne ennek a szűrőnek Betöltés… Fogadott + Zapelt bejegyzés megnyitása Elküldött Frissítés Összes diff --git a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml index babf13d793..9738d9e484 100644 --- a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml @@ -44,6 +44,7 @@ Kamera kecepatan Kemacetan Kejadian jalan + Buka Baca Tulis @@ -425,6 +426,8 @@ Biarkan Amethyst membaca latihan yang selesai dari Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) dan menyarankan postingan. Hubungkan Dari Health Connect + Belum ada nSites ditemukan. Muat ulang Browser dalam aplikasi memerlukan Android 11 atau lebih baru. @@ -738,6 +741,9 @@ Memanggil… Menghubungkan… Panggilan berakhir + Abaikan Ganti kamera Panggilan suara diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index 918261e691..264d49a8f4 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -46,6 +46,7 @@ Autovelox Ingorgo Evento stradale + Apri Lettura Scrittura @@ -425,6 +426,8 @@ Consenti ad Amethyst di leggere gli allenamenti completati da Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) e suggerire un post. Connetti Da Health Connect + Nessun nSite trovato ancora. Ricarica Il browser integrato richiede Android 11 o versioni successive. @@ -737,6 +740,9 @@ Chiamata in corso… Connessione in corso… Chiamata terminata + Ignora Cambia fotocamera Chiamata vocale diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index a43618ab14..328ddab56e 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -45,6 +45,7 @@ 速度カメラ 渋滞 道路上のイベント + オープン 読み取り 書き込み @@ -431,6 +432,8 @@ AmethystがHealth Connect(Samsung Health、Google Fit、Fitbit、Garmin…)から完了したワークアウトを読み取り、投稿を提案できるようにしましょう。 接続する Health Connectから + nSitesはまだ見つかりません。 再読み込み アプリ内ブラウザにはAndroid 11以降が必要です。 @@ -752,6 +755,9 @@ 発信中… 接続中… 通話終了 + 閉じる カメラ切替 音声通話 diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index b8602618a7..d4fe4e6745 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -45,6 +45,7 @@ 과속 카메라 교통 정체 도로 이벤트 + 열기 읽기 쓰기 @@ -427,6 +428,8 @@ Amethyst가 Health Connect(Samsung Health, Google Fit, Fitbit, Garmin…)에서 완료된 운동을 읽어 게시물을 제안하도록 허용하세요. 연결 Health Connect에서 + 아직 발견된 nSite가 없습니다. 새로고침 인앱 브라우저는 Android 11 이상이 필요합니다. @@ -745,6 +748,9 @@ 통화 중… 연결 중… 통화 종료 + 닫기 카메라 전환 음성 통화 diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index e39901478a..a32dc18a0e 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -47,6 +47,7 @@ Ātruma kamera Sastrēgums Ceļa notikums + Atvērt Lasīt Rakstīt @@ -439,6 +440,8 @@ Ļaujiet Amethyst nolasīt pabeigtus treniņus no Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) un ieteikt ziņu. Savienot No Health Connect + nSites vēl nav atrasti. Ielādēt atkārtoti Lietotnes pārlūkprogrammai nepieciešama Android 11 vai jaunāka versija. @@ -756,6 +759,9 @@ Zvana… Savienojas… Zvans beigts + Aizvērt Mainīt kameru Balss zvans diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml index 35914a41db..d974b994c8 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml @@ -161,6 +161,7 @@ Flitser File Weggebeurtenis + Zaps hiervoor worden verdeeld over: %1$d%% Value-for-Value @@ -609,6 +610,8 @@ Laat Amethyst voltooide workouts lezen uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en voorstellen om ze als bericht te delen. Verbinden Van Health Connect + Nog geen nSites gevonden. Herladen De ingebouwde browser vereist Android 11 of nieuwer. @@ -1147,6 +1150,9 @@ Bellen… Verbinden… Oproep beëindigd + Afwijzen Camera wisselen Spraak diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 8af1ee771c..1d392e51dc 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -173,6 +173,7 @@ Fotoradar Korek Wydarzenie drogowe + Zaps do tego są podzielone między: %1$d%% Wartość-za-wartość @@ -197,6 +198,36 @@ Baner profilu Najedź na QR kod + Amethyst potrzebuje aparatu do skanowania kodów QR. Nic nie jest rejestrowane ani przesyłane — klatki są dekodowane na urządzeniu i usuwane. + Zezwalaj na kamerę + Dostęp do kamery jest wyłączony dla Ametysta. Możesz go ponownie włączyć w ustawieniach Androida. + Otwórz ustawienia + Na tym urządzeniu nie udało się uruchomić dekodera kodów QR. Nadal możesz wkleić kod jako tekst. + Włącz światło + Wyłącz światło + Jest ciemno — włącz światło + Zeskanuj zdjęcie + Wklej + Nie znaleziono kodu QR na tym obrazku + Nic w schowku do skanowania + Wybierz żądany kod + Zdobyto %1$s z %2$s części — kontynuuj + Zresetuj zoom + Ametyst nie może otworzyć tego kodu + Ten kod zawiera klucz prywatny lub hasło parowania, dlatego jego treść nie jest tutaj wyświetlana. Należy skorzystać z odpowiedniego ekranu, na którym należy go wprowadzić. + To jest klucz prywatny. Wklej go na ekranie logowania. + To jest połączenie z portfelem. Dodaj je w ustawieniach portfela. + To jest adres do zdalnego sygnatariusza. Amethyst udostępnia takie adresy, aby inne aplikacje mogły poprosić ją o podpisy — sam nie może się za pomocą takiego adresu zalogować. + Jest to aplikacja, która prosi o nawiązanie połączenia z Twoim urządzeniem do podpisu. Otwórz ją z poziomu ekranu urządzenia do podpisu zgodnego ze standardem NIP-46. + To jest faktura Lightning. + To jest token Cashu. + To jest link do strony internetowej. + To jest zwykły tekst, a nie kod Nostra. + To jest kod Nostr, ale nie może on zostać otwarty. + Otwórz link + Kopiuj + Skopiowano + Skanuj ponownie Pokaż QR kod Zdjęcie profilowe Twoje zdjęcie profilowe @@ -677,6 +708,48 @@ Żadna treść nie opuszcza Twojego telefonu, dopóki nie wybierzesz jednej z sugestii i sam nie opublikujesz wpisu. Amethyst nie posiada serwera: wpis trafia do skonfigurowanych przez Ciebie transmiterów Nostr. Cała ta funkcja jest opcjonalna. Można ją wyłączyć w sekcji Ustawienia → Tworzenie wiadomości lub w dowolnym momencie cofnąć uprawnienia w aplikacji Health Connect — pozostałe funkcje aplikacji Amethyst będą nadal działać. Przeczytaj pełną politykę prywatności + Moja kondycja + Ostatnie 4 tygodnie + Ładowanie odległości, kalorii i tętna… + Ostatnie cztery pełne tygodnie Twojego treningu: opublikowane przez Ciebie treningi oraz wszelkie dane zarejestrowane przez aplikację Health Connect na tym telefonie. Amethyst sporządza podsumowanie na urządzeniu i nie publikuje żadnej z tych informacji. + Bieżący tydzień + w porównaniu z zeszłym tygodniem + Średnia tygodniowa + Treningi + Aktywne dni + Seria dni + Czas + + %1$dg %2$dm + %1$dmin. + %1$ds + Odległość + Kalorie + Kroki + Wspinaczka + Średnie tętno + Maks. tętno + Wg aktywności + Największe wysiłki + Najdłuższy dystans + Najdłuższy trening + Największe wspięcie + Najwięcej kroków + Najwyższe tętno + Ostatnie treningi + W ciągu ostatnich 4 tygodni nie zarejestrowano żadnych danych. Wyświetlają się tutaj treningi, które publikujesz, oraz wszystkie dane zarejestrowane przez Twój zegarek. + Śledź swój trening + Połącz się z aplikacją Health Connect, aby sprawdzić swoje tygodniowe wyniki, trendy w ujęciu tygodniowym, najlepsze wyniki oraz passę treningową. Wszystkie dane są przechowywane w Twoim telefonie. + Połącz aplikację Health Connect, aby dodać treningi zarejestrowane przez zegarek, wraz z tętnem, liczbą kroków i pokonanym wzniesieniem. + Połącz + Udostępnij ten trening + uderzeń na minutę + kcal + km + mil + m + stopa Nie znaleziono żadnych witryn nSite. Odśwież Przeglądarka w aplikacji wymaga Androida 11 lub nowszego. @@ -1248,6 +1321,11 @@ Dzwoni\u2026 Łączenie\u2026 Połączenie zakończone + + + Ignoruj Przełącz kamerę Połączenie głosowe @@ -1658,6 +1736,7 @@ Brak transakcji spełniających te kryteria Wczytywanie… Otrzymano + Otwórz zappowany post Wysłano Odśwież Wszystkie diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index bab416609a..6a243009af 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -165,6 +165,7 @@ Radar de velocidade Congestionamento Evento na via + Os zaps para isto são divididos entre: %1$d%% Valor-para-Valor @@ -664,6 +665,8 @@ Nada sai do seu telefone até você tocar em uma sugestão e publicar por conta própria. O Amethyst não tem servidor: a publicação vai para os relays do Nostr que você configurou. Todo o recurso é opcional. Desative-o em Configurações → Configurações de composição, ou revogue as permissões no Health Connect a qualquer momento — o restante do Amethyst continua funcionando. Ler a política de privacidade completa + Nenhum nSite encontrado ainda. Recarregar O navegador integrado requer Android 11 ou superior. @@ -1235,6 +1238,9 @@ Chamando\u2026 Conectando\u2026 Chamada encerrada + Dispensar Trocar câmera Chamada de voz diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index 1ab0062813..d6c5be536e 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -47,6 +47,7 @@ Radar de velocidade Congestionamento Evento na via + Abrir Leitura Escrita @@ -427,6 +428,8 @@ Permita que o Amethyst leia treinos concluídos do Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) e sugira uma publicação. Conectar Do Health Connect + Nenhum nSite encontrado ainda. Recarregar O navegador integrado requer Android 11 ou superior. @@ -746,6 +749,9 @@ Chamando\u2026 Conectando\u2026 Chamada encerrada + Dispensar Trocar câmera Chamada de voz diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 1dd8bf42d4..cf916c7062 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -48,6 +48,7 @@ Камера контроля скорости Пробка Дорожное событие + Открытие Чтение Запись @@ -435,6 +436,8 @@ Подключить %1$s км Из Health Connect + nSites пока не найдены. Обновить Встроенный браузер требует Android 11 или новее. @@ -754,6 +757,9 @@ Звонок… Подключение… Звонок завершён + Закрыть Переключить камеру Голосовой звонок diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index 4ad2c67559..48b00103df 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -48,6 +48,7 @@ Камера контроля скорости Пробка Дорожное событие + Открытие Чтение Запись @@ -429,6 +430,8 @@ Подключить %1$s км Из Health Connect + nSites пока не найдены. Обновить Встроенный браузер требует Android 11 или новее. @@ -747,6 +750,9 @@ Звонок… Подключение… Звонок завершён + Закрыть Переключить камеру Голосовой звонок diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 2c7be1835d..788537a0ab 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -173,6 +173,7 @@ Hitrostna kamera Zastoj Dogodek na cesti + Zapi bojo razdeljeni med: %1$d%% Vrednost za vrednost @@ -659,6 +660,8 @@ Poveži %1$s km Iz Health Connect + nSites še niso najdena. Ponovno naloži Vgrajeni brskalnik zahteva Android 11 ali novejši. @@ -1230,6 +1233,9 @@ Kličem\u2026 Povezujem\u2026 Klic zaključen + Prekini Zamenjaj kamero Govorni klic diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index ec1ad54c15..fa07b29a4b 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -47,6 +47,7 @@ Radar Saobraćajna gužva Saobraćajni događaj + Otvori Čitaj Piši @@ -431,6 +432,8 @@ Dozvolite Amethyst-u da čita završene treninge iz Health Connect-a (Samsung Health, Google Fit, Fitbit, Garmin…) i predloži objavu. Poveži Iz Health Connect-a + Još uvek nema pronađenih nSites. Ponovo učitaj Pregledač unutar aplikacije zahteva Android 11 ili noviji. @@ -745,6 +748,9 @@ Pozivanje… Povezivanje… Poziv završen + Odbaci Prebaci kameru Glasovni poziv diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index d5cf3725c4..16a772329a 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -165,6 +165,7 @@ Fartkamera Trafikstockning Väghändelse + Zaps till detta fördelas mellan: %1$d%% Värde -för-värde @@ -664,6 +665,8 @@ Ingenting lämnar din telefon förrän du trycker på ett förslag och publicerar inlägget själv. Amethyst har ingen server: inlägget går till de Nostr-reläer du har ställt in. Hela funktionen är valfri. Stäng av den under Inställningar → Skrivinställningar, eller återkalla behörigheterna i Health Connect när som helst — resten av Amethyst fortsätter att fungera. Läs hela integritetspolicyn + Inga nSites hittades ännu. Ladda om Den inbyggda webbläsaren kräver Android 11 eller senare. @@ -1235,6 +1238,9 @@ Ringer\u2026 Ansluter\u2026 Samtalet avslutat + Avvisa Byt kamera Röstsamtal diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 041f87cc15..037e3b8aae 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -46,6 +46,7 @@ Kamera ya kasi Msongamano wa magari Tukio la barabara + Fungua Soma Andika @@ -429,6 +430,8 @@ Ruhusu Amethyst kusoma mazoezi yaliyokamilika kutoka Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) na kupendekeza chapisho. Unganisha Kutoka Health Connect + Bado hakuna nSites zilizopatikana. Pakia upya Kivinjari cha ndani ya programu kinahitaji Android 11 au mpya zaidi. @@ -747,6 +750,9 @@ Inapiga simu… Inaunganika… Simu imeisha + Ondoa Badilisha kamera Simu ya sauti diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 88cb6c0255..01f47fafd1 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -46,6 +46,7 @@ வேக கண்காணிப்பு கேமரா போக்குவரத்து நெரிசல் சாலை நிகழ்வு + திற படி எழுது @@ -434,6 +435,8 @@ இணைக்கவும் %1$s கி.மீ Health Connect இலிருந்து + இன்னும் nSites எதுவும் கிடைக்கவில்லை. மீண்டும் ஏற்று உள்-பயன்பாட்டு உலாவிக்கு Android 11 அல்லது புதியது தேவை. @@ -752,6 +755,9 @@ அழைக்கிறது… இணைக்கிறது… அழைப்பு முடிந்தது + நிராகரி கேமரா மாற்று குரல் அழைப்பு diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index cb4f07ce52..40dc6eb0a1 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -45,6 +45,7 @@ กล้องจับความเร็ว การจราจรติดขัด เหตุการณ์บนถนน + เปิด อ่าน เขียน @@ -429,6 +430,8 @@ เชื่อมต่อ %1$s กม. จาก Health Connect + ยังไม่พบ nSites โหลดใหม่ เบราว์เซอร์ในแอปต้องใช้ Android 11 หรือใหม่กว่า @@ -741,6 +744,9 @@ กำลังโทร… กำลังเชื่อมต่อ… สิ้นสุดการโทรแล้ว + ยกเลิก สลับกล้อง โทรด้วยเสียง diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index 973ecf15e8..bafbfc0977 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -46,6 +46,7 @@ Hız kamerası Trafik sıkışıklığı Yol olayı + Aç Oku Yaz @@ -432,6 +433,8 @@ Amethyst'in Health Connect'ten (Samsung Health, Google Fit, Fitbit, Garmin…) tamamlanan antrenmanları okumasına ve gönderi önerisinde bulunmasına izin ver. Bağlan Health Connect'ten + Henüz hiç nSite bulunamadı. Yenile Uygulama içi tarayıcı Android 11 veya daha yeni bir sürüm gerektirir. @@ -752,6 +755,9 @@ Aranıyor… Bağlanıyor… Arama bitti + Kapat Kamerayı değiştir Sesli arama diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index f74f7f561d..f1fd24f251 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -48,6 +48,7 @@ Камера швидкості Затор Дорожня подія + Відкрити Читати Писати @@ -435,6 +436,8 @@ Підключити %1$s км З Health Connect + nSites ще не знайдено. Оновити Вбудований браузер потребує Android 11 або новішої версії. @@ -756,6 +759,9 @@ Виклик… З'єднання… Дзвінок завершено + Закрити Перемкнути камеру Голосовий дзвінок diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index e84edc39a9..ec106402fe 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -46,6 +46,7 @@ Tezlik kamerasi Tirbandlik Yo'l hodisasi + Ochish O'qish Yozish @@ -433,6 +434,8 @@ Amethyst dasturiga Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) dan tugatilgan mashqlarni o'qish va post taklif qilish imkonini bering. Ulash Health Connect orqali + Hali nSite topilmadi. Qayta yuklash Ilova ichidagi brauzer Android 11 yoki yangroq versiyasini talab qiladi. @@ -749,6 +752,9 @@ Qo'ng'iroq qilinmoqda… Ulanilmoqda… Qo'ng'iroq tugadi + Bekor qilish Kamerani almashtirish Ovozli qo'ng'iroq diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index 957a0f8511..cfa63ff92d 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -45,6 +45,7 @@ Camera tốc độ Tắc đường Sự cố đường bộ + Mở Đọc Ghi @@ -427,6 +428,8 @@ Cho phép Amethyst đọc bài tập đã hoàn thành từ Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) và đề xuất bài đăng. Kết nối Từ Health Connect + Chưa tìm thấy nSite nào. Tải lại Trình duyệt trong ứng dụng yêu cầu Android 11 trở lên. @@ -745,6 +748,9 @@ Đang gọi… Đang kết nối… Cuộc gọi đã kết thúc + Bỏ qua Đổi camera Cuộc gọi thoại diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index 8ec7a04ae1..de8837b024 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -124,6 +124,7 @@ 测速照相 塞车 道路事件 + 对此的Zaps 在以下对象间分割: %1$d%% 值对值 @@ -557,6 +558,8 @@ 连接 %1$s 公里 来自 Health Connect + 尚未找到nSite。 重新加载 应用内浏览器需要 Android 11 或更高版本。 @@ -1050,6 +1053,9 @@ 正在呼叫\u2026 正在连.\u2026 通话结束 + 忽略 切换相机 语音通话 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index e33f74c786..8541ee6d8c 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -47,6 +47,7 @@ 测速照相 塞车 道路事件 + 打开 读取 写入 @@ -439,6 +440,8 @@ 连接 %1$s 公里 来自 Health Connect + 尚未找到nSite。 重新加载 应用内浏览器需要 Android 11 或更高版本。 @@ -761,6 +764,9 @@ 正在呼叫\u2026 正在连.\u2026 通话结束 + 忽略 切换相机 语音通话 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index 3bfc48a08a..2d6a973eb7 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -47,6 +47,7 @@ 测速照相 塞车 道路事件 + 打开 读取 写入 @@ -439,6 +440,8 @@ 连接 %1$s 公里 来自 Health Connect + 尚未找到nSite。 重新加载 应用内浏览器需要 Android 11 或更高版本。 @@ -761,6 +764,9 @@ 正在呼叫\u2026 正在连.\u2026 通话结束 + 忽略 切换相机 语音通话 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index 864d0df60b..43a6d49812 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -47,6 +47,7 @@ 測速攝影機 塞車 道路事件 + 開啟 讀取 寫入 @@ -433,6 +434,8 @@ 連接 %1$s 公里 來自 Health Connect + 尚未找到任何 nSite。 重新載入 應用程式內瀏覽器需要 Android 11 或更新版本。 @@ -753,6 +756,9 @@ 撥打中… 連線中… 通話已結束 + 關閉 切換攝影機 語音通話 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 7e79922095..fbf50b1938 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -177,6 +177,149 @@ Speed camera Traffic jam Road event + + + Geocache + Traditional + Multi-cache + Mystery + Micro + Small + Regular + Large + Other size + D%1$d + T%1$d + First to find + Artwork + Key Quest + Verified finds + Claimed + Archived + Stuck? Tap to reveal the hint + Not readable? Tap for the other reading + Nearby + Found it! + Verified find + Proof does not check out + Loading cache… + Hidden by %1$s + Navigate + Copy coordinates + Coordinates copied + Photos + Key Quest mission + Logs + No one has logged this cache yet. Be the first. + I found it + Didn't find it + Add a note + Report needs maintenance + Add to a hunt + New hunt… + You have not made a hunt yet. + Already on this hunt + %1$d caches here + Remove this photo + This cache has been archived. It is no longer in play. + First to find claimed by %1$s + First to find is still up for grabs + Owner + Edit this cache + Archive this cache + Archive it. Finders will see it is out of play, and the listing stays readable. + Lock in the first finder + Publishes an F tag naming %1$s. This is permanent. + No verified finds yet, so there is nobody to lock in. + Show the verification code + Print this and put it in the cache. Amethyst does not store it — if you lose it, finders can no longer prove they were there. + Didn't find + Note + Needs maintenance + Log a find + Cache + Your log + How did it go? + Add a photo + Your Key Quest answer + Proof of presence + Scan the code in the cache + Scan a different code + Verified — you were at the cache + Signed once from the code in the box. The key is never saved and never leaves this screen. + That is not a verification code. + That code belongs to a different cache. + This cache does not ask for proof, so you can just log it. + Your own location is not attached to this log. + Post + Add a log + Nearby + Map + Hunts + Finds + Mine + Unfound + D ≤ %1$d + T ≤ %1$d + Has a prize + Proof required + Use my location + Sorted by distance + Newest first + Hide a cache + No caches here yet. Try a wider follow list, or hide the first one. + You have not logged a find yet. + You have not hidden a cache yet. + No hunts yet. + Finds + First finds + Verified + Hidden + Centre on me + Long-press the map to hide a cache there + Open + %1$d caches + Where + What + Extras + Proof + Relays + This publishes a public location accurate to about 5 metres. Anyone on Nostr can see it, and a published event cannot be recalled. + Pick the spot + Publishes %1$s, plus coarser cells down to 3 characters for proximity search. + Name + Description + Type + Size + Difficulty + Terrain + Hint + Finders will see + Scramble the hint (ROT13) + Key Quest mission + Modifiers + Require proof of presence + A verification key has been generated for this cache. + Where finders should log + Publish + Give the cache a name. + Pick where the cache is hidden. + %1$d of %2$d found + Start the hunt + %1$d caches + Title + Description + Banner image + Caches, in order + Add a cache + Move up + Move down + Remove + Page theme + Map style + Give the hunt a title. + Add at least one cache. + Could not publish. Check your relay connection and try again. Zaps to this are split between: %1$d%% Value-for-Value @@ -199,6 +342,48 @@ Profile Banner Point to the QR Code + Amethyst needs the camera to scan QR codes. Nothing is recorded or uploaded — frames are decoded on the device and discarded. + Allow camera + Camera access is turned off for Amethyst. You can turn it back on in Android settings. + Open settings + The QR decoder could not start on this device. You can still paste the code as text. + Turn on the light + Turn off the light + It is dark — try the light + Scan a picture + Paste + No QR code found in that picture + Nothing on the clipboard to scan + Tap the code you want + Captured %1$s of %2$s parts — keep going + Reset zoom + Amethyst can't open this code + This code carries a private key or a pairing secret, so its contents are not shown here. Use the screen that expects it. + This is a private key. Paste it on the login screen instead. + This is a wallet connection. Add it from Wallet settings. + This is a remote-signer address. Amethyst hands these out so other apps can ask it to sign — it cannot sign in with one. + This is an app asking to connect to your signer. Open it from the NIP-46 signer screen. + This is a Lightning invoice. + This is a Cashu token. + This is a web link. + This is plain text, not a Nostr code. + This is a Nostr code, but not one this screen can open. + Profile + Note + Nostr code + Relay + Private key + Wallet connection + Remote signer address + Signer connection request + Lightning invoice + Cashu token + Web link + Plain text + Open link + Copy + Copied + Scan again Show QR Profile Picture Your Profile Picture @@ -684,6 +869,48 @@ Nothing leaves your phone until you tap a suggestion and publish the post yourself. Amethyst has no server: the post goes to the Nostr relays you configured. The whole feature is optional. Turn it off under Settings → Compose, or revoke the permissions in Health Connect at any time — the rest of Amethyst keeps working. Read the full privacy policy + My Fitness + Last 4 weeks + Loading distance, calories and heart rate… + The last four whole weeks of your training: the workouts you have published, plus anything Health Connect records on this phone. Amethyst works the summary out on the device and publishes none of it. + This week + vs last week + Weekly average + Workouts + Active days + Day streak + Time + + %1$dh %2$dm + %1$dm + %1$ds + Distance + Calories + Steps + Climb + Avg HR + Max HR + By activity + Best efforts + Longest distance + Longest workout + Biggest climb + Most steps + Highest heart rate + Recent workouts + Nothing logged in the last 4 weeks. Workouts you post, and any your watch records, show up here. + Track your training + Connect Health Connect to see your weekly totals, week-over-week trend, best efforts and training streak. Everything stays on your phone. + Connect Health Connect to add the workouts your watch records, with heart rate, steps and climb. + Connect + Share this workout + bpm + kcal + km + mi + m + ft No nSites found yet. Reload The in-app browser needs Android 11 or newer. @@ -1257,6 +1484,11 @@ Calling\u2026 Connecting\u2026 Call ended + + %1$02d:%2$02d + %1$d:%2$02d:%3$02d Dismiss Switch camera Voice call diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index c4425921a5..1ac39519bf 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -115,6 +115,8 @@ object MaterialSymbols { val FileOpen = MaterialSymbol("\uEAF3") val FilterAlt = MaterialSymbol("\uEF4F") val FitnessCenter = MaterialSymbol("\uEB43") + val FlashlightOff = MaterialSymbol("\uF00A") + val FlashlightOn = MaterialSymbol("\uF00B") val Folder = MaterialSymbol("\uE2C7") val FolderZip = MaterialSymbol("\uEB2C") val ForkRight = MaterialSymbol("\uEBAC") @@ -189,6 +191,7 @@ object MaterialSymbols { val PersonRemove = MaterialSymbol("\uEF66") val Phone = MaterialSymbol("\uF0D4") val Photo = MaterialSymbol("\uE432") + val PhotoLibrary = MaterialSymbol("\uE413") val PictureAsPdf = MaterialSymbol("\uE415") val PictureInPicture = MaterialSymbol("\uE8AA") val PlayArrow = MaterialSymbol("\uE037") diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt index 67b5fd7745..185acec8d4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Text @@ -123,6 +124,7 @@ private fun ToolbarIconButton( onClick = onClick, containerColor = containerColor, contentColor = contentColor, + shape = CircleShape, modifier = Modifier .size(32.dp) @@ -159,6 +161,7 @@ private fun ToolbarButton( onClick = onClick, containerColor = containerColor, contentColor = contentColor, + shape = CircleShape, modifier = Modifier .size(32.dp) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheCard.kt new file mode 100644 index 0000000000..61f6b860da --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheCard.kt @@ -0,0 +1,601 @@ +/* + * 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.ui.note + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.geocache_archived +import com.vitorpamplona.amethyst.commons.resources.geocache_claimed +import com.vitorpamplona.amethyst.commons.resources.geocache_difficulty_short +import com.vitorpamplona.amethyst.commons.resources.geocache_first_to_find +import com.vitorpamplona.amethyst.commons.resources.geocache_found_it +import com.vitorpamplona.amethyst.commons.resources.geocache_hint_other_reading +import com.vitorpamplona.amethyst.commons.resources.geocache_hint_tap_to_reveal +import com.vitorpamplona.amethyst.commons.resources.geocache_invalid_proof +import com.vitorpamplona.amethyst.commons.resources.geocache_is_art +import com.vitorpamplona.amethyst.commons.resources.geocache_mission +import com.vitorpamplona.amethyst.commons.resources.geocache_needs_verification +import com.vitorpamplona.amethyst.commons.resources.geocache_size_large +import com.vitorpamplona.amethyst.commons.resources.geocache_size_micro +import com.vitorpamplona.amethyst.commons.resources.geocache_size_other +import com.vitorpamplona.amethyst.commons.resources.geocache_size_regular +import com.vitorpamplona.amethyst.commons.resources.geocache_size_small +import com.vitorpamplona.amethyst.commons.resources.geocache_terrain_short +import com.vitorpamplona.amethyst.commons.resources.geocache_type_multi +import com.vitorpamplona.amethyst.commons.resources.geocache_type_mystery +import com.vitorpamplona.amethyst.commons.resources.geocache_type_traditional +import com.vitorpamplona.amethyst.commons.resources.geocache_unnamed +import com.vitorpamplona.amethyst.commons.resources.geocache_verified_find +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.commons.ui.theme.warningColor +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.HintObfuscation +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** + * Renders a map at the cache's location. The map is platform-specific (an Android tile view + * today), so the host supplies it as this typed slot. + * + * [aspectRatio] is the card's call, not the host's: the card uses the same slot for a wide hero + * and for a small square inset tucked into a photo, and a map that always picked its own shape + * could only serve one of them. + */ +typealias GeocacheMap = @Composable ( + latitude: Double, + longitude: Double, + pinColor: Color, + pinEmoji: String, + pinAlpha: Float, + aspectRatio: Float, +) -> Unit + +/** + * Whether a found log's embedded proof holds up. + * + * Deliberately not a boolean. "No verification attached" and "a verification that does not check + * out" are different things and only one of them is suspicious, while "the listing has not been + * fetched yet" must not render as either — a card that guesses here is the exact mistake + * `GeocacheVerificationValidator` exists to prevent. + */ +enum class FoundLogProof { + /** No `verification` tag, or the cache does not use verification at all. */ + NONE, + + /** A verification is attached but the listing needed to check it is not in hand. */ + UNKNOWN, + + /** Checked against the listing and it holds: the finder was physically at the cache. */ + VALID, + + /** Checked and rejected — wrong signer, wrong finder, wrong cache, or a bad signature. */ + INVALID, +} + +private val HeroShape = RoundedCornerShape(14.dp) +private val InsetShape = RoundedCornerShape(10.dp) +private const val HERO_RATIO = 16f / 9f + +/** The pin glyph for a cache type — shared so the map, the cards and the composer agree. */ +fun CacheType?.geocacheEmoji(): String = + when (this) { + CacheType.TRADITIONAL -> "📦" + CacheType.MULTI -> "🧭" + CacheType.MYSTERY -> "❓" + null -> "📍" + } + +fun CacheType?.geocacheLabelRes(): StringResource? = + when (this) { + CacheType.TRADITIONAL -> Res.string.geocache_type_traditional + CacheType.MULTI -> Res.string.geocache_type_multi + CacheType.MYSTERY -> Res.string.geocache_type_mystery + null -> null + } + +fun CacheSize.geocacheLabelRes(): StringResource = + when (this) { + CacheSize.MICRO -> Res.string.geocache_size_micro + CacheSize.SMALL -> Res.string.geocache_size_small + CacheSize.REGULAR -> Res.string.geocache_size_regular + CacheSize.LARGE -> Res.string.geocache_size_large + CacheSize.OTHER -> Res.string.geocache_size_other + } + +/** + * Self-contained card for a geocache listing (NIP-CC kind 37516). + * + * The layout spends its weight on the three questions a reader actually has — what is it, how far + * away, and is it still up for grabs: + * + * - **One hero.** The cache's photo when it has one, with the map demoted to a corner inset that + * still answers "where"; the map alone when it does not. Both are 16:9. Stacking a square map + * *and* a wide photo, as this card first did, spent most of a phone screen before a word. + * - **Ratings are a line, not chips.** `Traditional · Regular · D1 · T1` — the same four facts, + * one quiet row. As chips they outnumbered and outshouted the modifiers, which are the part + * worth seeing. + * - **Colour only where it is rare.** First-to-find and verified-finds get a tint; art stays + * neutral; claimed and archived go muted, because they say "this one is over". A cache with + * every modifier set lights up four chips here rather than nine identical grey ones. + * + * [distance] and [claimed] are passed in rather than derived: distance needs the reader's + * location, and a first-to-find claim needs the cache's logs. A card has neither. + * + * [showImages] is the reader's "automatically show images" setting, and it has no default on + * purpose. The photo URL is supplied by whoever published the cache, so fetching it announces + * the reader's IP to a stranger's server and spends their data — exactly what that setting + * exists to let them refuse. A caller that cannot answer the question should pass `false`. + */ +@Composable +fun GeocacheCard( + noteEvent: GeocacheListingEvent, + showImages: Boolean, + claimed: Boolean = false, + distance: String? = null, + map: GeocacheMap, +) { + val name = remember(noteEvent) { noteEvent.cacheName()?.trim().orEmpty() } + val description = remember(noteEvent) { noteEvent.content.trim() } + val point = remember(noteEvent) { noteEvent.geocachePoint() } + val type = remember(noteEvent) { noteEvent.cacheType() } + val size = remember(noteEvent) { noteEvent.cacheSize() } + val difficulty = remember(noteEvent) { noteEvent.difficulty() } + val terrain = remember(noteEvent) { noteEvent.terrain() } + val hint = remember(noteEvent) { noteEvent.hintOnWire()?.trim()?.ifBlank { null } } + val mission = remember(noteEvent) { noteEvent.mission()?.trim()?.ifBlank { null } } + val isArchived = remember(noteEvent) { noteEvent.isArchived() } + val isFirstToFind = remember(noteEvent) { noteEvent.isFirstToFind() } + val isArt = remember(noteEvent) { noteEvent.hasTypeModifier(TypeModifier.ART) } + val needsVerification = remember(noteEvent) { noteEvent.requiresVerification() } + val photo = + remember(noteEvent) { + noteEvent + .images() + .firstOrNull() + ?.trim() + ?.ifBlank { null } + } + + // An archived cache, or one whose single claim is taken, is history rather than an + // invitation — the map pin is dimmed to say so without hiding the cache. + val over = isArchived || claimed + val pinAlpha = if (over) 0.45f else 1f + val accent = MaterialTheme.colorScheme.primary + + Column(MaterialTheme.colorScheme.replyModifier) { + GeocacheHero(if (showImages) photo else null, point, accent, type.geocacheEmoji(), pinAlpha, map) + + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = "${type.geocacheEmoji()} ${name.ifEmpty { stringResource(Res.string.geocache_unnamed) }}", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (distance != null) { + Spacer(Modifier.width(8.dp)) + Text( + text = distance, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = accent, + maxLines = 1, + ) + } + } + + GeocacheSpecLine(type, size, difficulty, terrain) + + GeocacheChips( + claimed = claimed, + isArchived = isArchived, + isFirstToFind = isFirstToFind, + isArt = isArt, + hasMission = mission != null, + needsVerification = needsVerification, + ) + + if (description.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + if (mission != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = "🗝 $mission", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + if (hint != null) { + Spacer(Modifier.height(8.dp)) + GeocacheSpoilerHint(hint) + } + } + } +} + +/** + * The photo if there is one, the map otherwise — never both stacked. + * + * With a photo, the map rides along as a small inset so "where" is still answered without + * spending a second full-width block on it. With neither, the card opens straight on its title. + */ +@Composable +fun GeocacheHero( + photo: String?, + point: Pair?, + accent: Color, + emoji: String, + pinAlpha: Float, + map: GeocacheMap, +) { + when { + photo != null -> + Box(Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp)) { + AsyncImage( + model = photo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(HERO_RATIO).clip(HeroShape), + ) + if (point != null) { + Box( + Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .width(76.dp) + .clip(InsetShape) + .background(MaterialTheme.colorScheme.surface), + ) { + map(point.first, point.second, accent, emoji, pinAlpha, 1f) + } + } + } + + point != null -> + Box(Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp).clip(HeroShape)) { + map(point.first, point.second, accent, emoji, pinAlpha, HERO_RATIO) + } + + else -> Spacer(Modifier.height(2.dp)) + } +} + +/** + * `Traditional · Regular · D3 · T2` as a string, for callers that need the facts without the + * [GeocacheSpecLine] styling — the hero renders it white on a scrim, which no amount of + * parameters on a Text would express as clearly as handing over the words. + */ +@Composable +fun geocacheSpecSummary( + type: CacheType?, + size: CacheSize?, + difficulty: Int?, + terrain: Int?, +): String = + listOfNotNull( + type.geocacheLabelRes()?.let { stringResource(it) }, + size?.let { stringResource(it.geocacheLabelRes()) }, + difficulty?.let { stringResource(Res.string.geocache_difficulty_short, it) }, + terrain?.let { stringResource(Res.string.geocache_terrain_short, it) }, + ).joinToString(" · ") + +/** The same facts for a whole listing. */ +@Composable +fun GeocacheListingEvent.specSummary(): String = geocacheSpecSummary(cacheType(), cacheSize(), difficulty(), terrain()) + +/** `Traditional · Regular · D1 · T1` — the ratings, in one line they can be skimmed past. */ +@Composable +fun GeocacheSpecLine( + type: CacheType?, + size: CacheSize?, + difficulty: Int?, + terrain: Int?, +) { + val parts = + listOfNotNull( + type.geocacheLabelRes()?.let { stringResource(it) }, + size?.let { stringResource(it.geocacheLabelRes()) }, + difficulty?.let { stringResource(Res.string.geocache_difficulty_short, it) }, + terrain?.let { stringResource(Res.string.geocache_terrain_short, it) }, + ) + if (parts.isEmpty()) return + + Text( + text = parts.joinToString(" · "), + modifier = Modifier.padding(top = 3.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** + * Only the facts that make one cache different from the next. + * + * The ratings moved to [SpecLine], so what is left is what a reader would change plans for: a + * prize nobody has taken, a find that can be proved, a cache that is actually a piece of art — + * and, muted, the two states that mean there is nothing left to go and get. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun GeocacheChips( + claimed: Boolean, + isArchived: Boolean, + isFirstToFind: Boolean, + isArt: Boolean, + hasMission: Boolean, + needsVerification: Boolean, +) { + if (!claimed && !isArchived && !isFirstToFind && !isArt && !hasMission && !needsVerification) return + + // The tints are backgrounds rather than text colors on purpose: the theme's amber is + // unreadable as text on a light ground, and onSurface over a wash reads in both themes. + val gold = MaterialTheme.colorScheme.warningColor.copy(alpha = 0.22f) + val green = MaterialTheme.colorScheme.allGoodColor.copy(alpha = 0.18f) + val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f) + + FlowRow( + modifier = Modifier.padding(top = 9.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (isFirstToFind && !claimed) GeocacheChip("🥇 " + stringResource(Res.string.geocache_first_to_find), gold, strong = true) + if (needsVerification) GeocacheChip("🔐 " + stringResource(Res.string.geocache_needs_verification), green, strong = true) + if (isArt) GeocacheChip("🎨 " + stringResource(Res.string.geocache_is_art), muted) + if (hasMission) GeocacheChip("🗝 " + stringResource(Res.string.geocache_mission), muted) + if (claimed) GeocacheChip("🏁 " + stringResource(Res.string.geocache_claimed), muted, dim = true) + if (isArchived) GeocacheChip("🗃️ " + stringResource(Res.string.geocache_archived), muted, dim = true) + } +} + +/** + * The hint: an invitation, then the text, then the other reading of the text. + * + * Shows *no* hint text while hidden — not even the encoded form. Geocaching clients have always + * done it this way (Lightning Piggy's detail screen offers "Stuck? Tap to reveal the hint"), and + * it is the stronger design for two reasons: a wall of ROT13 reads as corruption rather than as + * a hint, and nothing on screen can spoil a find no matter which way round the publisher + * encoded it. + * + * The third state is the one that matters. Publishers disagree about whether `hint` goes on the + * wire plain or rotated (see [HintObfuscation]), so which form is the readable one is a guess, + * and the guess assumes English — a plaintext Spanish hint scores as ciphertext and "reveals" + * to noise. Tapping again shows the other rotation, so a misfire costs a tap rather than the + * hint. Without that, a reader whose language the heuristic does not speak has no way to reach + * their own hint at all. + */ +@Composable +fun GeocacheSpoilerHint(hint: String) { + val readings = remember(hint) { listOf(HintObfuscation.revealed(hint), HintObfuscation.hidden(hint)) } + var shown by remember(hint) { mutableStateOf(0) } + + Column(Modifier.clickable { shown = (shown + 1) % 3 }) { + if (shown == 0) { + Text( + text = stringResource(Res.string.geocache_hint_tap_to_reveal), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else { + Text( + text = readings[shown - 1], + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + if (shown == 1) { + Text( + text = stringResource(Res.string.geocache_hint_other_reading), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } +} + +/** + * Self-contained card for a found log (NIP-CC kind 7516). + * + * The log's own message with a "Found it!" pill, and a proof badge only where [proof] has + * actually been checked. A log is a claim until something verifies it, so nothing here upgrades + * an attached verification into a verified one. + * + * [cacheName] is what the log is *about*, and without it the card says only "Found it!" — the + * one thing a reader scrolling a feed already assumed. The cache is named by an `a` tag, so the + * name costs a lookup the caller has to do anyway; pass null while it is still loading. + * + * [showImages] gates the log's photo for the same reason it gates the cache's — see [GeocacheCard]. + */ +@Composable +fun GeocacheFoundLogCard( + noteEvent: GeocacheFoundLogEvent, + showImages: Boolean, + proof: FoundLogProof = FoundLogProof.NONE, + cacheName: String? = null, +) { + val message = remember(noteEvent) { noteEvent.content.trim() } + val photo = + remember(noteEvent, showImages) { + if (!showImages) { + null + } else { + noteEvent + .images() + .firstOrNull() + ?.trim() + ?.ifBlank { null } + } + } + + Column(MaterialTheme.colorScheme.replyModifier) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = "🎯 " + (cacheName ?: stringResource(Res.string.geocache_found_it)), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (cacheName != null || proof == FoundLogProof.VALID || proof == FoundLogProof.INVALID) { + Row( + modifier = Modifier.padding(top = 3.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (cacheName != null) { + Text( + text = stringResource(Res.string.geocache_found_it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + when (proof) { + FoundLogProof.VALID -> + GeocacheChip( + "✅ " + stringResource(Res.string.geocache_verified_find), + MaterialTheme.colorScheme.allGoodColor.copy(alpha = 0.18f), + strong = true, + ) + FoundLogProof.INVALID -> + GeocacheChip( + "⚠️ " + stringResource(Res.string.geocache_invalid_proof), + MaterialTheme.colorScheme.warningColor.copy(alpha = 0.22f), + strong = true, + ) + FoundLogProof.NONE, FoundLogProof.UNKNOWN -> Unit + } + } + } + + if (message.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + if (photo != null) { + Spacer(Modifier.height(8.dp)) + AsyncImage( + model = photo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(HERO_RATIO).clip(HeroShape), + ) + } + } + } +} + +/** One fact, on a [tint] wash. [strong] bolds it; [dim] fades it to "this is over". */ +@Composable +fun GeocacheChip( + label: String, + tint: Color, + strong: Boolean = false, + dim: Boolean = false, +) { + Text( + text = label, + modifier = Modifier.clip(RoundedCornerShape(50)).background(tint).padding(horizontal = 9.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (strong) FontWeight.SemiBold else FontWeight.Normal, + color = if (dim) MaterialTheme.colorScheme.placeholderText else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) +} + +/** + * The centre of the cache's finest published geohash, or null when it published none. + * + * A listing has no `lat`/`lon` tags — unlike a road event — so the geohash is the only location + * there is, and the finest one is the only one precise enough to walk to. + */ +fun GeocacheListingEvent.geocachePoint(): Pair? { + val finest = location() ?: return null + val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null + return decoded.centerLat to decoded.centerLon +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheDetailHero.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheDetailHero.kt new file mode 100644 index 0000000000..a3b567bdab --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocacheDetailHero.kt @@ -0,0 +1,178 @@ +/* + * 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.ui.note + +import androidx.compose.foundation.background +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.geocache_unnamed +import org.jetbrains.compose.resources.stringResource + +/** + * The banner a cache's own page opens with. + * + * Built to the same rule as the feed card — one hero, photo when there is one and the map + * demoted to a corner inset, the map alone at 16:9 when there is not — because a detail screen + * that opens with less presence than the card that led to it reads as a downgrade. The + * difference from the card is that here the title lives *on* the hero, over a scrim, which buys + * back the vertical space the card spends on a separate title row and makes the name and the + * place one object rather than two. + * + * The state colour runs as a 3dp rule under the image rather than tinting anything: at banner + * size a wash would fight the photo, while a rule reads instantly and costs no legibility. An + * out-of-play cache fades the whole hero instead, which says "finished" without hiding it. + */ +@Composable +fun GeocacheDetailHero( + name: String, + photo: String?, + point: Pair?, + accent: Color, + emoji: String, + distance: String?, + isOver: Boolean, + subtitle: @Composable () -> Unit, + image: @Composable (url: String, modifier: Modifier) -> Unit, + map: GeocacheMap, +) { + val heroAlpha = if (isOver) GeocachePalette.OVER_ALPHA else 1f + + Column { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(HERO_RATIO) + .clip(HeroShape) + .alpha(heroAlpha), + ) { + when { + photo != null -> { + image(photo, Modifier.fillMaxSize()) + if (point != null) { + Box( + Modifier + .align(Alignment.BottomEnd) + .padding(10.dp) + .size(INSET_SIZE) + .clip(InsetShape), + ) { + map(point.first, point.second, accent, emoji, 1f, 1f) + } + } + } + + point != null -> map(point.first, point.second, accent, emoji, 1f, HERO_RATIO) + + else -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant)) + } + + // A bottom-up scrim so white text survives a bright photo. Only painted where the + // text sits, so the top two thirds of the picture stay untouched. + Box( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .aspectRatio(HERO_RATIO / SCRIM_FRACTION) + .background( + Brush.verticalGradient( + listOf(Color.Transparent, Color.Black.copy(alpha = 0.62f)), + ), + ), + ) + + distance?.let { + Text( + text = it, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Column( + Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { + Text(text = emoji, style = MaterialTheme.typography.titleLarge) + Text( + text = name.ifEmpty { stringResource(Res.string.geocache_unnamed) }, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + subtitle() + } + } + + // height, not size: size() after fillMaxWidth() overrides the width too and leaves a + // 3dp dot instead of a rule. + Box( + Modifier + .fillMaxWidth() + .height(ACCENT_RULE) + .background(accent.copy(alpha = if (isOver) GeocachePalette.OVER_ALPHA else 1f)), + ) + } +} + +private val HeroShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) +private val InsetShape = RoundedCornerShape(10.dp) +private val INSET_SIZE = 76.dp +private val ACCENT_RULE = 3.dp +private const val HERO_RATIO = 16f / 9f + +/** How much of the hero's height the scrim covers, bottom-up. */ +private const val SCRIM_FRACTION = 0.45f diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocachePalette.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocachePalette.kt new file mode 100644 index 0000000000..a5b497d1cc --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GeocachePalette.kt @@ -0,0 +1,104 @@ +/* + * 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.ui.note + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.warningColor +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +/** + * The one place geocaching decides what a colour means. + * + * Four states, and only four, because the palette is doing information design rather than + * decoration: a player scanning a map, a feed and a detail screen should read the same colour as + * the same fact on all three. Before this existed the map invented its own tints and the cards + * used a different pair, so a cache could be gold on one surface and amber on the next. + * + * - [prize] — an unclaimed first-to-find. The only thing on screen that is scarce. + * - [proven] — a verified find, or a cache that demands proof. The feature nothing else has. + * - [live] — an ordinary cache still in play. Deliberately the theme's own primary, so the + * common case does not shout. + * - [over] — archived, or a claim already taken. Muted, never hidden: a finished cache is still + * a place worth visiting, it is simply no longer a prize. + * + * Each colour comes with a [wash] for use as a background. The full-strength value is for marks + * — a pin, a rule, an icon — and the wash for anything with text on top of it, because the + * theme's amber is unreadable as text on a light ground while `onSurface` over a wash reads in + * both themes. + */ +@Immutable +data class GeocachePalette( + val prize: Color, + val proven: Color, + val live: Color, + val over: Color, +) { + fun wash(color: Color): Color = color.copy(alpha = WASH_ALPHA) + + /** The colour that describes a cache's current state, in priority order. */ + fun forState( + isArchived: Boolean, + isClaimed: Boolean, + isFirstToFind: Boolean, + requiresVerification: Boolean, + ): Color = + when { + isArchived || isClaimed -> over + isFirstToFind -> prize + requiresVerification -> proven + else -> live + } + + companion object { + const val WASH_ALPHA = 0.16f + + /** Alpha for a pin or hero whose cache is out of play. */ + const val OVER_ALPHA = 0.45f + } +} + +@Composable +fun rememberGeocachePalette(): GeocachePalette { + val prize = MaterialTheme.colorScheme.warningColor + val proven = MaterialTheme.colorScheme.allGoodColor + val live = MaterialTheme.colorScheme.primary + val over = MaterialTheme.colorScheme.onSurfaceVariant + + return remember(prize, proven, live, over) { GeocachePalette(prize, proven, live, over) } +} + +/** The state colour for this listing, given whether its first-to-find claim is taken. */ +@Composable +fun GeocachePalette.forListing( + listing: GeocacheListingEvent, + isClaimed: Boolean, +): Color = + forState( + isArchived = listing.isArchived(), + isClaimed = isClaimed, + isFirstToFind = listing.isFirstToFind(), + requiresVerification = listing.requiresVerification(), + ) diff --git a/desktopApp/packaging/homebrew/amethyst-nostr.rb b/desktopApp/packaging/homebrew/amethyst-nostr.rb index ce1a88865b..31e7c6b110 100644 --- a/desktopApp/packaging/homebrew/amethyst-nostr.rb +++ b/desktopApp/packaging/homebrew/amethyst-nostr.rb @@ -45,8 +45,8 @@ # which writes into that single SHARED plist — deleting it would wipe every # other Java application's preferences too. cask "amethyst-nostr" do - version "1.15.2" - sha256 "c02141beeb95fa2160f8c074a4a08bbf9851240da6dec66fa10a3ae41a3b5b1e" + version "1.16.0" + sha256 "70173b0ebba325549bd0f4b1532874c96bbe98bb4811172c7081efabd7684042" url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg" name "Amethyst" diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml index d08e1214dd..bb7d6cbed2 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml @@ -13,7 +13,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.2 +PackageVersion: 1.16.0 Platform: - Windows.Desktop MinimumOSVersion: 10.0.0.0 @@ -22,11 +22,11 @@ Scope: machine UpgradeBehavior: install Installers: - Architecture: x64 - InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/amethyst-desktop-1.15.2-windows-x64.msi + InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.16.0/amethyst-desktop-1.16.0-windows-x64.msi # Quoted deliberately: an all-digit 64-char value is a YAML *integer*, which # fails the schema's `string` type. Quoting makes it a string whatever the # digest happens to be. - InstallerSha256: '2AE2C5FCE1C89D28EBFB984C75D9CC0144D0DDFAB279C68E47A85AD197101EDD' - ProductCode: '{B1E85B09-3C55-3223-981E-34F39970FC63}' + InstallerSha256: 'CBC0FECB1C5367C376AF35508E3D7AC0ED309261445B7FB2E67488ECEDA0CC00' + ProductCode: '{78004CBF-F7C1-30FD-9AD4-832721D1318E}' ManifestType: installer ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml index 82905c605c..3bcffa3e6c 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml @@ -5,7 +5,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.2 +PackageVersion: 1.16.0 PackageLocale: en-US Publisher: Vitor Pamplona PublisherUrl: https://github.com/vitorpamplona @@ -28,7 +28,7 @@ Tags: - nostr - social - social-network -ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.15.2 +ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.16.0 Documentations: - DocumentLabel: Building DocumentUrl: https://github.com/vitorpamplona/amethyst/blob/main/BUILDING.md diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml index 0495cadffe..85245a26ba 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml @@ -9,7 +9,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.2 +PackageVersion: 1.16.0 DefaultLocale: en-US ManifestType: version ManifestVersion: 1.12.0 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index da14a783f0..48bcea77b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -148,9 +148,6 @@ class DesktopLocalCache : ICacheProvider { const val MAX_CHAT_MESSAGES_PER_CHANNEL = 500 } - /** Index of notes by author pubkey — for fast metadata invalidation */ - private val notesByAuthor = ConcurrentHashMap>() - val paymentTracker = NwcPaymentTracker() /** @@ -164,13 +161,6 @@ class DesktopLocalCache : ICacheProvider { */ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private fun trackNoteAuthor( - note: Note, - authorPubkey: HexKey, - ) { - notesByAuthor.getOrPut(authorPubkey) { ConcurrentHashMap.newKeySet() }.add(note) - } - // ----- User operations ----- override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey) @@ -257,12 +247,6 @@ class DesktopLocalCache : ICacheProvider { if (newUserMetadata != null) { user.updateUserInfo(newUserMetadata, event) _metadataVersion.value++ - // Invalidate metadata flows on notes by this author (O(K) via index) - notesByAuthor[event.pubKey]?.forEach { note -> - if (note.flowSet?.metadata?.hasObservers() == true) { - note.flowSet?.metadata?.invalidateData() - } - } } } @@ -484,7 +468,6 @@ class DesktopLocalCache : ICacheProvider { val author = getOrCreateUser(event.pubKey) val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } note.loadEvent(event, author, repliesTo) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } addQuoteBoosts(event, note, repliesTo) @@ -504,7 +487,6 @@ class DesktopLocalCache : ICacheProvider { val author = getOrCreateUser(event.pubKey) val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } note.loadEvent(event, author, repliesTo) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } addQuoteBoosts(event, note, repliesTo) @@ -527,7 +509,6 @@ class DesktopLocalCache : ICacheProvider { if (note.event != null) return false val author = getOrCreateUser(event.pubKey) note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } return true } @@ -552,7 +533,6 @@ class DesktopLocalCache : ICacheProvider { if (responseNote.event != null) return false val author = getOrCreateUser(event.pubKey) responseNote.loadEvent(event, author, emptyList()) - trackNoteAuthor(responseNote, event.pubKey) relay?.let { responseNote.addRelay(it) } pollNote.pollState().addResponse(responseNote) return true @@ -598,7 +578,6 @@ class DesktopLocalCache : ICacheProvider { event.originalPost().mapNotNull { getNoteIfExists(it) } + event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } note.loadEvent(event, author, reactedTo) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } reactedTo.forEach { it.addReaction(note) } return true @@ -616,7 +595,6 @@ class DesktopLocalCache : ICacheProvider { if (note.event != null) return false val author = getOrCreateUser(event.pubKey) note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } return true } @@ -648,7 +626,6 @@ class DesktopLocalCache : ICacheProvider { event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } note.loadEvent(event, author, zappedNotes) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } // Link zap to target notes @@ -676,7 +653,6 @@ class DesktopLocalCache : ICacheProvider { val boostedNote = boostedId?.let { getOrCreateNote(it) } val repliesTo = listOfNotNull(boostedNote) note.loadEvent(event, author, repliesTo) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } boostedNote?.addBoost(note) return true @@ -696,7 +672,6 @@ class DesktopLocalCache : ICacheProvider { val boostedNote = event.boostedEventId()?.let { getOrCreateNote(it) } val repliesTo = listOfNotNull(boostedNote) note.loadEvent(event, author, repliesTo) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } boostedNote?.addBoost(note) return true @@ -770,7 +745,6 @@ class DesktopLocalCache : ICacheProvider { if (note.event != null) return false val author = getOrCreateUser(event.pubKey) note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } return true } @@ -884,7 +858,6 @@ class DesktopLocalCache : ICacheProvider { val author = getOrCreateUser(event.pubKey) note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } val activityAddress = event.activityAddress() ?: return true @@ -953,7 +926,6 @@ class DesktopLocalCache : ICacheProvider { if (note.event != null) return false note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } zappedNote?.addZapPayment(note, null) @@ -999,7 +971,6 @@ class DesktopLocalCache : ICacheProvider { if (note.event != null) return false note.loadEvent(event, author, emptyList()) - trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } // Link response to zapped note via request @@ -1140,7 +1111,6 @@ class DesktopLocalCache : ICacheProvider { _followedUsers.value = emptySet() followerCounts.clear() followingCounts.clear() - notesByAuthor.clear() lastContactListByAuthor.clear() lastContactListEvent = null accountPubkey = null diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index c580f863c7..058c6567c1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,8 +41,25 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub */ @Composable fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData { - val author = remember(pubKey, cache) { cache?.getUserIfExists(pubKey) } - val authorMetaValue by produceState(initialValue = null, key1 = author) { + // getOrCreate, not getIfExists: a note is routinely cached before its author's profile is, + // and a null author here would leave nothing to observe, so the name would never correct + // itself from the pubkey stub. + val author = remember(pubKey, cache) { cache?.getOrCreateUser(pubKey) } + val authorMetaValue = rememberAuthorMetadataKey(author) + return remember(this, authorMetaValue) { toNoteDisplayData(cache) } +} + +/** + * A token that changes whenever [author]'s kind-0 metadata arrives. + * + * Use it as a `remember` key wherever rendering reads an author's name or avatar. A note is + * almost always cached before its author's profile is, so a one-time lookup draws a pubkey stub + * and never corrects itself — the cache holds no note-by-author index to invalidate from, and + * should not: that index would strongly retain every note it knows. + */ +@Composable +fun rememberAuthorMetadataKey(author: User?): Any? { + val metaValue by produceState(initialValue = null, key1 = author) { val a = author if (a == null) { value = null @@ -49,7 +67,7 @@ fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData { a.metadata().flow.collect { value = it } } } - return remember(this, authorMetaValue) { toNoteDisplayData(cache) } + return metaValue } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 9ae8b96117..e6ad78c4cc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -377,7 +377,7 @@ private fun FeedNoteCardBody( } // Original note content with actions inside card - val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) } + val displayData = originalEvent.rememberDisplayData(localCache) val showRepostFollowPill = account != null && onFollow != null && @@ -449,7 +449,7 @@ private fun FeedNoteCardBody( onDispose { note.clearFlow() } } - val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) } + val displayData = event.rememberDisplayData(localCache) val showFollowPill = account != null && onFollow != null && diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 69662213e8..afcb78f082 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -193,9 +194,10 @@ fun ThreadScreen( onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - // Thread-author metadata + note interactions now load per row: each thread - // note renders through NoteCard, which opens composition-scoped UserFinder + - // EventFinder subscriptions. (requestInteractions above remains the thread's + // Thread-author metadata + note interactions load per row, through the + // composition-scoped UserFinder + EventFinder subscriptions that NoteCard + // opens. Reply rows render through CommentItem instead, so they open their + // own UserFinder below. (requestInteractions above remains the thread's // explicit interaction-refresh path.) // Fetch quoted notes referenced in thread content @@ -369,35 +371,60 @@ fun ThreadScreen( ) } else { replyNotes.forEachIndexed { index, note -> - val event = note.event - - // Observe metadata + reactions so we recompose - // when author info arrives from relays + // Observe this note's own flows so the row recomposes + // when its event, reactions or zaps land. The author's + // profile is a separate observation, further down. val flowSet = remember(note) { note.flow() } val metadataState by flowSet.metadata.stateFlow.collectAsState() val reactionsState by flowSet.reactions.stateFlow.collectAsState() val zapsState by flowSet.zaps.stateFlow.collectAsState() + // A reply can be a placeholder until its event arrives. + // Note.loadEvent invalidates the metadata slot, so key + // the read on it rather than reading note.event bare — + // an unread collectAsState recomposes nothing. + val event = remember(note, metadataState) { note.event } + DisposableEffect(note) { onDispose { note.clearFlow() } } + // getOrCreate, not getIfExists: the reply's author is + // often a stranger the cache has no User for yet, and a + // null here would leave the row with nothing to observe + // and nothing to subscribe to. val author = - remember(event?.pubKey, metadataState) { - event?.pubKey?.let { localCache.getUserIfExists(it) } + remember(event?.pubKey) { + event?.pubKey?.let { localCache.getOrCreateUser(it) } } + // Ask for the author's kind-0 while this row is composed. + // A reply renders through CommentItem, not NoteCard, so + // it does not inherit NoteCard's UserFinder — without + // this nothing ever requests these profiles and the row + // shows a truncated pubkey forever. + if (author != null) { + UserFinderFilterAssemblerSubscription(author) + } + + // The reply almost always renders before its author's + // kind-0 does, so the name and avatar have to come from + // an observation rather than the lookup above. + val authorMeta = rememberAuthorMetadataKey(author) + val authorName = remember(author, authorMeta) { author?.toBestDisplayName() } + val authorAvatar = remember(author, authorMeta) { author?.profilePicture() } + val reactionCount = remember(reactionsState) { note.countReactions() } val zapAmount = remember(zapsState) { note.zapsAmount } CommentItem( authorName = - author?.toBestDisplayName() + authorName ?: event?.pubKey?.take(8) ?: "", authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "", - authorAvatarUrl = author?.profilePicture(), + authorAvatarUrl = authorAvatar, authorPubKeyHex = event?.pubKey ?: "", content = event?.content ?: "", timeAgo = (event?.createdAt ?: 0L).toTimeAgo(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt index 7a9dc76585..b668e4ddac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt @@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription -import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData import com.vitorpamplona.amethyst.desktop.ui.voteOnPoll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -206,7 +206,7 @@ fun DesktopPollCard( endsAtSec?.let { (if (hasEnded) "Ended " else "Ends ") + formatPollTimestamp(it) } } - val displayData = remember(event) { event.toNoteDisplayData(localCache) } + val displayData = event.rememberDisplayData(localCache) NoteCard( note = displayData, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 54b5971176..770479b8a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,7 +71,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar -import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -529,28 +528,14 @@ fun QuotedNoteEmbed( onDispose { note.clearFlow() } } - // Observe the author's user metadata (kind 0) so the embed's name + avatar - // update once metadata arrives via relay subscription. produceState avoids - // the conditional-composable trap when note.author is null until the parent - // event lands. - val author = note.author - val authorMetaValue by produceState(initialValue = null, key1 = author) { - val a = author - if (a == null) { - value = null - } else { - a.metadata().flow.collect { value = it } - } - } - - val event = note.event + // Keyed on metadataState, not read bare: Note.loadEvent invalidates the metadata + // slot when the fetch lands, and an unread collectAsState recomposes nothing — the + // embed would sit on "Loading quoted note..." forever. + val event = remember(note, metadataState) { note.event } if (event != null) { - // Recompute on every recomposition — picks up note + user metadata changes. - // authorMetaValue is read to mark this recomposition path as author-meta - // dependent so toNoteDisplayData() sees the latest avatar/displayName. - @Suppress("UNUSED_EXPRESSION") - authorMetaValue - val displayData = event.toNoteDisplayData(localCache) + // Re-derives when the author's kind-0 lands, so the embed's name and avatar fill in + // after the note itself. + val displayData = event.rememberDisplayData(localCache) SpamCheckedNoteRender( note = note, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 0f1ba2911f..8cb6212cc1 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -65,6 +66,28 @@ class DesktopCachePipelineTest { /** Wait for async bundling (250ms bundler + margin) */ private suspend fun waitForBundler() = delay(500) + /** + * Strong references to everything the cache built during one test. + * + * `DesktopLocalCache.notes`, `users` and `addressableNotes` are LargeSoftCaches: + * their values are held weakly, so a note nothing else points at can be collected + * between the `consume` that created it and the assertion that looks for it. In the + * app the screen showing a note is that strong referent; a test has none, so + * [ingest] pins the fixtures here for the lifetime of the test instance. Without + * this, every consume-then-query test below fails non-deterministically whenever a + * GC lands in that window -- which is what CI's tighter heap produces. + */ + private val pinnedFixtures = mutableListOf() + + /** Consumes [event] into the cache and pins whatever it created. See [pinnedFixtures]. */ + private fun DesktopLocalCache.ingest(event: Event): Boolean { + val consumed = consume(event, relayUrl, wasVerified = true) + pinnedFixtures.addAll(users.filterIntoSet { _, _ -> true }) + pinnedFixtures.addAll(notes.filterIntoSet { _, _ -> true }) + pinnedFixtures.addAll(addressableNotes.filterIntoSet { _, _ -> true }) + return consumed + } + private fun textNote( id: String, pubKey: String, @@ -127,7 +150,7 @@ class DesktopCachePipelineTest { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = textNote("note1".padEnd(64, '0'), userPubKey) - val consumed = cache.consume(event, relayUrl, wasVerified = true) + val consumed = cache.ingest(event) assertTrue(consumed, "First consume should return true") val note = cache.getNoteIfExists("note1".padEnd(64, '0')) @@ -140,8 +163,8 @@ class DesktopCachePipelineTest { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = textNote("note1".padEnd(64, '0'), userPubKey) - cache.consume(event, relayUrl, wasVerified = true) - val secondConsume = cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) + val secondConsume = cache.ingest(event) assertTrue(!secondConsume, "Second consume of same event should return false") } @@ -151,7 +174,7 @@ class DesktopCachePipelineTest { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey)) - cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) assertEquals(setOf(followedPubKey), cache.followedUsers.value) } @@ -168,8 +191,8 @@ class DesktopCachePipelineTest { createdAt = 200, ) - cache.consume(old, relayUrl, wasVerified = true) - cache.consume(newer, relayUrl, wasVerified = true) + cache.ingest(old) + cache.ingest(newer) assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value) } @@ -180,8 +203,8 @@ class DesktopCachePipelineTest { val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200) val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) - cache.consume(newer, relayUrl, wasVerified = true) - cache.consume(old, relayUrl, wasVerified = true) + cache.ingest(newer) + cache.ingest(old) assertEquals( setOf(followedPubKey, unfollowedPubKey), @@ -197,8 +220,8 @@ class DesktopCachePipelineTest { val note = textNote(noteId, userPubKey) val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId) - cache.consume(note, relayUrl, wasVerified = true) - cache.consume(react, relayUrl, wasVerified = true) + cache.ingest(note) + cache.ingest(react) val cachedNote = cache.getNoteIfExists(noteId)!! assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event") @@ -223,7 +246,7 @@ class DesktopCachePipelineTest { val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) // Phase A — hydration path: consume with accountPubkey unbound. - cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) assertEquals( emptySet(), cache.followedUsers.value, @@ -235,7 +258,7 @@ class DesktopCachePipelineTest { // Phase C — relay replay of the SAME event. Must NOT be rejected by // the createdAt gate; must populate _followedUsers. - cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) assertEquals( setOf(followedPubKey), cache.followedUsers.value, @@ -248,7 +271,7 @@ class DesktopCachePipelineTest { val cache = DesktopLocalCache() val other = contactList("cl2".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey), createdAt = 100) - cache.consume(other, relayUrl, wasVerified = true) + cache.ingest(other) cache.accountPubkey = userPubKey // followedUsers is for the active user only; a non-self kind-3 @@ -260,7 +283,7 @@ class DesktopCachePipelineTest { // reject; here we skipped stamping when pubkey was null so a // newer replay lands cleanly). val newer = contactList("cl2b".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey, userPubKey), createdAt = 200) - cache.consume(newer, relayUrl, wasVerified = true) + cache.ingest(newer) // No direct assertion on internal state; the fact that this // returns without throwing + does not affect followedUsers is // the invariant. The next line documents intent. @@ -286,7 +309,7 @@ class DesktopCachePipelineTest { delay(50) val event = textNote("note1".padEnd(64, '0'), userPubKey) - cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) val note = cache.getNoteIfExists(event.id)!! cache.emitNewNotes(setOf(note)) @@ -307,9 +330,9 @@ class DesktopCachePipelineTest { val filter = DesktopGlobalFeedFilter(cache) // Add notes from different authors - cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true) - cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl, wasVerified = true) - cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100)) + cache.ingest(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200)) + cache.ingest(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300)) val feed = filter.feed() assertEquals(3, feed.size, "Global feed should contain all text notes") @@ -318,12 +341,20 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter only includes notes from followed users`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) + cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey))) - cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true) - cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100)) + cache.ingest(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200)) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + + // Deliberate: force the weak cache's hand before querying it, so the fixtures' + // survival is asserted here instead of left to whatever the heap happens to do. + // Without [ingest]'s pinning this collects the notes and the feed comes back empty + // -- the shape that failed on CI and passed on a roomier dev heap. + System.gc() + System.gc() + val feed = filter.feed() assertEquals(1, feed.size, "Following feed should only contain notes from followed users") @@ -333,7 +364,7 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter returns empty when no follows`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey)) val filter = DesktopFollowingFeedFilter(cache) { emptySet() } val feed = filter.feed() @@ -344,8 +375,8 @@ class DesktopCachePipelineTest { @Test fun `ProfileFeedFilter only shows notes from target pubkey`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true) - cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100)) + cache.ingest(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200)) val filter = DesktopProfileFeedFilter(followedPubKey, cache) val feed = filter.feed() @@ -360,8 +391,8 @@ class DesktopCachePipelineTest { val rootId = "root".padEnd(64, '0') val replyId = "reply".padEnd(64, '0') - cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl, wasVerified = true) - cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl, wasVerified = true) + cache.ingest(textNote(rootId, userPubKey, createdAt = 100)) + cache.ingest(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId)) val filter = DesktopThreadFilter(rootId, cache) val feed = filter.feed() @@ -373,11 +404,11 @@ class DesktopCachePipelineTest { fun `NotificationFeedFilter shows events tagging user`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val noteId = "note1".padEnd(64, '0') - cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true) + cache.ingest(textNote(noteId, userPubKey, createdAt = 100)) // Reaction from someone else targeting user's note val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200) - cache.consume(react, relayUrl, wasVerified = true) + cache.ingest(react) val filter = DesktopNotificationFeedFilter(userPubKey, cache) val feed = filter.feed() @@ -397,7 +428,7 @@ class DesktopCachePipelineTest { fun `ViewModel starts in Loading then transitions to Loaded after refresh`() = runBlocking { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey)) val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) @@ -436,7 +467,7 @@ class DesktopCachePipelineTest { // Simulate relay event arriving val event = textNote("n1".padEnd(64, '0'), userPubKey) - cache.consume(event, relayUrl, wasVerified = true) + cache.ingest(event) val note = cache.getNoteIfExists(event.id)!! cache.emitNewNotes(setOf(note)) @@ -452,7 +483,7 @@ class DesktopCachePipelineTest { fun `Following ViewModel only shows followed users notes via eventStream`() = runBlocking { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) + cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey))) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } val vm = DesktopFeedViewModel(filter, cache) @@ -460,7 +491,7 @@ class DesktopCachePipelineTest { // Add followed user's note val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100) - cache.consume(e1, relayUrl, wasVerified = true) + cache.ingest(e1) val note1 = cache.getNoteIfExists(e1.id)!! cache.emitNewNotes(setOf(note1)) waitForBundler() @@ -469,7 +500,7 @@ class DesktopCachePipelineTest { // Add unfollowed user's note val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200) - cache.consume(e2, relayUrl, wasVerified = true) + cache.ingest(e2) val note2 = cache.getNoteIfExists(e2.id)!! cache.emitNewNotes(setOf(note2)) waitForBundler() @@ -485,7 +516,7 @@ class DesktopCachePipelineTest { // No contact list consumed — followedUsers remains empty val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) - cache.consume(e1, relayUrl, wasVerified = true) + cache.ingest(e1) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } val vm = DesktopFeedViewModel(filter, cache) @@ -505,8 +536,8 @@ class DesktopCachePipelineTest { @Test fun `clear resets all cache state`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true) - cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) + cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey)) + cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey))) cache.clear() @@ -522,9 +553,9 @@ class DesktopCachePipelineTest { @Test fun `global feed is sorted newest first`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true) - cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true) - cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true) + cache.ingest(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100)) + cache.ingest(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200)) + cache.ingest(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300)) val filter = DesktopGlobalFeedFilter(cache) val feed = filter.feed() @@ -550,7 +581,7 @@ class DesktopCachePipelineTest { sig = dummySig, ) - cache.consume(metadata, relayUrl, wasVerified = true) + cache.ingest(metadata) val user = cache.getUserIfExists(userPubKey) assertTrue(user != null, "User should exist after metadata consumption") @@ -569,12 +600,12 @@ class DesktopCachePipelineTest { // Create a text note val textEvent = textNote("t1".padEnd(64, '0'), userPubKey) - cache.consume(textEvent, relayUrl, wasVerified = true) + cache.ingest(textEvent) val textNote = cache.getNoteIfExists(textEvent.id)!! // Create a reaction (not a text note) val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0')) - cache.consume(reactEvent, relayUrl, wasVerified = true) + cache.ingest(reactEvent) val reactNote = cache.getNoteIfExists(reactEvent.id)!! val filtered = filter.applyFilter(setOf(textNote, reactNote)) @@ -586,16 +617,16 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter applyFilter respects follow set`() { val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } - cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) + cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey))) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) - cache.consume(e1, relayUrl, wasVerified = true) + cache.ingest(e1) val note1 = cache.getNoteIfExists(e1.id)!! val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey) - cache.consume(e2, relayUrl, wasVerified = true) + cache.ingest(e2) val note2 = cache.getNoteIfExists(e2.id)!! val filtered = filter.applyFilter(setOf(note1, note2)) @@ -655,7 +686,7 @@ class DesktopCachePipelineTest { sig = dummySig, ) - cache.consume(metadata, relayUrl, wasVerified = true) + cache.ingest(metadata) val user = cache.getUserIfExists(userPubKey)!! val cached = user.metadataOrNull() diff --git a/docs/changelog/README.md b/docs/changelog/README.md index add6454352..aa78adc8d5 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -2,6 +2,7 @@ Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release. +- [v1.16.0 — My Fitness, BOLT12 Payments, and a Leaner Core](v1.16.00.md) - [v1.15.2 — Media Previews, Nested Replies, and Health Connect](v1.15.02.md) - [v1.15.1 — Release Build Fix](v1.15.01.md) - [v1.15.0 — Marmot Updates, a Advanced Search Box, and Books, DVM Updates](v1.15.00.md) diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index c1c54c98be..8ac15a8e8f 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -75,61 +75,9 @@ "rchk": "" }, "sinceLastTag": { - "tag": "v1.15.2", - "since": "2026-09-12T10:34:37-04:00", + "tag": "v1.16.0", + "since": "2026-09-17T13:39:51-04:00", "translators": [ - { - "user": "vitorpamplona", - "languages": [ - "Arabic, Saudi Arabia", - "Bengali", - "Chinese Simplified", - "Chinese Simplified, Singapore", - "Chinese Traditional", - "Chinese Traditional, Hong Kong", - "Czech", - "Dutch", - "English, United Kingdom", - "Esperanto", - "Finnish", - "French", - "French, Canada", - "German", - "Greek", - "Hindi", - "Hungarian", - "Indonesian", - "Italian", - "Japanese", - "Korean", - "Latvian", - "Persian", - "Polish", - "Portuguese", - "Portuguese, Brazilian", - "Russian", - "Rսssian, Սkraine", - "Serbian (Cyrillic)", - "Slovenian", - "Spanish", - "Spanish, Mexico", - "Spanish, United States", - "Swahili, Kenya", - "Swedish", - "Tamil", - "Thai", - "Turkish", - "Ukrainian", - "Uzbek", - "Vietnamese" - ] - }, - { - "user": "maxblake2015", - "languages": [ - "Polish" - ] - }, { "user": "rajs19420616", "languages": [ @@ -142,6 +90,16 @@ "Hungarian" ] }, + { + "user": "maxblake2015", + "languages": [ + "Polish" + ] + }, + { + "user": "vitorpamplona", + "languages": [] + }, { "user": "greenart7c3", "languages": [] diff --git a/docs/changelog/v1.16.00.md b/docs/changelog/v1.16.00.md new file mode 100644 index 0000000000..b125c437c5 --- /dev/null +++ b/docs/changelog/v1.16.00.md @@ -0,0 +1,120 @@ +# v1.16.0: My Fitness, BOLT12 Payments, and a Leaner Core + +Highlights: + +- Adds **My Fitness**, a dashboard over everything you have trained. +- Moves **BOLT12 offers** into the profile payment rail, and zaps over BOLT11 + when a wallet refuses one. +- Opens the **zapped note** from wallet history. +- Fixes **Blossom uploads**, broken on most servers since 1.15.0. +- Takes **~40 MB off every `amy` distribution**. +- Stops counting a relay that hung up as connected. +- Updates **Arti to 2.6.0** for two medium-severity fixes. + +## New Features + +### My Fitness + +- Adds a training dashboard, reached from the drawer or a pinned bottom-bar slot. +- Builds it from both sources: Health Connect, and the kind 1301 workouts you + published. Neither is required. +- Summarises four weeks: this week against last, weekly averages, a breakdown by + activity, best efforts, active days and a day streak. +- Shows distance, calories, steps, climb and heart rate only when a workout + carries them. +- Offers to share a workout only when it is not already published. +- Prefers the Health Connect copy when the same effort appears in both sources — + it carries the heart rate, steps and climb the published event drops. +- Filters the Workouts feed by **Mine** from the top nav. +- Reads workouts in the POWR dialect, which carry `start`/`end` and no `duration`. + +### Payments + +- Renders each BOLT12 offer as a chip in the profile payment rail. Offers were + already payable, but only through a small button in the profile header. +- Removes both header wallet buttons. The rail lists everything they reached. +- Shows the zap bolt to recipients who publish an offer and no lightning address. + The send path already paid them; the picker just never offered it. +- Falls back to a BOLT11 zap when a wallet refuses an offer and the recipient also + publishes a lightning address. +- Retries only refusals that mean nothing was attempted. A `PAYMENT_FAILED` can + still settle later, so retrying it could pay twice. +- Reports a timeout when a wallet never answers, instead of hanging the zap. +- Opens the zapped note when you tap a zap in wallet history. + +## Performance + +- Stops emptying the media connection pool on every client rebuild. Both HTTP + clients share one pool, so the proxy check misread every rebuild as a route + change. +- Drops pooled connections once per real Tor route change instead. +- Stops `debugState()` walking the whole cache on every backgrounding. Its nine + scans were built eagerly and then discarded unread. +- Signs one Blossom read-auth token per server, not one per racing caller. + +## Improvements and Bug fixes + +- Sends the Blossom authorization token as standard padded Base64 again. Deployed + servers reject 1.15.0's unpadded base64url, so roughly two uploads in three + failed. +- Points the Sovbit default Blossom server at `files.sovbit.host`. +- Answers a relay's WebSocket CLOSE frame, in both the shared and the Android + socket. Without it a closed socket looked connected for up to four minutes, + silently discarding everything sent. +- Counts connected relays from the pool's connected flow, which the pool now + clears itself whenever it drops a relay. +- Renders a GIF with no `imeta` as a loading placeholder. It used to collapse the + whole note to a gap in the feed until the fetch finished. +- Drains the event-sync outbox before closing the client. Events from the final + page were reported Done and never delivered. +- Fixes 34 bugs found auditing `commons` and `commonsUI`. Among them: AES-GCM + initialised with a spec every JDK rejects; an `Onion-Location` header from any + host re-pointing Tor traffic for 24 hours; a bundled insert wedged after one + exception; a URL validator backtracking exponentially per keystroke. +- Uses `https` in preview data and image-URL placeholders. + +## Desktop + +- Stops a silent account wipe on macOS. One Deny on the Keychain prompt rotated + the encryption key and orphaned every saved account. +- Consolidates the keychain into a single `vault-v1` item, so a cold boot prompts + once. +- Allows first-launch key bootstrap on Linux and Windows. + +## Cli + +- Ships ~40 MB lighter, now that `amy` no longer drags Compose UI and Skiko behind + `commons`. +- Fails fast when a buzz-agent wrapper cannot be made executable. + +## Quartz + +- Answers a duplicate `EVENT` with `OK true`, per NIP-01, and a superseded + replaceable the same way. Clients used to get raw SQLite text they could not + classify, and retried forever. +- Gives each socket adapter its own session, removing the pool's locking and + identity checks. + +## Build & Documentation + +- Updates Arti 2.3.0 → 2.6.0, the JNI crate to 0.22, the NDK to r30 and Rust to + 1.98.1. Picks up TROVE-2026-24 and TROVE-2026-27, both reachable in normal use. +- Splits Compose UI out of `commons` into a new `commonsUI` module. 236 files + moved and no consumer import changed. +- Bumps LightCompressor-enhanced to 2.2.3. +- Drops the unused Guardian Project Maven repository. +- Runs relay-backed tests against geode instead of external relays. +- Clears the compiler, Android Lint and Gradle 10 deprecation warnings. +- Bumps `appCode` to 461. + +## Contributors + +- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z +- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- mstrofnone + +## Translations + +- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm +- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 +- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp diff --git a/docs/health-connect-play-declaration.md b/docs/health-connect-play-declaration.md index 5bc2ed8b12..cafe931432 100644 --- a/docs/health-connect-play-declaration.md +++ b/docs/health-connect-play-declaration.md @@ -1,168 +1,217 @@ # Health Connect — Play Console declaration -Source text for the Health Connect permissions declaration in Play Console, written against what -`HealthConnectManager` actually does. Keep this file and the declaration in sync: Google re-reviews -the declaration on every Health Connect permission change. +Source text for the Health Connect permissions declaration in Play Console. Keep this file and +the declaration in sync: Google re-reviews the declaration on every Health Connect permission +change. -Related code and copy: +**Approved use case claimed: "Fitness, wellness and coaching"** — *"apps designed to help users +track, monitor, analyze, manage, and improve their physical fitness."* -- `amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect. -- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt` — the only UI that calls it. -- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen Health Connect links to. -- `PRIVACY.md` § "Health and fitness data (Health Connect)" — the public policy the declaration points at. +## Why the earlier declaration was rejected, and what changed + +The first submission described the feature as "read finished workouts so the user can post them +to their social network." That is not one of the six approved use cases, and it reads as the +prohibited "publicly displaying or socially sharing sensitive data." It was rejected as *"Use of +permission is not a permitted/valid use case."* + +The app changed, not just the wording. Amethyst now has **My Fitness**, a personal training +dashboard that summarises the user's own Health Connect workouts for them: weekly totals, +week-over-week movement, a per-activity breakdown, best efforts, active days and a training +streak. It is its own destination in the navigation drawer, under "You", and works fully whether +or not the user ever publishes anything. Publishing a workout is now one optional action on a row of that dashboard. + +**The permissions serve the dashboard.** If the declaration below is ever re-read against the +app, the test to apply is: every data type listed is rendered back to the user as their own +statistic, on a screen that has no posting requirement. + +**The dashboard does not depend on Health Connect.** It builds the training log from two sources: +Health Connect, and the user's own published kind 1301 workout events. A user who never grants a +health permission still gets the full summary of everything they have logged — Health Connect adds +the device detail (heart rate, steps, climb) and the workouts they never posted. This matters for +the declaration's honesty: Health Connect *enriches* a tracking feature that exists on its own +rather than *being* the feature. + +Related code: + +- `commons/.../commons/fitness/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested. +- `commons/.../commons/fitness/TrainingLog.kt` — merges Health Connect with the user's own + published workouts, deduping the ones that appear in both. +- `ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt` — the dashboard. +- `service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect. +- `ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen. +- `PRIVACY.md` § "Health and fitness data (Health Connect)". --- ## 1. App functionality -> Amethyst is a social media client for the Nostr protocol — an open, decentralized social network. -> Users post updates and their followers read them. There is no Amethyst server and no Amethyst -> account: posts are signed on the device and sent to the public relay servers the user chooses. +> Amethyst is a social client for Nostr, an open decentralized social protocol. It also includes +> a fitness feature, **My Fitness**, which is what uses Health Connect. > -> One of the things users post is a workout summary. Amethyst's Workouts section publishes a -> structured workout post (a NIP-101e "kind 1301" event) — the activity, when it happened, how long -> it lasted, and the metrics that describe the effort — so the people who follow the user can see -> what they did, congratulate them, and compare with their own. This is the fitness equivalent of -> sharing a run on Strava or a ride on Garmin Connect, except the post goes to the user's own -> chosen relays instead of a company's servers. +> My Fitness is a personal training dashboard. It builds a log of the user's own workouts — from +> the workouts their watch or fitness app has saved to Health Connect, and from the workout +> records they have logged in Amethyst itself — and turns it into a picture of how that person is +> training: how much they did this week and whether that is up or down on last week, how their +> time splits across running, cycling, walking, swimming and the gym, their best efforts, how +> many days they trained, and their current streak of consecutive active days. > -> Health Connect is used for exactly one thing: to fill that post in. Without it the user has to -> retype numbers their watch already recorded — activity, duration, distance, calories, heart rate, -> steps, climb — which is slow and error-prone enough that most people simply do not post. With it, -> the composer shows the workouts that finished in the last 7 days as one-tap cards; tapping one -> pre-fills the form, and the user then edits and decides whether to publish. +> The purpose is to help the user track, monitor, analyze and improve their own physical +> fitness. Health Connect is not a precondition for it: the dashboard works from the user's own +> logged workouts alone, and Health Connect is what lets it also count the sessions their watch +> recorded and show the device metrics — heart rate, steps and elevation — that a hand-entered +> workout does not carry. The numbers are shown to the person who recorded them. No part of the dashboard +> requires posting anything, and nothing is transmitted anywhere to produce it — the summary is +> computed on the device from Health Connect data and displayed. +> +> Separately, and entirely optionally, a user who wants to tell their followers about a +> particular workout can tap "Share this workout" on a row of that dashboard. That opens a +> composer pre-filled with the workout's figures, which the user reviews and chooses to publish +> or discard. Sharing is a user-initiated action on top of the tracking feature, with per-post +> review and consent; it is never automatic, and the tracking feature is fully usable without it. ## 2. Reviewer walkthrough -> 1. Install and open Amethyst, and sign in (a new key can be generated in-app). -> 2. Open the navigation drawer (hamburger, top-left) and, under the **Feeds** section, tap -> **Workouts**. -> 3. Tap the **+** button. -> 4. At the top of the composer is a card titled **"Share your workouts"**. Tap **What Amethyst -> reads** to see the in-app rationale screen listing each data type and its purpose, then -> **Connect** to trigger the Health Connect permission request. -> 5. Grant the permissions. The card is replaced by a horizontal list of the workouts Health Connect -> holds from the last 7 days, labelled **"From Health Connect"**. -> 6. Tap any workout. The composer below is pre-filled with its activity type, title, duration, -> distance, calories, heart rate, steps and elevation gain, ready to edit and publish. +> 1. Install and open Amethyst, and sign in (a key can be generated in-app). +> 2. Open the navigation drawer (hamburger, top-left). **My Fitness** is the second entry under +> **You**, the first section — directly below Profile. Tap it. +> 3. The screen explains what will be read and offers **What Amethyst reads** (the full rationale +> screen) and **Connect**. Tap Connect and grant the permissions. (If the account has already +> logged workouts in Amethyst, the dashboard is already populated from those and the Health +> Connect offer appears as a banner above it instead — the feature does not gate on the +> permission.) +> 4. The dashboard appears: "This week" totals with the change against last week; the streak, +> active-days and workout-count tiles; the four-week weekly average; the per-activity +> breakdown; best efforts; and the recent-workout list. +> 5. Everything above is the tracking feature. To see the optional sharing path, tap **Share this +> workout** on a row in Recent workouts — it opens a pre-filled composer that the user must +> confirm. The button appears only on workouts that have not been published yet, so a workout +> is never offered for sharing twice. > -> Note for testing on an emulator or a fresh device: the carousel only appears once Health Connect -> actually holds a finished exercise session from the last 7 days, written by some fitness app or -> watch. With an empty Health Connect database the composer correctly shows nothing. +> My Fitness can also be pinned to the bottom bar, under Settings, like any other destination. +> +> Notes for testing: +> +> - The dashboard summarises two sources: workouts read from Health Connect, and workout records +> the account has published from Amethyst. On a fresh device with an empty Health Connect +> database and an account that has never logged a workout, it correctly reports that nothing has +> been recorded — there is no data to summarise, which is not a failure of the feature. +> - To see it populated, either grant Health Connect access on a device whose fitness app or watch +> has saved sessions in the last four weeks, or log one in the app: drawer → **Feeds** → +> **Workouts** → **+**. A workout logged that way appears in the dashboard without any health +> permission at all. ## 3. Per-permission justification -Paste one row per permission into the corresponding field. +Each permission maps to a statistic the dashboard shows the user about their own training. ### READ_EXERCISE — ExerciseSession (and CyclingPedalingCadence) -> This is the workout itself and the anchor for everything else. Amethyst reads the exercise -> session's activity type, start time and end time, and turns them into the post's activity, date -> and duration: "Running, 42:15, yesterday". The session's time window is also what every other -> metric below is aggregated over, so without this permission the feature cannot exist at all — the -> app would have no notion of "a workout" to attach numbers to. The session's own title, when the -> source app set one, becomes the suggested post title. +> The workout itself, and the spine of the whole dashboard. Amethyst reads each session's +> activity type, start time and end time to produce: the count of workouts this week versus last +> week, total training time, the per-activity breakdown ("Cycling: 3×, 4h 10m"), the number of +> active days, and the consecutive-day training streak. Every other metric below is aggregated +> over the session's time window, so without this permission there is no notion of "a workout" to +> attach any statistic to and the feature cannot exist. > -> Benefit to the user: the workout their watch recorded appears as a one-tap suggestion instead of a -> blank form. +> Benefit to the user: they can see how consistently and how much they are actually training, +> which is the basic question a training summary answers. > -> Note on CyclingPedalingCadence: Health Connect grants that data type under the same -> READ_EXERCISE permission. Amethyst does not read, store, or publish cadence — it reads only -> ExerciseSessionRecord. There is no separate permission available to request one without the other. +> CyclingPedalingCadence is granted by Health Connect under this same permission. Amethyst does +> not read, store, or display cadence — it reads only ExerciseSessionRecord. There is no separate +> permission that grants one without the other. ### READ_DISTANCE — Distance -> Amethyst aggregates the distance recorded over the workout's time window and fills it into the -> distance field of the post. Distance is the single most important number in a running, cycling, -> walking, hiking, rowing or swimming post — "5.2 km" is what the post is about, and a shared -> workout without it is largely meaningless to the people reading it. +> Aggregated per workout and summed into the dashboard's distance figures: distance this week, +> the percentage change against last week, the four-week weekly average, distance per activity, +> and the "Longest distance" best effort. > -> Benefit to the user: they do not have to look up and retype the distance their watch already -> measured, and the figure published is the accurate recorded one rather than a remembered estimate. +> Benefit to the user: distance is the primary training-load measure for running, cycling, +> walking, hiking, rowing and swimming. Seeing this week's total against last week's is how a +> user knows whether they are building up or falling off. ### READ_ACTIVE_CALORIES_BURNED — ActiveCaloriesBurned -> Amethyst aggregates active calories over the workout's time window and fills in the post's energy -> field. Active calories (energy burned by the activity, excluding resting metabolism) are the -> correct figure for describing a workout, and the one other fitness apps and Nostr fitness clients -> publish, so using it keeps Amethyst's posts comparable with theirs. +> Aggregated per workout and summed into the dashboard's energy figures: calories this week and +> the change against last week. Active calories — energy burned by the activity, excluding +> resting metabolism — are the correct measure of a workout's cost. > -> Benefit to the user: the effort figure in their post is the one their device computed, and it is -> filled in automatically. +> Benefit to the user: a view of training energy expenditure over time, and whether it is rising +> or falling week to week. ### READ_TOTAL_CALORIES_BURNED — TotalCaloriesBurned -> Fallback for the field above. Not every source writes ActiveCaloriesBurned — several popular -> watches and fitness apps record only total energy for a session. When active calories are absent, -> Amethyst uses total calories for the same field so the energy figure is not simply blank for those -> users. When active calories are present they are always preferred, because total calories include -> basal burn and would over-report the workout. +> Fallback for the same figure. Several widely used watches and fitness apps record only total +> energy for a session and never write ActiveCaloriesBurned. Where active calories are missing, +> Amethyst uses total calories so the energy statistics are not simply blank for those users; +> where active calories exist they are always preferred, because total calories include basal +> burn and would overstate the workout. > -> Benefit to the user: the feature works consistently regardless of which watch or fitness app they -> use, instead of silently dropping a metric for a subset of devices. +> Benefit to the user: the dashboard reports energy consistently regardless of which watch or app +> they use, instead of silently omitting the metric for a subset of devices. ### READ_HEART_RATE — HeartRate -> Amethyst aggregates the average and maximum heart rate over the workout's time window and fills in -> the post's two heart-rate fields. Heart rate is the standard measure of how hard an effort was and -> is what makes two workouts of the same distance comparable — an easy recovery run and a hard -> tempo run look identical without it. It is a headline field of the NIP-101e workout post format -> Amethyst publishes. +> Aggregated per workout into the average and maximum heart rate, then combined across the window +> into a duration-weighted average heart rate (so a two-hour ride weighs more than a ten-minute +> walk), a maximum for the period, the per-workout effort shown on each row of the recent list, +> and the "Highest heart rate" best effort. > -> Benefit to the user: their followers can see how hard the session actually was, not just how far -> it went, without the user transcribing two more numbers by hand. +> Benefit to the user: heart rate is how a user distinguishes an easy week from a hard one at the +> same distance, and the standard signal for whether they are training too hard or too easily. It +> is the single most informative intensity measure the dashboard can show. ### READ_STEPS — Steps (and StepsCadence) -> Amethyst aggregates the step count over the workout's time window and fills in the post's steps -> field. For walking, running and hiking posts the step count is a primary metric — for a walk it is -> often the metric the user cares about most — and it is one of the fields of the workout post -> format. +> Aggregated per workout and summed into the weekly step average and the "Most steps" best +> effort. > -> Benefit to the user: walk, run and hike posts carry the step count automatically. +> Benefit to the user: for walking and hiking — and for many users the majority of their activity +> — step count is the metric they actually track, and a weekly average is how they judge whether +> they are keeping it up. > -> Note on StepsCadence: Health Connect grants that data type under the same READ_STEPS permission. -> Amethyst does not read, store, or publish cadence — it reads only the aggregated step count. -> There is no separate permission available to request one without the other. +> StepsCadence is granted by Health Connect under this same permission. Amethyst does not read, +> store, or display cadence — it reads only the aggregated step count. There is no separate +> permission that grants one without the other. ### READ_ELEVATION_GAINED — ElevationGained -> Amethyst aggregates elevation gained over the workout's time window and fills in the post's climb -> field. Elevation is what distinguishes a flat ride or run from a hilly one — 30 km with 800 m of -> climbing is a completely different effort from 30 km on the flat — and it is the defining metric -> of a hiking post. It is one of the fields of the workout post format. +> Aggregated per workout and summed into the weekly climb average and the "Biggest climb" best +> effort, and shown per workout in the recent list. > -> Benefit to the user: hill and trail workouts are described accurately in the post rather than -> looking like flat ones. +> Benefit to the user: elevation is what separates a flat week from a hard hilly one at identical +> distance and time. Without it, a user training on hills sees no difference between a punishing +> week and an easy one, and the dashboard misrepresents their training load. -## 4. Scope and data handling (state this alongside the table above) +## 4. Scope and data handling -> - **Read-only.** Amethyst holds no write permissions and never writes to Health Connect. -> - **Foreground only.** Reads happen only while the New Workout composer is on screen, in direct -> response to the user opening it. Amethyst does not request READ_HEALTH_DATA_IN_BACKGROUND and -> has no background worker, service or job that touches health data. -> - **Last 7 days only.** Only sessions finishing in the previous 7 days are read. Amethyst does not -> request READ_HEALTH_DATA_HISTORY. -> - **No location.** Amethyst does not request READ_EXERCISE_ROUTE and never receives the GPS track -> of a workout. -> - **No transmission without an explicit user action.** Health data is used to populate an on-screen -> form. Nothing leaves the device unless the user taps a suggestion, reviews the pre-filled post, -> and publishes it — at which point the post goes to the Nostr relays that user configured. The -> developer operates no server, so no health data is ever received, stored or processed by the -> developer or any third party on the developer's behalf. -> - **No secondary use.** Health data is never used for advertising, analytics, profiling, marketing -> or sale, is never shared with data brokers or information-resellers, and is never used for -> determining eligibility for insurance, credit or employment. -> - **No persistence beyond the session.** Suggestions are held in memory while the composer is open. +> - **Read-only.** Amethyst holds no Health Connect write permissions and never writes to it. +> - **Foreground only.** Reads happen only while the user has the My Fitness dashboard or the +> workout composer on screen. Amethyst does not request READ_HEALTH_DATA_IN_BACKGROUND and has +> no background worker, service or job that touches health data. +> - **Last four weeks only.** The dashboard reads a rolling 28-day window. Amethyst does not +> request READ_HEALTH_DATA_HISTORY and cannot see anything older. +> - **No location.** Amethyst does not request READ_EXERCISE_ROUTE and never receives the GPS +> track of a workout. +> - **No transmission to produce the feature.** The dashboard is computed on the device and +> displayed. Nothing is uploaded to render it. The developer operates no server, so no health +> data is ever received, stored or processed by the developer or any third party acting for the +> developer. +> - **Sharing is separate, explicit and per-item.** A user may choose to publish one workout as a +> post. That takes a deliberate tap, shows the user exactly what will be published, and requires +> them to confirm. It is never automatic, never bulk, and never a condition of using the +> tracking feature. Published posts go to the Nostr relays that user configured. +> - **No secondary use.** Health data is never used for advertising, analytics, profiling, +> marketing or sale, never shared with data brokers, and never used to determine eligibility for +> insurance, credit or employment. +> - **No persistence beyond the session.** The summary is held in memory while the screen is open. > The only thing stored is whatever the user chose to publish, as an ordinary post. -> - **Revocable, and optional.** The user can turn the feature off at Settings → Compose Settings → -> "Suggest workouts to share", or revoke the permissions in Health Connect, at any time; the rest -> of the app is unaffected. -> - **In-app rationale.** Amethyst handles both ACTION_SHOW_PERMISSIONS_RATIONALE (Android 13 and +> - **Revocable and optional.** The user can revoke the permissions in Health Connect at any time — +> the dashboard immediately returns to its prompt — or turn the composer suggestions off under +> Settings → Compose Settings. The rest of the app is unaffected. +> - **In-app rationale.** Amethyst handles ACTION_SHOW_PERMISSIONS_RATIONALE (Android 13 and > below) and ACTION_VIEW_PERMISSION_USAGE + CATEGORY_HEALTH_PERMISSIONS (Android 14+), showing a -> screen that lists each data type, its purpose, and a link to the full privacy policy. The same -> screen is reachable in-app from the "What Amethyst reads" link on the Connect card, before the -> permission request. -> - **Privacy policy:** https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md — see the -> section "Health and fitness data (Health Connect)", which lists every data type, its purpose, and -> each of the limits above. +> screen listing each data type, its purpose and a link to the privacy policy. The same screen +> is reachable from "What Amethyst reads" before any permission is requested. +> - **Privacy policy:** https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md — see +> "Health and fitness data (Health Connect)". diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 5f11d74d80..2b2f056798 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -13,23 +13,30 @@ therefore it works.

  • Browse dedicated feeds: articles, pictures, short videos, live streams, podcasts, music, communities, classifieds, calendars and more.
  • Send and receive Bitcoin Lightning zaps.
  • -
  • Share your workouts — see below.
  • +
  • Track your training and share a workout — see below.
  • -

    Workouts

    -

    Amethyst has a Workouts section where you can publish a summary of a run, ride, walk, hike, swim, row -or gym session so the people who follow you can see what you did. Open the left drawer, tap -Workouts, then the + button to write one.

    -

    Typing every number by hand is tedious, so Amethyst can pre-fill the post from a workout your watch or -fitness app already saved to Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin Connect, -Strava and others all write there). With your permission, Amethyst reads finished workouts from the last -7 days and offers them as one-tap suggestions: pick one and the activity, duration, distance, calories, -heart rate, steps and elevation gain are filled in for you. You review the post and decide whether to -publish it.

    -

    This is optional and read-only. Amethyst asks for Health Connect permission only when you open the New -Workout composer, never reads in the background, never writes to Health Connect, never asks for your -exercise route or location, and never uploads anything until you publish a post yourself. You can switch -it off under Settings → Compose Settings, or revoke access in Health Connect at any time. Full -details are in the privacy policy at +

    My Fitness — track your training

    +

    Amethyst includes a personal training dashboard. Open the left drawer and tap My Fitness, +under You. It summarises the workouts you have logged in Amethyst, and — with your +permission — the ones your watch or fitness app has already saved to Android Health Connect +(Samsung Health, Google Fit, Fitbit, Garmin Connect, Strava and others all write there). It shows +you:

    +
      +
    • How much you trained this week, and whether that is up or down on last week.
    • +
    • Your four-week weekly average for time, distance, steps and climb.
    • +
    • Where your time goes — running, cycling, walking, hiking, swimming, rowing, the gym.
    • +
    • Your best efforts: longest distance, longest workout, biggest climb, most steps, highest heart rate.
    • +
    • How many days you trained, and your current streak of consecutive active days.
    • +
    +

    All of it is computed on your phone and shown to you. Nothing is uploaded to produce it, and you +never have to post anything to use it.

    +

    Sharing a workout (optional)

    +

    If you want to tell your followers about a particular session, tap Share this workout on any +row. Amethyst opens a post pre-filled with that workout's figures; you review it and decide whether to +publish. One workout at a time, always with your confirmation, never automatic.

    +

    The Health Connect integration is read-only. Amethyst never writes to Health Connect, never reads in +the background, never asks for your exercise route or location, and only ever looks at the last four +weeks. You can revoke access in Health Connect at any time. Full details are in the privacy policy at github.com/vitorpamplona/amethyst.

    Amethyst is free and open source (MIT). There is no Amethyst server and no Amethyst account: your posts go straight to the Nostr relays you configure.

    diff --git a/geode/README.md b/geode/README.md index e336c5b203..4c45f710fc 100644 --- a/geode/README.md +++ b/geode/README.md @@ -32,7 +32,7 @@ docker run -d --name geode -p 7447:7447 \ ``` with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your -`geode.toml`. Pin a version (`:1.15.2`) instead of `:latest` for reproducible +`geode.toml`. Pin a version (`:1.16.0`) instead of `:latest` for reproducible deploys. To build the image yourself, from the repo root: ```bash @@ -47,7 +47,7 @@ it — a minimal JRE is bundled, so no system Java is required. It installs to `/opt/geode/` with the launcher at `/opt/geode/bin/geode`. ```bash -sudo dpkg -i geode-1.15.2-linux-x64.deb # or: sudo rpm -i geode-1.15.2-linux-x64.rpm +sudo dpkg -i geode-1.16.0-linux-x64.deb # or: sudo rpm -i geode-1.16.0-linux-x64.rpm ``` To run it as a managed service, wire up the shipped systemd unit @@ -69,7 +69,7 @@ formula: [`packaging/homebrew/geode-relay.rb`](packaging/homebrew/geode-relay.rb Download `geode---.tar.gz`, unpack, and run: ```bash -tar xzf geode-1.15.2-linux-x64.tar.gz +tar xzf geode-1.16.0-linux-x64.tar.gz ./geode/bin/geode --config geode/share/geode/config.example.toml ``` diff --git a/geode/packaging/homebrew/geode-relay.rb b/geode/packaging/homebrew/geode-relay.rb index ae687390fd..b099baefb0 100644 --- a/geode/packaging/homebrew/geode-relay.rb +++ b/geode/packaging/homebrew/geode-relay.rb @@ -38,8 +38,8 @@ class GeodeRelay < Formula desc "Standalone Nostr relay from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/geode-1.15.2-jvm.tar.gz" - sha256 "caa9adf0b70d39679b39cb6ce667cc7bcb291d2e74bdfeb6aaa4c95de513fe7a" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.16.0/geode-1.16.0-jvm.tar.gz" + sha256 "d072801dbc9bf3a3518826e8d3ebe71d16c96309bdb599269f2a364b4cb495e5" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08582067ef..0aa82c3a96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ # Amethyst app version — single source of truth consumed by both Android (amethyst/) # and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic # integer that must increment on every release, even when `app` is unchanged. -app = "1.15.2" -appCode = "460" +app = "1.16.0" +appCode = "461" accompanistAdaptive = "0.37.3" # Pinned: 0.3.0 turns CacheMap.entries/keys/values into DeprecationLevel.ERROR (and # throws UnsupportedOperationException at runtime), which breaks quartz's appleMain @@ -38,6 +38,7 @@ firebaseBom = "34.19.0" fragmentKtx = "1.9.0" gms = "4.5.0" healthConnect = "1.1.0" +jackson = "2.22.2" jacksonModuleKotlin = "2.22.2" javaKeyring = "1.0.4" kmpTorRuntime = "2.6.0" @@ -93,7 +94,6 @@ jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" thumbnailator = "0.4.21" zxing = "3.5.4" -zxingAndroidEmbedded = "4.3.0" webkit = "1.17.0" # Cross-process UI embedding (SurfaceControlViewHost wrapper) for the in-app browser surface. Apache-2.0. privacysandboxUi = "1.0.0-alpha17" @@ -203,6 +203,7 @@ google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewri google-mlkit-genai-image-description = { group = "com.google.mlkit", name = "genai-image-description", version.ref = "genaiImageDescription" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } +jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } kmp-tor-runtime = { group = "io.matthewnelson.kmp-tor", name = "runtime", version.ref = "kmpTorRuntime" } @@ -252,7 +253,6 @@ vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ver zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } -zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 9db22ebaf1..2ae70ca1cb 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -177,8 +177,16 @@ kotlin { dependsOn(commonMain.get()) dependencies { - // Performant Parser of JSONs into Events - api(libs.jackson.module.kotlin) + // Performant Parser of JSONs into Events. + // + // jackson-databind, NOT jackson-module-kotlin: the module exists to bind + // Kotlin classes reflectively off their constructor parameter names, and + // nothing in the app does that any more — every wire format goes through a + // hand-written serializer or kotlinx. Dropping it takes kotlin-reflect with + // it, which is ~1,000 classes of DEX the app never called. `cli` still + // declares the module itself: it genuinely binds its config files + // reflectively, and is never minified. + api(libs.jackson.databind) // Websockets API implementation(libs.okhttp) diff --git a/quartz/consumer-rules.pro b/quartz/consumer-rules.pro index 04373ed538..5c7330a4a2 100644 --- a/quartz/consumer-rules.pro +++ b/quartz/consumer-rules.pro @@ -1,25 +1,39 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# ============================================================================= +# Keep rules Quartz contributes to every app that consumes it (wired through +# `optimization.consumerKeepRules` in build.gradle.kts, so these end up merged +# into the consumer's own R8 configuration). # -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# Scope them tightly. A rule here applies to the CONSUMER's whole program, so +# the `-keepnames class ** { *; }` that used to sit in this file pinned every +# name in every app that depends on Quartz — ours included — and blocked R8 +# from optimizing any member anywhere (`-keepnames` is `-keep,allowshrinking`). +# Only list what breaks at runtime if the name changes. +# ============================================================================= -keepdirectories libs -# Keep all names --keepnames class ** { *; } - -# Keep All enums --keep enum ** { *; } - -# preserve access to native classses +# secp256k1's JNI layer resolves these from native code. -keep class fr.acinq.secp256k1.** { *; } -# libscrypt --keep class com.lambdaworks.codec.** { *; } --keep class com.lambdaworks.crypto.** { *; } --keep class com.lambdaworks.jni.** { *; } +# Nothing keeps libscrypt any more: quartz replaced it with a pure-Kotlin +# implementation and `:quartz:dependencies` shows no com.lambdaworks on any +# configuration. These rules are merged into the R8 config of EVERY app that +# depends on quartz, so a rule we do not need is noise in somebody else's build. -# JSON parsing --keep class com.vitorpamplona.quartz.** { *; } \ No newline at end of file +# No Jackson keeps. Every wire format Quartz speaks is now handled by a +# hand-written serializer that names its fields as string literals: the +# StdSerializer/StdDeserializer pairs registered on JacksonMapper (Event, Filter, +# Message, Command, Rumor, EventTemplate, TagArray, the NIP-46 Bunker messages), +# JsonMapperNip55 (IntentResult, Permission), and the kotlinx serializers that +# NIP-47 and CLINK route through on every target. None of it reads a Kotlin +# constructor parameter name at runtime, so none of it has to survive R8. + +# Quartz serialises enums by name (Jackson writes/reads Enum.name, and +# Enum.valueOf resolves that string against the static field name), so the +# constants of its own enums have to keep their names. Consumers that persist +# their OWN enums by name need the equivalent rule in their own configuration. +-keepclassmembers enum com.vitorpamplona.quartz.** { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} diff --git a/quartz/proguard-rules.pro b/quartz/proguard-rules.pro deleted file mode 100644 index e1f545f4da..0000000000 --- a/quartz/proguard-rules.pro +++ /dev/null @@ -1,40 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# preserve the line number information for debugging stack traces. --dontobfuscate --keepattributes LocalVariableTable --keepattributes LocalVariableTypeTable --keepattributes *Annotation* --keepattributes SourceFile --keepattributes LineNumberTable --keepattributes Signature --keepattributes Exceptions --keepattributes InnerClasses --keepattributes EnclosingMethod --keepattributes MethodParameters --keepparameternames - --keepdirectories libs - -# Keep all names --keepnames class ** { *; } - -# Keep All enums --keep enum ** { *; } - -# preserve access to native classses --keep class fr.acinq.secp256k1.** { *; } - -# libscrypt --keep class com.lambdaworks.codec.** { *; } --keep class com.lambdaworks.crypto.** { *; } --keep class com.lambdaworks.jni.** { *; } - -# JSON parsing --keep class com.vitorpamplona.quartz.** { *; } - diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index f7f5b655a0..3132e3358c 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -41,8 +40,9 @@ class LargeDBSignatureCheck { val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_short.json") val eventArray = - JacksonMapper.mapper.readValue>( + JacksonMapper.mapper.readValue( InputStreamReader(fullDBInputStream), + JacksonMapper.eventListTypeInstance, ) as List var counter = 0 @@ -61,8 +61,9 @@ class LargeDBSignatureCheck { val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json.gz") val eventArray = - JacksonMapper.mapper.readValue>( + JacksonMapper.mapper.readValue( GZIPInputStream(fullDBInputStream), + JacksonMapper.eventListTypeInstance, ) as List var counter = 0 diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt index 9042339582..0eabd91da5 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.RandomInstance @@ -34,7 +34,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class Bip39MnemonicsTest { private val tests = - jacksonObjectMapper() + ObjectMapper() .readTree(javaClass.classLoader?.getResourceAsStream("bip39.vectors.json")) @Test diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerMemoryTest.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerMemoryTest.kt similarity index 100% rename from quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerMemoryTest.kt rename to quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerMemoryTest.kt diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt similarity index 100% rename from quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt rename to quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/utils/RuntimeExt.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/utils/RuntimeExt.kt similarity index 100% rename from quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/utils/RuntimeExt.kt rename to quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/utils/RuntimeExt.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt index 41123a7386..a922a03a72 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt @@ -22,12 +22,11 @@ package com.vitorpamplona.quartz.nip55AndroidSigner import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.jackson.InliningTagArrayPrettyPrinter import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonDeserializer @@ -35,11 +34,10 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.result import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionDeserializer import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionSerializer -import java.io.InputStream object JsonMapperNip55 { val defaultMapper: ObjectMapper = - jacksonObjectMapper() + ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) .setDefaultPrettyPrinter(InliningTagArrayPrettyPrinter()) @@ -51,9 +49,41 @@ object JsonMapperNip55 { .addSerializer(Permission::class.java, PermissionSerializer()), ) - inline fun fromJsonTo(json: String): T = defaultMapper.readValue(json) + /** + * Four named entry points instead of one `inline fun fromJsonTo`. + * + * The generic version resolved T through a TypeReference, which reads its type + * argument back off an anonymous subclass's generic superclass. R8 in full mode + * does not keep that: on device it throws + * + * IllegalArgumentException: Internal error: TypeReference constructed without + * actual type information + * + * and because these JavaTypes are built in , a single failure poisons the + * whole object — every later touch comes back as NoClassDefFoundError. That is + * what took out JacksonMapper on the first minified build; this file carries the + * identical pattern and only escaped because NIP-55 needs an external signer + * installed to reach. + * + * `T::class.java` cannot stand in for all four: List erases to List + * and every element comes back a LinkedHashMap. TypeFactory takes Class objects + * directly and keeps the element type, so there is nothing left for R8 to erase. + */ + val permissionType: JavaType = defaultMapper.typeFactory.constructType(Permission::class.java) - inline fun fromJsonTo(json: InputStream): T = defaultMapper.readValue(json) + val permissionArrayType: JavaType = defaultMapper.typeFactory.constructArrayType(Permission::class.java) + + val intentResultType: JavaType = defaultMapper.typeFactory.constructType(IntentResult::class.java) + + val intentResultListType: JavaType = defaultMapper.typeFactory.constructCollectionType(List::class.java, IntentResult::class.java) + + fun fromJsonToPermission(json: String): Permission = defaultMapper.readValue(json, permissionType) + + fun fromJsonToPermissionArray(json: String): Array = defaultMapper.readValue(json, permissionArrayType) + + fun fromJsonToIntentResult(json: String): IntentResult = defaultMapper.readValue(json, intentResultType) + + fun fromJsonToIntentResultList(json: String): List = defaultMapper.readValue(json, intentResultListType) fun toJson(event: ArrayNode): String = defaultMapper.writeValueAsString(event) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt index 3cb6635d66..8b6b84780a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt @@ -55,8 +55,8 @@ data class IntentResult( rejected = data.extras?.containsKey("rejected"), ) - fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonTo(json) + fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonToIntentResult(json) - fun fromJsonArray(json: String): List = JsonMapperNip55.fromJsonTo>(json) + fun fromJsonArray(json: String): List = JsonMapperNip55.fromJsonToIntentResultList(json) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt index 9e750a1b40..f4c36acaeb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt @@ -30,8 +30,8 @@ class Permission( fun toJson(): String = JsonMapperNip55.toJson(this) companion object { - fun fromJson(json: String): Permission = JsonMapperNip55.fromJsonTo(json) + fun fromJson(json: String): Permission = JsonMapperNip55.fromJsonToPermission(json) - fun fromJsonArray(json: String): Array = JsonMapperNip55.fromJsonTo>(json) + fun fromJsonArray(json: String): Array = JsonMapperNip55.fromJsonToPermissionArray(json) } } diff --git a/quartz/src/androidUnitTest/resources/relayDB.txt b/quartz/src/androidUnitTest/resources/relayDB.txt deleted file mode 100644 index 0977f44b3a..0000000000 --- a/quartz/src/androidUnitTest/resources/relayDB.txt +++ /dev/null @@ -1,2539 +0,0 @@ -wss://nostr.wine -wss://relay.orangepill.dev -wss://xmr.usenostr.org -wss://nostr.portemonero.com -wss://nostr.xmr.rocks -wss://relay.nostr.band -wss://filter.nostr.wine -wss://nostr.milou.lol -wss://nostr.mutinywallet.com -wss://nostr-pub.wellorder.net -wss://nostr.zebedee.cloud -wss://nos.lol -wss://brb.io -wss://bitcoiner.social -wss://nostr.decentony.com -wss://relay.nostriches.org -wss://paid.spore.ws -wss://eden.nostr.land -wss://puravida.nostr.land -wss://5dzvuefllevkhk7miqynaviguedxfnofrayu2xwfwtlkdg4radjdlyqd.onion -wss://relay-jp.nostr.wirednet.jp -wss://relay.nostrich.land -wss://nostr.holybea.com -wss://nostr-relay.nokotaro.com -wss://nostr-paid.h3z.jp -wss://nostrja-kari.heguro.com -wss://nostr.mom -wss://nostr.fediverse.jp -wss://nostr.h3z.jp -wss://universe.nostrich.land -wss://nostr.uselessshit.co -wss://atlas.nostr.land -wss://relay.snort.social -wss://universe.nostrich.landlangenlanges -wss://nostr.slothy.win -wss://nostr.plebchain.org -wss://nostr-relay.untethr.me -wss://relay.nostr.com.au -wss://nostr.inosta.cc -wss://relay.nostrati.com -wss://nostr.bitcoiner.social -wss://relay.nostrplebs.com -wss://relay.nostr.info -wss://nostr-relay.wlvs.space -wss://nostr.oxtr.dev -wss://nostr.onsats.org -wss://relay.wellorder.net -wss://relay.plebstr.com -wss://no.str.cr -wss://nostr.walletofsatoshi.com -wss://nostr.mwmdev.com -wss://relay.nostr.bg -wss://nostr.rocks -wss://nostr.fmt.wiz.biz -wss://nostr.orangepill.dev -wss://nostr-pub.semisol.dev -wss://nostr.sandwich.farm -wss://relay.nostr.ch -wss://relay.orange-crush.com -wss://private.red.gb.net -wss://nostr.lnprivate.network -wss://nostr.lu.ke -wss://relay.nostr.wirednet.jp -wss://lightningrelay.com -wss://relay.nostrgraph.net -wss://relay.nostrica.com -wss://relay.mostr.pub -wss://nostr-sg.com -wss://nostr.zkid.social -wss://relay.nostr.vet -wss://relay.nostr3.io -wss://relay.arsip.my.id -wss://relay.current.fyi -wss://global.relay.red -wss://nostr.island.network -wss://node01.nostress.cc -wss://relay.nostr.net.in -wss://relay.utxo.one -wss://relay-1.arsip.my.id -wss://nostrical.com -wss://nostro.cc -wss://rsslay.nostr.moe -wss://relay.nostr.or.jp -wss://nostream.unift.xyz -wss://blastr.f7z.xyz -wss://relay.honk.pw -wss://universe.nostrich.landlangja -wss://nostream.ocha.one -wss://paid-relay.nost.love -wss://offchain.pub -wss://nostr-usa.ka1gbeoa21bnm.us-west-2.cs.amazonlightsail.com -wss://nostr.terminus.money -wss://nostr.shawnyeager.net -wss://public.nostr.swissrouting.com -wss://nostrex.fly.dev -wss://relay.727whisky.com -wss://relay.cryptocculture.com -wss://relay.bleskop.com -wss://nostr.lorentz.is -wss://nostr.actn.io -wss://nostr-relay.lnmarkets.com -wss://nostr.openchain.fr -wss://relay.punkhub.me -wss://nostr.blipme.app -wss://nostr.swiss-enigma.ch -wss://nostr-verified.wellorder.net -wss://at.nostrworks.com -wss://21sats.net -wss://e.nos.lol -wss://nostr.mikedilger.com -wss://nostr.azte.co -wss://noster.bitcoiner.social -wss://relay.nostr.moe -wss://nostream.nostrly.io -wss://nostr.gives.africa -wss://nostr.21sats.net -wss://bitcoinmaximalists.online -wss://paid.nostrified.org -wss://nostr.1sat.org -wss://nostr.relayer.se -wss://sg.qemura.xyz -wss://nostr.coinos.io -wss://nostr.bitcoinplebs.de -wss://nostrich.friendship.tw -wss://nostr.sg -wss://eosla.com -wss://nostr.sidnlabs.nl -wss://nostr.foundrydigital.com -wss://relay.nostrview.com -wss://nostr.semisol.dev -wss://relay.f7z.io -wss://wlvs.space -wss://nostr.v0l.io -wss://nostr-relay.digitalmob.ro -wss://rsslay.fiatjaf.com -wss://relay.theorangepillapp.com -wss://relay.zeh.app -wss://nostr.zoomout.chat -wss://relay.stoner.com -wss://nostr.cercatrova.me -wss://relay.ryzizub.com -wss://nostr-1.nbo.angani.co -wss://nostr21.com -wss://spore.ws -wss://nostrue.com -wss://no-str.org -wss://relay.taxi -wss://ragnar-relay.com -wss://relay.austrich.net -wss://relay.nostr-latam.link -wss://1.noztr.com -wss://relay.nostr.scot -wss://test.relay.nostrich.day -wss://jiggytom.ddns.net -wss://nostr.bongbong.com -wss://relay.nostromo.social -wss://relay.sendstr.com -wss://nostr-dev.universalname.space -wss://relay.nostrcheck.me -wss://nostr.libertasprimordium.com -wss://nostr.kollider.xyz -wss://expensive-relay.fiatjaf.com -wss://nostr-sandbox.minds.io -wss://relay.nostrich.de -wss://nostr.gromeul.eu -wss://relay.nostr.wine -wss://nostr.screaminglife.io -wss://nostr-relay.derekross.me -wss://nostrica.nostrnotes.com -wss://paid.no.str.cr -wss://nostr.sethforprivacy.com -wss://nostr.dumpit.top -wss://nostr.herci.one -wss://cheery-paddock-rsakdrtc35c55n6yregn.wnext.app -wss://nostr.blockpower.capital -wss://nostr.nym.life -wss://nostr-verif.slothy.win -wss://fiatdenier.com -wss://nostr.bitcoin-21.org -wss://nostr.fluidtrack.in -wss://nostr.developer.li -wss://r.ayit.org -wss://relay.nostr.nu -wss://nostr.bostonbtc.com -wss://rly.social -wss://nostr.bridgey.dev -wss://relay.nostrprotocol.net -wss://nostr.mado.io -wss://nostr.einundzwanzig.space -wss://nostr2.actn.io -wss://nostr-relay.freedomnode.com -wss://nostr.pleb.network -wss://nostr.mouton.dev -wss://eelay.current.fyi -wss://nostr.notmyhostna.me -wss://nostr.pjv.me -wss://nostr.jatm.link -wss://nostr.fractalized.ovh -wss://nostr-relay.app.ikeji.ma -wss://relayer.ocha.one -wss://nostr.com.de -wss://nostr-2.afarazit.eu -wss://nostr.l00p.org -wss://nostr.drss.io -wss://relay.nostrify.io -wss://nostr.radixrat.com -wss://nostr-relay.bitcoin.ninja -wss://nostrsatva.net -wss://nostr.mustardnodes.com -wss://nostr01.vida.dev -wss://nostr.noones.com -wss://nostr.easify.de -wss://nostr3.actn.io -wss://moonbreeze.richardbondi.net -wss://nostr.naut.social -wss://private-nostr.v0l.io -wss://nostr.zaprite.io -wss://nostr.lightninglinks.xyz -wss://nostr.hackerman.pro -wss://nr.yay.so -wss://nostr.roundrockbitcoiners.com -wss://nostr.sovbit.host -wss://nostrelay.yeghro.site -wss://pow32.nostr.land -wss://nostr.1729.cloud -wss://nostr.rdfriedl.com -wss://nostr.h4x0r.host -wss://nostr.up.railway.app -wss://nostr.lnorb.com -wss://nostr.lordkno.ws -wss://relay.nostr.vision -wss://nostr-3.orba.ca -wss://satstacker.cloud -wss://freedom-relay.herokuapp.com -wss://nostr-relay.freeberty.net -wss://nostr.unknown.place -wss://nostr.delo.software -wss://relay.nostr.pro -wss://relay.minds.com -wss://nostr.ono.re -wss://relay.grunch.dev -wss://relay.cynsar.foundation -wss://relay.oldcity-bitcoiners.info -wss://relay.bitid.nz -wss://relay.nostr.xyz -wss://relay.futohq.com -wss://relay.farscapian.com -wss://astral.ninja -wss://relay.sovereign-stack.org -wss://nostr-2.zebedee.cloud -wss://nostr.nymsrelay.com -wss://relay.kronkltd.net -wss://relay.r3d.red -wss://universe.nostrich.landlangen -wss://nostr-dev.wellorder.net -wss://nostr.beta3.dev -wss://nostr.data.haus -wss://nostr.hugo.md -wss://relay-dev.cowdle.gg -wss://relay.dwadziesciajeden.pl -wss://tmp-relay.cesc.trade -wss://nostr.massmux.com -wss://relay.nostr.africa -wss://nostr1.tunnelsats.com -wss://nostr.f44.dev -wss://relay.n057r.club -wss://nostr-verif.slothy.com -wss://nostr.1f52b.xyz -wss://nostr.sebastix.dev -wss://nostr.lightning.contact -wss://nostr.rly.social -wss://noster.online -wss://relay.lexingtonbitcoin.org -wss://nostr.bitcoinbay.engineering -wss://nostr.howtobitcoin.shop -wss://blg.nostr.sx -wss://deschooling.us -wss://foolay.nostr.moe -wss://freespeech.casa -wss://nostr-01.bolt.observer -wss://nostr-01.dorafactory.org -wss://nostr-au.coinfundit.com -wss://nostr-eu.coinfundit.com -wss://nostr-relay.alekberg.net -wss://nostr-pub1.southflorida.ninja -wss://nostr-relay.gkbrk.com -wss://nostr-relay.pcdkd.fyi -wss://nostr-relay.schnitzel.world -wss://nostr-us.coinfundit.com -wss://nostr.21crypto.ch -wss://nostr.600.wtf -wss://nostr.8e23.net -wss://nostr.app.runonflux.io -wss://nostr.arguflow.gg -wss://nostr.bch.ninja -wss://nostr.chainofimmortals.net -wss://nostr.cizmar.net -wss://nostr.cheeserobot.org -wss://nostr.coollamer.com -wss://nostr.corebreach.com -wss://nostr.cro.social -wss://nostr.easydns.ca -wss://nostr.globals.fans -wss://nostr.handyjunky.com -wss://nostr.itas.li -wss://nostr.sectiontwo.org -wss://nostr.spleenrider.one -wss://nostr.thibautrey.fr -wss://nostr.uthark.com -wss://nostr.vulpem.com -wss://nostr.w3ird.tech -wss://nostr.whoop.ph -wss://nostr.yuv.al -wss://nostr01.opencult.com -wss://nostre.cc -wss://nostream.denizenid.com -wss://nostring.deno.dev -wss://pow.nostrati.com -wss://relay-pub.deschooling.us -wss://nostr.jiashanlu.synology.me -wss://nostr.klabo.blog -wss://relay.valireum.net -wss://nostr.fly.dev -wss://nostr.nordlysln.net -wss://nostr.zerofeerouting.com -wss://rsslay.nostr.net -wss://nostr-relay.nonce.academy -wss://nostr.rewardsbunny.com -wss://lv01.tater.ninja -wss://nostr-2.orba.ca -wss://nostr.orba.ca -wss://nostr.supremestack.xyz -wss://nostrrelay.com -wss://relay.nostr.au -wss://nostr.oooxxx.ml -wss://nostr.yael.at -wss://nostr-relay.trustbtc.org -wss://nostr.namek.link -wss://nostr-relay.wolfandcrow.tech -wss://nostr.satsophone.tk -wss://relay.dev.kronkltd.net -wss://nostr2.namek.link -wss://relay.21spirits.io -wss://relay.minds.io -wss://nostr.d11n.net -wss://nostr.tunnelsats.com -wss://nostr.leximaster.com -wss://mule.platanito.org -wss://nostr.robotechy.com -wss://relay.nostrmoto.xyz -wss://relay.boring.surf -wss://nostr.gruntwerk.org -wss://nostr.hyperlingo.com -wss://nostr.ethtozero.fr -wss://nostr.nodeofsven.com -wss://nostr.jimc.me -wss://nostr.utxo.lol -wss://relay.nyx.ma -wss://nostr.shmueli.org -wss://wizards.wormrobot.org -wss://nostr.sovbit.com -wss://nostr.datamagik.com -wss://relay.nostrid.com -wss://nostr1.starbackr.me -wss://relay.nostr.express -wss://nostr.formigator.eu -wss://nostr.xpersona.net -wss://nostr.digitalreformation.info -wss://nostr-relay.usebitcoin.space -wss://nostr-alpha.gruntwerk.org -wss://nostr-relay.australiaeast.cloudapp.azure.com -wss://nostr-relay.smoove.net -wss://nostr-relay.j3s7m4n.com -wss://nostr.demovement.net -wss://nostr.thesimplekid.com -wss://nostr.aozing.com -wss://nostr.blocs.fr -wss://no.str.watch -wss://btc.klendazu.com -wss://nostr.mrbits.it -wss://nostr.zenon.wtf -wss://no.contry.xyz -wss://nostream.gromeul.eu -wss://relay.nostr.ro -wss://nostr.ncsa.illinois.edu -wss://nostr.itssilvestre.com -wss://nostr.chaker.net -wss://knostr.neutrine.com -wss://nostr.pobblelabs.org -wss://nostr.simatime.com -wss://relay.nosphr.com -wss://student.chadpolytechnic.com -wss://nostr.localhost.re -wss://nostr.coinsamba.com.br -wss://deconomy-netser.ddns.net:2121 -wss://nostr.21m.fr -wss://zur.nostr.sx -wss://nostr-relay.texashedge.xyz -wss://spleenrider.herokuapp.com -wss://nostr.bitcoin.sex -wss://relay.nostrzoo.com -wss://nostr.blockchaincaffe.it -wss://nostr-bg01.ciph.rs -wss://knostr.neutrine.com:8880 -wss://nostr.ahaspharos.de -wss://nostr.argdx.net -wss://nostr.snblago.com -wss://merrcurr.up.railway.app -wss://nostr.bingtech.tk -wss://relay.nostr.wf -wss://relay.koreus.social -wss://nostr.randomdevelopment.biz -wss://relay.nostr.hu -wss://relay.nostr.lu -wss://relay.nostr.ae -wss://middling.myddns.me:8080 -wss://nostr.nikolaj.online -wss://relay.nostrology.org -wss://nostr.satoshi.fun -wss://nostream.kinchie.snowinning.com -wss://nostr.lapalomilla.mx -wss://relay.thes.ai -wss://rsr.uyky.net:30443 -wss://nostrafrica.pcdkd.fyi -wss://nostr.bitcoin-basel.ch -wss://relay.21baiwan.com -wss://nostr.ddns.net:8008 -wss://free-relay.nostrich.land -wss://nostr.lukeacl.com -wss://nostr.ddns.net -wss://nostr.rocket-tech.net -wss://nostr-1.afarazit.eu -wss://nostr.0nyx.eu -wss://nostr-mv.ashiroid.com -wss://lbrygen.xyz -wss://nostr.community.networks.deavmi.assigned.network -wss://nostr.ownscale.org -wss://relay1.gems.xyz -wss://nostr.soscary.net -wss://nostr.0xtr.dev -wss://damus.io -wss://relay.alien.blue -wss://nostr.btcmp.com -wss://relayer.fiatjaf.com -wss://relay.lacosanostr.com -wss://adult.18plus.social -wss://nostrrr.bublina.eu.org -wss://relay.stoner -wss://nostr.pwnshop.cloud -wss://nostr.directory -wss://nostr-relay-dev.wlvs.space -wss://member.cash -wss://relay.nyc1.vinux.app -wss://nostr-relay.digitamob.ro -wss://nor.st -wss://nostr.topeth.info -wss://nostr.rocketstyle.com.au -wss://relay.tnano.duckdns.org -wss://nostr.21l.st -wss://electra.nostr.land -wss://relay.codl.co -wss://nostr.koning-degraaf.nl -wss://relay.mrjohnsson.net -wss://nostr.thank.eu -wss://relay.stonez.me -wss://relay.nostr.distrl.net -wss://relay.valera.co -wss://api.semisol.dev -wss://nostr.lol -wss://relay.shitforce.one -wss://n-word.sharivegas.com -wss://lamp.wtf -wss://nostr.bitcoinpuertori.co -wss://nostr-01.bolt.oberver -wss://3d515c5277e9.ngrok.io -wss://nostr.xmrk.mooo.com -wss://alphapanda.pro -wss://relays.world -wss://universe.nostrich.landlangzh -wss://arnostr.permadao.io -wss://relay.chenxixian.cn -wss://universe.nostrich.landlangzhlangen -wss://v2r.chenxixian.cn -wss://nostr-relay-test.nokotaro.work -wss://universe.nostrich.landlangjalangen -wss://nostr.risa.zone -wss://relay.nosbin.com -wss://translate.argosopentech.com -wss://edennostr.land -wss://nostr.kawagarbo.xyz -wss://nostr.member.cash -wss://ch1.duno.com -wss://nostream-production-b80e.up.railway.app -wss://relay1.nostrich.cloud -wss://relay.t5y.ca -wss://nostr.zhongwen.world -wss://nostr.p2sh.co -wss://nostr.thomascdnns.com -wss://nostream.simon.snowinning.com -wss://relay.nostr.blockhenge.com -wss://nostr.buythisdip.com -wss://nostrua.com -wss://relay.bigred.social -wss://lingoh.dev -wss://nostr.poster.place -wss://nostr.geekgalaxy.com -wss://oarnx6xdrq5mygfdrbmzsvh3is3holefpz2x4qwbopwcicwd63gcivid.onion -wss://relay.nostropolis.xyz -wss://nostream-production-ba43.up.railway.app -wss://nostr.sabross.xyz -wss://relay.nvote.co -wss://nostrati.com -wss://cloudnull.land -wss://nostr.frennet.xyz -wss://nostr.wine.com -wss://nostr.sactiontwo.org -wss://nostr.liberty.fans -wss://nostr.primz.org -wss://btc-italia.online -wss://homenode.local:4848 -wss://nostr.frennet.xyzl -wss://relay.roosoft.com -wss://rasca.asnubes.art -wss://nostr.bitcoin.sexanewlycre -wss://nostr.barf.bz -wss://nostr.middling.mydns.jp -wss://relay.xuzmail.com -wss://no-str.wnhefei.cn:28443 -wss://quirky-bunch-isubghsvoi26fbbt3n7o.wnext.app -wss://nostr.fennel.org:7000 -wss://nostr.0ne.day -wss://nostr.vpn1.codingmerc.com -wss://nostr.jacany.com -wss://nostream.lucas.snowinning.com -wss://relay.beta.fogtype.com -wss://nostr.zue.news -wss://nostream.madbean.snowinning.com -wss://nostr2.rbel.co -wss://relay.1bps.io -wss://nostream-relay-nostr.831.pp.ua -wss://zee-relay.fly.dev -wss://nostrrelay.geforcy.com -wss://relay.nostr.jhot.me -wss://nostr.itredneck.com -wss://nostr.h3y6e.com -wss://relay.bitcoiner.social -wss://hos.lol -wss://iris.to -wss://nostr-pub.senisol.dev -wss://nostr-pub.wellirder.net -wss://bitcoinforthe.lol -wss://relav.nostr.info -wss://3e32-200-229-144-129.ngrok.io -wss://nostr-relay.hzrd149.com -wss://nostr-world.h3z.jp -wss://nostr.thesamecat.io -wss://nostr.compile-error.net -wss://relayable.org -wss://mostra.milou.lol -wss://nproxy.cc -wss://nostr.bitmatk.io -wss://coracle.social -wss://umbrel.local:4848 -wss://nostr.ownbtc.online -wss://wss.nostrgram.co:444 -wss://nostr.minimue81.selfhost.co -wss://relay.current.fy -wss://nostream.nostr.parts -wss://nostr.zebede.cloud -wss://nostrwhoop.ph -wss://relay.nostrified.org -wss://nproxy.zerologin.co -wss://nostr.pcdkd.fyi -wss://relay.kongerik.et -wss://nostr.eden.land -wss://nostr.retroware.run.place -wss://relay.humanumest.social -wss://bhagos.org -wss://hushvault.ie -wss://nostream-production-9458.up.railway.app -wss://nostr.nakamotosatoshi.cf -wss://globals.fans -wss://nostr.cruncher.com -wss://nostr.global.fan -wss://relay.nostr.snblago.com -wss://nostr.nokotaro.com -wss://ostr-1.afarazit.eu -wss://relay.zhix.in -wss://stats.nostr.band -wss://nostr.fine -wss://vxlw4rlg7go34ol43g4gxbvfu4txdzjauquvnbptzwjflezs3vik55id.onion -wss://big.fist.black -wss://universe.nostrich.landlangzhlangja -wss://relay.plebz.space -wss://nostrich.land -wss://relay.mynostr.fun -wss://swiss-enigma.ch -wss://nostr1.current.fyi -wss://relay.atlas.nostr.land -wss://nostr.band -wss://n.wingu.se -wss://nostr.jmdtx.com -wss://nostrproxy-1.f7z.io -wss://nostr.ch -wss://roundrockbitcoiners.com -wss://nostr.sept.ml -wss://srelay.roli.social -wss://nostr.monostr.com -wss://nostr.dojotunnel.online -wss://nostrica.dojotunnel.online -wss://nostr.shadownode.org -wss://thes.ai -wss://rsslay.wss -wss://nostr.vol.io -wss://nostrgram.co -wss://habla.news -wss://runningnostr.lol -wss://mostr.pub -wss://relay.example2.com -wss://profiles.f7z.io -wss://nostr.adpo.co -wss://jp-relay-nostr.invr.chat -wss://nostr.anchel.nl -wss://mutinywallet.com -wss://relay.nostrbr.online -wss://filter.eden.nostr.land -wss://relay.nostrdocs.com -wss://relay.nostr.lucentlabs.co -wss://n.xmr.se -wss://nostr.relayer.rs -wss://monad.jb55.com:8080 -wss://nostr.watch -wss://universe.nostrich.landlangenlangzhlangja -wss://nostr.asdf.mx -wss://ts.relays.world -wss://arc1.arcadelabs.co -wss://stealth.wine -wss://nostr.bg -wss://really.nostr.bg -wss://realy.nostr.bg -wss://relai.nostr.bg -wss://relay-verified.deschooling.us -wss://4.up.railway.app -wss://nostr-relay.aapi.me -wss://nostr-z9tc.onrender.com -wss://nostrich.site -wss://nostr.ginuerzh.xyz -wss://310b-200-229-144-129.ngrok.io -wss://nostr.aste.co -wss://black.nostrcity.club -wss://nostr.guru -wss://nostrica.com -wss://relay.leesalminen.com -wss://nostr.shroomslab.net -wss://meta-relay-beta.nostr.wirednet.jp -wss://nostr.reamde.dev -wss://nostr.africa -wss://powrelay.xyz -wss://rsslay.data.haus -wss://nostr.danvergara.com -wss://nostr.one.re -wss://dev2.hazilitt.fiatjaf.com -wss://nostr-2.zebdeee.cloud -wss://zerosequioso.com -wss://brb.io.relay -wss://relay.realsearch.cc -wss://nodestr.fmt.wiz.biz -wss://r.alphaama.com -wss://nostr.relay-wlvs.space -wss://relay.21spiritis.io -wss://nostr.pinkanki.org -wss://nostr.damus.io -wss://fin-nostr.seekdisruption.com -wss://relay.nostr.lu.ke -wss://nostr.rdfried.com -wss://nostr.trustbtc.org -wss://nostr.verymad.net -wss://relay.damus.info -wss://relays.nostrplebs.com -wss://nostr.688.org -wss://15171031.688.org -wss://dgi4mb7antpcmrx4rynm6xq52xzt5duvxa4iwucq4mszgpz6smrjajqd.onion -wss://mastodon.cloud -wss://nostr.ch3n2k.com -wss://nostr.forecastdao.com -wss://nostr.nostrelay.org -wss://nostr.robotesc.ro -wss://nostr.test.aesyc.io -wss://nostr.web3infra.xyz -wss://nostrrelay.maciejz.net -wss://nostrsxz4lbwe-nostr.functions.fnc.fr-par.scw.cloud -wss://nostrpurple.com -wss://rsslay.ch3n2k.com -wss://nos.qghs.in -wss://nostr.nordlysln.net:3241 -wss://nostr.net.in -wss://relay.rip -wss://universe.nostrich.landlangenlangja -wss://wmv-vm.local:4848 -wss://relay.austritch.net -wss://relay.oxtr.dev -wss://rwlay.bigred.social -wss://relay.lexongtonbitcoin.org -wss://knostr.neutrine -wss://nostr.online -wss://filter.stealth.wine -wss://nostream-production-5895.up.railway.app -wss://nostr.stereosteve.com -wss://relay01.apus.network -wss://test.nostr.0x50.tech -wss://nostr.0x50.tech -wss://nostr.256k1.dev -wss://nostr.malin.onl -wss://jqiwgflfw4dezjsy42frompmknrlcfazoiyngftgknj7yrmnhtobd7id.local -wss://b.ayit.org -wss://nostrelay.rajabi.ca -wss://off20chain.pub -wss://nostr.milou.land -wss://nostr.primedomain.fr -wss://nostr.theblockreward.com -wss://anon.computer -wss://relay.hamnet.io -wss://nostramsterdam.vpx.moe -wss://global-relay.cesc.trade -wss://btcpay.kukks.org -wss://relay.cent2sat.com -wss://nostr.mnethome.de -wss://nostream.sh4.red -wss://klockenga.social -wss://nostream.megadope.snowinning.com -wss://nostr.cvilleblockchain.org -wss://nostr.bitocial.xyz -wss://nostr.bitcoiner.socail -wss://nostr-relay.eniehack.net -wss://greenart7c3.dedyn.io -wss://nostr.data.naus -wss://8.tcp.ngrok.io:19607 -wss://relay.nostr24.com -wss://d463rbo7dgbfuxvvxpory2og2etl4gttfzmqcixdq7rpts47lpgolkyd.onion -wss://dublin.saoirse.dev -wss://www.weixin.com -wss://nostr-rs-relay.cryptoassetssubledger.com -wss://nostr.kojira.net -wss://nostr.fan -wss://nostr.pk -wss://getalpy.com -wss://billert.xyz -wss://circle-ay.info -wss://jawsh.xyz -wss://walletofsatoshi.com -wss://ogblock.xyz -wss://nodestrich.com -wss://kunigaku.gith -wss://nostr.21ideas.org -wss://133332.xyz -wss://asats.io -wss://nostrchack.me -wss://chalow.net -wss://cashu.me -wss://tsukemonogit.git -wss://h3y6e.com -wss://elder.nostr.land -wss://xmr.rocks -wss://nostr.build -wss://mofumemo.com -wss://kpherox.dev -wss://sb.nostr.band -wss://tyiu.xyz -wss://welkinhere.githu -wss://ocha.one -wss://in.tips -wss://vitorpamplona.co -wss://fiatjaf.com -wss://akiomik.github.io -wss://stacker.news -wss://nvk.org -wss://lordkno.ws -wss://nostr.indus -wss://lotdkno.ws -wss://nosutora.com -wss://milou.lol -wss://mostr.pu -wss://dergigi.com -wss://shirehodl.com -wss://cash.app -wss://h3z.jp -wss://murachue.cytes.net -wss://snowcait.gith -wss://jb55.com -wss://nodeless.io -wss://orange-crush.com -wss://nostr.com.au -wss://oooxxx.ml -wss://plebs.place -wss://ahr999.com -wss://penpenpng.github.io -wss://nostrpurple.co -wss://thank.eu -wss://weep.jp -wss://tigerville.no -wss://nostrcheck.me -wss://frenstr.com -wss://ln.tips -wss://ryumu.dev -wss://honeyroad.store -wss://harlembitcoin.com -wss://f7z.io -wss://relay.fan -wss://nisshiee.org -wss://www.lopp.net -wss://getalby.com -wss://heguro.com -wss://wil.bio -wss://b.tc -wss://nodedttich.com -wss://relay.taldra.in -wss://relat.nostrica.com -wss://nostr.boring.surf -wss://nostr.raitisoja.net -wss://nostr.astrox.app -wss://nostr.mjex.me -wss://slick.mjex.me -wss://nostr.hrmb.org -wss://relay.semaphore.life -wss://rss.nostr.band -wss://63ragcfwb5xhoe5gfflazfyrde3qjdo73cblhhmnbviizowdo2q5haid.onion:5051 -wss://nostrblip.app -wss://relay.vanderwarker.family -wss://relay-local.cowdle.gg -wss://relay.damus.com -wss://relay.reeve.cn -wss://relay.strfry.net -wss://relay.alxgsv.com -wss://nostrv0l.io -wss://relay.nostrula.com -wss://release.nostr.band -wss://nostr.k3tan.com -wss://nostr.bitcoin.social -wss://thesimplekid.space -wss://relay.ypcloud.com -wss://at.nostrwork.at -wss://nostream.dev.kronkltd.net -wss://nostr-relay.xbytez.io -wss://relay.nostr.io -wss://relay.nvote.co:443 -wss://relay.cryptoculture.com -wss://rjj6ejkihilniytxs56qrgtttgcfnnjvbii6vaas6jzppcmekd63ugad.local -wss://puravida.nostr.land.com -wss://bitcoinmaximalist.online -wss://wine.nostr -wss://nostr.messagepush.io -wss://nostrich.love -wss://relaynostrplebs.com -wss://hamstr.to -wss://yosupp.app -wss://snort.social -wss://relay.nostr.gt -wss://nost.ratchat.nl -wss://nostr.chrissmith.site -wss://i.relay.boats -wss://nostr.wineto -wss://nostr.eluc.ch -wss://nostrplebs.com -wss://nostr.tools.global.id -wss://nostr.rocketnode.space -wss://relay.roli.social -wss://bitcoin.nostr.com -wss://relay.badgr.space -wss://nostriches.club -wss://nostr-check.me -wss://nostrelay.nokotaro.com -wss://rbr.bio -wss://rly.bopln.com -wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.onion -wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.local -wss://20nos.lol -wss://test.theglobalpersian.com -wss://nostr.exposed -wss://nostr-pub.liujiale.me -wss://nostream.frank.snowinning.com -wss://nstrs.fly.dev -wss://eospark.com -wss://relay.nosterplebs.com -wss://nproxy.kristapsk.lv -wss://nostr.universalname.space -wss://relays.snort.social -wss://nostr.how -wss://kukks.org -wss://nostr.dutch.cryptonews -wss://relay.cryptojournaal.net -wss://relay.dutch.cryptonews -wss://nostr.cryptojournaal.net -wss://no-str.wnhefei.cn -wss://www.131.me -wss://rsr.uyky.net -wss://nostr-relay.ie9.org -wss://nostr.fmt.wis.biz -wss://nostr.exotr.dev -wss://nostr.merrcurr.com -wss://realy.damus.io -wss://nerostr.xmr.rocks -wss://rkdgwzgvcgrciemlnfsxqgyrv5whgpw44s6zycokmuchpq4ucflgjtqd.local -wss://nostr.simplex.icu -wss://ralay.damus.io -wss://relay.kronkitd.net -wss://nostr.info -wss://nostr.lnnodeinsight.com -wss://nostr.truckenbucks.com -wss://brt.io -wss://nostr.lingoh.dev -wss://relay.nor.st -wss://nostr-relay.inmarkets.com -wss://wiz.biz -wss://nostream.git -wss://nostr.dpbu.de -wss://wcl2meyp236fa3dmfzfyq6aacbdoixrlocb6zozjs6xklxizschj2did.local -wss://relay.nostr.co.jp -wss://relap.orzv.workers.dev -wss://nostr.bitcoiner.socia -wss://eden.nosrt.land -wss://strfry.cryptocartel.social -wss://nostr.citizenry.technology -wss://universe.nostrich.landlang -wss://nostr.inprivate.network -wss://relay.snort.test -wss://lpkue6jtz3pnp7zok4jwlct4n3mzuffsfrpagiffsuplyaswhdtmpoid.local -wss://nostr.rezhajulio.id -wss://lnbits.eldamar.icu -wss://nostr.freefrom.fi -wss://yael.at -wss://rain8128.github.io -wss://badges.page -wss://latam1-nostr.stealthy.co -wss://nostr.relay.se -wss://nfdn.testnet.dotalgo.io -wss://relav.nostr3.io -wss://ofchain.pub -wss://nostr.fmt.wiz.bi -wss://relay.gui.dog -wss://eden.nostry.land -wss://nostr.relay.limo -wss://relay.nostrichs.org -wss://20nostr.mom -wss://relay20damus.io -wss://nostr.sandwich.pro -wss://sq.qemura.xyz -wss://nostr-pub.seminol.dev -wss://nostr.eunundzwanzig.space -wss://nostr.bitcoinet.social -wss://nostr-relay.untether.me -wss://relay.nostr.pub -wss://nostr.100p.org -wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.local -wss://nostr.gram -wss://nostr.videre.net -wss://nostr-2.zebedee.cloudwss -wss://nostr-pub.wellorder.netwss -wss://sonzai.net -wss://snort.fail -wss://ppavybjpqjft5slnpeovehbegomhwtvvxtesvwwdrfndz6qe2c5kf2ad.local -wss://nostr.land -wss://relay.nvote.com -wss://purevida.nostr.land -wss://nostr.bitcoiner.soical -wss://nostr.inosta.co -wss://nas.lol -wss://wss.nostr.milou -wss://nostr.cheesebot.org -wss://eyeswideshut.ath.cx -wss://test.relays.world -wss://relay.snort.com -wss://relay.nostrplebs.co -wss://nostr.kimi.im -wss://nostrum.com -wss://wss.nostr.wine -wss://relay.usenostr.org -wss://paladium.my:4848 -wss://umbrel.home.local:4848 -wss://nostr.metamadeenah.com -wss://lnbits.sdbtc.org -wss://relay.nostr.mutinywallet.com -wss://relay.theglobalpersian.com -wss://relay1.easymeta.app -wss://relay.nostris.online -wss://s1.wonder3.org -wss://eden.nostraland -wss://byc.klendazu.com -wss://ephemerelay.mostr.pub -wss://7si6co27cvaw5yjyx6asvxfmaw5ah2arywwgrem4y5svi5ntskoeb5id.onion -wss://nwmdev.com -wss://nostro.online -wss://nostr.massimux.com -wss://nostr.glate.ch -wss://nostr.openordex.org -wss://nostr.schorsch.fans -wss://nostr-relay-dev.nisshiee.org -wss://ibz.me -wss://alexandernostrplebs.com -wss://fishbanananostrplebs.com -wss://relay.nostrcheck.com -wss://nostr.roli.io -wss://nostr.net.za -wss://nostr.worldkey.io -wss://nostr-pub.welloorder.net -wss://nostr.actin.io -wss://nostrzebedee.cloud -wss://nostr.zclub.app -wss://nostr.13x.sh -wss://nostr.totient.xyz -wss://nostr1.federated.computer -wss://nostr.zhix.in -wss://nostr.vdstruis.com -wss://caro-relay.fiatjaf.com -wss://nostr.winewss -wss://notstro.wine -wss://nostr.btc-library.com -wss://nostr.phenomenon.space -wss://nostr.octr.dev -wss://nostr.impervious.live -wss://nostr.plebs.space -wss://iefan.tech -wss://w3ird.tech -wss://yunginter.net -wss://nostr.coach -wss://sleepy.cafe -wss://freespeechextremist.com -wss://nostr.uselessshit.com -wss://liberdon.com -wss://eveningzoo.club -wss://gleasonator.com -wss://mindly.social -wss://ottawa.place -wss://noagendasocial.com -wss://poa.st -wss://misskey.io -wss://misskey.cf -wss://nostr.bybieyang.com -wss://mastodon.online -wss://toad.social -wss://best-friends.chat -wss://universeodon.com -wss://mastodon.world -wss://mastodon.social -wss://nostr.vulpem -wss://nicecrew.digital -wss://front-end.social -wss://nostr-relay.wellorder.net -wss://relays.pro -wss://rsslay.sovbit.host -wss://seal.cafe -wss://social.6bq.de -wss://universe.nostrich.landlangjalangzh -wss://social.xenofem.me -wss://mi.hibi-tsumo.com -wss://mstdn.social -wss://mas.to -wss://relay.rebelbase.site -wss://pixelfed.social -wss://digitalcourage.social -wss://ruby.social -wss://cr8r.gg -wss://nijimiss.moe -wss://returtle.com -wss://lor.sh -wss://toot.community -wss://mstdn.jp -wss://relay.berserker.town -wss://nostr.rajabi.ca -wss://fosstodon.org -wss://misskey.takehi.to -wss://izj3isbk3pmade74ontdijodhehsytnw2iokdhh6k3flk4mq2pau6sid.onion -wss://mastodon.scot -wss://nostriches.org -wss://aus.social -wss://relay.nostr.amane.moe -wss://romancelandia.club -wss://stonez.me -wss://merrcurr.com -wss://nostr.ist -wss://onprem.wtf -wss://fedibird.com -wss://s2.wonder3.org -wss://freespech.casa -wss://disabled.social -wss://relay.house -wss://nostr-pub.welllorder.net -wss://social.teamb.space -wss://infosec.exchange -wss://blockedur.mom -wss://progressivecafe.social -wss://mstdn.ca -wss://c.im -wss://mefi.social -wss://basebitcoinplebs.place -wss://med-mastodon.com -wss://ohai.social -wss://defcon.social -wss://pxlmo.com -wss://zlocur7ctbds4qsdswb3qpkp6n2e2ywqne2fp2tdn4fqubpudfiyxwid.local -wss://thebag.social -wss://mstdn.party -wss://relay.serpae.xyz -wss://clew.lol -wss://qoto.org -wss://relay.exchange -wss://nostr.relayable.org -wss://mastodon.coffee -wss://kmy.blue -wss://home.social -wss://detmi.social -wss://brighteon.social -wss://nostr.nom -wss://theblower.au -wss://astral.nostr.land -wss://beige.party -wss://orangepill.dev -wss://redgreenblue.click -wss://nostr.fredix.xyz -wss://nostr.essydns.ca -wss://nostr.private.network -wss://nostr.member.cas -wss://nostr-rely.digitalmob.ro -wss://relay.nostr -wss://relay.current -wss://no-str.or -wss://pl.gamers.exposed -wss://studentchadpolytechnic.com -wss://scicomm.xyz -wss://relay.alien-sos.gov -wss://masto.es -wss://spinster.xyz -wss://parallels-parallels-virtual-platform.local:4848 -wss://relay.daums.io -wss://kolektiva.social -wss://mastodonapp.uk -wss://convo.casa -wss://sfba.social -wss://techhub.social -wss://leafposter.club -wss://nrw.social -wss://mastodon.uno -wss://handon.club -wss://social.vivaldi.net -wss://nostr.goller.net -wss://minazukey.uk -wss://mstdn.beer -wss://expressional.social -wss://paid.nostr.0x50.tech -wss://mstdn.nere9.help -wss://relay.nostr.ai -wss://relay.noswss -wss://relay.nwss -wss://furry.engineer -wss://uselessshit.co -wss://kosmos.social -wss://mynostr.io -wss://premis.one -wss://social.tchncs.de -wss://retro.pizza -wss://pearl-mount-showed-fishing.trycloudflare.com -wss://kpa4k6acxzjv2m2p72keftbpaymwpq2h67jqnin3d4y3djxyheuifoqd.onion -wss://gm7.social -wss://relays.nostr.info -wss://chitter.xyz -wss://hackers.town -wss://anarchism.space -wss://relay.nostrica -wss://halifaxsocial.ca -wss://asimon.org -wss://nostr.blipme.add -wss://troet.cafe -wss://octodon.social -wss://m.cmx.im -wss://filename-ambassador-distance-mountains.trycloudflare.com -wss://nostr.getgle.org -wss://relay.froth.zone -wss://novoa.nagoya -wss://relay.dispute.systems -wss://clubcyberia.co -wss://thechimp.zone -wss://coolsite.win -wss://puravida.nostra.land -wss://shelter.local:1111 -wss://det.social -wss://nostr-2.zebee.cloud -wss://chaosfem.tw -wss://nostr.v01.io -wss://social.kechpaja.com -wss://mastodon.green -wss://plebchain.nostr -wss://plebchain.nostr.land -wss://relay.onsats.org -wss://u5epuanp2fbie4phw6zekzna6zotvsffji4td4ee7iwgwdxlz4kwqqad.onion:5051 -wss://3ddc8bbee6db.ngrok.app -wss://b4968f09859e.ngrok.io -wss://genserver.social -wss://masto.ai -wss://hachyderm.io -wss://shitpost.cloud -wss://oldbytes.space -wss://mastodon.ie -wss://baraag.net -wss://nostr.dvdt.dev -wss://relay.com.de -wss://nostr.rbel.co -wss://bologna.one -wss://nostr-relay.app -wss://kagamisskey.com -wss://nostr-rs-relay.phamthanh.me -wss://nostr.bcmp.com -wss://blogstack.io -wss://rly.nostrkid.com -wss://mastodon.bida.im -wss://freeatlantis.com -wss://pieville.net -wss://climatejustice.rocks -wss://relay.mynostr.id -wss://relay.farscapian -wss://relay.blogstack.io -wss://orwell.fun -wss://misskey.04.si -wss://sushi.ski -wss://nostr.milou.lo -wss://pawoo.net -wss://tooter.social -wss://mastodon.sdf.org -wss://nostr.com -wss://misskey.design -wss://macaw.social -wss://relay.nostr.inforelay.nostr.band -wss://pub1.southflorida.ninja -wss://strawberry-pudding.net -wss://mastodon-japan.net -wss://nostream.0x50.tech -wss://multiplextr.coracle.social -wss://pleroma.skyshanty.xyz -wss://uxxq6b2enojvflhkrzsg4erakd5rrb7v2cql4m4pspj4xtqwyli47rid.local -wss://masto.deoan.org -wss://replay.damus.io -wss://melhorque.com.br -wss://nostr1.actn.io -wss://nostr.bolt.fun -wss://relay.vtbmoyu.com -wss://eosla.comrelay.zeh.app -wss://bikeshed.party -wss://nostr.xanny.family -wss://milker.cafe -wss://nostr.give.africa -wss://relay.got-relayed.com -wss://cum.salon -wss://puravid.nostr.land -wss://soc.punktrash.club -wss://seafoam.space -wss://h4.io -wss://nostr.swiss.enigma.ch -wss://toot.cafe -wss://sneed.social -wss://newsie.social -wss://indg.club -wss://xoxo.zone -wss://relay.nostr.bitcoiner.social -wss://relay.snort.band -wss://socel.net -wss://social.coop -wss://postpandemicparty.org -wss://merveilles.town -wss://search.nostr.wine -wss://search.nos.today -wss://mstdn-huahin.com -wss://eden.nostr.la -wss://nostrja-kari-nip50.heguro.com -wss://relay.gems.xyz -wss://plnetwork.xyz -wss://nostr.swiss-enigma.sh -wss://geofront.rocks -wss://toot.io -wss://indieweb.social -wss://mastodon.content.town -wss://awaymessage.club -wss://relay.intify.io -wss://mstdn.science -wss://maniakey.com -wss://otadon.com -wss://mstdn.guru -wss://misskey.noellabo.jp -wss://hessen.social -wss://mastodon.top -wss://ipv6.nostr.wirednet.jp -wss://relay.nostr-relay.org -wss://cum.camp -wss://nebbia.fail -wss://current.fyi -wss://bae.st -wss://lolison.network -wss://ioc.exchange -wss://bylines.social -wss://decayable.ink -wss://nostr.unitedserializer.com -wss://urbanists.social -wss://dark-elves.social -wss://writing.exchange -wss://nostr.rikmeijer.nl -wss://misskey.social -wss://ht.nixre.net -wss://mathstodon.xyz -wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.local -wss://abla.news -wss://muenchen.social -wss://homeserver.drake-carp.ts.net:4848 -wss://snailedit.social -wss://mastodon.gamedev.place -wss://tech.lgbt -wss://mast.lat -wss://econtwitter.net -wss://veganism.social -wss://btclolap6mm4tl37huslk6j76enq7qxaj2kwq7w6cdr5ros56eletcqd.onion -wss://toot.cat -wss://nostr.zenon.info -wss://misskey.art -wss://nostr.hushvault.ie:4848 -wss://o6ga6lxnax2z7pgkkenifollohkrv55r36mdzdtofi7d5yyif2f4o5yd.onion:5051 -wss://20nostr-pub.wellorder.net -wss://ecoevo.social -wss://nostr.zerofiat.world -wss://nostr.atitlan.io -wss://detroitriotcity.com -wss://rollenspiel.social -wss://paquita.masto.host -wss://literatur.social -wss://dave.st.germa.in -wss://sunny.garden -wss://nostrpro.xyz -wss://relay.getalby.com -wss://misskey.systems -wss://mamot.fr -wss://social.anoxinon.de -wss://relay.nostr.social -wss://mindmachine.org -wss://microblog.club -wss://relay.utxo.com -wss://universe.nostrich.landlangth -wss://social.teci.world -wss://social.freetalklive.com -wss://mk.absturztau.be -wss://social.ornella.xyz -wss://left-tusk.com -wss://bofh.social -wss://a11y.social -wss://shroomslab.net -wss://relay2.nostr.vet -wss://mugicha.club -wss://laserbeak.local:4848 -wss://annihilation.social -wss://humble.cafe -wss://nostr.relay.damus.io -wss://nostr.milol.lol -wss://nostr.blimpme.app -wss://noc.social -wss://wetdry.world -wss://nostr.taxi -wss://nostr.21-bitcoin.org -wss://relay.llevotu-bitcoiners.info -wss://pl.kitsunemimi.club -wss://djsumdog.com -wss://citadel.local:4848 -wss://federate.blogpocket.com -wss://chaos.social -wss://mastodon.me.uk -wss://oxtr.dev -wss://misskey.cloud -wss://varishangout.net -wss://lacosanostr.com -wss://willem.currycash.net:4848 -wss://lightniningrelay.com -wss://queer.party -wss://lightning.relay.com -wss://mastodon.nl -wss://purplepag.es -wss://cupoftea.social -wss://bitcoiner.socialwss -wss://relay.current.io -wss://sigmoid.social -wss://wandering.shop -wss://braydmedia.de -wss://quey.la -wss://nostr.zebeedee.cloud -wss://nostr-2.zebeedee.cloud -wss://artsio.com -wss://nostr1676031941328.app.runonflux.io -wss://arsip.ddns.net -wss://relay.nostrcitadel.org -wss://relay.nostr-citadel.org -wss://nostr-citadel.org -wss://blastr.f7z.io -wss://nostr.myowndamnnode.com -wss://snowdin.town -wss://nostr.zxcvbn.space -wss://relay.notmandatory.org -wss://bitcoin.social -wss://friendsofdesoto.social -wss://zirk.us -wss://digipres.club -wss://nostr-test.elastos.io -wss://nostr.onsat.org -wss://damus.relay.io -wss://beefyboys.win -wss://nostrija-kari.heguro.com -wss://froth.zone -wss://ns.penseer.com -wss://relay.nostrical.com -wss://nostr.hoshizora.ch -wss://kunigaku.github.io -wss://nostr.shino3.net -wss://umbrel.tailbb128.ts.net:4848 -wss://tailbb128.ts.net:4848 -wss://fedi.twoshortplanks.com -wss://filter.nostr.winebroadcasttrue -wss://nostr.doufu-tech.com -wss://relay.hodl.haus -wss://pgh.social -wss://coeditor-congested.fractalnetworks.co -wss://mastodon.podaboutli.st -wss://frenfiverse.net -wss://nostr.f4255529.fun -wss://nostream.megadope.snowinning -wss://kiritan.work -wss://forever21.lol -wss://zitron.net -wss://mastodon-swiss.org -wss://relay1.current.fyi -wss://nostr.plebs.com -wss://multiplextr.corocal.social -wss://nostr.zedebee.cloud -wss://nostr.mutinywallet.comisntbanned.addthatonesotheycangetnotesrelayedtotherestofthenetworkfrominsideofchina -wss://mastodon.mit.edu -wss://relaynostrati.com -wss://relaynostr.band -wss://relaynostr.info -wss://relaychenxixian.cn -wss://relaysnort.social -wss://relaynostr.com.au -wss://nostr-tbd.website -wss://relay.hoshizora.ch -wss://social.balsillie.net -wss://strangeobject.space -wss://relay.nvote20.co -wss://fla.red -wss://urusai.social -wss://chad.polytechnic.com -wss://robo358.com -wss://conxole.io -wss://relay.ohbe.me -wss://nostr.bitcoiner.com -wss://www.mutinywallet.com -wss://x.9600.link:10070 -wss://a11y.info -wss://homelab.host -wss://k65qz57zx4sw24fow2bvchjgmpjqljj4cp3oa7dtpbbprjifsdotggid.local -wss://relay.vtuber.directory -wss://x.9600.link:8000 -wss://nostr.plebs.win -wss://renkontu.com -wss://nost.massmux.com -wss://submarin.online -wss://social.librem.one -wss://relai.kongerik.et -wss://mstdn.io -wss://astral.swiss-enigma.ch -wss://stereophonic.space -wss://relayer.pleb.social -wss://mastodon.nz -wss://nostr.nostr.de -wss://nostr.thezap.club -wss://proxy.shroomslab.net -wss://pfr24mrpxowclhm4y6adu36kbo3erx7gskzyfqqhnhfpdkdmylpfgkyd.local -wss://nostr.hodl.haus -wss://vtdon.com -wss://relay.nost.band -wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.onion -wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.local -wss://md.hugo.nostr -wss://boks.moe -wss://truthsocial.co.in -wss://mastodon.xyz -wss://relay.universalname.space -wss://relay-nostr.wirednet.jp -wss://fedi.pawlicker.com -wss://favcalc.com -wss://rot13maxi.com -wss://awayuki.net -wss://mazinkhoury.com -wss://g0v.social -wss://shpposter.club -wss://a.lufimianet.jp -wss://pura20vida.nostr.land -wss://sotalive.net -wss://sendsats.lol -wss://vida.page -wss://wagvwfrdrikrqzp7h3b5lwl6btyuttu7mqpeji35ljzq36ovzgjhsfqd.onion -wss://uec2cmjauzufrtlq6wq6l2ujfncdvo3suezz423gsvz5xvhehm2mcgid.onion -wss://mastodon-belgium.be -wss://rejecttheframe.xyz -wss://nogood.store -wss://getaiby.com -wss://plebchain.club -wss://nostrverified.com -wss://x.9600.link -wss://n0p0.shroomslab.net -wss://orangemakura.xyz -wss://jamw.net -wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.local -wss://snabelen.no -wss://floss.social -wss://mastoot.fr -wss://kmc-nostr.amiunderwater.com -wss://hodl.camp -wss://xmr.usenostr.com -wss://nostr-pub.wellorn.net -wss://gudako.net -wss://ryona.agency -wss://kafeneio.social -wss://massmux.com -wss://freezepeach.online -wss://nostream-production-f83d.up.railway.app -wss://taobox.pub -wss://mastodon.radio -wss://einundzwanzig.relay.com -wss://journa.host -wss://social.here.blue -wss://toot.wales -wss://staging.nostr.com.se -wss://pleroma.soykaf.com -wss://berserker.town -wss://zapforart.site -wss://forall.social -wss://nostrja-kari.heguro.comyee -wss://higheredweb.social -wss://social.gnuhacker.org -wss://the.hodl.haus -wss://ng4jk6yiqgfczo4wyxszuj7w6jok3fptehu533o3mlzs3vph3dvjfdid.onion -wss://nostr.mtpx.ovh -wss://relay.coollamer.com -wss://glasgow.social -wss://fedi.absturztau.be -wss://nostr.frostr.xyz -wss://relay.runningnostr.lol -wss://relay2.vtuber.directory -wss://nerdculture.de -wss://nostr-relay.untethr.meoperator -wss://nostr.verif-slothy.win -wss://nostr.pub -wss://social.process-one.net -wss://invillage-outvillage.com -wss://otofu.uk -wss://universe.nostrich.landlangenlangpt -wss://mastodon.iriseden.eu -wss://frighteningdeafeningagent.nailuogg.repl.co -wss://obo.sh -wss://relay.hodlhaus.net -wss://rdrama.cc -wss://nostr.packetlostandfound.us -wss://test.relay -wss://mastodonbooks.net -wss://southflorida.ninja -wss://blob.cat -wss://tooting.ch -wss://relay.pineapple.pizza -wss://relay.nostr.directory -wss://relar.nostr.bg -wss://reisen.church -wss://relay.honk.pub -wss://nostr.rsfriedl.com -wss://relay.nort.social -wss://1611.social -wss://the.hodl.house -wss://relayable.com -wss://fedi.syspxl.xyz -wss://nostrpub.yeghro.site -wss://skr5bbrgzfnideglw4cs2iw6au2jm2b7gupocxbmkv5qopo6rcitmiqd.local -wss://a2mi.social -wss://status.relayable.org -wss://toot.blue -wss://btcqspp5dl4rlgl5pomcyv3odfeki7a5zrmjoekyu5vsoqz5bth4e7yd.onion -wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion -wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.onion -wss://dnze4ekho2kuiejwatjw5omeprtmdaum2ukok52roiu5rztii3rp2aid.onion -wss://53snncs7vegargpaardbxjnii2oan3xpmbeaf6czwoqa2axz5mvbsjid.onion -wss://fnqdhz3df33da6wxg7jskvumd5rjn3nknln6ecun7uwwysc7vkwkjgid.onion -wss://relay.thefockinfury.wtf -wss://silliness.observer -wss://freak.university -wss://piaille.fr -wss://nostr.weking.tk -wss://f.reun.de -wss://radixrat.com -wss://hostux.social -wss://chat.freenode.net -wss://no.str..cr -wss://nostr.inoata.cc -wss://kappa.seijin.jp -wss://floyds.io -wss://mast.dragon-fly.club -wss://zbd.ai -wss://misc.name -wss://pdx.social -wss://0w0.is -wss://d6jvu2tev2rblkuzgu4ydw2413jizr53j26ut47hxpykvtusbvekhiid.onion -wss://d6jvu2tcv2rblkuzgu4ydw24l3jizr53j26ut47hxpykvtusbvekhiid.onion -wss://drcassone.social -wss://mastodon.energy -wss://rayci.st -wss://ischool.social -wss://nostr.halfway2forever.com -wss://relay.nostr.rocks -wss://nostr.stoner.com -wss://2nodez.com -wss://rs.nostr-x.com -wss://schleuss.online -wss://thrashzone.org -wss://relay.nostrgraph.com -wss://relay.2nodez.com -wss://occult-zuki.com -wss://mastodon.lithium03.info -wss://bigbadpc.local:4848 -wss://social.gr0k.net -wss://nostr.wirednet.jp -wss://relay.plebster.com -wss://bitcoin.nostr -wss://mastodon.im -wss://brb..io -wss://nostr.sandwhich.farm -wss://relay.nos.lol -wss://beta.nostr.v0l.io -wss://eden.nostr.space -wss://powerlay.xyz -wss://rap.social -wss://relay.mutinywallet.com -wss://rogue.earth -wss://600.wtf -wss://klabo.blog -wss://petrikajander.com -wss://tgkzmdd.help -wss://nostr.red -wss://brb.lol -wss://jz2l2bf6f6wssdqwkg7ogthkc5i3ymyiwkaz3tbhff6ro3h3zqddekyd.onion -wss://mstdn.mini4wd-engineer.com -wss://nostr.exposd -wss://nostr.a-ef.org -wss://computerfairi.es -wss://social.targaryen.house -wss://o3o.ca -wss://walkah.social -wss://cosocial.ca -wss://filter.nostr.band -wss://relais.nostrview.com -wss://gigaohm.bio -wss://dobbs.town -wss://bark.lgbt -wss://mastodon.gal -wss://snug.moe -wss://genomic.social -wss://relay.orangepilldev.com -wss://social.sdf.org -wss://social.camph.net -wss://mstdn.poyo.me -wss://nein.lol -wss://nostr.i00.org -wss://kemono.ink -wss://mu.zaitcev.nu -wss://libera.site -wss://ca.hibi-tsumo.com -wss://social.bund.de -wss://xscape.top -wss://social.lol -wss://birds.town -wss://arnostr.com -wss://nostr.33co.de -wss://relay.nostr.lighting -wss://metadata-contacts-relays.pages.dev -wss://webs.node9.org -wss://pleroma.elementality.org -wss://suya.place -wss://livellosegreto.it -wss://peoplemaking.games -wss://nattois.life -wss://typo.social -wss://neutrine.com -wss://ragner-relay.com -wss://wss.nostr.uselessshit.co -wss://wss.nostrue.com -wss://relay.nvote.co:433 -wss://disobey.net -wss://rneetup.com -wss://arnostr.com:8433 -wss://relay.uxto.one -wss://relay.hackerman.pro -wss://thisis.mylegendary.quest -wss://poliversity.it -wss://sats.lnaddy.com -wss://rs2.abaiba.top -wss://rs1.abaiba.top -wss://rs2.abaiba.top.abaiba.top -wss://social.matarillo.com -wss://nostr01.counterclockwise.io -wss://backup.local:4848 -wss://touhou.vodka -wss://mi-wo.site -wss://nostr.f7z.io -wss://alive.bar -wss://strfry.nostr-x.com -wss://mastodontti.fi -wss://nostr.wellorder.net -wss://y.9600.link:8000 -wss://ephemrelay.mostr.pub -wss://byc-italia.online -wss://fissionator.com -wss://stranger.social -wss://eupolicy.social -wss://nostr-desktop.local:4848 -wss://aoir.social -wss://mstdn.plus -wss://nostrproxy.io:3333 -wss://mastodon.hams.social -wss://jorts.horse -wss://metalhead.club -wss://dice.camp -wss://mstdn.y-zu.org -wss://loffchain.pub -wss://mastodon.llarian.net -wss://nostr-relay2.thefockinfury.wtf -wss://2g2jzcfgq5lcrceuq23lmya2drm3ku5qmqimr3bvu3amol55vidctrad.onion -wss://mastodon.au -wss://bgme.me -wss://nostr.badran.xyz -wss://nostr.coincreek.com -wss://nostream-test.up.railway.app -wss://relay.blackthunder.click -wss://relay.grorp.com -wss://atomicpoet.org -wss://iddqd.social -wss://gusto.masto.host -wss://lifehack.social -wss://blorbo.social -wss://freecumextremist.com -wss://13bells.com -wss://rsslay.nostr.netrelay -wss://relay.zerosequioso.com -wss://nauka-relay.herokuapp.com -wss://nostr.nofdeofsven.com -wss://out.of.milk -wss://cawfee.club -wss://r.relay.fan -wss://alo.ottonove891.cf -wss://keinoha.tailnet-0240.ts.net -wss://nostr.paralelnipolis.cz -wss://tuiter.rocks -wss://elizur.me -wss://nostr-dev.newstr.io -wss://discuss.systems -wss://blahaj.zone -wss://mastodon.art -wss://makersocial.online -wss://gamepad.club -wss://nostr.flameofsoul.ru -wss://dmv.community -wss://relay.nostr.com -wss://nostr-desktop.saiga-shark.ts.net:4848 -wss://nostrfoxden.ddns.net:4848 -wss://soc.umrath.net -wss://ravenation.club -wss://oisaur.com -wss://nostr-relay.net -wss://social.mikutter.hachune.net -wss://lewacki.space -wss://fediscience.org -wss://todon.eu -wss://nuccy-nuc7i5bnk.local:4848 -wss://games.gamertron.net:4848 -wss://fiedlerfamily.net -wss://postnstuffds.lol -wss://nostr.planetary.social -wss://worldkey.io -wss://hcommons.social -wss://gymp7qquljs47xbbvs47hkptnyyzegy2jkst26mkjxaciifqffjatqid.onion -wss://rs3.abaiba.top -wss://sudo-nostr.com -wss://satgag.site -wss://nostr.lnbitcoin.cz -wss://relay20nostrplebs.com -wss://2pbkpndvpeebljfvjew6auq63lndzszqnntct5aqfmazslerzxe75kad.onion -wss://dragonchat.org -wss://welcome.nostr.wine -wss://relay.nostr.land -wss://social.linux.pizza -wss://dnppj4kopczovvzvpzmihv2iwe5wt3gbrxjnltjc2zdjpttrdz4owpad.onion -wss://potofu.me -wss://nostrbr.online -wss://mastorol.es -wss://notebook.taild34d0.ts.net -wss://t.aqn.jp -wss://nostr.mutinywallet -wss://wcone.nostr.wine -wss://norden.social -wss://eostagram.com -wss://shigusegubu.club -wss://toot.jkiviluoto.fi -wss://kiwifarms.cc -wss://swiss-talk.net -wss://v532btfg2fb4za2g476a7w23pgpkllc7uq274wqtktwjogt5ynb3ukqd.local -wss://mstdn.maud.io -wss://arc1.arcadelabs.com -wss://nostr.jp -wss://relay-jp.nostr.wirrdnet.jp -wss://climatejustice.social -wss://witter.cz -wss://mastodon.pnpde.social -wss://ttrpg-hangout.social -wss://beehaw.org -wss://thecanadian.social -wss://nostr.fbxl.net -wss://relay.sandwich.farm -wss://nostr.olwe.link -wss://botsin.space -wss://zeroes.ca -wss://photog.social -wss://paid.nostr.lc -wss://free.nostr.lc -wss://test.nostr.lc -wss://gzanlkgurj7zd3psqms3da4vrw4imurnyyzaycfuiiug7elqow7xlayd.onion:5051 -wss://masto.nu -wss://mastodon.uy -wss://bit.relay.center -wss://offchain.relay.center -wss://damus.relay.center -wss://wine.relay.center -wss://eden.relay.center -wss://moth.social -wss://nostr.masmux.com -wss://chrome.pl -wss://mastodon.ktachibana.party -wss://ak.kawen.space -wss://mementomori.social -wss://relay.s3x.social -wss://lnbits.michaelantonfischer.com -wss://yof23ggqmert72c5wcl5qglphapy3o2xjdedtkbrn2dt5rbae2s7f6qd.onion -wss://relay.snort.socail -wss://post.lurk.org -wss://yiff.life -wss://q3zaylwjjhq77yzx34lbydz26szzjljberwetkjgxgsapcekrpjzsmqd.onion -wss://lnbits.b1tco1n.org -wss://welcome.nostr.relay -wss://sound-money-relay.denizenid.com -wss://carnivore-diet-relay.denizenid.com -wss://africa.nostr.joburg -wss://nostr.jolt.run -wss://nostr.chainbits.co.uk -wss://ithurtswhenip.ee -wss://nostr.cloudversia.com -wss://relay1.east.us.nostr.btron.io -wss://ca.orangepill.dev -wss://pdx.land -wss://linh.social -wss://okla.social -wss://androiddev.social -wss://spore.social -wss://mastodo.fi -wss://kabedon.space -wss://nost.inosta.cc -wss://relay2cdamus.io -wss://nostr.openhoofd.nl -wss://dragonscave.space -wss://genart.social -wss://dewp.space -wss://layer8.space -wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion:5051 -wss://nostr.wines -wss://relay.snort.relay.ryzizub.com -wss://pixelfed.de -wss://nostr.holyscapegoat.com -wss://nostr.einunzwanzig.space -wss://nostr.hifish.org -wss://colearn.social -wss://topspicy.social -wss://mastodon.neat.computer -wss://relay.nostr.hach.re -wss://nostr.dakukitsune.ca -wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.onion -wss://esq.social -wss://famichiki.jp -wss://tribe.net -wss://masto.nobigtech.es -wss://umbrel-nuc.local:4848 -wss://mastodon.cocoasamurai.social -wss://debian.taildd32b.ts.net:4848 -wss://vlt.ge -wss://relay.johnnyasantos.com -wss://snort.relay.center -wss://nb.relay.center -wss://waag.social -wss://concentrical.com -wss://stat.rocks -wss://oransns.com -wss://relay-jpp.nostr.wirednet.jp -wss://oc.todon.fr -wss://jundow.gitlab.io -wss://neurodifferent.me -wss://jazztodon.com -wss://nostrich.friendship -wss://indieauthors.social -wss://werunbtc.com -wss://frogtalk.lol -wss://pop-os.local:4848 -wss://fediver.de -wss://d6qxo55dhms6revgrmbindvb5ejd3gw5hrji7ylkm6khghii3hjs3uyd.onion -wss://eldritch.cafe -wss://karlsruhe-social.de -wss://social.yl.ms -wss://nostr.mycloudhouse.duckdns.org -wss://nostr.otc.sh -wss://nya.social -wss://relay2.nostrchat.io -wss://relay1.nostrchat.io -wss://nostrja-world-relays-test.heguro.com -wss://ndk-relay.local -wss://reespeech.casa -wss://pleroma.atyh.cc -wss://lawfedi.blue -wss://akkoma.jasminetea.uk -wss://peeledoffmy.skin -wss://plush.city -wss://astrodon.social -wss://samenet.social -wss://toot.bike -wss://mi.yukioke.com -wss://social.growyourown.services -wss://mastodon.nu -wss://lou.lt -wss://functional.cafe -wss://relaydamus.io -wss://coma.social -wss://social.fbxl.net -wss://biplus.social -wss://toots.matapacos.dog -wss://psychoet.ml:3250 -wss://danserver.equipment -wss://nostr.lacrypta.com.ar -wss://wonkodon.com -wss://nostr.seankibler.com -wss://autistics.life -wss://cambrian.social -wss://rvqkqr5kl3dvvxyn67rfowcnvoflx4zby5tjbysavym4ycckti4dbjyd.onion -wss://swiss.nostr.lc -wss://snac.saifulh.online -wss://loma.ml -wss://nostr.privoxy.io -wss://366.koyomi.online -wss://mastodon.stormy178.com -wss://podcastindex.social -wss://bitcoiner.socia -wss://fedi.ml -wss://replayable.org -wss://nostr.schroomslab.net -wss://nostr.global.fans -wss://relay.weedstr.net -wss://vocalodon.net -wss://relay.nostr.bandadd -wss://nostr.oxtr.devadd -wss://jarvis.taild68e2.ts.net:4848 -wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.onion -wss://stonez.local:4848 -wss://notrustverify.ch -wss://woof.group -wss://mastodon.floe.earth -wss://lnbits.plebtag.com -wss://kpop.social -wss://relay.wavlake.com -wss://mastodon.sharma.io -wss://travelpandas.fr -wss://alphapanda.prowss -wss://relay.saes.io -wss://barelysocial.org -wss://masto.komintern.work -wss://norcal.social -wss://nostr.zbd.gg -wss://mk.outv.im -wss://mstdn.mx -wss://col.social -wss://nostr.freedom.fi -wss://filter.nostr.winebroadcasttrueglobalall -wss://eden.nostr.landv -wss://me.dm -wss://emacs.ch -wss://winonostr.wine -wss://infoplebstr.com -wss://mas.towss -wss://gruene.social -wss://relay.freeplace.nl -wss://itis.to -wss://bsky.social -wss://lnbits.thefockinfury.wtf -wss://5xxkt7zvmh4zdsjw64lgvchjdlrrgw4w2huujiiud35qms6gnkn5azad.onion -wss://eientei.org -wss://artisan.chat -wss://nustr.mom -wss://relay.nostrhraph.net -wss://shitposter.club -wss://nostril.cam -wss://nostr.spaceshell.xyz -wss://relay.wtr.app -wss://tdd.social -wss://d3meec25b53kegrnjmtmtyynikbkmuxf4jqgtk3sonjs6e62hpaezyqd.onion -wss://tkz.one -wss://freerelay.xyz -wss://nfdn.betanet.dotalgo.io -wss://mitra.social -wss://hablanews.io -wss://calle.wtf -wss://voskey.icalo.net -wss://nostr.sloyhy.win -wss://framapiaf.org -wss://nostrnodeofsven.com -wss://ciberlandia.pt -wss://gnusocial.net -wss://relay.s3x.socia -wss://paste.2nodez.com -wss://union.place -wss://bofh.socia -wss://sovbit.dev -wss://lnbits.btc-payserver.eu -wss://osna.social -wss://im-in.space -wss://junxingwang.org -wss://relay.nostr.wirednet.jpcheck -wss://libranet.de -wss://fedisnap.com -wss://woodpecker.social -wss://gensokyo.town -wss://social.imirhil.fr -wss://links.potsda.mn -wss://law-and-politics.online -wss://nostrich.bar -wss://tweesecake.social -wss://nostr.kisiel.net.pl -wss://relay.kisiel.net.pl -wss://calckey.social -wss://nostr.cercatrowa.me -wss://meganekeesu.tokyo -wss://lndiscs.duckdns.org -wss://omochi.xyz -wss://wue.social -wss://nostr.libreleaf.com -wss://rusnak.io -wss://rsslay-production.up.railway.app -wss://nostr.yuhr.org -wss://bod4ojj37fneith2setv3qjbii563wesbjqgdipdz4ag6voic2xk5iad.onion -wss://fediverse.blog -wss://pouet.chapril.org -wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.local -wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.onion -wss://cryptodon.lol -wss://nostr.debancariser.com -wss://mizunashi.hostdon.ne.jp -wss://relay.deezy.io -wss://bbq.snoot.com -wss://historians.social -wss://mi.mashiro.site -wss://mastodonpost.social -wss://nostrpub.welliorder.net -wss://social.cologne -wss://metapixl.com -wss://wandzeitung.xyz -wss://lightninhrelay.com -wss://techopolis.social -wss://lnbits.btcpins.com -wss://purplenostrich.com -wss://onewilshire.la -wss://sself.co -wss://anygemini13.blogs.sapo.pt -wss://federated.press -wss://metadata.nostr.com -wss://nostr.hodl.ar -wss://goreslut.xyz -wss://mastodon.com.tr -wss://climatejustice.global -wss://brotka.st -wss://sueden.social -wss://mstdn.fr -wss://abid.cc -wss://lnbits.fuckedbitcoin.com -wss://meow.social -wss://nostr.rehab -wss://mstdn.media -wss://nodeo1.nostress.cc -wss://nostrmassmux.com -wss://sauropods.win -wss://civilians.social -wss://pnw.zone -wss://zebeedee.cloud -wss://nostr.walletofsatishi.com -wss://aufovmqaxj5nhqmtorhgpogdjxefhkff25cbyyjt2sub3vwg6b6rplid.onion -wss://forfuture.social -wss://76f67qcwxsxpz7cfozlzunota2ejqznpldc5pnqtyq233hjpjzrmlfid.local -wss://toot.garden -wss://umbrel.tail9dfb.ts.net:4848 -wss://mastodon.chasem.dev -wss://misskey.pm -wss://merovingian.club -wss://chirp.enworld.org -wss://paid.no.str.ce -wss://masto.bike -wss://masto.1146.nohost.me -wss://eosla.comno-str.orgrelay.zeh.appno-str.orgrelay.zeh.app -wss://nixnet.social -wss://pay.zapit.live -wss://respublicae.eu -wss://nekomiya.net -wss://mastodon.internet-czas-dzialac.pl -wss://plushies.social -wss://lnb.openchain.fr -wss://mastodon.lol -wss://social.rebellion.global -wss://ruhr.social -wss://mi.farland.world -wss://pkutalk.com -wss://systemli.social -wss://nostr.minimue81.selfhost.com -wss://mastodon.la -wss://everything.happens.horse -wss://pagan.plus -wss://clacks.link -wss://u-tokyo.social -wss://fediverse.projectftm.com -wss://mastodon.bachgau.social -wss://social.kabi.tk -wss://ty3zdjkwlxo4zah6tgdoolznjcbvkhxpcvjyqe2buxeg23hbeyvr3rad.local -wss://pleroma.wakuwakup.net -wss://social.horrorhub.club -wss://nostr.nightowlstudios.ca -wss://mastodon.ml -wss://relay.orangepillapp.com -wss://misskey.sup39.dev -wss://mfmf.club -wss://pokemon.mastportal.info -wss://gohan-oisii.net -wss://aipi.social -wss://nostr.semisol.com -wss://oslo.town -wss://relay.layer.systems -wss://naharia.net -wss://social.elbespace.de -wss://linuxrocks.online -wss://b81m3pf94ridtry53g8ufyyrjtjaoxgbyjbs5k8qrqkr1whocxiy.loki:8080 -wss://lvl01.tater.ninja -wss://kinky.business -wss://relay.bitblockboom.com -wss://fedi.omada.cafe -wss://social.secret-wg.org -wss://celebrity.social -wss://weirdo.network -wss://mastodon.design -wss://berlin.social -wss://misskey.yukineko.me -wss://mindmachine.688.org -wss://sackheads.social -wss://a.farook.org -wss://social.ridetrans.it -wss://nostr-2.crypticthreadz.com -wss://test.itas.li -wss://fashionsocial.host -wss://ordinary.cafe -wss://social.arinbasu.online -wss://nostr.crypticthreadz.com -wss://relay.zebedee.cloud -wss://nostr.pub.wellorder.net -wss://09d4-5-161-189-144.ngrok-free.app -wss://andalucia.social -wss://udongein.xyz -wss://squeet.me -wss://mastodon.org.uk -wss://guild.pmdcollab.org -wss://relay.fi -wss://black.nostrscity.club -wss://relay.darker.to -wss://denostr.paiya.app -wss://noste.lu.ke -wss://wss.node01.nostress.cc -wss://theverge.space -wss://swiss.social -wss://relay.webstr.org -wss://nostr.shsbt.xyz -wss://relay.nostr.mom -wss://bitcoinmaximlaists.online -wss://relay.openhoofd.nl -wss://toot.aquilenet.fr -wss://toot.ale.gd -wss://relay.devstr.org -wss://lounge.town -wss://amala.schwartzwelt.xyz -wss://planetasieve.com.br -wss://alcrypt.ru:20911 -wss://webzero.grin.plus:8080 -wss://pylons.lightlns.com:28556 -wss://etourneau.fr:28343 -wss://sentie.relay.rts.network -wss://hermes.boarstudios.com -wss://non-central.pw -wss://nostrum.casa -wss://press.coop -wss://neovibe.app -wss://mstdn.starnix.network -wss://nostr.ameristraliagov.com -wss://cryptodon.chat -wss://umbrell.local:4848 -wss://mastodon.codingfield.com -wss://fe.disroot.org -wss://national.catposting.agency -wss://mastodon.pinewoodroad.net -wss://podcasts.social -wss://20nostr.semisol.dev -wss://nostr.oxtr.net -wss://mstdn.o-nature-culture.net -wss://dearcoati6.lnbits.com -wss://die-partei.social -wss://donotban.com -wss://creative.ai -wss://metaskey.net -wss://spacey.space -wss://node01.nostreess.cc -wss://node01.nostress.co -wss://niscii.xyz -wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.onion -wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.local -wss://sportsbots.xyz -wss://videos.lukesmith.xyz -wss://nostr.btcfreedom.ca -wss://gardenstate.social -wss://bg-btc.local:4848 -wss://0fa53e299287.ngrok.app -wss://bird.makeup -wss://nlayer.lbdev.fun -wss://relay.queiroz.vip -wss://mstdn.business -wss://osage.moe -wss://botrelay.com -wss://filter.wine -wss://gingadon.com -wss://noncentral.pw -wss://honi.club -wss://xn--baw-joa.social -wss://nostr.semisol.devwss -wss://relay.iris.to -wss://mynostrrelay.deno.dev -wss://social.heise.de -wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5051 -wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5050 -wss://relao.nostr.bg -wss://phpc.social -wss://mastodon.kylerank.in -wss://gameliberty.club -wss://rot.gives -wss://www.nostrweb.xyz -wss://ligma.pro -wss://mastodon.grin.hu -wss://geeknews.chat -wss://devdilettante.com -wss://relay.nosr-latam.link -wss://nosr.bitcoiner.social -wss://raru.re -wss://create-key.net -wss://lgbtqia.space -wss://fluffy.family -wss://mas.town -wss://bird.froth.zone -wss://akkoma.cryptoschizo.club -wss://relay.ramus.io -wss://40two.site -wss://relay.40two.site -wss://vis.social -wss://mk.paritybit.ca -wss://nyan.network -wss://ln.weedstr.net -wss://wikis.world -wss://social.fringe.com -wss://umbraxenu.no-ip.biz -wss://heads.social -wss://tsqdakwo4dh5ej3llsi52ftxfbialteu3jm4cmvxaksl3psbyeoyxxqd.onion -wss://jameliris.to -wss://mastodon.thirring.org -wss://miniwa.moe -wss://welcom.nostr.wine -wss://nostr.vulpem.comwss -wss://relay.semisol.dev -wss://kitsunes.club -wss://songbird.cloud -wss://nostr.wyssblitz.org -wss://libera.tokyo -wss://trpger.us -wss://comam.es -wss://nostr-pub.semisol.devaddittoyourrel -wss://social.dev-wiki.de -wss://ostfrie.se -wss://darmstadt.social -wss://nostr.cx.ms -wss://alentours.cc -wss://nostr.kleofash.eu -wss://gib.social -wss://test23.hifish.org -wss://rapemeat.solutions -wss://filter.stealth.winebroadcasttrue -wss://nostr.montre -wss://grumble.social -wss://nostr.roli.social -wss://primarycare.app -wss://hodlr.rocks -wss://superlinks.me -wss://mastodon.lawprofs.org -wss://tictoc.social -wss://nostest.dojotunnel.online -wss://kafka.icu -wss://nostr.lanparty.one -wss://filter.nostr.wineglobaltrue -wss://social.b10m.net -wss://worm.pink -wss://nostr-test.cx.ms -wss://nostrverifired.com -wss://relay.nostrss.re -wss://eliitin-some.fi -wss://dju.social -wss://jeremy.hu -wss://stream.criminallycute.fi -wss://nostr.0x50.dev -wss://n.s.nyc -wss://n.8.s.nyc -wss://relay.rocks -wss://relay.fiatjaf.com -wss://n-lan.s.nyc -wss://sciences.social -wss://nostr.swiss-enigma.com -wss://quietplace.xyz -wss://universe.nostrich.landlangenlangzh -wss://5280.city -wss://feddit.de -wss://base.lc -wss://social.medusmedia.com -wss://umha4zl6xk62a4dous6e7tq4qlmt462hlzs2su33en6qrvtvs3hkjgid.onion -wss://etorneau.fr:28343 -wss://foggyminds.com -wss://neurodiversity-in.au -wss://nostr.hendrixson.net -wss://ramen-fsm.eu.org -wss://www.nostrical.com -wss://feedbeat.me -wss://relay.bsky.social -wss://babka.social -wss://mastodontech.de -wss://commiespace.duckdns.org -wss://openbiblio.social -wss://karkatdyinginagluetrap.com -wss://relay.fundr.vanderwarker.family -wss://hannover.town -wss://nostr.relay.info -wss://mastodon.tetaneutral.net -wss://cache2.primal.net -wss://nostr.petrkr.net -wss://ifwo.eu -wss://mastodong.lol -wss://venera.social -wss://wallets.fyoumoneypod.com -wss://nostr.relay-nokotaro.com -wss://social.exozy.me -wss://gqgjp2bun4opme6mepz3rrgprkw4xatb6h5ogayorqwi6sajsxcp5sad.local -wss://branle.netlify.app -wss://rsslay.fiat.jaf -wss://chrislace.damus.io -wss://relay.leafbodhi.com -wss://social.opendesktop.org -wss://relay.nostr.watch -wss://gearlandia.haus -wss://freiburg.social -wss://atlas.nostro.land -wss://nostr.io -wss://patrizio.tn.al -wss://chaintools.io -wss://kn.icu -wss://relaywithme.eu -wss://la-autopilot-this-end-up.dvm.email -wss://coinfinity.co -wss://assemblag.es -wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion -wss://indigenouscreatives.social -wss://bookwyrm.social -wss://kokoro.shugetsu.space -wss://eden.nost.land -wss://misskey.gothloli.club -wss://sersleepy.com -wss://rsslay.nos.pink -wss://pettingzoo.co -wss://witches.live -wss://7craxnzfi42touzi23etut5qjzqro27sqcuottxj7opntcin4fstruad.onion -wss://mastodonsweden.se -wss://mv2k.com -wss://nostr.tchaicap.space -wss://relay.whoop.ph -wss://bitcoiner.nostr.social -wss://kinkyelephant.com -wss://www.superstork.org -wss://mastodon.iftas.org -wss://lea.pet -wss://zug.network -wss://homeserver.local:4848 -wss://frontrange.co -wss://sciencemastodon.com -wss://montereybay.social -wss://social.securecryptomining.com -wss://rssrelay.nostr.moe -wss://nostr.org -wss://nostr.tw -wss://nostr.hk -wss://wxw.moe -wss://mastodonmusic.social -wss://puntarella.party -wss://oyasumi.space -wss://drumstodon.net -wss://social.wikimedia.de -wss://iyasaretai.pw -wss://social.coletivos.org -wss://nostream.localtest.me -wss://ubuntu201.local:4848 -wss://masto.pt -wss://taiwan.riley-tech.net -wss://freeradical.zone -wss://blastrf7z.xyz -wss://onemorestop.photo -wss://frikiverse.zone -wss://toot.bldrweb.org -wss://electroverse.tech -wss://mstdn.games -wss://relay1.nostr.unitedfop.com -wss://gratefuldread.masto.host -wss://mk.gabe.rocks -wss://widerweb.org -wss://mastodon.eternalaugust.com -wss://lay.southeastasia.cloudapp.azure.com:445 -wss://me.ns.ci -wss://gnostr.th -wss://fritter.cn -wss://nex.cn -wss://nex.tw -wss://fritter.jp -wss://fritter.tw -wss://gnostr.cn -wss://mstdn.dk -wss://akkoma.simulacrum-emporium.eu -wss://dalliance.social -wss://toot.re -wss://avatastic.uk -wss://blastr20f7z.xyz -wss://the.voiceover.bar -wss://porcodon.net -wss://nostr.dncn.xyz -wss://nostr.dnxn.xyz -wss://relay.xmr.rocks -wss://d6egak3woofrixu26gr3utb5qezhkktavsuwlrfqaauu55lmpudxudqd.onion:5051 -wss://social.wuebbsy.com -wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.local -wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.onion -wss://tooters.org -wss://nostre.wine -wss://relay.xplive.local -wss://relay2.xplive.local -wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.onion -wss://pipou.academy -wss://opjk6jxrcyicuwhe62tqy6zwx776u7rfi6cqo6iodurjvege7piz5wqd.local -wss://mythology.social -wss://nostr.millou.lol -wss://ryogrid.net:7777 -wss://nost.debancariser.com -wss://fault.stsecurity.moe -wss://jan-optiplex-5040.local:4848 -wss://relay-jp.wirednet.jp -wss://mastodon.kitchen -wss://relay.mnethome.de -wss://verkehrswende.social -wss://nostr.gleeze.com -wss://dair-community.social -wss://shota.house -wss://kavlak.uk -wss://social.inex.rocks -wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.local -wss://startrekshitposting.com -wss://nostrfmar.ddns.net -wss://4yqp7gzuf15zfc3hpwhz3j5p2uarvdsnf75ovpdiqvyjdsmku771jfid.onion -wss://nostrgraph.net -wss://idolheaven.org -wss://mstdn.kemono-friends.info -wss://mastodon.bawue.social -wss://social.pmj.rocks -wss://ursal.zone -wss://nstr.milou.lol -wss://poweredbygay.social -wss://gochisou.photo -wss://lnb3.openchain.fr -wss://nostr.filmweb.pl -wss://nostr-word.h3z.jp -wss://blastr.f7z.xyzanotherinstanceofblastr -wss://shakedown.social -wss://nostr.bubu.hair -wss://hyper-nostr.inosta.cc -wss://ieji.de -wss://wawmartme.com -wss://nostr.kungfu-g.rip -wss://bozgor.org -wss://todon.nl -wss://nostpy.lol -wss://ostatus.taiyolab.com -wss://polsum.rocks -wss://freespeech.group -wss://im.allmendenetz.de -wss://shitpost.poridge.club -wss://twingyeo.kr -wss://social.platypush.tech -wss://rsslay-production-bc22.up.railway.app -wss://lonely.damus.io -wss://kirche.social -wss://cubalibre.social -wss://relay.txinito.xyz -wss://realy.orangepill.dev -wss://3zi.ru -wss://plebstr.com -wss://social.seattle.wa.us -wss://social.bim.land -wss://cubhub.social -wss://relay.nostr-x.com -wss://hispagatos.space -wss://node101.nostress.cc -wss://lsbt.me -wss://jgqaglhautb4k6e6i2g34jakxiemqp6z4wynlirltuukgkft2xuglmqd.onion -wss://nostdemo.dojotunnel.online -wss://f.cz \ No newline at end of file diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt index 6e2749cc3d..8cf25bc1d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt @@ -34,14 +34,19 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement -import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyMap +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.intOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyMap import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull @@ -51,7 +56,6 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -60,19 +64,13 @@ import kotlinx.serialization.json.put * (`OfferRequest`/`OfferResponse`/`OfferReceipt`, `DebitRequest`/`DebitResponse`, * `ManageRequest`/`ManageResponse`). They mirror what Jackson does reflectively on * JVM/Android — including coercing a lone `details` object into a one-element list - * (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so native targets parse the same wire shapes. + * (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so every target parses the same wire shapes. + * + * Field readers come from `nip01Core.kotlinSerialization.LenientJson`, shared with the + * NIP-47 serializers. The private copies that used to sit here read + * `it.jsonPrimitive.content`, which throws the moment a peer sends an object or an array + * where a string belongs — one malformed field took down the whole offer. */ - -private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content } - -private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull } - -private fun JsonObject.intOrNull(key: String): Int? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.intOrNull } - -private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) as? JsonObject - -private fun JsonObject.stringListOrNull(key: String): List? = (get(key) as? JsonArray)?.map { it.jsonPrimitive.content } - private fun serializeSatRange(range: SatRange): JsonObject = buildJsonObject { range.min?.let { put("min", it) } @@ -117,11 +115,11 @@ object OfferRequestKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): OfferRequest { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return OfferRequest( offer = obj.stringOrNull("offer"), amount_sats = obj.longOrNull("amount_sats"), - payer_data = obj.objectOrNull("payer_data")?.toAnyMap(), + payer_data = obj.objOrNull("payer_data")?.toAnyMap(), zap = obj.stringOrNull("zap"), expires_in_seconds = obj.longOrNull("expires_in_seconds"), description = obj.stringOrNull("description"), @@ -148,12 +146,12 @@ object OfferResponseKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): OfferResponse { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return OfferResponse( bolt11 = obj.stringOrNull("bolt11"), error = obj.stringOrNull("error"), code = obj.intOrNull("code"), - range = obj.objectOrNull("range")?.let { parseSatRange(it) }, + range = obj.objOrNull("range")?.let { parseSatRange(it) }, latest = obj.stringOrNull("latest"), ) } @@ -175,7 +173,7 @@ object OfferReceiptKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): OfferReceipt { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return OfferReceipt( res = obj.stringOrNull("res"), preimage = obj.stringOrNull("preimage"), @@ -203,14 +201,14 @@ object DebitRequestKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): DebitRequest { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return DebitRequest( pointer = obj.stringOrNull("pointer"), amount_sats = obj.longOrNull("amount_sats"), bolt11 = obj.stringOrNull("bolt11"), description = obj.stringOrNull("description"), k1 = obj.stringOrNull("k1"), - frequency = obj.objectOrNull("frequency")?.let { parseDebitFrequency(it) }, + frequency = obj.objOrNull("frequency")?.let { parseDebitFrequency(it) }, ) } @@ -248,15 +246,15 @@ object DebitResponseKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): DebitResponse { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return DebitResponse( res = obj.stringOrNull("res"), preimage = obj.stringOrNull("preimage"), code = obj.intOrNull("code"), error = obj.stringOrNull("error"), - range = obj.objectOrNull("range")?.let { parseSatRange(it) }, + range = obj.objOrNull("range")?.let { parseSatRange(it) }, retry_after = obj.longOrNull("retry_after"), - delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) }, + delta = obj.objOrNull("delta")?.let { parseGfyDelta(it) }, ) } } @@ -279,12 +277,12 @@ object ManageRequestKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): ManageRequest { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return ManageRequest( resource = obj.stringOrNull("resource"), action = obj.stringOrNull("action"), pointer = obj.stringOrNull("pointer"), - offer = obj.objectOrNull("offer")?.let { parseManageOffer(it) }, + offer = obj.objOrNull("offer")?.let { parseManageOffer(it) }, ) } @@ -297,7 +295,7 @@ object ManageRequestKSerializer : KSerializer { private fun parseManageOffer(obj: JsonObject): ManageOffer = ManageOffer( id = obj.stringOrNull("id"), - fields = obj.objectOrNull("fields")?.let { parseOfferFields(it) }, + fields = obj.objOrNull("fields")?.let { parseOfferFields(it) }, ) private fun serializeOfferFields(fields: OfferFields): JsonObject = @@ -342,7 +340,7 @@ object ManageResponseKSerializer : KSerializer { } override fun deserialize(decoder: Decoder): ManageResponse { - val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val obj = decoder.decodeRootJsonObject("A CLINK message") return ManageResponse( res = obj.stringOrNull("res"), resource = obj.stringOrNull("resource"), @@ -350,9 +348,9 @@ object ManageResponseKSerializer : KSerializer { code = obj.intOrNull("code"), error = obj.stringOrNull("error"), field = obj.stringOrNull("field"), - range = obj.objectOrNull("range")?.let { parseSatRange(it) }, + range = obj.objOrNull("range")?.let { parseSatRange(it) }, retry_after = obj.longOrNull("retry_after"), - delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) }, + delta = obj.objOrNull("delta")?.let { parseGfyDelta(it) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 6c66330b09..0a1191cf14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -319,6 +319,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent @@ -401,6 +405,10 @@ object KindNames { ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"), ClassifiedsEvent.KIND to KindName("Classifieds", "99"), CommentEvent.KIND to KindName("Comments", "22"), + GeocacheListingEvent.KIND to KindName("Geocache", "CC"), + GeocacheFoundLogEvent.KIND to KindName("Geocache Found Log", "CC"), + GeocacheVerificationEvent.KIND to KindName("Geocache Verification", "CC"), + GeocacheCurationListEvent.KIND to KindName("Geocache Curation List", "CC"), CommunityDefinitionEvent.KIND to KindName("Community Def", "72"), CommunityListEvent.KIND to KindName("Community List", "72"), CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt index fc1a6be6b7..254c9356d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.core.RawJson +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonUnquotedLiteral import kotlinx.serialization.json.add @@ -56,3 +58,32 @@ fun anyToJsonElement(value: Any?): JsonElement = is Array<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } } else -> JsonPrimitive(value.toString()) } + +/** + * The inverse of [anyToJsonElement]: decodes a [JsonElement] back into the untyped + * `Any?` tree those free-form fields are modelled as. + * + * An unquoted primitive is narrowed to the most specific type it parses as, so a + * round trip through JSON does not turn `true` into `"true"`. Integers are tried + * BEFORE doubles: `toDoubleOrNull` happily accepts "42" and answers 42.0, which + * then re-encodes as `42.0` and changes the bytes of a metadata field we were only + * meant to carry. A quoted primitive stays a String, because the quotes are the + * peer telling us it is one. + * + * Lives here, not under a NIP, for the same reason [anyToJsonElement] does — CLINK + * and NIP-47 both need it, and a per-NIP copy is how the two backends drift. + */ +fun JsonElement.toAnyValue(): Any = + when (this) { + is JsonPrimitive -> + if (isString) { + content + } else { + content.toBooleanStrictOrNull() ?: content.toLongOrNull() ?: content.toDoubleOrNull() ?: content + } + + is JsonObject -> toAnyMap() + is JsonArray -> map { it.toAnyValue() } + } + +fun JsonObject.toAnyMap(): Map = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt new file mode 100644 index 0000000000..a205ca07b5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Field readers for JSON written by somebody else. + * + * These back the hand-written NIP-47 and CLINK serializers, where every byte + * arrives from a third-party wallet or client. The rule they all follow: a field + * that is missing, null, or of the wrong shape reads as `null` and the rest of + * the message still parses. Nothing here throws. + * + * That is deliberately MORE forgiving than what Jackson did on this path. Jackson + * ignored unknown properties but still raised MismatchedInputException when a + * declared `String` arrived as an object, taking the whole message down over one + * bad field. A payment response whose `fees_paid` is `"12"` instead of `12`, or + * whose `metadata` is a string instead of an object, is worth reading for the + * preimage it does carry. + * + * Use `jsonObject` / `jsonPrimitive` / `.content` directly only where the value + * is ours and its shape is guaranteed — they throw, which is what these avoid. + * + * The base case: JSON `null` carries no more information than an absent key, so + * every reader here treats the two alike. + */ +private fun JsonElement?.presentOrNull(): JsonElement? = if (this == null || this is JsonNull) null else this + +fun JsonElement?.asObjectOrNull(): JsonObject? = presentOrNull() as? JsonObject + +/** + * The element as an array — wrapping a lone value in a one-element array, which is + * what Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY did here. Several Nostr-native RPCs + * (CLINK Manage `details`, typed `OfferData | OfferData[]`) answer with a bare + * object for a single result and an array for a list. + */ +fun JsonElement?.asArrayOrNull(): JsonArray? = + when (val e = presentOrNull()) { + null -> null + is JsonArray -> e + else -> JsonArray(listOf(e)) + } + +private fun JsonElement?.asPrimitiveOrNull(): JsonPrimitive? = presentOrNull() as? JsonPrimitive + +// ---- object field readers --------------------------------------------------- + +fun JsonObject.objOrNull(key: String): JsonObject? = this[key].asObjectOrNull() + +fun JsonObject.arrayOrNull(key: String): JsonArray? = this[key].asArrayOrNull() + +/** Text content, or null when the key is absent, null, an object or an array. */ +fun JsonObject.stringOrNull(key: String): String? = this[key].asPrimitiveOrNull()?.content + +/** + * Like [stringOrNull] but drops the empty string too, for the many fields where a + * peer writes `""` to mean "I have nothing for this". + */ +fun JsonObject.nonEmptyStringOrNull(key: String): String? = stringOrNull(key)?.ifBlank { null } + +/** Accepts `12`, `"12"` and `12.0` — wallets send all three for the same field. */ +fun JsonObject.longOrNull(key: String): Long? { + val raw = this[key].asPrimitiveOrNull()?.content ?: return null + return raw.toLongOrNull() ?: raw.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong() +} + +/** + * Out of Int range reads as null, not as the low 32 bits. `Long.toInt()` truncates: + * a `limit` of 2^32 would come back 0 and an `offset` of 3_000_000_000 negative. + * Every other reader here answers a value it cannot represent with null, and a + * plausible-looking wrong number is the one outcome worse than no number. + */ +fun JsonObject.intOrNull(key: String): Int? = longOrNull(key)?.takeIf { it >= Int.MIN_VALUE.toLong() && it <= Int.MAX_VALUE.toLong() }?.toInt() + +/** + * Finite values only. `"NaN"` and `"Infinity"` parse as Doubles in Kotlin, and a + * NaN read out of a peer's JSON propagates silently through every comparison it + * touches — it is not a number the caller can do anything with. + */ +fun JsonObject.doubleOrNull(key: String): Double? = + this[key] + .asPrimitiveOrNull() + ?.content + ?.toDoubleOrNull() + ?.takeIf { it.isFinite() } + +/** Accepts `true`/`false`, `"true"`/`"false"`, and the `1`/`0` some wallets send. */ +fun JsonObject.booleanOrNull(key: String): Boolean? { + val raw = this[key].asPrimitiveOrNull()?.content ?: return null + return raw.toBooleanStrictOrNull() + ?: when (raw) { + "1" -> true + "0" -> false + else -> null + } +} + +/** + * Every string in the array, skipping entries that are not primitives rather than + * failing the array. A lone string is read as a single-element list. + */ +fun JsonObject.stringListOrNull(key: String): List? = arrayOrNull(key)?.mapNotNull { it.asPrimitiveOrNull()?.content } + +/** The object as a plain map, or null when the key holds anything else. */ +fun JsonObject.anyMapOrNull(key: String): Map? = objOrNull(key)?.toAnyMap() + +/** Every object in the array, skipping entries that are not objects. */ +fun JsonObject.objectListOrNull(key: String): List? = arrayOrNull(key)?.mapNotNull { it.asObjectOrNull() } + +/** A shape name for error messages that survives R8 — `::class.simpleName` does not. */ +private fun JsonElement.shapeName(): String = + when (this) { + is JsonNull -> "null" + is JsonPrimitive -> if (isString) "a string" else "a number or boolean" + is JsonArray -> "an array" + is JsonObject -> "an object" + } + +/** + * The root of the message as an object. + * + * The one place these serializers still refuse input: a payload whose root is an + * array, a bare string or null is not a partially-readable message, it is not a + * message. Fails with an [IllegalArgumentException] naming what arrived, rather + * than the ClassCastException `decodeJsonElement().jsonObject` raises. + */ +fun Decoder.decodeRootJsonObject(what: String): JsonObject { + val decoder = + this as? JsonDecoder + ?: throw IllegalArgumentException("$what can only be read from JSON") + val element = decoder.decodeJsonElement() + return element as? JsonObject + ?: throw IllegalArgumentException("$what must be a JSON object, but the payload is ${element.shapeName()}") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt index 8766e21e36..cda6dd388f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt @@ -40,6 +40,27 @@ class GeoHashTag { fun geoMipMap(geohash: String): List = geohash.indices.map { geohash.substring(0, it + 1) }.reversed() + /** + * The prefixes of [geohash] between [minPrecision] and [maxPrecision] characters, + * coarse-to-fine. + * + * [geoMipMap] starts at one character and runs fine-to-coarse, which is what the geohash + * chat channels want. Specs that pin a precision band instead — NIP-CC asks geocache + * listings for 3 to 9 characters — want this: a 1- or 2-character geohash spans thousands + * of kilometres, so tagging one is noise on the relay and useless for proximity search. + * + * Returns an empty list when [geohash] is shorter than [minPrecision]. + */ + fun geoMipMap( + geohash: String, + minPrecision: Int, + maxPrecision: Int, + ): List { + val finest = minOf(maxPrecision, geohash.length) + if (minPrecision > finest) return emptyList() + return (minPrecision..finest).map { geohash.substring(0, it) } + } + fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { assembleSingle(it) }.toTypedArray() fun assemble(geohash: String) = geohashMipMap(geohash) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index e3588f77d5..527e8f235d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedData import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification @@ -33,10 +37,8 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -88,16 +90,15 @@ object Nip47NotificationKSerializer : KSerializer { override fun deserialize(decoder: Decoder): Notification { val jsonDecoder = decoder as JsonDecoder - val jsonObject = jsonDecoder.decodeJsonElement().jsonObject - val notificationType = - jsonObject["notification_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val jsonObject = decoder.decodeRootJsonObject("An NWC notification") + val notificationType = jsonObject.stringOrNull("notification_type") return when (notificationType) { NwcNotificationType.PAYMENT_RECEIVED -> { PaymentReceivedNotification( notification = Nip47ResponseKSerializer.parseTransaction( - jsonObject["notification"]?.jsonObject, + jsonObject.objOrNull("notification"), ), ) } @@ -106,24 +107,24 @@ object Nip47NotificationKSerializer : KSerializer { PaymentSentNotification( notification = Nip47ResponseKSerializer.parseTransaction( - jsonObject["notification"]?.jsonObject, + jsonObject.objOrNull("notification"), ), ) } NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { - val notifObj = jsonObject["notification"]?.jsonObject + val notifObj = jsonObject.objOrNull("notification") HoldInvoiceAcceptedNotification( notification = notifObj?.let { HoldInvoiceAcceptedData( - type = it["type"]?.jsonPrimitive?.content, - invoice = it["invoice"]?.jsonPrimitive?.content, - payment_hash = it["payment_hash"]?.jsonPrimitive?.content, - amount = it["amount"]?.jsonPrimitive?.longOrNull, - created_at = it["created_at"]?.jsonPrimitive?.longOrNull, - expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, - settle_deadline = it["settle_deadline"]?.jsonPrimitive?.longOrNull, + type = it.stringOrNull("type"), + invoice = it.stringOrNull("invoice"), + payment_hash = it.stringOrNull("payment_hash"), + amount = it.longOrNull("amount"), + created_at = it.longOrNull("created_at"), + expires_at = it.longOrNull("expires_at"), + settle_deadline = it.longOrNull("settle_deadline"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 8f281ebc4b..f0f1a16251 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -20,7 +20,16 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyMapOrNull import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.booleanOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.intOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objectListOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod @@ -58,17 +67,13 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -258,8 +263,8 @@ object Nip47RequestKSerializer : KSerializer { override fun deserialize(decoder: Decoder): Request { val jsonDecoder = decoder as JsonDecoder - val jsonObject = jsonDecoder.decodeJsonElement().jsonObject - val method = jsonObject["method"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val jsonObject = decoder.decodeRootJsonObject("An NWC request") + val method = jsonObject.stringOrNull("method") return when (method) { NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) @@ -282,62 +287,61 @@ object Nip47RequestKSerializer : KSerializer { } private fun parsePayInvoice(json: JsonObject): PayInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return PayInvoiceMethod( params?.let { PayInvoiceParams( - invoice = it["invoice"]?.jsonPrimitive?.content, - amount = it["amount"]?.jsonPrimitive?.longOrNull, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), + invoice = it.stringOrNull("invoice"), + amount = it.longOrNull("amount"), + metadata = it.anyMapOrNull("metadata"), ) }, ) } private fun parsePay(json: JsonObject): PayMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return PayMethod( params?.let { // contentOrNull / `as? JsonObject` treat an explicit JSON `null` as absent — // Jackson (JVM/Android) writes null-valued keys, so a native/iOS peer parsing // that output must not read `JsonNull` as the string "null" or crash on it. PayParams( - payment = it["payment"]?.jsonPrimitive?.contentOrNull, - amount = it["amount"]?.jsonPrimitive?.longOrNull, - payer_note = it["payer_note"]?.jsonPrimitive?.contentOrNull, - metadata = (it["metadata"] as? JsonObject)?.toAnyMap(), + payment = it.stringOrNull("payment"), + amount = it.longOrNull("amount"), + payer_note = it.stringOrNull("payer_note"), + metadata = it.anyMapOrNull("metadata"), ) }, ) } private fun parseReceive(json: JsonObject): ReceiveMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return ReceiveMethod( params?.let { ReceiveParams( - amount = it["amount"]?.jsonPrimitive?.longOrNull, - description = it["description"]?.jsonPrimitive?.contentOrNull, - metadata = (it["metadata"] as? JsonObject)?.toAnyMap(), + amount = it.longOrNull("amount"), + description = it.stringOrNull("description"), + metadata = it.anyMapOrNull("metadata"), ) }, ) } private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return PayKeysendMethod( params?.let { PayKeysendParams( - amount = it["amount"]?.jsonPrimitive?.longOrNull, - pubkey = it["pubkey"]?.jsonPrimitive?.content, - preimage = it["preimage"]?.jsonPrimitive?.content, + amount = it.longOrNull("amount"), + pubkey = it.stringOrNull("pubkey"), + preimage = it.stringOrNull("preimage"), tlv_records = - it["tlv_records"]?.jsonArray?.map { record -> - val obj = record.jsonObject + it.objectListOrNull("tlv_records")?.map { record -> TlvRecord( - type = obj["type"]?.jsonPrimitive?.longOrNull, - value = obj["value"]?.jsonPrimitive?.content, + type = record.longOrNull("type"), + value = record.stringOrNull("value"), ) }, ) @@ -346,113 +350,113 @@ object Nip47RequestKSerializer : KSerializer { } private fun parseMakeInvoice(json: JsonObject): MakeInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return MakeInvoiceMethod( params?.let { MakeInvoiceParams( - amount = it["amount"]?.jsonPrimitive?.longOrNull, - description = it["description"]?.jsonPrimitive?.content, - description_hash = it["description_hash"]?.jsonPrimitive?.content, - expiry = it["expiry"]?.jsonPrimitive?.longOrNull, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), + amount = it.longOrNull("amount"), + description = it.stringOrNull("description"), + description_hash = it.stringOrNull("description_hash"), + expiry = it.longOrNull("expiry"), + metadata = it.anyMapOrNull("metadata"), ) }, ) } private fun parseLookupInvoice(json: JsonObject): LookupInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return LookupInvoiceMethod( params?.let { LookupInvoiceParams( - payment_hash = it["payment_hash"]?.jsonPrimitive?.content, - invoice = it["invoice"]?.jsonPrimitive?.content, + payment_hash = it.stringOrNull("payment_hash"), + invoice = it.stringOrNull("invoice"), ) }, ) } private fun parseListTransactions(json: JsonObject): ListTransactionsMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return ListTransactionsMethod( params?.let { ListTransactionsParams( - from = it["from"]?.jsonPrimitive?.longOrNull, - until = it["until"]?.jsonPrimitive?.longOrNull, - limit = it["limit"]?.jsonPrimitive?.intOrNull, - offset = it["offset"]?.jsonPrimitive?.intOrNull, - unpaid = it["unpaid"]?.jsonPrimitive?.booleanOrNull, - unpaid_outgoing = it["unpaid_outgoing"]?.jsonPrimitive?.booleanOrNull, - unpaid_incoming = it["unpaid_incoming"]?.jsonPrimitive?.booleanOrNull, - type = it["type"]?.jsonPrimitive?.content, + from = it.longOrNull("from"), + until = it.longOrNull("until"), + limit = it.intOrNull("limit"), + offset = it.intOrNull("offset"), + unpaid = it.booleanOrNull("unpaid"), + unpaid_outgoing = it.booleanOrNull("unpaid_outgoing"), + unpaid_incoming = it.booleanOrNull("unpaid_incoming"), + type = it.stringOrNull("type"), ) }, ) } private fun parseSignMessage(json: JsonObject): SignMessageMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return SignMessageMethod( params?.let { SignMessageParams( - message = it["message"]?.jsonPrimitive?.content, + message = it.stringOrNull("message"), ) }, ) } private fun parseCreateConnection(json: JsonObject): CreateConnectionMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return CreateConnectionMethod( params?.let { CreateConnectionParams( - pubkey = it["pubkey"]?.jsonPrimitive?.content, - name = it["name"]?.jsonPrimitive?.content, - request_methods = it["request_methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, - notification_types = it["notification_types"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, - max_amount = it["max_amount"]?.jsonPrimitive?.longOrNull, - budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content, - expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, - isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), + pubkey = it.stringOrNull("pubkey"), + name = it.stringOrNull("name"), + request_methods = it.stringListOrNull("request_methods"), + notification_types = it.stringListOrNull("notification_types"), + max_amount = it.longOrNull("max_amount"), + budget_renewal = it.stringOrNull("budget_renewal"), + expires_at = it.longOrNull("expires_at"), + isolated = it.booleanOrNull("isolated"), + metadata = it.anyMapOrNull("metadata"), ) }, ) } private fun parseMakeHoldInvoice(json: JsonObject): MakeHoldInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return MakeHoldInvoiceMethod( params?.let { MakeHoldInvoiceParams( - amount = it["amount"]?.jsonPrimitive?.longOrNull, - description = it["description"]?.jsonPrimitive?.content, - description_hash = it["description_hash"]?.jsonPrimitive?.content, - expiry = it["expiry"]?.jsonPrimitive?.longOrNull, - payment_hash = it["payment_hash"]?.jsonPrimitive?.content, - min_cltv_expiry_delta = it["min_cltv_expiry_delta"]?.jsonPrimitive?.intOrNull, + amount = it.longOrNull("amount"), + description = it.stringOrNull("description"), + description_hash = it.stringOrNull("description_hash"), + expiry = it.longOrNull("expiry"), + payment_hash = it.stringOrNull("payment_hash"), + min_cltv_expiry_delta = it.intOrNull("min_cltv_expiry_delta"), ) }, ) } private fun parseCancelHoldInvoice(json: JsonObject): CancelHoldInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return CancelHoldInvoiceMethod( params?.let { CancelHoldInvoiceParams( - payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + payment_hash = it.stringOrNull("payment_hash"), ) }, ) } private fun parseSettleHoldInvoice(json: JsonObject): SettleHoldInvoiceMethod { - val params = json["params"]?.jsonObject + val params = json.objOrNull("params") return SettleHoldInvoiceMethod( params?.let { SettleHoldInvoiceParams( - preimage = it["preimage"]?.jsonPrimitive?.content, + preimage = it.stringOrNull("preimage"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index ded0e24008..92ee8668cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -20,7 +20,15 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyMapOrNull import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objectListOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyMap import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse @@ -35,6 +43,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcUnknownResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse @@ -55,10 +64,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -75,6 +81,10 @@ object Nip47ResponseKSerializer : KSerializer { buildJsonObject { put("result_type", value.resultType) when (value) { + is NwcUnknownResponse -> { + value.result?.let { put("result", anyToJsonElement(it)) } + } + is NwcErrorResponse -> { value.error?.let { put("error", serializeNwcError(it)) } } @@ -250,8 +260,8 @@ object Nip47ResponseKSerializer : KSerializer { override fun deserialize(decoder: Decoder): Response { val jsonDecoder = decoder as JsonDecoder - val jsonObject = jsonDecoder.decodeJsonElement().jsonObject - val resultType = jsonObject["result_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val jsonObject = decoder.decodeRootJsonObject("An NWC response") + val resultType = jsonObject.stringOrNull("result_type") val hasError = jsonObject["error"]?.let { it !is JsonNull } ?: false val hasResult = jsonObject["result"]?.let { it !is JsonNull } ?: false @@ -262,7 +272,7 @@ object Nip47ResponseKSerializer : KSerializer { } else -> { - val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } + val error = jsonObject.objOrNull("error")?.let { parseNwcError(it) } NwcErrorResponse(resultType ?: "", error) } } @@ -287,11 +297,11 @@ object Nip47ResponseKSerializer : KSerializer { } NwcMethod.MAKE_INVOICE -> { - MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + MakeInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result"))) } NwcMethod.LOOKUP_INVOICE -> { - LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + LookupInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result"))) } NwcMethod.LIST_TRANSACTIONS -> { @@ -319,7 +329,7 @@ object Nip47ResponseKSerializer : KSerializer { } NwcMethod.MAKE_HOLD_INVOICE -> { - MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result"))) } NwcMethod.CANCEL_HOLD_INVOICE -> { @@ -332,28 +342,33 @@ object Nip47ResponseKSerializer : KSerializer { else -> { // backward compatibility: guess by result content - val resultObj = jsonObject["result"]?.jsonObject + val resultObj = jsonObject.objOrNull("result") if (resultObj?.containsKey("preimage") == true) { return parsePayInvoiceSuccess(jsonObject) } - throw IllegalArgumentException("Unknown NWC response type: $resultType") + // A result_type from a newer NIP-47, or an extension we do not + // implement. The response is well-formed; hand it back with the + // result intact rather than failing the parse. + NwcUnknownResponse(resultType ?: "", resultObj?.toAnyMap()) } } } - throw IllegalArgumentException("NWC response has neither result nor error") + // Neither result nor error nor result_type: nothing to dispatch on, but the + // payload was still a valid JSON object, so surface it rather than throw. + return NwcUnknownResponse(resultType ?: "", jsonObject.objOrNull("result")?.toAnyMap()) } private fun parseNwcError(obj: JsonObject): NwcError { val code = - obj["code"]?.jsonPrimitive?.content?.let { codeName -> + obj.stringOrNull("code")?.let { codeName -> try { NwcErrorCode.valueOf(codeName) } catch (_: Exception) { null } } - return NwcError(code, obj["message"]?.jsonPrimitive?.content) + return NwcError(code, obj.stringOrNull("message")) } fun serializeTransaction(transaction: NwcTransaction?): JsonObject? { @@ -379,177 +394,177 @@ object Nip47ResponseKSerializer : KSerializer { fun parseTransaction(obj: JsonObject?): NwcTransaction? { if (obj == null) return null return NwcTransaction( - type = obj["type"]?.jsonPrimitive?.content, - state = obj["state"]?.jsonPrimitive?.content, - invoice = obj["invoice"]?.jsonPrimitive?.content, - description = obj["description"]?.jsonPrimitive?.content, - description_hash = obj["description_hash"]?.jsonPrimitive?.content, - preimage = obj["preimage"]?.jsonPrimitive?.content, - payment_hash = obj["payment_hash"]?.jsonPrimitive?.content, - amount = obj["amount"]?.jsonPrimitive?.longOrNull, - fees_paid = obj["fees_paid"]?.jsonPrimitive?.longOrNull, - created_at = obj["created_at"]?.jsonPrimitive?.longOrNull, - expires_at = obj["expires_at"]?.jsonPrimitive?.longOrNull, - settled_at = obj["settled_at"]?.jsonPrimitive?.longOrNull, - settle_deadline = obj["settle_deadline"]?.jsonPrimitive?.longOrNull, - metadata = obj["metadata"]?.jsonObject?.toAnyMap(), + type = obj.stringOrNull("type"), + state = obj.stringOrNull("state"), + invoice = obj.stringOrNull("invoice"), + description = obj.stringOrNull("description"), + description_hash = obj.stringOrNull("description_hash"), + preimage = obj.stringOrNull("preimage"), + payment_hash = obj.stringOrNull("payment_hash"), + amount = obj.longOrNull("amount"), + fees_paid = obj.longOrNull("fees_paid"), + created_at = obj.longOrNull("created_at"), + expires_at = obj.longOrNull("expires_at"), + settled_at = obj.longOrNull("settled_at"), + settle_deadline = obj.longOrNull("settle_deadline"), + metadata = obj.anyMapOrNull("metadata"), ) } private fun parsePayInvoiceSuccess(json: JsonObject): PayInvoiceSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return PayInvoiceSuccessResponse( result?.let { PayInvoiceSuccessResponse.PayInvoiceResultParams( - preimage = it["preimage"]?.jsonPrimitive?.content, - fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + preimage = it.stringOrNull("preimage"), + fees_paid = it.longOrNull("fees_paid"), ) }, ) } private fun parsePayInvoiceError(json: JsonObject): PayInvoiceErrorResponse { - val error = json["error"]?.jsonObject + val error = json.objOrNull("error") return PayInvoiceErrorResponse( error?.let { PayInvoiceErrorResponse.PayInvoiceErrorParams( code = - it["code"]?.jsonPrimitive?.content?.let { codeName -> + it.stringOrNull("code")?.let { codeName -> try { NwcErrorCode.valueOf(codeName) } catch (_: Exception) { null } }, - message = it["message"]?.jsonPrimitive?.content, + message = it.stringOrNull("message"), ) }, ) } private fun parsePaySuccess(json: JsonObject): PaySuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return PaySuccessResponse( result?.let { // contentOrNull, not content: an explicit JSON `null` (which Jackson writes // for every null field) must read back as a real null, not the string "null". PaySuccessResponse.PayResult( - transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull, - state = it["state"]?.jsonPrimitive?.contentOrNull, - instruction_type = it["instruction_type"]?.jsonPrimitive?.contentOrNull, - amount = it["amount"]?.jsonPrimitive?.longOrNull, - fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, - payment_hash = it["payment_hash"]?.jsonPrimitive?.contentOrNull, - preimage = it["preimage"]?.jsonPrimitive?.contentOrNull, - payer_proof = it["payer_proof"]?.jsonPrimitive?.contentOrNull, - txid = it["txid"]?.jsonPrimitive?.contentOrNull, - failure_reason = it["failure_reason"]?.jsonPrimitive?.contentOrNull, - created_at = it["created_at"]?.jsonPrimitive?.longOrNull, - settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull, + transaction_id = it.stringOrNull("transaction_id"), + state = it.stringOrNull("state"), + instruction_type = it.stringOrNull("instruction_type"), + amount = it.longOrNull("amount"), + fees_paid = it.longOrNull("fees_paid"), + payment_hash = it.stringOrNull("payment_hash"), + preimage = it.stringOrNull("preimage"), + payer_proof = it.stringOrNull("payer_proof"), + txid = it.stringOrNull("txid"), + failure_reason = it.stringOrNull("failure_reason"), + created_at = it.longOrNull("created_at"), + settled_at = it.longOrNull("settled_at"), ) }, ) } private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return ReceiveSuccessResponse( result?.let { ReceiveSuccessResponse.ReceiveResult( - bip321 = it["bip321"]?.jsonPrimitive?.contentOrNull, - transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull, + bip321 = it.stringOrNull("bip321"), + transaction_id = it.stringOrNull("transaction_id"), ) }, ) } private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return PayKeysendSuccessResponse( result?.let { PayKeysendSuccessResponse.PayKeysendResult( - preimage = it["preimage"]?.jsonPrimitive?.content, - fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + preimage = it.stringOrNull("preimage"), + fees_paid = it.longOrNull("fees_paid"), ) }, ) } private fun parseListTransactionsSuccess(json: JsonObject): ListTransactionsSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return ListTransactionsSuccessResponse( result?.let { ListTransactionsSuccessResponse.ListTransactionsResult( - transactions = it["transactions"]?.jsonArray?.mapNotNull { t -> parseTransaction(t.jsonObject) }, - total_count = it["total_count"]?.jsonPrimitive?.longOrNull, + transactions = it.objectListOrNull("transactions")?.mapNotNull { t -> parseTransaction(t) }, + total_count = it.longOrNull("total_count"), ) }, ) } private fun parseGetBalanceSuccess(json: JsonObject): GetBalanceSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return GetBalanceSuccessResponse( result?.let { GetBalanceSuccessResponse.GetBalanceResult( - balance = it["balance"]?.jsonPrimitive?.longOrNull, + balance = it.longOrNull("balance"), ) }, ) } private fun parseGetInfoSuccess(json: JsonObject): GetInfoSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return GetInfoSuccessResponse( result?.let { GetInfoSuccessResponse.GetInfoResult( - alias = it["alias"]?.jsonPrimitive?.content, - color = it["color"]?.jsonPrimitive?.content, - pubkey = it["pubkey"]?.jsonPrimitive?.content, - network = it["network"]?.jsonPrimitive?.content, - block_height = it["block_height"]?.jsonPrimitive?.longOrNull, - block_hash = it["block_hash"]?.jsonPrimitive?.content, - methods = it["methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, - notifications = it["notifications"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), - lud16 = it["lud16"]?.jsonPrimitive?.content, + alias = it.stringOrNull("alias"), + color = it.stringOrNull("color"), + pubkey = it.stringOrNull("pubkey"), + network = it.stringOrNull("network"), + block_height = it.longOrNull("block_height"), + block_hash = it.stringOrNull("block_hash"), + methods = it.stringListOrNull("methods"), + notifications = it.stringListOrNull("notifications"), + metadata = it.anyMapOrNull("metadata"), + lud16 = it.stringOrNull("lud16"), ) }, ) } private fun parseGetBudgetSuccess(json: JsonObject): GetBudgetSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return GetBudgetSuccessResponse( result?.let { GetBudgetSuccessResponse.GetBudgetResult( - used_budget = it["used_budget"]?.jsonPrimitive?.longOrNull, - total_budget = it["total_budget"]?.jsonPrimitive?.longOrNull, - renews_at = it["renews_at"]?.jsonPrimitive?.longOrNull, - renewal_period = it["renewal_period"]?.jsonPrimitive?.content, + used_budget = it.longOrNull("used_budget"), + total_budget = it.longOrNull("total_budget"), + renews_at = it.longOrNull("renews_at"), + renewal_period = it.stringOrNull("renewal_period"), ) }, ) } private fun parseSignMessageSuccess(json: JsonObject): SignMessageSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return SignMessageSuccessResponse( result?.let { SignMessageSuccessResponse.SignMessageResult( - message = it["message"]?.jsonPrimitive?.content, - signature = it["signature"]?.jsonPrimitive?.content, + message = it.stringOrNull("message"), + signature = it.stringOrNull("signature"), ) }, ) } private fun parseCreateConnectionSuccess(json: JsonObject): CreateConnectionSuccessResponse { - val result = json["result"]?.jsonObject + val result = json.objOrNull("result") return CreateConnectionSuccessResponse( result?.let { CreateConnectionSuccessResponse.CreateConnectionResult( - wallet_pubkey = it["wallet_pubkey"]?.jsonPrimitive?.content, + wallet_pubkey = it.stringOrNull("wallet_pubkey"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index 7d90f3b256..d5fca147f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.RawJson import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyValue import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull class NwcTransactionMetadata( @@ -186,7 +187,7 @@ class NwcTransactionMetadata( * of key order, escaping and number formatting matching by coincidence, and * it fails as a silently unlabelled row rather than as an error. Passing the * bytes through also sidesteps the number-widening hazard in - * [com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyValue], + * [com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyValue], * which resolves untyped numbers with `toDoubleOrNull()` BEFORE * `toLongOrNull()`: nothing here decomposes the event at all. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index b109074f68..6144cfdd47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -206,3 +206,20 @@ class CreateConnectionSuccessResponse( val wallet_pubkey: String? = null, ) } + +/** + * A successful response whose `result_type` this build does not know. + * + * NIP-47 grows, and the wallet on the other end is somebody else's software: it may + * answer a method added after this release, or one from an extension we do not + * implement. Failing the whole parse would throw away a response that is perfectly + * well-formed and, for anything that reads [result] generically, perfectly usable — + * so the unrecognised ones arrive here with their result intact instead. + * + * It is deliberately NOT an [IErrorResponseLike]: an unknown answer is not a refusal, + * and code that branches on error must not treat it as one. + */ +class NwcUnknownResponse( + resultType: String, + val result: Map? = null, +) : Response(resultType) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt index e1862c2295..a8b2cf2731 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt @@ -103,6 +103,7 @@ object SearchableKinds { 5302, // NIP90ContentSearchRequestEvent 5303, // NIP90PeopleSearchRequestEvent 6969, // ZapPollEvent + 7516, // GeocacheFoundLogEvent 8333, // OnchainZapEvent 9002, // EditMetadataEvent 9041, // GoalEvent @@ -183,6 +184,8 @@ object SearchableKinds { 35128, // NamedSiteEvent 35129, // NamedNappletEvent 36787, // MusicTrackEvent + 37516, // GeocacheListingEvent + 37517, // GeocacheCurationListEvent 38000, // MintRecommendationEvent 38192, // Ps1SaveEvent 38383, // P2POrderEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt index c9dab6f3d0..5af4449485 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag @@ -50,6 +51,7 @@ class CalendarDateSlotEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") @@ -81,6 +83,13 @@ class CalendarDateSlotEvent( fun participants() = tags.mapNotNull(PTag.Companion::parse) + // NIP-52 `p` tags are the invitees/hosts of this appointment. Exposing them as pubkey + // hints lets the broadcaster route the appointment - and anything that a-tags it, like an + // RSVP - into every participant's inbox relays instead of just the author's outbox. + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun references() = tags.references() companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt index a5888a45c2..923b177b96 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag @@ -52,6 +53,7 @@ class CalendarTimeSlotEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") @@ -90,6 +92,13 @@ class CalendarTimeSlotEvent( fun participants() = tags.mapNotNull(PTag.Companion::parse) + // NIP-52 `p` tags are the invitees/hosts of this appointment. Exposing them as pubkey + // hints lets the broadcaster route the appointment - and anything that a-tags it, like an + // RSVP - into every participant's inbox relays instead of just the author's outbox. + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun references() = tags.references() companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt index be976afc58..5bbebfda64 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt @@ -24,7 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -43,6 +45,7 @@ class CalendarEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") @@ -57,6 +60,13 @@ class CalendarEvent( fun calendarEventAddresses() = taggedAddresses() + // A calendar is a list of `a` tags pointing at the appointments it collects. Surfacing them + // lets the broadcaster route a published calendar to the relays those appointments live on, + // and lets the hint index learn the calendar -> appointment links. + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + companion object { const val KIND = 31924 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt index e8999be7d7..1fefa155dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -51,6 +53,8 @@ class CalendarRSVPEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, + AddressHintProvider, + EventHintProvider, SearchableEvent { override fun indexableContent() = content @@ -64,6 +68,16 @@ class CalendarRSVPEvent( override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + // NIP-52's optional `e` tag pins the exact appointment revision this RSVP answered, next to + // the `a` tag's coordinate. Routing on it as well reaches whoever served that revision. + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + fun status() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parse) fun statusValue() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parseValue) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/GeocacheLogComment.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/GeocacheLogComment.kt new file mode 100644 index 0000000000..313d9d63f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/GeocacheLogComment.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.comment + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The non-found logs of NIP-CC: did-not-find, notes and maintenance reports. + * + * These are not a geocaching kind at all — they are ordinary NIP-22 comments (kind 1111) rooted + * on the geocache listing, with a `t` tag naming the log type. That is the whole point of the + * design: a client that already renders comment threads renders a cache's history for free, and + * the `dnf` pile-up that tells you a cache has gone missing is just a thread. + * + * The root and the parent are both the listing, which is what + * [CommentEvent.replyBuilder] produces for an addressable event: `A`/`K`/`P` and `a`/`k`/`p` + * pointing at the cache. It also adds `E`/`e` for the listing's event id, which NIP-22 permits + * and the rest of this codebase relies on. + */ +object GeocacheLogComment { + fun build( + message: String, + cache: EventHintBundle, + type: GeocacheLogType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = CommentEvent.replyBuilder( + message, + // EventHintBundle is invariant, so the listing's bundle is rebuilt as a bundle of Event. + EventHintBundle(cache.event, cache.relay, cache.authorHomeRelay), + createdAt, + ) { + geocacheLogType(type) + initializer() + } + + fun didNotFind( + message: String, + cache: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(message, cache, GeocacheLogType.DNF, createdAt, initializer) + + fun note( + message: String, + cache: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(message, cache, GeocacheLogType.NOTE, createdAt, initializer) + + fun needsMaintenance( + message: String, + cache: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(message, cache, GeocacheLogType.MAINTENANCE, createdAt, initializer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..472c70344d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.comment + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag + +/** + * Adds the log type. + * + * [addUniqueValueIfNew] rather than `addUnique` because `t` is shared with hashtags on this kind + * — replacing every `t` would drop the author's topics along with it. + */ +fun TagArrayBuilder.geocacheLogType(type: GeocacheLogType) = addUniqueValueIfNew(GeocacheLogTypeTag.assemble(type)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayExt.kt new file mode 100644 index 0000000000..8d57095a05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/TagArrayExt.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.comment + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag + +/** + * The log type of a kind 1111 comment on a geocache. + * + * NIP-CC: "If no `t` tag is present, the comment is assumed to be a general note." Hashtags in + * `t` are skipped rather than mistaken for a type, because only the four defined codes parse. + */ +fun TagArray.geocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse) ?: GeocacheLogType.NOTE + +/** The declared log type, or null where the comment left it to the default. */ +fun TagArray.declaredGeocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/tags/GeocacheLogTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/tags/GeocacheLogTypeTag.kt new file mode 100644 index 0000000000..7970a3964a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/comment/tags/GeocacheLogTypeTag.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.comment.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The log types NIP-CC defines for a kind 1111 comment on a geocache. */ +enum class GeocacheLogType( + val code: String, +) { + /** Did not find — the searcher looked and came away empty-handed. */ + DNF("dnf"), + + /** Helpful or neutral context. The default when a comment does not say. */ + NOTE("note"), + + /** The cache needs attention: wet, full, damaged, muggled. */ + MAINTENANCE("maintenance"), + + /** The owner retiring the cache, preserving its history rather than deleting it. */ + ARCHIVED("archived"), + ; + + companion object { + fun fromCode(code: String?): GeocacheLogType? = entries.firstOrNull { it.code == code } + } +} + +/** + * The `t` tag of a geocache log comment (kind 1111). + * + * `t` is also NIP-01's hashtag tag, and a log comment may carry both. Parsing to the enum rather + * than to the raw value is what keeps them apart: a hashtag is simply not one of these four codes + * and falls through. + * + * Not to be confused with the `t` on a *listing*, which carries the cache type and the archived + * marker — see [com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag]. + */ +class GeocacheLogTypeTag { + companion object { + const val TAG_NAME = "t" + + fun isTag(tag: Array) = parse(tag) != null + + fun parse(tag: Array): GeocacheLogType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return GeocacheLogType.fromCode(tag[1]) + } + + fun assemble(type: GeocacheLogType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationListEvent.kt new file mode 100644 index 0000000000..be1db599b4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationListEvent.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A geocache curation list (kind 37517): an ordered collection of caches, presented as an + * adventure, a trail, a treasure hunt, or whatever the creator has in mind. + * + * The `a` references may point at caches by any author — a list is not limited to its creator's + * own caches — and their order is meaningful, so [geocaches] preserves it. + * + * `theme` and `map` are presentation *defaults* the viewer is allowed to override; see + * [com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag]. + * + * Required tags: `d`, `title`, and at least one `a`. + */ +@Immutable +class GeocacheCurationListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") + + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + if (!visitor.visit(description())) return + visitor.visit(content) + } + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + fun title() = tags.listTitle() + + fun description() = tags.listDescription() + + fun image() = tags.listImage() + + fun geohashes() = tags.geohashes() + + /** The list's default page theme, or null when it names one this version does not know. */ + fun theme() = tags.listTheme() + + fun themeCode() = tags.listThemeCode() + + /** The list's initial map style, or null when it names one this version does not know. */ + fun mapStyle() = tags.listMapStyle() + + fun mapStyleCode() = tags.listMapStyleCode() + + /** The curated caches, in list order. */ + fun geocaches() = tags.curatedGeocaches() + + /** Every `a` reference in order, including any that do not point at a cache. */ + fun addresses() = tags.curatedAddresses() + + fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) && geocaches().isNotEmpty() + + companion object { + const val KIND = 37517 + + /** Coarsest `g` tag worth publishing for a list (~156km). */ + const val MIN_TAGGED_PRECISION = 3 + + /** Finest `g` tag NIP-CC asks a list for (~1.2km) — a trail centre, not a hiding spot. */ + const val MAX_TAGGED_PRECISION = 6 + + val REQUIRED_FIELDS = setOf(DTag.TAG_NAME, TitleTag.TAG_NAME, ATag.TAG_NAME) + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + geocaches: List
    , + content: String = "", + description: String? = null, + image: String? = null, + geohash: String? = null, + theme: ListTheme? = null, + mapStyle: MapStyle? = null, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(dTag) + listTitle(title) + geocaches(geocaches) + + description?.let { listDescription(it) } + image?.let { listImage(it) } + geohash?.let { listLocation(it) } + theme?.let { listTheme(it) } + mapStyle?.let { listMapStyle(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationRevision.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationRevision.kt new file mode 100644 index 0000000000..ebce49fa68 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/GeocacheCurationRevision.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.update +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Adding a cache to a hunt that already exists. + * + * A curation list is replaceable, so "add to hunt" is a new revision at the same address rather + * than a new list. Built on [update] for the same reason the listing's revisions are: a real + * 37517 carries tags this library models nothing for, and rebuilding from parsed fields would + * drop them. + * + * The cache lands at the end. NIP-CC makes the `a` order meaningful — a hunt is a route someone + * designed — and appending is the only placement that needs no opinion about where in that route + * the new stop belongs. Reordering is the composer's job. + */ +object GeocacheCurationRevision { + fun withCache( + hunt: GeocacheCurationListEvent, + cache: Address, + relayHint: NormalizedRelayUrl? = null, + createdAt: Long = TimeUtils.now(), + ) = hunt.update(createdAt) { geocache(cache, relayHint) } + + /** Whether [cache] is already on this hunt, so callers can avoid a no-op republish. */ + fun contains( + hunt: GeocacheCurationListEvent, + cache: Address, + ) = hunt.geocaches().any { it == cache } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a6db9a7c53 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayBuilderExt.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag + +fun TagArrayBuilder.listTitle(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.listDescription(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.listImage(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.listTheme(theme: ListTheme) = addUnique(ListThemeTag.assemble(theme)) + +fun TagArrayBuilder.listMapStyle(style: MapStyle) = addUnique(MapStyleTag.assemble(style)) + +/** + * The `g` ladder for a list centred on [geohash], from 3 to 6 characters. + * + * Coarser than a listing's 3..9 on purpose: NIP-CC asks a list for discovery precision, and a + * trail's centre point is not a place anybody walks to. + */ +fun TagArrayBuilder.listLocation(geohash: String) = + addAll( + GeoHashTag.geoMipMap(geohash, GeocacheCurationListEvent.MIN_TAGGED_PRECISION, GeocacheCurationListEvent.MAX_TAGGED_PRECISION).map(GeoHashTag::assembleSingle), + ) + +/** Appends one cache. Call order is the list order NIP-CC says to preserve. */ +fun TagArrayBuilder.geocache( + cache: Address, + relayHint: NormalizedRelayUrl? = null, +) = addUniqueValueIfNew(ATag.assemble(cache, relayHint)) + +fun TagArrayBuilder.geocaches(caches: List
    ) = addAllUniqueValueIfNew(caches.map { ATag.assemble(it, null) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayExt.kt new file mode 100644 index 0000000000..b9cd807a49 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/TagArrayExt.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +fun TagArray.listTitle() = firstNotNullOfOrNull(TitleTag::parse) + +fun TagArray.listDescription() = firstNotNullOfOrNull(DescriptionTag::parse) + +fun TagArray.listImage() = firstNotNullOfOrNull(ImageTag::parse) + +fun TagArray.listTheme() = firstNotNullOfOrNull(ListThemeTag::parse) + +fun TagArray.listThemeCode() = firstNotNullOfOrNull(ListThemeTag::parseCode) + +fun TagArray.listMapStyle() = firstNotNullOfOrNull(MapStyleTag::parse) + +fun TagArray.listMapStyleCode() = firstNotNullOfOrNull(MapStyleTag::parseCode) + +/** Every `a` tag, in publication order — which NIP-CC says is meaningful. */ +fun TagArray.curatedAddresses() = mapNotNull(ATag::parseAddress) + +/** + * The curated geocaches, in order, filtered to the kinds NIP-CC allows a list to reference. + * + * A list can point at anything; only 37516 (and the referenced-but-undefined 37515) are caches. + * Dropping the rest keeps an unrelated address out of a treasure-hunt itinerary. + */ +fun TagArray.curatedGeocaches(): List
    = + curatedAddresses().filter { + it.kind == GeocacheListingEvent.KIND || it.kind == GeocacheListingEvent.LEGACY_KIND + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/ListThemeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/ListThemeTag.kt new file mode 100644 index 0000000000..373360ab9c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/ListThemeTag.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The page themes NIP-CC defines for a curation list. */ +enum class ListTheme( + val code: String, +) { + ADVENTURE("adventure"), + MOJAVE("mojave"), + ; + + companion object { + fun fromCode(code: String?): ListTheme? = entries.firstOrNull { it.code == code } + } +} + +/** + * The `theme` tag of a curation list (kind 37517). + * + * A *default*, not an instruction: NIP-CC says to apply it "unless the user has explicitly chosen + * a different theme". + */ +class ListThemeTag { + companion object { + const val TAG_NAME = "theme" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ListTheme? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return ListTheme.fromCode(tag[1]) + } + + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(theme: ListTheme) = arrayOf(TAG_NAME, theme.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/MapStyleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/MapStyleTag.kt new file mode 100644 index 0000000000..46e9e184e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/curation/tags/MapStyleTag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.curation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The map styles NIP-CC defines for a curation list. */ +enum class MapStyle( + val code: String, +) { + ORIGINAL("original"), + DARK("dark"), + SATELLITE("satellite"), + ADVENTURE("adventure"), + ; + + companion object { + fun fromCode(code: String?): MapStyle? = entries.firstOrNull { it.code == code } + } +} + +/** + * The `map` tag of a curation list (kind 37517). + * + * The *initial* style, which NIP-CC explicitly says the user must be able to change. + */ +class MapStyleTag { + companion object { + const val TAG_NAME = "map" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): MapStyle? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return MapStyle.fromCode(tag[1]) + } + + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(style: MapStyle) = arrayOf(TAG_NAME, style.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/firstToFind/FirstToFindResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/firstToFind/FirstToFindResolver.kt new file mode 100644 index 0000000000..9f69ea88df --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/firstToFind/FirstToFindResolver.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.firstToFind + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator + +/** + * Who claimed a `first-to-find` cache. + * + * NIP-CC decides this in two stages, and the second one exists because the first cannot be + * trusted: + * + * 1. **Provisionally**, the winner is the verified found log with the earliest `created_at`, ties + * broken by ascending lexicographic event id. + * 2. **Finally**, once the owner publishes an `F` tag on the listing, that pubkey is the winner — + * "regardless of which verified found log currently appears earliest". + * + * Stage 2 is not a convenience. `created_at` is author-supplied, so anyone who finds the cache a + * month late can sign a log dated before the real winner's and take the claim by stage 1 alone. + * Locking the winner in is what makes the claim stick, which is why [winnerPubKey] reads `F` + * first and only falls back to timestamps while none has been published. + * + * Every function here filters to *verified* logs first. An unverified found log on a + * `first-to-find` cache is somebody's word; the claim is reserved for logs that carry a kind 7517 + * that holds up against the listing. + */ +object FirstToFindResolver { + /** The logs that are about [listing] and carry a verification that checks out against it. */ + fun verifiedLogs( + listing: GeocacheListingEvent, + logs: List, + ): List = + logs.filter { + it.geocache() == listing.address() && GeocacheVerificationValidator.isValid(it, listing) + } + + /** + * The earliest verified log, ties broken by ascending event id. + * + * "Provisional" is literal: this is only the winner while the owner has not locked one in, + * and a log with a forged `created_at` wins it. + */ + fun provisionalWinningLog( + listing: GeocacheListingEvent, + logs: List, + ): GeocacheFoundLogEvent? = verifiedLogs(listing, logs).minWithOrNull(compareBy({ it.createdAt }, { it.id })) + + /** + * The winning log: the owner's locked-in winner's earliest verified log where `F` is + * published, the provisional winner otherwise. + * + * Null on a cache that is not `first-to-find` — there is no exclusive claim to award. + */ + fun winningLog( + listing: GeocacheListingEvent, + logs: List, + ): GeocacheFoundLogEvent? { + if (!listing.isFirstToFind()) return null + + val lockedIn = listing.firstToFindWinner() ?: return provisionalWinningLog(listing, logs) + + return verifiedLogs(listing, logs) + .filter { it.pubKey == lockedIn } + .minWithOrNull(compareBy({ it.createdAt }, { it.id })) + } + + /** + * The pubkey holding the exclusive claim, or null if nobody does yet. + * + * An `F` tag wins even when no log for it is in [logs] — the owner has confirmed the claim + * and the log may simply not have been fetched. + */ + fun winnerPubKey( + listing: GeocacheListingEvent, + logs: List, + ): HexKey? { + if (!listing.isFirstToFind()) return null + return listing.firstToFindWinner() ?: provisionalWinningLog(listing, logs)?.pubKey + } + + /** Whether the owner has locked the winner in, as opposed to it resting on timestamps. */ + fun isLockedIn(listing: GeocacheListingEvent) = listing.firstToFindWinner() != null + + /** + * Whether the claim is taken. + * + * NIP-CC: once it is, clients should render the listing as effectively archived and hide the + * find-submission affordances. Later verified logs stay valid records of physical presence — + * they are simply not additional claims. + */ + fun isClaimed( + listing: GeocacheListingEvent, + logs: List, + ) = winnerPubKey(listing, logs) != null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/GeocacheFoundLogEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/GeocacheFoundLogEvent.kt new file mode 100644 index 0000000000..7d47b7a35b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/GeocacheFoundLogEvent.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.foundLog + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A found log (kind 7516): a claim that the author found a geocache. + * + * `content` is the log message. The `a` tag names the cache. A log may carry a kind 7517 + * verification event inline, which is what turns "I say I found it" into "I can prove I was + * there" — but only after [com.vitorpamplona.quartz.nipCCGeocaching.verification + * .GeocacheVerificationValidator] has checked it against the listing. [isVerified] on its own + * means nothing more than "a verification event was attached". + * + * Only *found* logs are this kind. Did-not-find, notes and maintenance reports are NIP-22 + * comments rooted on the listing — see [com.vitorpamplona.quartz.nipCCGeocaching.comment + * .GeocacheLogComment]. + */ +@Immutable +class GeocacheFoundLogEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun addressHints() = tags.mapNotNull(GeocacheTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(GeocacheTag::parseAddressId) + + fun geocache() = tags.geocache() + + fun geocacheId() = tags.geocacheId() + + fun images() = tags.logImages() + + fun embeddedVerification() = tags.embeddedVerification() + + /** + * Whether a `verification` tag is attached at all. + * + * Says nothing about whether it holds up — only + * [com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator] + * answers that. Named for what it checks rather than `isVerified`, which invites exactly the + * mistake of treating an attached string as proof. + * + * Cheap enough for a render path: it does not parse the payload. + */ + fun hasVerificationAttached() = tags.hasEmbeddedVerification() + + companion object { + const val KIND = 7516 + + fun build( + message: String, + cache: EventHintBundle, + verification: GeocacheVerificationEvent? = null, + images: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + geocache(cache) + verification?.let { embeddedVerification(it) } + images?.let { logImages(it) } + initializer() + } + + fun build( + message: String, + cache: Address, + relayHint: NormalizedRelayUrl? = null, + verification: GeocacheVerificationEvent? = null, + images: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + geocache(cache, relayHint) + verification?.let { embeddedVerification(it) } + images?.let { logImages(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..6a0ff15487 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayBuilderExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.foundLog + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent + +fun TagArrayBuilder.geocache(cache: EventHintBundle) = addUnique(GeocacheTag.assemble(cache)) + +fun TagArrayBuilder.geocache( + cache: Address, + relayHint: NormalizedRelayUrl? = null, +) = addUnique(GeocacheTag.assemble(cache, relayHint)) + +fun TagArrayBuilder.embeddedVerification(verification: GeocacheVerificationEvent) = addUnique(EmbeddedVerificationTag.assemble(verification)) + +fun TagArrayBuilder.logImages(urls: List) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayExt.kt new file mode 100644 index 0000000000..6ab8f30a80 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/TagArrayExt.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.foundLog + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +/** Whether [kind] is a geocache listing — 37516, or the referenced-but-undefined 37515. */ +private fun isGeocacheKind(kind: Int) = kind == GeocacheListingEvent.KIND || kind == GeocacheListingEvent.LEGACY_KIND + +/** + * The geocache this log is about. + * + * Restricted to the listing kinds on purpose. The `a` tag is whatever its author wrote, and + * nothing stops a found log from pointing at an article, a calendar event or a profile badge — + * so a consumer that resolves it unfiltered files a "Found it!" under an unrelated addressable, + * or hands a caller a note of the wrong type. A log naming a non-cache is malformed, and reads + * as absent. + */ +fun TagArray.geocache(): Address? = firstNotNullOfOrNull(GeocacheTag::parseAddress)?.takeIf { isGeocacheKind(it.kind) } + +/** The raw `a` value, for the same reason and with the same restriction as [geocache]. */ +fun TagArray.geocacheId(): String? = geocache()?.toValue() + +fun TagArray.logImages() = mapNotNull(ImageTag::parse) + +/** + * Whether a `verification` tag is present at all. + * + * Cheap: it looks at tag names only. [embeddedVerification] has to run the payload through the + * JSON parser and the event factory, which is not something a composable should do on every + * recomposition just to decide whether there is anything to check. + */ +fun TagArray.hasEmbeddedVerification() = fastAny(EmbeddedVerificationTag::isTag) + +fun TagArray.embeddedVerification() = firstNotNullOfOrNull(EmbeddedVerificationTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/EmbeddedVerificationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/EmbeddedVerificationTag.kt new file mode 100644 index 0000000000..783dd18a2f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/EmbeddedVerificationTag.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `verification` tag of a found log (kind 7516): a whole kind 7517 event, as JSON, inline. + * + * NIP-CC allows the verification to be embedded, published standalone, or both; embedding is what + * makes a log self-contained, so this is the common case. + * + * The payload is attacker-controlled — anybody can publish a found log with any string in this + * tag — so a parse failure has to be an ordinary null rather than an exception that takes the + * surrounding feed down with it. + * + * Note this returns a [GeocacheVerificationEvent] only because kind 7517 is registered with + * [com.vitorpamplona.quartz.utils.EventFactory]. Unregistered, the same JSON would parse to a + * plain [Event] and every embedded verification in the wild would read as absent. + */ +class EmbeddedVerificationTag { + companion object { + const val TAG_NAME = "verification" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): GeocacheVerificationEvent? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return try { + Event.fromJson(tag[1]) as? GeocacheVerificationEvent + } catch (e: Exception) { + Log.w("EmbeddedVerificationTag") { "Could not parse the embedded verification event: ${e.message}" } + null + } + } + + fun assemble(verification: GeocacheVerificationEvent) = arrayOf(TAG_NAME, verification.toJson()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/GeocacheTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/GeocacheTag.kt new file mode 100644 index 0000000000..037cc536bd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/foundLog/tags/GeocacheTag.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +/** + * The `a` tag of a found log (kind 7516): the geocache listing the log is about. + * + * An ordinary NIP-01 addressable reference — unlike the composite `a` on a kind 7517 — so the + * parsing delegates to [ATag]. The named wrapper exists so the package reads in its own + * vocabulary and so the assemble overload can take a [GeocacheListingEvent] directly. + */ +class GeocacheTag { + companion object { + const val TAG_NAME = ATag.TAG_NAME + + fun isTag(tag: Array) = ATag.isTagged(tag) + + fun parse(tag: Array) = ATag.parse(tag) + + fun parseAddress(tag: Array) = ATag.parseAddress(tag) + + fun parseAddressId(tag: Array) = ATag.parseAddressId(tag) + + fun parseAsHint(tag: Array) = ATag.parseAsHint(tag) + + fun assemble( + cache: Address, + relayHint: NormalizedRelayUrl?, + ) = ATag.assemble(cache, relayHint) + + fun assemble(cache: EventHintBundle) = assemble(cache.event.address(), cache.relay) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheGeohash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheGeohash.kt new file mode 100644 index 0000000000..43638ff0a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheGeohash.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize + +/** + * The geohash precision rules NIP-CC puts on geocache listings (kind 37516). + * + * Two different numbers are involved and they are easy to confuse: + * + * - [MIN_TAGGED]..[MAX_TAGGED] is the band of `g` tags a listing publishes, so that relays can + * answer proximity queries at several zoom levels. + * - [minPublishPrecision] is how precise the *location the owner picked* must be before a client + * accepts the submission at all. A cache you cannot walk up to is not a cache. + */ +object GeocacheGeohash { + /** Coarsest `g` tag worth publishing (~156km). */ + const val MIN_TAGGED = 3 + + /** Finest `g` tag NIP-CC asks for (~5m). */ + const val MAX_TAGGED = 9 + + /** NIP-CC: "Validate geohash precision meets minimum requirements (8+ characters …)". */ + const val MIN_PUBLISH = 8 + + /** "… 9+ for micro caches" — a 38m box does not find a film canister. */ + const val MIN_PUBLISH_MICRO = 9 + + /** The precision a submission of [size] must reach before a client accepts it. */ + fun minPublishPrecision(size: CacheSize?) = if (size == CacheSize.MICRO) MIN_PUBLISH_MICRO else MIN_PUBLISH + + /** Whether [geohash] is precise enough to publish as a cache of [size]. */ + fun isPreciseEnough( + geohash: String, + size: CacheSize?, + ) = geohash.length >= minPublishPrecision(size) + + /** The `g` values a listing at [geohash] should publish, coarse-to-fine. */ + fun ladder(geohash: String) = GeoHashTag.geoMipMap(geohash, MIN_TAGGED, MAX_TAGGED) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingEvent.kt new file mode 100644 index 0000000000..992cf93cde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingEvent.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A geocache listing (kind 37516), as defined by + * [NIP-CC](https://github.com/nostr-protocol/nips/blob/master/CC.md). + * + * Addressable, so the owner keeps editing the same cache — that is how a cache gets archived, + * how its hint is corrected, and how a first-to-find winner is locked in. `content` is the cache + * description. + * + * Community history about the cache does *not* live here: found logs are kind + * [com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent] and everything else + * (did-not-find, notes, maintenance) is a NIP-22 comment rooted on this listing — see + * [com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment]. + * + * Required tags: `d`, `name`, `g`, `D`, `T`, `S`. See [isWellFormed]. + */ +@Immutable +class GeocacheListingEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope, + SearchableEvent { + // The hint and the mission are deliberately absent: a cache is found by walking to it, and a + // search that matches on "in the branches" hands out the answer to anyone who types it. + override fun indexableContent() = listOfNotNull(cacheName(), content).joinToString("\n") + + // The read path: the same fields indexableContent() joins, without building the join a scan + // would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(cacheName())) return + visitor.visit(content) + } + + fun cacheName() = tags.cacheName() + + /** Every `g` tag as published, coarse-to-fine. */ + fun geohashes() = tags.geohashes() + + /** The finest `g` tag — the one that actually points at the cache. */ + fun location() = tags.geohashes().maxByOrNull { it.length } + + fun difficulty() = tags.difficulty() + + fun terrain() = tags.terrain() + + fun cacheSize() = tags.cacheSize() + + fun cacheSizeCode() = tags.cacheSizeCode() + + /** The `t` cache type, defaulting to `traditional` when the listing does not say. */ + fun cacheTypeCode() = tags.cacheTypeCode() + + fun cacheType() = tags.cacheType() + + fun isArchived() = tags.isArchived() + + fun typeModifiers() = tags.typeModifiers() + + fun typeModifierCodes() = tags.typeModifierCodes() + + fun hasTypeModifier(modifier: TypeModifier) = tags.hasTypeModifier(modifier) + + fun isFirstToFind() = tags.isFirstToFind() + + /** The `hint` tag exactly as published, in whichever form its author chose. */ + fun hintOnWire() = tags.hint() + + /** The hint form safe to show before the reader asks. See [HintObfuscation]. */ + fun hintHidden() = tags.hintHidden() + + /** The hint form the reader gets when they ask. See [HintObfuscation]. */ + fun hintRevealed() = tags.hintRevealed() + + fun mission() = tags.mission() + + fun hasMission() = tags.mission() != null + + fun verificationKey() = tags.verificationKey() + + /** Whether finders can prove physical presence at this cache. */ + fun requiresVerification() = tags.verificationKey() != null + + /** + * The locked-in first-to-find winner, or null. + * + * Only honoured on a listing that carries the `first-to-find` modifier: NIP-CC scopes `F` to + * that modifier, and an `F` on any other cache would silently invent a claim nobody made. + */ + fun firstToFindWinner() = if (isFirstToFind()) tags.firstToFindWinner() else null + + fun images() = tags.cacheImages() + + fun logRelays() = tags.logRelays() + + fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) + + companion object { + const val KIND = 37516 + + /** + * A kind NIP-CC references once — the curation list accepts "kind 37516 or 37515" — but + * never defines. Readers accept it; nothing here ever writes it. + */ + const val LEGACY_KIND = 37515 + + val REQUIRED_FIELDS = + setOf( + DTag.TAG_NAME, + CacheNameTag.TAG_NAME, + GeoHashTag.TAG_NAME, + DifficultyTag.TAG_NAME, + TerrainTag.TAG_NAME, + CacheSizeTag.TAG_NAME, + ) + + @OptIn(ExperimentalUuidApi::class) + fun build( + name: String, + description: String, + geohash: String, + difficulty: Int, + terrain: Int, + size: CacheSize, + type: CacheType? = null, + modifiers: Collection = emptySet(), + hint: String? = null, + mission: String? = null, + verificationPubKey: HexKey? = null, + images: List? = null, + relays: List? = null, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + // The `g` ladder starts at 3 characters, so a coarser geohash produces no `g` tag at + // all — and `g` is required. Without this the builder happily signs a listing that + // fails its own isWellFormed(), and the caller finds out after publishing it. + // This is the floor for a *well-formed* event; NIP-CC's submission rule is stricter + // (8+, 9+ for micro) and is [GeocacheGeohash.isPreciseEnough]'s job at the composer. + require(geohash.length >= GeocacheGeohash.MIN_TAGGED) { + "a geocache needs a geohash of at least ${GeocacheGeohash.MIN_TAGGED} characters, got \"$geohash\"" + } + + dTag(dTag) + cacheName(name) + cacheLocation(geohash) + difficulty(difficulty) + terrain(terrain) + cacheSize(size) + + type?.let { cacheType(it) } + if (modifiers.isNotEmpty()) typeModifiers(modifiers) + // Written rot13'd: it is what the reference client does, and it is the choice that + // degrades safely — a reader assuming plaintext sees noise rather than the answer. + hint?.let { hint(rot13(it)) } + mission?.let { mission(it) } + verificationPubKey?.let { verificationKey(it) } + images?.let { cacheImages(it) } + relays?.let { logRelays(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingRevision.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingRevision.kt new file mode 100644 index 0000000000..7007e35795 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/GeocacheListingRevision.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.update +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Owner-side edits that republish an existing listing at the same address. + * + * 37516 is replaceable, so the owner's lifecycle — archiving a cache, locking in the first + * finder — is not a separate kind and not a delete. It is a new revision carrying every tag the + * old one had plus the change, which every client already rendering the cache picks up for free. + * + * Both build on [update], which seeds the builder from the event's own tag array rather than + * rebuilding from parsed fields. That matters: a real listing carries tags this library models + * nothing for — `client`, `expiration`, NIP-32 labels, Lightning Piggy's payout hints — and a + * rebuild would silently drop them. That is how an owner loses data by pressing Archive in the + * wrong app. + */ +object GeocacheListingRevision { + /** + * The same listing with `archived` added to its `t` tags. + * + * NIP-CC puts `archived` alongside the cache type rather than in a field of its own, so this + * adds a value rather than replacing one: a traditional cache that gets archived is still a + * traditional cache, and a client that reads `t` as a single value — as the reference ones + * do — at least still sees something true. + */ + fun archived( + listing: GeocacheListingEvent, + createdAt: Long = TimeUtils.now(), + ) = listing.update(createdAt) { archived() } + + /** + * The same listing with its first-to-find winner locked in. + * + * `F` is authoritative and permanent once published — NIP-CC says clients MUST attribute the + * exclusive claim to the pubkey it names — so this replaces any earlier `F` rather than + * appending to it. Two winners is not a state the spec has an answer for. + */ + fun withFirstToFindWinner( + listing: GeocacheListingEvent, + winnerPubKey: HexKey, + createdAt: Long = TimeUtils.now(), + ) = listing.update(createdAt) { firstToFindWinner(winnerPubKey) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/HintObfuscation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/HintObfuscation.kt new file mode 100644 index 0000000000..73d13f9235 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/HintObfuscation.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import kotlin.math.ln + +/** + * Which of a hint's two forms is the one that spoils. + * + * NIP-CC is self-contradictory here and the network followed it both ways. The tag table calls + * `hint` "plaintext" and the spec's own example carries `["hint", "In the branches"]`, but the + * Clients section asks for "hint encoding, such as ROT13, to prevent spoilers" — and the + * reference client (treasures.to, mirrored in Lightning Piggy's `nostrPlacesService.ts`) rot13s + * on write and rot13s back on read. + * + * Measured, not assumed: of 34 hints sampled from relay.damus.io / nos.lol / relay.primal.net / + * nostr.wine, exactly 17 were ROT13 ciphertext and 17 were plaintext, split cleanly *by author* + * — one publisher's caches are all encoded, another's are all plain. So there is no convention + * to follow, only a coin flip, and either fixed choice hands the answer to the reader unasked on + * half the caches in the world. + * + * ROT13 is its own inverse, so the two candidate forms of any hint are just `text` and + * `rot13(text)` — one of them is the hint and the other is noise. Rather than guess the + * publisher's convention, [hidden] picks whichever form reads *less* like prose and shows that; + * [revealed] is the other one. Under either convention the reader sees noise until they ask. + * + * The test is average log letter-frequency against English. A vowel count is the obvious idea + * and it is *wrong*: ROT13 maps the common consonants `n→a`, `r→e`, `h→u` and `b→o`, so English + * ciphertext usually has **more** vowels than the plaintext it came from — "In the branches" has + * 4 vowels in 13 letters, its rotation "Va gur oenapurf" has 6. Scoring the whole distribution + * instead gets every hint in the sample right, including short ones like "Small" and + * "Look up". + * + * It is still a guess, and a narrow one: it assumes English and ASCII. A plaintext Spanish hint + * ("Bajo el banco") scores worse than its own rotation and would be "revealed" as noise. That is + * why the UI cycles prompt → best guess → other rotation rather than revealing once — a misfire + * costs a tap, not the hint — and why nothing here should ever be the last word on which form a + * reader gets to see. + * + * None of this is secrecy. The hint is public on the relay in whatever form its author chose; + * the only goal is that a reader has to opt in. + */ +object HintObfuscation { + /** Relative frequency of `a`..`z` in English text, as percentages. */ + private val ENGLISH_FREQUENCY = + doubleArrayOf( + 8.17, + 1.49, + 2.78, + 4.25, + 12.70, + 2.23, + 2.02, + 6.09, + 6.97, + 0.15, + 0.77, + 4.03, + 2.41, + 6.75, + 7.51, + 1.93, + 0.10, + 5.99, + 6.33, + 9.06, + 2.76, + 0.98, + 2.36, + 0.15, + 1.97, + 0.07, + ) + + /** + * How much [text] looks like English: the mean log frequency of its ASCII letters, 0 when it + * has none. Higher is more English-like. Only ever compared against the same measure of the + * same text rotated, so the scale itself carries no meaning. + */ + fun englishScore(text: String): Double { + var letters = 0 + var total = 0.0 + text.forEach { char -> + val lower = char.lowercaseChar() + if (lower in 'a'..'z') { + letters++ + total += ln(ENGLISH_FREQUENCY[lower - 'a']) + } + } + return if (letters == 0) 0.0 else total / letters + } + + /** + * Whether [onWire] already looks like readable prose — i.e. the publisher wrote it plain and + * it is the *rotated* form that is safe to show. + */ + fun isProse(onWire: String) = englishScore(onWire) >= englishScore(rot13(onWire)) + + /** The form to show before the reader asks: whichever of the two reads less like prose. */ + fun hidden(onWire: String) = if (isProse(onWire)) rot13(onWire) else onWire + + /** The other form — what the reader gets when they ask. */ + fun revealed(onWire: String) = if (isProse(onWire)) onWire else rot13(onWire) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/Rot13.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/Rot13.kt new file mode 100644 index 0000000000..cd34320f45 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/Rot13.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +/** + * ROT13, the encoding geocaching has always used to keep a hint from spoiling itself. + * + * NIP-CC asks clients to "support hint encoding, such as ROT13, to prevent spoilers". The `hint` + * tag itself travels as plaintext, so this is a display transform: show the rotated text, and + * rotate it back when the reader asks for it. Rotating twice returns the original, so one + * function serves both directions. + * + * Only ASCII letters move; digits, punctuation and every non-ASCII letter are left alone, which + * is what every other geocaching implementation does and what makes the round trip exact. + */ +fun rot13(text: String): String = + text + .map { char -> + when (char) { + in 'a'..'z' -> 'a' + ((char - 'a') + 13) % 26 + in 'A'..'Z' -> 'A' + ((char - 'A') + 13) % 26 + else -> char + } + }.joinToString("") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..18a7e7a513 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayBuilderExt.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag + +fun TagArrayBuilder.cacheName(name: String) = addUnique(CacheNameTag.assemble(name)) + +fun TagArrayBuilder.difficulty(rating: Int) = addUnique(DifficultyTag.assemble(rating)) + +fun TagArrayBuilder.terrain(rating: Int) = addUnique(TerrainTag.assemble(rating)) + +fun TagArrayBuilder.cacheSize(size: CacheSize) = addUnique(CacheSizeTag.assemble(size)) + +/** + * The `g` ladder for a cache at [geohash], from 3 to 9 characters. + * + * Deliberately not [com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash], which publishes + * every prefix from one character up. + */ +fun TagArrayBuilder.cacheLocation(geohash: String) = addAll(GeocacheGeohash.ladder(geohash).map(GeoHashTag::assembleSingle)) + +/** + * The cache type. Kept off [addUnique] because `t` also carries the `archived` marker — replacing + * every `t` here would un-retire a cache the owner had archived. + */ +fun TagArrayBuilder.cacheType(type: CacheType) = addUniqueValueIfNew(CacheTypeTag.assemble(type)) + +fun TagArrayBuilder.cacheType(code: String) = addUniqueValueIfNew(CacheTypeTag.assemble(code)) + +/** Retires the cache, preserving its history instead of deleting it. */ +fun TagArrayBuilder.archived() = addUniqueValueIfNew(CacheTypeTag.assembleArchived()) + +fun TagArrayBuilder.typeModifier(modifier: TypeModifier) = addUniqueValueIfNew(TypeModifierTag.assemble(modifier)) + +fun TagArrayBuilder.typeModifiers(modifiers: Collection) = addAllUniqueValueIfNew(TypeModifierTag.assemble(modifiers)) + +fun TagArrayBuilder.hint(hint: String) = addUnique(HintTag.assemble(hint)) + +fun TagArrayBuilder.mission(mission: String) = addUnique(MissionTag.assemble(mission)) + +fun TagArrayBuilder.verificationKey(pubKey: HexKey) = addUnique(VerificationKeyTag.assemble(pubKey)) + +fun TagArrayBuilder.firstToFindWinner(winnerPubKey: HexKey) = addUnique(FirstToFindWinnerTag.assemble(winnerPubKey)) + +fun TagArrayBuilder.cacheImages(urls: List) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble)) + +fun TagArrayBuilder.logRelays(relays: List) = addAllUniqueValueIfNew(relays.map(RelayTag::assemble)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayExt.kt new file mode 100644 index 0000000000..1afce998ad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/TagArrayExt.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag + +fun TagArray.cacheName() = firstNotNullOfOrNull(CacheNameTag::parse) + +fun TagArray.difficulty() = firstNotNullOfOrNull(DifficultyTag::parse) + +fun TagArray.terrain() = firstNotNullOfOrNull(TerrainTag::parse) + +fun TagArray.cacheSize() = firstNotNullOfOrNull(CacheSizeTag::parse) + +/** The raw `S` value, which may be outside the [CacheSize] vocabulary. */ +fun TagArray.cacheSizeCode() = firstNotNullOfOrNull(CacheSizeTag::parseCode) + +/** The `t` cache type as published, defaulting to `traditional` per NIP-CC when absent. */ +fun TagArray.cacheTypeCode() = firstNotNullOfOrNull(CacheTypeTag::parseTypeCode) ?: CacheType.TRADITIONAL.code + +/** The parsed [CacheType], or null when the listing names a client-defined type. */ +fun TagArray.cacheType() = CacheType.fromCode(cacheTypeCode()) + +/** Whether the owner retired this cache with `["t", "archived"]`. */ +fun TagArray.isArchived() = fastAny(CacheTypeTag::isArchived) + +/** + * The known `n` modifiers, at most one per category. + * + * NIP-CC rule 2: where a listing carries several values from one category, the first occurrence + * wins and the rest are ignored. Unknown values are dropped (rule 4) — see [typeModifierCodes] + * to see them anyway. + */ +fun TagArray.typeModifiers(): Map { + val byCategory = mutableMapOf() + fastForEach { tag -> + TypeModifierTag.parse(tag)?.let { + if (!byCategory.containsKey(it.category)) byCategory[it.category] = it + } + } + return byCategory +} + +/** Every raw `n` value in publication order, unknown modifiers included. */ +fun TagArray.typeModifierCodes() = mapNotNull(TypeModifierTag::parseCode) + +fun TagArray.hasTypeModifier(modifier: TypeModifier) = typeModifiers()[modifier.category] == modifier + +fun TagArray.isFirstToFind() = hasTypeModifier(TypeModifier.FIRST_TO_FIND) + +/** + * The `hint` tag exactly as published. + * + * Which form that is — plaintext or ROT13 — depends on the publisher; see [HintObfuscation]. + */ +fun TagArray.hint() = firstNotNullOfOrNull(HintTag::parse) + +/** The hint form safe to show before the reader asks. See [HintObfuscation]. */ +fun TagArray.hintHidden() = hint()?.let(HintObfuscation::hidden) + +/** The hint form the reader gets when they ask. See [HintObfuscation]. */ +fun TagArray.hintRevealed() = hint()?.let(HintObfuscation::revealed) + +/** The first `mission` tag. A listing must not carry more than one; extras are ignored. */ +fun TagArray.mission() = firstNotNullOfOrNull(MissionTag::parse) + +fun TagArray.verificationKey() = firstNotNullOfOrNull(VerificationKeyTag::parse) + +/** The first `F` tag. Only meaningful on a `first-to-find` listing; extras are ignored. */ +fun TagArray.firstToFindWinner() = firstNotNullOfOrNull(FirstToFindWinnerTag::parse) + +fun TagArray.cacheImages() = mapNotNull(ImageTag::parse) + +/** The `r` tags: relays the owner prefers logs to be published to. */ +fun TagArray.logRelays() = mapNotNull(RelayTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheNameTag.kt similarity index 57% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheNameTag.kt index 472972cb6e..c858af7cc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheNameTag.kt @@ -18,31 +18,25 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure -// Helper function to convert JsonElement to standard Kotlin types recursively -fun JsonElement.toAnyValue(): Any = - when (this) { - is JsonPrimitive -> { - if (isString) { - content - } else { - content.toBooleanStrictOrNull() ?: content.toDoubleOrNull() ?: content.toLongOrNull() ?: content - } +/** The `name` tag of a geocache listing (kind 37516): the cache's human-readable name. */ +class CacheNameTag { + companion object { + const val TAG_NAME = "name" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] } - is JsonObject -> { - toAnyMap() - } - - is JsonArray -> { - map { it.toAnyValue() } - } + fun assemble(name: String) = arrayOf(TAG_NAME, name) } - -fun JsonObject.toAnyMap(): Map = entries.associate { it.key to it.value.toAnyValue() } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheSizeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheSizeTag.kt new file mode 100644 index 0000000000..4f7ed9dcd1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheSizeTag.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The container sizes NIP-CC defines for the `S` tag. */ +enum class CacheSize( + val code: String, +) { + MICRO("micro"), + SMALL("small"), + REGULAR("regular"), + LARGE("large"), + OTHER("other"), + ; + + companion object { + fun fromCode(code: String?): CacheSize? = entries.firstOrNull { it.code == code } + } +} + +/** The `S` tag of a geocache listing (kind 37516): the container size. */ +class CacheSizeTag { + companion object { + const val TAG_NAME = "S" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** The parsed [CacheSize], or null when the tag is missing or names a size we don't know. */ + fun parse(tag: Array): CacheSize? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CacheSize.fromCode(tag[1]) + } + + /** The raw `S` value, even when it is outside the vocabulary above. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(size: CacheSize) = arrayOf(TAG_NAME, size.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheTypeTag.kt new file mode 100644 index 0000000000..3618b72938 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/CacheTypeTag.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The cache types NIP-CC names as common. The vocabulary is deliberately open — the spec says + * types "are determined by individual clients" — so [CacheTypeTag.parseTypeCode] hands back + * unknown codes unchanged and only [CacheTypeTag.parse] narrows to this set. + */ +enum class CacheType( + val code: String, +) { + TRADITIONAL("traditional"), + MULTI("multi"), + MYSTERY("mystery"), + ; + + companion object { + fun fromCode(code: String?): CacheType? = entries.firstOrNull { it.code == code } + } +} + +/** + * The `t` tag of a geocache listing (kind 37516). + * + * `t` carries two unrelated things on this kind: the cache *type* (`traditional`, `multi`, + * `mystery`, or anything a client invents) and the retirement marker [ARCHIVED], which owners + * add to preserve a cache's history instead of deleting it. A third meaning — the log type on + * a kind 1111 comment — lives in + * [com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag]. The parsers are + * kept apart per kind on purpose: sharing one would let `archived` read as a cache type. + */ +class CacheTypeTag { + companion object { + const val TAG_NAME = "t" + + /** `["t", "archived"]` retires a listing. It is a lifecycle marker, never a cache type. */ + const val ARCHIVED = "archived" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** The raw `t` value, including [ARCHIVED]. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + /** The raw `t` value when it names a cache type — never [ARCHIVED]. */ + fun parseTypeCode(tag: Array): String? = parseCode(tag)?.takeIf { it != ARCHIVED } + + /** The parsed [CacheType], or null when the value is [ARCHIVED], missing, or client-defined. */ + fun parse(tag: Array): CacheType? = CacheType.fromCode(parseTypeCode(tag)) + + fun isArchived(tag: Array) = parseCode(tag) == ARCHIVED + + fun assemble(type: CacheType) = arrayOf(TAG_NAME, type.code) + + fun assemble(code: String) = arrayOf(TAG_NAME, code) + + fun assembleArchived() = arrayOf(TAG_NAME, ARCHIVED) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/DifficultyTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/DifficultyTag.kt new file mode 100644 index 0000000000..762b2ffb92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/DifficultyTag.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `D` tag of a geocache listing (kind 37516): how hard the cache is to work out, 1..5. + * + * Values outside the range do not parse. NIP-CC states the range without saying what an + * out-of-range value means, and a 9 read as "very hard" would sort above every legitimate + * cache in a client that ranks on it — so it is treated as absent instead. + */ +class DifficultyTag { + companion object { + const val TAG_NAME = "D" + const val MIN = 1 + const val MAX = 5 + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val rating = tag[1].toIntOrNull() ?: return null + ensure(rating in MIN..MAX) { return null } + return rating + } + + /** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */ + fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/FirstToFindWinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/FirstToFindWinnerTag.kt new file mode 100644 index 0000000000..0d0ff09bdf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/FirstToFindWinnerTag.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `F` tag of a geocache listing (kind 37516): the locked-in first-to-find winner. + * + * The owner publishes it in a revision of the listing once they have confirmed a claim, normally + * alongside `["t", "archived"]`. It exists because `created_at` is author-supplied and forgeable: + * without it, a later log carrying a backdated timestamp would displace the real winner. Once + * present, clients MUST attribute the claim to this pubkey regardless of timestamps — which is + * what [com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver] implements. + * + * Only meaningful on a listing carrying the `first-to-find` `n` modifier. + */ +class FirstToFindWinnerTag { + companion object { + const val TAG_NAME = "F" + + /** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */ + private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value) + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1]) + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isPubKey(tag[1])) { return null } + return tag[1] + } + + fun assemble(winnerPubKey: HexKey) = arrayOf(TAG_NAME, winnerPubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/HintTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/HintTag.kt new file mode 100644 index 0000000000..51a35ffc3d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/HintTag.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `hint` tag of a geocache listing (kind 37516): help for finding the cache. + * + * **Which form is on the wire is not knowable from the tag.** NIP-CC contradicts itself — its + * tag table calls this "plaintext" and its example is plaintext, while its Clients section asks + * for "hint encoding, such as ROT13, to prevent spoilers" — and publishers went both ways. Of 34 + * hints sampled from relay.damus.io / nos.lol / relay.primal.net / nostr.wine, 17 were plaintext + * and 17 were ROT13, split cleanly by author. + * + * So this parser hands back exactly what was published and takes no view. + * [com.vitorpamplona.quartz.nipCCGeocaching.listing.HintObfuscation] is where the guess lives, + * and it is the only thing that should be deciding which of a hint's two rotations to show. + * + * Whatever the encoding, it is obfuscation and never secrecy: the hint is public on the relay + * and anyone can rotate it. The only goal is that a reader has to opt in. + */ +class HintTag { + companion object { + const val TAG_NAME = "hint" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hint: String) = arrayOf(TAG_NAME, hint) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/MissionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/MissionTag.kt new file mode 100644 index 0000000000..3ef983a84d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/MissionTag.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `mission` tag of a geocache listing (kind 37516): the "Key Quest" a finder is expected to + * complete to legitimately claim the cache. + * + * A listing must not carry more than one; where one does, the spec says to use the first, which + * is what [com.vitorpamplona.quartz.nipCCGeocaching.listing.mission] does. + * + * NIP-CC says completions may be recorded as NIP-GD "Good Deed" events referencing the cache. + * No such NIP exists in the repository yet, so nothing here models it — `mission` is free text. + */ +class MissionTag { + companion object { + const val TAG_NAME = "mission" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(mission: String) = arrayOf(TAG_NAME, mission) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TerrainTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TerrainTag.kt new file mode 100644 index 0000000000..9c1fd7652f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TerrainTag.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `T` tag of a geocache listing (kind 37516): how hard the cache is to reach, 1..5. + * + * Out-of-range values do not parse, for the same reason as [DifficultyTag]. + */ +class TerrainTag { + companion object { + const val TAG_NAME = "T" + const val MIN = 1 + const val MAX = 5 + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val rating = tag[1].toIntOrNull() ?: return null + ensure(rating in MIN..MAX) { return null } + return rating + } + + /** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */ + fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TypeModifierTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TypeModifierTag.kt new file mode 100644 index 0000000000..09bbeadc5f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/TypeModifierTag.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The category a [TypeModifier] belongs to. + * + * NIP-CC allows at most one `n` modifier per category and lets modifiers from different + * categories compose freely, so the category — not the modifier — is the unit of exclusivity. + * That is why this is modelled as a map keyed by category rather than a set of strings. + */ +enum class TypeModifierCategory { + /** How claims on the treasure are interpreted. */ + CLAIM_SEMANTICS, + + /** What the physical treasure *is*. */ + PRIZE_NATURE, +} + +/** The `n` type modifiers NIP-CC defines today. New ones are expected; see [TypeModifierTag]. */ +enum class TypeModifier( + val code: String, + val category: TypeModifierCategory, +) { + /** Single-claim cache: the earliest verified found log is the exclusive claim. */ + FIRST_TO_FIND("first-to-find", TypeModifierCategory.CLAIM_SEMANTICS), + + /** The cache itself is a physical work of art. */ + ART("art", TypeModifierCategory.PRIZE_NATURE), + ; + + companion object { + fun fromCode(code: String?): TypeModifier? = entries.firstOrNull { it.code == code } + } +} + +/** + * The `n` tag of a geocache listing (kind 37516): a type modifier. + * + * Unknown values parse to null rather than making the listing unreadable — forward + * compatibility is an explicit requirement of the spec, which reserves the right to define new + * modifiers and new categories. [parseCode] exposes the raw value for clients that want to show + * a modifier they don't understand. + */ +class TypeModifierTag { + companion object { + const val TAG_NAME = "n" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** The parsed [TypeModifier], or null for a modifier this version does not know. */ + fun parse(tag: Array): TypeModifier? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return TypeModifier.fromCode(tag[1]) + } + + /** The raw `n` value, whether or not it is a known modifier. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(modifier: TypeModifier) = arrayOf(TAG_NAME, modifier.code) + + fun assemble(modifiers: Collection) = modifiers.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/VerificationKeyTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/VerificationKeyTag.kt new file mode 100644 index 0000000000..1b2d002661 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/listing/tags/VerificationKeyTag.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `verification` tag of a geocache listing (kind 37516): the public half of the keypair a + * finder proves physical presence with. + * + * The matching private key lives at the cache itself, normally behind a QR code. It is not the + * owner's key and is not an account key — see + * [com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent]. + */ +class VerificationKeyTag { + companion object { + const val TAG_NAME = "verification" + + /** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */ + private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value) + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1]) + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isPubKey(tag[1])) { return null } + return tag[1] + } + + fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationEvent.kt new file mode 100644 index 0000000000..f03a56aeda --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationEvent.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.verification + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A geocache verification event (kind 7517): cryptographic proof that someone stood at a cache. + * + * **Signed by the cache's verification key, not by the finder and not by the cache owner.** The + * private half lives at the cache itself, normally behind a QR code, so producing one of these + * requires having physically been there. A finder signs it with an ephemeral signer built from + * the scanned key, embeds the result in their found log, and throws the key away — it is not an + * account key and must never reach a keystore, an account, or a log line. + * + * Read [GeocacheVerificationValidator] before trusting one: a verification event on its own + * proves only that *somebody* was at the cache. It becomes evidence about a particular finder + * only once the signer, the finder and the cache address have all been checked against the + * listing and the log. + */ +@Immutable +class GeocacheVerificationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun finderCache() = tags.finderCache() + + /** The pubkey this verification was issued to. */ + fun finder() = tags.finder() + + /** The cache it was issued at. */ + fun cache() = tags.verifiedCache() + + /** Whether `content` matches the static format NIP-CC mandates for [finder]. */ + fun hasExpectedContent() = finder()?.let { content == contentFor(it) } ?: false + + companion object { + const val KIND = 7517 + + /** NIP-CC fixes the content exactly: `"Geocache verification for "`. */ + fun contentFor(finderPubKey: HexKey) = "Geocache verification for ${NPub.create(finderPubKey)}" + + fun build( + finderPubKey: HexKey, + cache: Address, + relayHint: NormalizedRelayUrl? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, contentFor(finderPubKey), createdAt) { + finderCache(finderPubKey, cache, relayHint) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationValidator.kt new file mode 100644 index 0000000000..ae212f322a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/GeocacheVerificationValidator.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.verification + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent + +/** Why a kind 7517 does not prove what it claims to prove. Ordered cheapest check first. */ +enum class VerificationFailure { + /** The found log carries no `verification` tag, or its payload did not parse as a 7517. */ + NO_EMBEDDED_VERIFICATION, + + /** The listing has no `verification` tag, so nothing at this cache can be verified. */ + CACHE_HAS_NO_VERIFICATION_KEY, + + /** The `a` tag is missing or is not the `:` pair NIP-CC defines. */ + MALFORMED_FINDER_CACHE_TAG, + + /** The 7517 names a different cache than the listing it is being checked against. */ + CACHE_MISMATCH, + + /** The 7517 was issued to somebody other than the author of the found log. */ + FINDER_MISMATCH, + + /** `content` is not the static `"Geocache verification for "` string. */ + CONTENT_MISMATCH, + + /** The 7517 was signed by some other key than the cache's verification key. */ + WRONG_SIGNER, + + /** The id or the signature does not check out. */ + BAD_SIGNATURE, +} + +/** + * The four checks NIP-CC lists under "Verification Validation", plus the two the spec leaves + * implicit. + * + * Each one exists because skipping it breaks something concrete: + * + * - **signer** — without it, anybody signs their own "verification" and every cache is verified. + * - **finder** — without it, a verification issued to somebody else can be lifted out of their + * log and replayed into yours. This is the check that makes the proof about *a person* rather + * than about the cache. + * - **cache** — without it, a verification earned at an easy cache is replayed at a hard one. + * - **signature** — without it, all three of the above are checks against unauthenticated text. + * - **content** — NIP-CC fixes the content string, so a mismatch means the event was not + * produced by a conforming client and its other fields are not worth trusting either. + * + * Note what a valid result does and does not mean. It means somebody who had access to the + * private key at the cache issued this to that finder for that cache. It does not mean the finder + * went there in person — a key that has leaked verifies just as well, which is why NIP-CC treats + * these as evidence of presence rather than proof of identity, and why first-to-find claims get + * locked in by the owner rather than decided by timestamps. + */ +object GeocacheVerificationValidator { + /** + * Validates [verification] as proof that [finderPubKey] was at [listing]. + * + * @return null when it checks out, or the first [VerificationFailure] found. + */ + fun validate( + verification: GeocacheVerificationEvent, + listing: GeocacheListingEvent, + finderPubKey: HexKey, + ): VerificationFailure? { + val expectedSigner = listing.verificationKey() ?: return VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY + val claim = verification.finderCache() ?: return VerificationFailure.MALFORMED_FINDER_CACHE_TAG + + if (claim.cache != listing.address()) return VerificationFailure.CACHE_MISMATCH + if (claim.finderPubKey != finderPubKey) return VerificationFailure.FINDER_MISMATCH + if (verification.content != GeocacheVerificationEvent.contentFor(claim.finderPubKey)) return VerificationFailure.CONTENT_MISMATCH + if (verification.pubKey != expectedSigner) return VerificationFailure.WRONG_SIGNER + + // Last because it is the only check that costs a curve operation. + if (!verification.verify()) return VerificationFailure.BAD_SIGNATURE + + return null + } + + /** Validates the verification embedded in [log], attributing it to the log's own author. */ + fun validate( + log: GeocacheFoundLogEvent, + listing: GeocacheListingEvent, + ): VerificationFailure? { + val verification = log.embeddedVerification() ?: return VerificationFailure.NO_EMBEDDED_VERIFICATION + return validate(verification, listing, log.pubKey) + } + + fun isValid( + verification: GeocacheVerificationEvent, + listing: GeocacheListingEvent, + finderPubKey: HexKey, + ) = validate(verification, listing, finderPubKey) == null + + /** Whether [log] carries a verification that holds up against [listing]. */ + fun isValid( + log: GeocacheFoundLogEvent, + listing: GeocacheListingEvent, + ) = validate(log, listing) == null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ff0fd23de1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayBuilderExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.verification + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag + +fun TagArrayBuilder.finderCache( + finderPubKey: HexKey, + cache: Address, + relayHint: NormalizedRelayUrl? = null, +) = addUnique(FinderCacheTag.assemble(finderPubKey, cache, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayExt.kt new file mode 100644 index 0000000000..c877de4b7d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/TagArrayExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.verification + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag + +fun TagArray.finderCache() = firstNotNullOfOrNull(FinderCacheTag::parse) + +fun TagArray.finder() = firstNotNullOfOrNull(FinderCacheTag::parseFinder) + +fun TagArray.verifiedCache() = firstNotNullOfOrNull(FinderCacheTag::parseCache) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/tags/FinderCacheTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/tags/FinderCacheTag.kt new file mode 100644 index 0000000000..1d3e371568 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/verification/tags/FinderCacheTag.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching.verification.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `a` tag of a geocache verification event (kind 7517): who was at which cache. + * + * **This is not a NIP-01 `a` tag.** NIP-CC defines its value as + * `":"` — a colon-joined pair, where a NIP-01 address is + * `kind:pubkey:d`. [com.vitorpamplona.quartz.nip01Core.core.Address.parse] rejects it (two + * segments fail its three-segment guard, and the remainder does not start with `naddr1`) and logs + * a warning on the way out, so a generic `a`-tag reader that meets one of these gets nothing + * useful and a noisy log. Hence a parser of its own. + * + * Reading is slightly more generous than writing: the cache half goes through + * [Address.parse], which accepts both the spec's `naddr1…` and a plain `kind:pubkey:d`. The + * finder half must be exactly 64 hex characters, which is what keeps the two forms unambiguous — + * a bare NIP-01 address would have `37516` on the left and is rejected. + */ +@Immutable +data class FinderCacheTag( + val finderPubKey: HexKey, + val cache: Address, +) { + fun toTagArray() = assemble(finderPubKey, cache, null) + + companion object { + const val TAG_NAME = "a" + + private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value) + + fun isTag(tag: Array) = parse(tag) != null + + fun parse(tag: Array): FinderCacheTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val separator = tag[1].indexOf(':') + ensure(separator > 0) { return null } + + val finder = tag[1].substring(0, separator) + ensure(isPubKey(finder)) { return null } + + val cache = Address.parse(tag[1].substring(separator + 1)) ?: return null + + return FinderCacheTag(finder, cache) + } + + fun parseFinder(tag: Array) = parse(tag)?.finderPubKey + + fun parseCache(tag: Array) = parse(tag)?.cache + + fun assemble( + finderPubKey: HexKey, + cache: Address, + relayHint: NormalizedRelayUrl?, + ) = arrayOf(TAG_NAME, "$finderPubKey:${NAddress.create(cache.kind, cache.pubKeyHex, cache.dTag, relayHint)}") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index f99ff9628b..a9d7267e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -416,6 +416,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent @@ -643,6 +647,10 @@ class EventFactory { FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig) + GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig) + GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig) + GeocacheCurationListEvent.KIND -> GeocacheCurationListEvent(id, pubKey, createdAt, tags, content, sig) GeohashChatEvent.KIND -> GeohashChatEvent(id, pubKey, createdAt, tags, content, sig) GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) // kind:20001 is shared by BitChat's GeohashPresenceEvent and Buzz's diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt new file mode 100644 index 0000000000..57c479c808 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The CLINK half of [com.vitorpamplona.quartz.nip47WalletConnect.Nip47MalformedInputTest]: + * a payment service is somebody else's software too, and CLINK is experimental, so its + * wire shapes are still moving. + */ +class ClinkMalformedInputTest { + private inline fun parse(json: String): T = KotlinSerializationMapper.fromJsonTo(json) + + @Test + fun anOfferRequestParsesFromNothingButTheOffer() { + val req = parse("""{"offer":"lno1abc"}""") + + assertEquals("lno1abc", req.offer) + assertNull(req.amount_sats) + assertNull(req.payer_data) + } + + @Test + fun unknownFieldsAreIgnoredAndWrongShapesCostOnlyTheirField() { + val req = + parse( + """{"offer":"lno1abc","amount_sats":{"not":"a number"}, + "description":"dinner","invented_by_a_newer_service":[1,2]}""", + ) + + assertEquals("lno1abc", req.offer) + assertEquals("dinner", req.description) + assertNull(req.amount_sats) + } + + @Test + fun amountsQuotedAsStringsStillParse() { + val req = parse("""{"offer":"lno1abc","amount_sats":"2500"}""") + + assertEquals(2500L, req.amount_sats) + } + + @Test + fun anErrorResponseSurvivesACodeSentAsAString() { + val res = parse("""{"error":"Invalid Amount","code":"5"}""") + + assertEquals(5, res.code) + assertEquals("Invalid Amount", res.error) + assertTrue(!res.isSuccess()) + } + + @Test + fun aLoneDetailsObjectIsReadAsAOneElementList() { + // Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY was enabled for exactly this: a service + // answers with a bare object for one result and an array for several. + val res = parse("""{"res":"ok","resource":"offer","details":{"offer_id":"a1"}}""") + + assertEquals(1, res.details?.size) + assertTrue(res.isOk()) + } + + @Test + fun aBrokenEntryInDetailsDoesNotLoseTheGoodOnes() { + val res = + parse( + """{"res":"ok","resource":"offer","details":[{"offer_id":"a1"},"junk",{"offer_id":"a2"}]}""", + ) + + assertEquals(2, res.details?.size) + } + + @Test + fun explicitNullsReadAsAbsent() { + val res = parse("""{"bolt11":null,"error":null,"code":null}""") + + assertNull(res.bolt11) + assertNull(res.error) + assertNull(res.code) + } + + @Test + fun anEmptyObjectIsNotAnException() { + val res = parse("""{}""") + + assertNull(res.bolt11) + assertTrue(!res.isSuccess()) + } + + @Test + fun aRangeOfTheWrongShapeDoesNotFailTheResponse() { + val res = parse("""{"error":"Invalid Amount","code":5,"range":"1-100"}""") + + assertEquals(5, res.code) + assertNull(res.range) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJsonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJsonTest.kt new file mode 100644 index 0000000000..61941cc498 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJsonTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The readers behind the NIP-47 and CLINK parsers, where every byte comes from + * somebody else's wallet. Their contract is that a field of the wrong shape reads + * as null and the rest of the message still parses — never a wrong value. + */ +class LenientJsonTest { + private fun obj(json: String) = Json.parseToJsonElement(json) as JsonObject + + @Test + fun intOutOfRangeReadsAsNullRatherThanWrapping() { + // 2^32 truncates to 0 and 3_000_000_000 to a negative int if the Long is + // narrowed with toInt(). A limit of 0 or a negative offset is worse than + // no value at all: it is a plausible-looking wrong answer. + assertNull(obj("""{"limit":4294967296}""").intOrNull("limit")) + assertNull(obj("""{"offset":3000000000}""").intOrNull("offset")) + assertNull(obj("""{"n":-3000000000}""").intOrNull("n")) + } + + @Test + fun intInRangeStillReads() { + assertEquals(42, obj("""{"n":42}""").intOrNull("n")) + assertEquals(Int.MAX_VALUE, obj("""{"n":2147483647}""").intOrNull("n")) + assertEquals(Int.MIN_VALUE, obj("""{"n":-2147483648}""").intOrNull("n")) + // the same string/float tolerance longOrNull has + assertEquals(12, obj("""{"n":"12"}""").intOrNull("n")) + assertEquals(12, obj("""{"n":12.0}""").intOrNull("n")) + } + + @Test + fun longAcceptsTheThreeShapesWalletsSend() { + assertEquals(12L, obj("""{"n":12}""").longOrNull("n")) + assertEquals(12L, obj("""{"n":"12"}""").longOrNull("n")) + assertEquals(12L, obj("""{"n":12.0}""").longOrNull("n")) + assertNull(obj("""{"n":{"nested":1}}""").longOrNull("n")) + assertNull(obj("""{"n":null}""").longOrNull("n")) + assertNull(obj("""{}""").longOrNull("n")) + } + + @Test + fun doubleRejectsNonFiniteText() { + // "NaN" and "Infinity" parse as Doubles in Kotlin but are not values any + // caller can do arithmetic with. + assertNull(obj("""{"n":"NaN"}""").doubleOrNull("n")) + assertNull(obj("""{"n":"Infinity"}""").doubleOrNull("n")) + assertEquals(1.5, obj("""{"n":1.5}""").doubleOrNull("n")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt index 6e25c9bc33..d3d5078a3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt @@ -39,7 +39,7 @@ import kotlin.test.assertNull * null, not the string "null", and must not crash on a null `metadata` object. * * Our own Jackson backend no longer emits those on request params — see - * [com.vitorpamplona.quartz.nip01Core.jackson.OmitNullsMixin] — but a third-party + * Jackson's OmitNullsMixin, now deleted along with that path — but a third-party * wallet still may, so tolerating them on the way in remains required. */ class Nip47KotlinSerializationNullTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt new file mode 100644 index 0000000000..3373492a03 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcUnknownResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a NIP-47 response parser has to survive, because the wallet on the other end + * is somebody else's software and NIP-47 keeps growing. + * + * The rule every case here asserts: one bad field costs you that field, never the + * message. Jackson used to bind these reflectively and answered a type mismatch with + * MismatchedInputException — a `fees_paid` of `"0"` instead of `0` threw away a + * settled payment's preimage. + */ +class Nip47MalformedInputTest { + private fun parse(json: String): Response = KotlinSerializationMapper.fromJsonTo(json) + + @Test + fun unknownResultTypeKeepsItsResultInsteadOfFailing() { + // A method from a newer NIP-47, or an extension this build does not implement. + val res = parse("""{"result_type":"make_offer","result":{"offer":"lno1abc","amount":42}}""") + + assertTrue(res is NwcUnknownResponse) + assertEquals("make_offer", res.resultType) + assertEquals("lno1abc", res.result?.get("offer")) + assertEquals(42L, res.result?.get("amount")) + } + + @Test + fun unknownExtraFieldsAreIgnored() { + val res = + parse( + """{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":3, + "totally_new_field":{"nested":true}},"extra_root_field":[1,2,3]}""", + ) + + assertTrue(res is PayInvoiceSuccessResponse) + assertEquals("abc", res.result?.preimage) + assertEquals(3L, res.result?.fees_paid) + } + + @Test + fun numbersSentAsStringsStillParse() { + // Several wallets quote their integers. + val res = parse("""{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":"12"}}""") + + assertTrue(res is PayInvoiceSuccessResponse) + assertEquals(12L, res.result?.fees_paid) + } + + @Test + fun aFieldOfTheWrongShapeCostsOnlyThatField() { + // `preimage` arrives as an object. The payment still settled; read what is there. + val res = + parse( + """{"result_type":"pay_invoice","result":{"preimage":{"unexpected":"object"},"fees_paid":7}}""", + ) + + assertTrue(res is PayInvoiceSuccessResponse) + assertNull(res.result?.preimage) + assertEquals(7L, res.result?.fees_paid) + } + + @Test + fun explicitNullsReadTheSameAsAbsentKeys() { + val res = parse("""{"result_type":"pay_invoice","result":{"preimage":null,"fees_paid":null}}""") + + assertTrue(res is PayInvoiceSuccessResponse) + assertNull(res.result?.preimage) + assertNull(res.result?.fees_paid) + } + + @Test + fun aBrokenEntryDoesNotTakeDownTheWholeList() { + val res = + parse( + """{"result_type":"list_transactions","result":{"transactions":[ + {"type":"incoming","amount":1000}, + "not-an-object", + {"type":"outgoing","amount":"2000"}]}}""", + ) + + assertTrue(res is ListTransactionsSuccessResponse) + val txs = res.result?.transactions + assertEquals(2, txs?.size) + assertEquals(1000L, txs?.get(0)?.amount) + assertEquals(2000L, txs?.get(1)?.amount) + } + + @Test + fun aStringListDropsNonStringEntriesRatherThanFailing() { + val res = + parse( + """{"result_type":"get_info","result":{"methods":["pay_invoice",{"bad":1},"get_balance"]}}""", + ) + + assertTrue(res is GetInfoSuccessResponse) + assertEquals(listOf("pay_invoice", "get_balance"), res.result?.methods) + } + + @Test + fun aLoneValueIsAcceptedWhereAListIsExpected() { + // Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY, preserved. + val res = parse("""{"result_type":"get_info","result":{"methods":"pay_invoice"}}""") + + assertTrue(res is GetInfoSuccessResponse) + assertEquals(listOf("pay_invoice"), res.result?.methods) + } + + @Test + fun anErrorWithoutAMessageStillReportsItsCode() { + val res = parse("""{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE"}}""") + + assertTrue(res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike) + assertEquals("INSUFFICIENT_BALANCE", res.errorMessage()) + } + + @Test + fun anErrorWhoseCodeIsUnknownIsStillAnError() { + val res = parse("""{"result_type":"pay_invoice","error":{"code":"SOMETHING_NEW","message":"nope"}}""") + + assertTrue(res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike) + assertEquals("nope", res.errorMessage()) + } + + @Test + fun anEmptyObjectIsNotAnException() { + val res = parse("""{}""") + + assertTrue(res is NwcUnknownResponse) + assertEquals("", res.resultType) + } + + @Test + fun anErrorTypedResponseSurvivesAnErrorFieldOfTheWrongShape() { + // `error` present but a string, not an object: still an error response. + val res = parse("""{"result_type":"pay_invoice","error":"boom"}""") + + assertTrue(res is NwcErrorResponse || res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse) + } + + @Test + fun aPayloadThatIsNotAnObjectFailsWithAClearMessage() { + // The one input still refused: there is no message to salvage from an array. + val thrown = + try { + parse("""["not","a","response"]""") + null + } catch (e: IllegalArgumentException) { + e + } + + assertTrue(thrown != null, "an array root must be rejected") + assertTrue(thrown.message?.contains("must be a JSON object") == true, "got: ${thrown.message}") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt index 374fdb4880..137db2238a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt @@ -89,6 +89,16 @@ class IndexableFieldVisitorTest { // Community definition: name + description + rules + content. assertAgrees(34550, arrayOf(arrayOf("name", "N"), arrayOf("description", "D"), arrayOf("rules", "R")), "c") assertAgrees(34550, emptyArray(), "c") + + // Geocache listing: name + content. The `hint` is deliberately not indexed, so a listing + // that has one must still rejoin to exactly name + content. + assertAgrees(37516, arrayOf(arrayOf("name", "First Treasure"), arrayOf("hint", "In the branches")), "a cache") + assertAgrees(37516, emptyArray(), "a cache") + + // Geocache curation list: title + description + content. + assertAgrees(37517, arrayOf(arrayOf("title", "T"), arrayOf("description", "D")), "c") + assertAgrees(37517, arrayOf(arrayOf("title", "T")), "c") + assertAgrees(37517, emptyArray(), "c") } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt new file mode 100644 index 0000000000..dba5cf91cd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip52Calendar + +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The broadcaster (EventBroadcaster) resolves a published event's relay set from these two + * interfaces: `p` tags become inbox relays of everyone tagged, `a` tags get followed into the + * referenced addressable and recursed. Without them an RSVP reaches only the sender's outbox. + */ +class CalendarHintProviderTest { + private val host = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val invitee = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + private val rsvper = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val apptAddress = "31923:$host:my-party" + private val apptId = "43575072239da152afe3d7b5c70ed2beb48db2b10e60c60da45229c09c877d2a" + private val relay = "wss://relay.damus.io/" + + private fun rsvp(vararg tags: Array) = + CalendarRSVPEvent( + id = "00".repeat(32), + pubKey = rsvper, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + private fun timeSlot(vararg tags: Array) = + CalendarTimeSlotEvent( + id = "11".repeat(32), + pubKey = host, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + private fun calendar(vararg tags: Array) = + CalendarEvent( + id = "33".repeat(32), + pubKey = host, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + private fun dateSlot(vararg tags: Array) = + CalendarDateSlotEvent( + id = "22".repeat(32), + pubKey = host, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + @Test + fun rsvpExposesTheTargetAppointmentAsALinkedAddress() { + val event = rsvp(arrayOf("a", apptAddress, relay), arrayOf("status", "accepted")) + + // Typed, not `is`: the point is that the class declares the interface at compile time. + val provider: AddressHintProvider = event + assertEquals(listOf(apptAddress), provider.linkedAddressIds()) + + val hint = provider.addressHints().single() + assertEquals(apptAddress, hint.addressId) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), hint.relay) + } + + @Test + fun rsvpWithoutARelayHintStillLinksTheAddress() { + val event = rsvp(arrayOf("a", apptAddress), arrayOf("status", "accepted")) + + assertEquals(listOf(apptAddress), event.linkedAddressIds()) + assertTrue(event.addressHints().isEmpty()) + } + + @Test + fun rsvpLinksEveryTaggedParticipantNotJustTheHost() { + val event = + rsvp( + arrayOf("a", apptAddress, relay), + arrayOf("p", host), + arrayOf("p", invitee, relay), + arrayOf("status", "accepted"), + ) + + assertEquals(listOf(host, invitee), event.linkedPubKeys()) + assertEquals(listOf(invitee), event.pubKeyHints().map { it.pubkey }) + } + + @Test + fun timeSlotExposesParticipantsAsLinkedPubKeys() { + val event = timeSlot(arrayOf("d", "my-party"), arrayOf("p", invitee, relay, "speaker")) + + val provider: PubKeyHintProvider = event + assertEquals(listOf(invitee), provider.linkedPubKeys()) + + val hint = provider.pubKeyHints().single() + assertEquals(invitee, hint.pubkey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), hint.relay) + } + + @Test + fun dateSlotExposesParticipantsAsLinkedPubKeys() { + val event = dateSlot(arrayOf("d", "my-party"), arrayOf("p", invitee, relay, "speaker")) + + val provider: PubKeyHintProvider = event + assertEquals(listOf(invitee), provider.linkedPubKeys()) + assertEquals(invitee, provider.pubKeyHints().single().pubkey) + } + + @Test + fun rsvpExposesTheOptionalETagAsALinkedEvent() { + // NIP-52's `e` tag pins the exact appointment revision the RSVP answered; the `a` tag + // alone follows the host's later edits. + val event = + rsvp( + arrayOf("a", apptAddress, relay), + arrayOf("e", apptId, relay, host), + arrayOf("status", "accepted"), + ) + + val provider: EventHintProvider = event + assertEquals(listOf(apptId), provider.linkedEventIds()) + + val hint = provider.eventHints().single() + assertEquals(apptId, hint.eventId) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), hint.relay) + } + + @Test + fun rsvpWithoutAnETagLinksNoEvents() { + val event = rsvp(arrayOf("a", apptAddress, relay), arrayOf("status", "accepted")) + + assertTrue(event.linkedEventIds().isEmpty()) + assertTrue(event.eventHints().isEmpty()) + } + + @Test + fun calendarExposesItsAppointmentsAsLinkedAddresses() { + val other = "31922:$invitee:standup" + val event = calendar(arrayOf("d", "my-calendar"), arrayOf("a", apptAddress, relay), arrayOf("a", other)) + + val provider: AddressHintProvider = event + assertEquals(listOf(apptAddress, other), provider.linkedAddressIds()) + assertEquals(apptAddress, provider.addressHints().single().addressId) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt index 0f878d0ee0..e31e88f745 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -25,7 +25,7 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Kinds 34235/34236 (legacy NIP-71 videos) are parameterized replaceable + * Kinds 34235/34236 (the addressable NIP-71 videos) are parameterized replaceable * events: their address MUST include the `d` tag. A wrong (empty-dTag) * address makes LocalCache consume the event into a different * AddressableNote than the one `a` tags point to, so quotes/reposts of diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/FirstToFindResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/FirstToFindResolverTest.kt new file mode 100644 index 0000000000..2bbe9c4fa4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/FirstToFindResolverTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The first-to-find claim rules. + * + * The interesting case is the forged timestamp: `created_at` is author-supplied, so the + * provisional ordering can be stolen by anyone willing to backdate a log. The `F` tag is what + * takes it back, and these tests pin that it actually does. + */ +class FirstToFindResolverTest { + private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd" + private val cacheKey = NostrSignerInternal(KeyPair()) + private val winner = NostrSignerInternal(KeyPair()) + private val latecomer = NostrSignerInternal(KeyPair()) + + private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "linocut-aftermath") + + private fun listing( + firstToFind: Boolean = true, + lockedInWinner: String? = null, + withVerificationKey: Boolean = true, + ) = GeocacheListingEvent( + "id", + owner, + 1_748_619_568L, + buildList { + add(arrayOf("d", cacheAddress.dTag)) + add(arrayOf("name", "Aftermath")) + add(arrayOf("g", "u4xsu6ryb")) + add(arrayOf("D", "2")) + add(arrayOf("T", "2")) + add(arrayOf("S", "small")) + if (firstToFind) add(arrayOf("n", "first-to-find")) + if (withVerificationKey) add(arrayOf("verification", cacheKey.pubKey)) + lockedInWinner?.let { add(arrayOf("F", it)) } + }.toTypedArray(), + "", + "sig", + ) + + private suspend fun verifiedLog( + finder: NostrSignerInternal, + createdAt: Long, + id: String = createdAt.toString().padStart(64, '0'), + ): GeocacheFoundLogEvent { + val verification = cacheKey.sign(GeocacheVerificationEvent.build(finder.pubKey, cacheAddress, createdAt = createdAt)) + return GeocacheFoundLogEvent( + id, + finder.pubKey, + createdAt, + arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", verification.toJson())), + "Found it!", + "sig", + ) + } + + private fun unverifiedLog( + finder: NostrSignerInternal, + createdAt: Long, + id: String = createdAt.toString().padStart(64, '0'), + ) = GeocacheFoundLogEvent( + id, + finder.pubKey, + createdAt, + arrayOf(arrayOf("a", cacheAddress.toValue())), + "Found it!", + "sig", + ) + + @Test + fun theEarliestVerifiedLogHoldsTheProvisionalClaim() = + runTest { + val first = verifiedLog(winner, 1_000L) + val second = verifiedLog(latecomer, 2_000L) + val cache = listing() + + assertEquals(first.id, FirstToFindResolver.provisionalWinningLog(cache, listOf(second, first))?.id) + assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(second, first))) + assertTrue(FirstToFindResolver.isClaimed(cache, listOf(second, first))) + } + + @Test + fun tiesOnCreatedAtBreakOnAscendingEventId() = + runTest { + val a = verifiedLog(winner, 1_000L, id = "a".repeat(64)) + val b = verifiedLog(latecomer, 1_000L, id = "b".repeat(64)) + + assertEquals(a.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(b, a))?.id) + } + + @Test + fun anUnverifiedLogIsNotAClaimHoweverEarlyItIs() = + runTest { + // "I got here first" without a 7517 is somebody's word. The exclusive claim is + // reserved for logs that prove physical presence. + val wordOfMouth = unverifiedLog(latecomer, 1L) + val real = verifiedLog(winner, 9_000L) + + assertEquals(real.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(wordOfMouth, real))?.id) + } + + @Test + fun aLogAboutAnotherCacheIsNotACandidate() = + runTest { + val elsewhere = + GeocacheFoundLogEvent( + "e".repeat(64), + latecomer.pubKey, + 1L, + arrayOf(arrayOf("a", Address(GeocacheListingEvent.KIND, owner, "some-other-cache").toValue())), + "Found it!", + "sig", + ) + val real = verifiedLog(winner, 9_000L) + + assertEquals(listOf(real.id), FirstToFindResolver.verifiedLogs(listing(), listOf(elsewhere, real)).map { it.id }) + } + + @Test + fun aForgedEarlierTimestampStealsTheProvisionalClaim() = + runTest { + // Not a bug to fix here — this is exactly the weakness NIP-CC calls out, and the + // reason the owner gets to lock a winner in. Pinned so the next test means something. + val real = verifiedLog(winner, 5_000L) + val backdated = verifiedLog(latecomer, 1L) + + assertEquals(latecomer.pubKey, FirstToFindResolver.winnerPubKey(listing(), listOf(real, backdated))) + } + + @Test + fun theFTagBeatsAnyTimestamp() = + runTest { + // "clients MUST attribute the exclusive claim to the pubkey in the `F` tag, + // regardless of which verified found log currently appears earliest." + val real = verifiedLog(winner, 5_000L) + val backdated = verifiedLog(latecomer, 1L) + val cache = listing(lockedInWinner = winner.pubKey) + + assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(real, backdated))) + assertEquals(real.id, FirstToFindResolver.winningLog(cache, listOf(real, backdated))?.id) + assertTrue(FirstToFindResolver.isLockedIn(cache)) + } + + @Test + fun theLockedInWinnerHoldsEvenWithNoLogFetchedForThem() = + runTest { + // The owner has confirmed the claim; the winning log may simply not be in hand yet. + val cache = listing(lockedInWinner = winner.pubKey) + + assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, emptyList())) + assertNull(FirstToFindResolver.winningLog(cache, emptyList())) + assertTrue(FirstToFindResolver.isClaimed(cache, emptyList())) + } + + @Test + fun theWinningLogIsTheWinnersEarliestVerifiedOne() = + runTest { + val early = verifiedLog(winner, 5_000L, id = "1".repeat(64)) + val later = verifiedLog(winner, 6_000L, id = "2".repeat(64)) + val cache = listing(lockedInWinner = winner.pubKey) + + assertEquals(early.id, FirstToFindResolver.winningLog(cache, listOf(later, early))?.id) + } + + @Test + fun aCacheThatIsNotFirstToFindHasNoExclusiveClaimToAward() = + runTest { + // Later verified logs stay valid records of presence on any cache; they are only + // *claims* where the modifier says the cache is single-claim. + val logs = listOf(verifiedLog(winner, 1_000L)) + val cache = listing(firstToFind = false, lockedInWinner = winner.pubKey) + + assertNull(FirstToFindResolver.winnerPubKey(cache, logs)) + assertNull(FirstToFindResolver.winningLog(cache, logs)) + assertFalse(FirstToFindResolver.isClaimed(cache, logs)) + } + + @Test + fun aFirstToFindCacheWithNoVerificationKeyCanNeverBeClaimed() = + runTest { + // NIP-CC: `first-to-find` "Requires a `verification` tag". With none, no log can be + // verified, so no log is a claim. + val logs = listOf(verifiedLog(winner, 1_000L)) + val cache = listing(withVerificationKey = false) + + assertNull(FirstToFindResolver.provisionalWinningLog(cache, logs)) + assertFalse(FirstToFindResolver.isClaimed(cache, logs)) + } + + @Test + fun anUnclaimedFirstToFindCacheReportsNoWinner() { + assertNull(FirstToFindResolver.winnerPubKey(listing(), emptyList())) + assertFalse(FirstToFindResolver.isClaimed(listing(), emptyList())) + assertFalse(FirstToFindResolver.isLockedIn(listing())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationListEventTest.kt new file mode 100644 index 0000000000..e32e3e642a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationListEventTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme +import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Kind 37517, checked against the spec's Ren Fest example. */ +class GeocacheCurationListEventTest { + private val curator = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd" + private val otherAuthor = "b".repeat(64) + + private fun list(vararg tags: Array) = GeocacheCurationListEvent("id", curator, 1_748_619_568L, arrayOf(*tags), "Explore the grounds!", "sig") + + private fun Array>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] } + + @Test + fun theKindIsRegisteredWithTheEventFactory() { + assertTrue(EventFactory.isKnownKind(GeocacheCurationListEvent.KIND)) + } + + @Test + fun parsesTheSpecsCurationListExample() { + val curated = + list( + arrayOf("d", "ren-fest-hunt-1748619568670"), + arrayOf("title", "Texas Ren Fest Treasure Hunt"), + arrayOf("description", "Find all the hidden treasures at the festival!"), + arrayOf("image", "https://blossom.primal.net/banner-example.jpg"), + arrayOf("g", "9vk"), + arrayOf("g", "9vk5"), + arrayOf("g", "9vk5b"), + arrayOf("g", "9vk5b7"), + arrayOf("theme", "adventure"), + arrayOf("map", "adventure"), + arrayOf("a", "37516:$curator:first-treasure-1748619568668"), + arrayOf("a", "37516:$curator:verified-treasure-1748619568669"), + ) + + assertEquals("Texas Ren Fest Treasure Hunt", curated.title()) + assertEquals("Find all the hidden treasures at the festival!", curated.description()) + assertEquals("https://blossom.primal.net/banner-example.jpg", curated.image()) + assertEquals(ListTheme.ADVENTURE, curated.theme()) + assertEquals(MapStyle.ADVENTURE, curated.mapStyle()) + assertEquals(4, curated.geohashes().size) + assertEquals(2, curated.geocaches().size) + assertTrue(curated.isWellFormed()) + } + + @Test + fun theOrderOfTheCachesIsPreserved() { + // "Order is preserved and meaningful" — a trail rendered out of order is a different + // trail. + val curated = + list( + arrayOf("d", "x"), + arrayOf("title", "t"), + arrayOf("a", "37516:$curator:third"), + arrayOf("a", "37516:$curator:first"), + arrayOf("a", "37516:$curator:second"), + ) + + assertEquals(listOf("third", "first", "second"), curated.geocaches().map { it.dTag }) + } + + @Test + fun aListCanSpanCachesFromSeveralAuthors() { + val curated = + list( + arrayOf("d", "x"), + arrayOf("title", "t"), + arrayOf("a", "37516:$curator:mine"), + arrayOf("a", "37516:$otherAuthor:theirs"), + ) + + assertEquals(listOf(curator, otherAuthor), curated.geocaches().map { it.pubKeyHex }) + } + + @Test + fun anAddressThatIsNotACacheIsNotInTheItinerary() { + // A list may reference anything; only the cache kinds belong in a treasure hunt. + val curated = + list( + arrayOf("d", "x"), + arrayOf("title", "t"), + arrayOf("a", "30023:$curator:a-blog-post"), + arrayOf("a", "37516:$curator:a-cache"), + ) + + assertEquals(listOf("a-cache"), curated.geocaches().map { it.dTag }) + assertEquals(2, curated.addresses().size) + } + + @Test + fun theUndefinedLegacyKindIsAcceptedOnRead() { + // NIP-CC says a list references "kind 37516 or 37515" but never defines 37515. Readers + // accept it; the builder never writes it. + val curated = list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "37515:$curator:old-cache")) + + assertEquals(listOf("old-cache"), curated.geocaches().map { it.dTag }) + } + + @Test + fun anUnknownThemeOrMapStyleIsNotSilentlyADefault() { + val curated = list(arrayOf("theme", "brutalist"), arrayOf("map", "topographic")) + + assertNull(curated.theme()) + assertNull(curated.mapStyle()) + assertEquals("brutalist", curated.themeCode()) + assertEquals("topographic", curated.mapStyleCode()) + } + + @Test + fun aListWithNoCachesIsNotWellFormed() { + assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t")).isWellFormed()) + assertFalse(list(arrayOf("d", "x"), arrayOf("a", "37516:$curator:c")).isWellFormed()) + assertFalse(list(arrayOf("title", "t"), arrayOf("a", "37516:$curator:c")).isWellFormed()) + } + + @Test + fun aListOfOnlyNonCachesIsNotWellFormed() { + // The `a` tag is there, so the tag-name gate passes; the list is still empty. + assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "30023:$curator:post")).isWellFormed()) + } + + @Test + fun buildProducesTheTagsTheSpecRequires() { + val template = + GeocacheCurationListEvent.build( + title = "Texas Ren Fest Treasure Hunt", + geocaches = + listOf( + Address(GeocacheListingEvent.KIND, curator, "first-treasure-1748619568668"), + Address(GeocacheListingEvent.KIND, curator, "verified-treasure-1748619568669"), + ), + content = "Explore the festival grounds!", + description = "Find all the hidden treasures at the festival!", + image = "https://blossom.primal.net/banner-example.jpg", + geohash = "9vk5b7xy", + theme = ListTheme.ADVENTURE, + mapStyle = MapStyle.ADVENTURE, + dTag = "ren-fest-hunt-1748619568670", + createdAt = 1_748_619_568L, + ) + + assertEquals(GeocacheCurationListEvent.KIND, template.kind) + assertEquals(listOf("ren-fest-hunt-1748619568670"), template.tags.values("d")) + assertEquals(listOf("Texas Ren Fest Treasure Hunt"), template.tags.values("title")) + assertEquals(listOf("adventure"), template.tags.values("theme")) + assertEquals(listOf("adventure"), template.tags.values("map")) + assertEquals( + listOf( + "37516:$curator:first-treasure-1748619568668", + "37516:$curator:verified-treasure-1748619568669", + ), + template.tags.values("a"), + ) + } + + @Test + fun aListsGeohashLadderStopsAtSix() { + // A trail's centre point is not a place anybody walks to, so it is published coarser + // than a listing's 3..9. + val template = + GeocacheCurationListEvent.build( + title = "t", + geocaches = listOf(Address(GeocacheListingEvent.KIND, curator, "c")), + geohash = "9vk5b7xyz", + ) + + assertEquals(listOf("9vk", "9vk5", "9vk5b", "9vk5b7"), template.tags.values("g")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationRevisionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationRevisionTest.kt new file mode 100644 index 0000000000..182cfec08d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheCurationRevisionTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent +import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationRevision +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Adding a cache to a hunt that already exists. + * + * The order of a curation list's `a` tags is its content — NIP-CC makes it meaningful — so the + * tests that matter are the ones about position and about what survives the republish. + */ +class GeocacheCurationRevisionTest { + private val owner = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + private fun cache(d: String) = Address(GeocacheListingEvent.KIND, owner, d) + + private fun hunt(extra: List> = emptyList()): GeocacheCurationListEvent { + val tags = + ( + listOf( + arrayOf("d", "lisbon-miradouros"), + arrayOf("title", "Lisbon Miradouros"), + arrayOf("a", cache("graca").toValue()), + arrayOf("a", cache("senhora-do-monte").toValue()), + ) + extra + ).toTypedArray() + + return GeocacheCurationListEvent("id", owner, 1_748_619_568L, tags, "a walk along the viewpoints", "sig") + } + + private fun reparse(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate) = GeocacheCurationListEvent("id2", owner, template.createdAt, template.tags, template.content, "sig") + + @Test + fun theNewCacheLandsAtTheEnd() { + val revision = GeocacheCurationRevision.withCache(hunt(), cache("santa-catarina")) + val reparsed = reparse(revision) + + // Appending is the only placement that needs no opinion about where in someone's route + // the new stop belongs. + assertEquals( + listOf("graca", "senhora-do-monte", "santa-catarina"), + reparsed.geocaches().map { it.dTag }, + ) + } + + @Test + fun addingACacheTwiceDoesNotDuplicateIt() { + val revision = GeocacheCurationRevision.withCache(hunt(), cache("graca")) + val reparsed = reparse(revision) + + assertEquals(2, reparsed.geocaches().size) + assertEquals(listOf("graca", "senhora-do-monte"), reparsed.geocaches().map { it.dTag }) + } + + @Test + fun containsAnswersWhatTheSheetAsks() { + assertTrue(GeocacheCurationRevision.contains(hunt(), cache("graca"))) + assertFalse(GeocacheCurationRevision.contains(hunt(), cache("santa-catarina"))) + } + + @Test + fun aRevisionKeepsTagsQuartzDoesNotModel() { + val withForeign = + hunt( + listOf( + arrayOf("client", "Treasures"), + arrayOf("image", "https://example.com/banner.jpg"), + ), + ) + + val revision = GeocacheCurationRevision.withCache(withForeign, cache("santa-catarina")) + + assertTrue(revision.tags.any { it.size > 1 && it[0] == "client" && it[1] == "Treasures" }) + assertTrue(revision.tags.any { it.size > 1 && it[0] == "image" }) + } + + @Test + fun theAddressSurvivesSoTheHuntIsReplacedRatherThanForked() { + val original = hunt() + val reparsed = reparse(GeocacheCurationRevision.withCache(original, cache("santa-catarina"))) + + assertEquals(original.address().toValue(), reparsed.address().toValue()) + assertEquals("Lisbon Miradouros", reparsed.title()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingBuilderTest.kt new file mode 100644 index 0000000000..c8f7af4775 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingBuilderTest.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheGeohash +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.HintObfuscation +import com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13 +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The write path for kind 37516, plus the two helpers NIP-CC asks clients for. */ +class GeocacheListingBuilderTest { + private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07" + + private fun Array>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] } + + @Test + fun buildProducesTheTagsTheSpecRequires() { + val template = + GeocacheListingEvent.build( + name = "First Treasure", + description = "The first Nostr treasure", + geohash = "u4xsu6ryb", + difficulty = 1, + terrain = 1, + size = CacheSize.SMALL, + type = CacheType.TRADITIONAL, + hint = "In the branches", + dTag = "first-treasure-1748619568668", + createdAt = 1_748_619_568L, + ) + + assertEquals(GeocacheListingEvent.KIND, template.kind) + assertEquals("The first Nostr treasure", template.content) + assertEquals(listOf("first-treasure-1748619568668"), template.tags.values("d")) + assertEquals(listOf("First Treasure"), template.tags.values("name")) + assertEquals(listOf("1"), template.tags.values("D")) + assertEquals(listOf("1"), template.tags.values("T")) + assertEquals(listOf("small"), template.tags.values("S")) + assertEquals(listOf("traditional"), template.tags.values("t")) + // Published rot13'd: the reference client does it, and it is the choice that fails + // safe — a reader assuming plaintext sees noise rather than the answer. + assertEquals(listOf("Va gur oenapurf"), template.tags.values("hint")) + } + + @Test + fun theGeohashLadderRunsThreeToNineCoarseToFine() { + // The spec's own example publishes u4x .. u4xsu6ryb. A 1- or 2-character geohash spans + // thousands of kilometres and is noise on the relay, so the ladder starts at 3. + val template = + GeocacheListingEvent.build( + name = "n", + description = "", + geohash = "u4xsu6ryb", + difficulty = 1, + terrain = 1, + size = CacheSize.SMALL, + ) + + assertEquals( + listOf("u4x", "u4xs", "u4xsu", "u4xsu6", "u4xsu6r", "u4xsu6ry", "u4xsu6ryb"), + template.tags.values("g"), + ) + } + + @Test + fun theLadderStopsAtNineEvenForAFinerGeohash() { + // Seven rungs, 3 through 9 — the three extra characters of precision are dropped rather + // than published, so the finest `g` tag is never finer than the band NIP-CC defines. + assertEquals(7, GeocacheGeohash.ladder("u4xsu6rybxyz").size) + assertEquals("u4x", GeocacheGeohash.ladder("u4xsu6rybxyz").first()) + assertEquals("u4xsu6ryb", GeocacheGeohash.ladder("u4xsu6rybxyz").last()) + } + + @Test + fun aGeohashTooCoarseForTheBandProducesNoTags() { + assertEquals(emptyList(), GeocacheGeohash.ladder("u4")) + assertEquals(emptyList(), GeoHashTag.geoMipMap("u4", 3, 9)) + } + + @Test + fun theBoundedMipMapLeavesTheOriginalOneAlone() { + // The unbounded form feeds the geohash chat channels and must keep running + // fine-to-coarse from one character. + assertEquals(listOf("u4x", "u4", "u"), GeoHashTag.geoMipMap("u4x")) + } + + @Test + fun microCachesNeedOneMoreCharacterThanEveryoneElse() { + // "Validate geohash precision meets minimum requirements (8+ characters, 9+ for micro + // caches)". A 38m box does not find a film canister. + assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.REGULAR)) + assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.MICRO)) + assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ryb", CacheSize.MICRO)) + assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6r", CacheSize.REGULAR)) + } + + @Test + fun aGeohashTooCoarseToTagIsRefusedRatherThanSignedWithoutALocation() { + // The ladder starts at 3 characters, so a coarser geohash yields no `g` tag at all — and + // `g` is required. Silently signing that produces a listing that fails its own + // isWellFormed(), which the caller only discovers after publishing it. + listOf("", "u", "u4").forEach { tooCoarse -> + assertFailsWith("<$tooCoarse> should not build") { + GeocacheListingEvent.build( + name = "n", + description = "", + geohash = tooCoarse, + difficulty = 1, + terrain = 1, + size = CacheSize.SMALL, + ) + } + } + } + + @Test + fun theCoarsestBuildableCacheIsStillWellFormed() { + val template = + GeocacheListingEvent.build( + name = "n", + description = "", + geohash = "u4x", + difficulty = 1, + terrain = 1, + size = CacheSize.SMALL, + ) + val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, template.tags, template.content, "sig") + + assertEquals(listOf("u4x"), template.tags.values("g")) + assertTrue(cache.isWellFormed()) + } + + @Test + fun anOutOfRangeRatingIsClampedRatherThanThrown() { + val template = + GeocacheListingEvent.build( + name = "n", + description = "", + geohash = "u4xsu6ryb", + difficulty = 9, + terrain = 0, + size = CacheSize.SMALL, + ) + + assertEquals(listOf("5"), template.tags.values("D")) + assertEquals(listOf("1"), template.tags.values("T")) + } + + @Test + fun modifiersAndVerificationRoundTripThroughBuild() { + val template = + GeocacheListingEvent.build( + name = "Aftermath", + description = "linocut", + geohash = "u4xsu6ryb", + difficulty = 2, + terrain = 2, + size = CacheSize.SMALL, + modifiers = listOf(TypeModifier.FIRST_TO_FIND, TypeModifier.ART), + verificationPubKey = verificationKey, + ) + + val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, template.tags, template.content, "sig") + + assertTrue(cache.isFirstToFind()) + assertTrue(cache.hasTypeModifier(TypeModifier.ART)) + assertEquals(verificationKey, cache.verificationKey()) + assertTrue(cache.isWellFormed()) + } + + @Test + fun rot13IsItsOwnInverseAndTouchesOnlyAsciiLetters() { + // The hint travels as plaintext; ROT13 is the display transform that stops a reader + // spoiling themselves by scrolling past it. + assertEquals("Va gur oenapurf", rot13("In the branches")) + assertEquals("In the branches", rot13(rot13("In the branches"))) + assertEquals("123 !?-é中", rot13("123 !?-é中")) + assertEquals("nomAZ", rot13("abzNM")) + } + + @Test + fun theHiddenHintIsTheFormThatReadsLessLikeProse() { + // Publishers disagree about which form goes on the wire, so the reader must not be + // handed the readable one either way round. Both of these carry the same hint; only + // the encoding differs. + val encoded = GeocacheListingEvent("id", "a".repeat(64), 1L, arrayOf(arrayOf("hint", "Va gur oenapurf")), "", "sig") + val plain = GeocacheListingEvent("id", "a".repeat(64), 1L, arrayOf(arrayOf("hint", "In the branches")), "", "sig") + + assertEquals("Va gur oenapurf", encoded.hintOnWire()) + assertEquals("Va gur oenapurf", encoded.hintHidden()) + assertEquals("In the branches", encoded.hintRevealed()) + + assertEquals("In the branches", plain.hintOnWire()) + assertEquals("Va gur oenapurf", plain.hintHidden()) + assertEquals("In the branches", plain.hintRevealed()) + + assertNull(GeocacheListingEvent("id", "a".repeat(64), 1L, emptyArray(), "", "sig").hintRevealed()) + } + + @Test + fun theProseTestSurvivesTheVowelTrap() { + // The obvious heuristic — count vowels — is backwards here, and quietly so. ROT13 maps + // n→a, r→e, h→u and b→o, all common in English, so ciphertext usually has MORE vowels + // than its plaintext: "In the branches" has 4 of 13, "Va gur oenapurf" has 6 of 13. + // These are the real hints that broke it, taken off the relays. + listOf( + "In the branches", + "Under the bench.", + "Third tree", + "Look up", + "Small", + "Hiding in the top of the wall", + "Between the tree and the lathe cactus", + ).forEach { plain -> + assertTrue(HintObfuscation.isProse(plain), "<$plain> should read as English") + assertTrue(!HintObfuscation.isProse(rot13(plain)), "<${rot13(plain)}> should not read as English") + assertEquals(plain, HintObfuscation.revealed(rot13(plain)), "encoded <$plain> should decode") + assertEquals(plain, HintObfuscation.revealed(plain), "plain <$plain> should stay put") + } + } + + @Test + fun theHeuristicOnlySpeaksEnglishAndBothReadingsStayReachable() { + // The guess is English-only, so a plaintext hint in another language scores as + // ciphertext and "reveals" to noise. That is not fixable with a bigger table — it is why + // hidden() and revealed() are always the same two strings in some order, so a UI that + // offers both can still get a reader to their own hint. + listOf("Bajo el banco", "Sob o banco de madeira", "Unter der Bank").forEach { plain -> + assertEquals( + setOf(plain, rot13(plain)), + setOf(HintObfuscation.hidden(plain), HintObfuscation.revealed(plain)), + "<$plain>: both readings must stay reachable even when the guess is wrong", + ) + } + } + + @Test + fun theRarestLettersStillScoreApart() { + // A frequency floor would flatten the bottom of the table together, and the comparison + // between a form and its rotation stops meaning anything for hints built from rare + // letters. `q`, `z` and `x` are the three the table puts lowest. + val q = HintObfuscation.englishScore("q") + val z = HintObfuscation.englishScore("z") + val x = HintObfuscation.englishScore("x") + + assertTrue(q != z, "q and z score identically — a floor is swallowing them") + assertTrue(z != x, "z and x score identically — a floor is swallowing them") + assertTrue(z < HintObfuscation.englishScore("e"), "the rarest letter should score below the commonest") + } + + @Test + fun theTwoHintFormsAreAlwaysEachOthersRotation() { + // Whatever the heuristic decides, it only ever chooses between these two — it never + // invents or drops text. + listOf("In the branches", "Va gur oenapurf", "Small", "\u00dfaum", "123 !?").forEach { onWire -> + assertEquals(onWire, rot13(rot13(onWire))) + assertEquals( + setOf(onWire, rot13(onWire)), + setOf(HintObfuscation.hidden(onWire), HintObfuscation.revealed(onWire)), + "<$onWire>", + ) + } + } + + @Test + fun buildTakesPlaintextAndRoundTripsThroughTheWireForm() { + val template = + GeocacheListingEvent.build( + name = "n", + description = "", + geohash = "u4xsu6ryb", + difficulty = 1, + terrain = 1, + size = CacheSize.SMALL, + hint = "In the branches", + ) + val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, template.tags, template.content, "sig") + + assertEquals("In the branches", cache.hintRevealed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingEventTest.kt new file mode 100644 index 0000000000..7e6f403849 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingEventTest.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Kind 37516, checked against the examples in NIP-CC itself. */ +class GeocacheListingEventTest { + private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd" + private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07" + + private fun listing(vararg tags: Array) = GeocacheListingEvent("id", owner, 1_748_619_568L, arrayOf(*tags), "a cache", "sig") + + /** The "Basic Cache" example from NIP-CC, tag for tag. */ + private fun basicCacheTags(): TagArray = + arrayOf( + arrayOf("d", "first-treasure-1748619568668"), + arrayOf("name", "First Treasure"), + arrayOf("g", "u4x"), + arrayOf("g", "u4xs"), + arrayOf("g", "u4xsu"), + arrayOf("g", "u4xsu6"), + arrayOf("g", "u4xsu6r"), + arrayOf("g", "u4xsu6ry"), + arrayOf("g", "u4xsu6ryb"), + arrayOf("D", "1"), + arrayOf("T", "1"), + arrayOf("S", "small"), + arrayOf("t", "traditional"), + arrayOf("hint", "In the branches"), + arrayOf("image", "https://blossom.primal.net/74efe.jpg"), + ) + + @Test + fun theKindIsRegisteredWithTheEventFactory() { + // Without this the class above is dead code: 37516 parses as a plain Event and no + // accessor on it is ever reached. + assertTrue(EventFactory.isKnownKind(GeocacheListingEvent.KIND)) + assertTrue(EventFactory.create("id", owner, 1L, GeocacheListingEvent.KIND, emptyArray(), "", "") is GeocacheListingEvent) + } + + @Test + fun parsesTheSpecsBasicCacheExample() { + val cache = listing(*basicCacheTags()) + + assertEquals("first-treasure-1748619568668", cache.dTag()) + assertEquals("First Treasure", cache.cacheName()) + assertEquals(1, cache.difficulty()) + assertEquals(1, cache.terrain()) + assertEquals(CacheSize.SMALL, cache.cacheSize()) + assertEquals(CacheType.TRADITIONAL, cache.cacheType()) + assertEquals("In the branches", cache.hintOnWire()) + assertEquals(listOf("https://blossom.primal.net/74efe.jpg"), cache.images()) + assertEquals("u4xsu6ryb", cache.location()) + assertEquals(7, cache.geohashes().size) + assertTrue(cache.isWellFormed()) + } + + @Test + fun parsesTheSpecsVerifiedCacheExample() { + val cache = + listing( + arrayOf("d", "verified-treasure-1748619568669"), + arrayOf("name", "Verified Treasure"), + arrayOf("g", "u4xsu6ry"), + arrayOf("D", "3"), + arrayOf("T", "2"), + arrayOf("S", "small"), + arrayOf("t", "traditional"), + arrayOf("hint", "Look for the secret code"), + arrayOf("verification", verificationKey), + ) + + assertEquals(verificationKey, cache.verificationKey()) + assertTrue(cache.requiresVerification()) + } + + @Test + fun parsesTheSpecsFirstToFindArtExample() { + val cache = + listing( + arrayOf("d", "linocut-aftermath-1748619568671"), + arrayOf("name", "Aftermath (Linocut #1)"), + arrayOf("g", "u4xsu6ry"), + arrayOf("D", "2"), + arrayOf("T", "2"), + arrayOf("S", "small"), + arrayOf("t", "traditional"), + arrayOf("n", "first-to-find"), + arrayOf("n", "art"), + arrayOf("verification", verificationKey), + ) + + assertTrue(cache.isFirstToFind()) + assertTrue(cache.hasTypeModifier(TypeModifier.ART)) + assertEquals( + mapOf( + TypeModifierCategory.CLAIM_SEMANTICS to TypeModifier.FIRST_TO_FIND, + TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART, + ), + cache.typeModifiers(), + ) + } + + @Test + fun parsesTheSpecsKeyQuestExample() { + val cache = + listing( + arrayOf("d", "key-quest-treasure-1748619568670"), + arrayOf("name", "Riddle of the Old Oak"), + arrayOf("g", "u4xsu6ry"), + arrayOf("D", "4"), + arrayOf("T", "2"), + arrayOf("S", "small"), + arrayOf("t", "mystery"), + arrayOf("mission", "Bring a token of nature you found along the way"), + arrayOf("verification", verificationKey), + ) + + assertEquals(CacheType.MYSTERY, cache.cacheType()) + assertTrue(cache.hasMission()) + assertEquals("Bring a token of nature you found along the way", cache.mission()) + } + + @Test + fun theCacheTypeDefaultsToTraditional() { + // "The type of cache (`t`) is optional and defaults to `traditional` if not specified." + val cache = listing(arrayOf("d", "x"), arrayOf("name", "n"), arrayOf("g", "u4xsu6ryb"), arrayOf("D", "1"), arrayOf("T", "1"), arrayOf("S", "micro")) + + assertEquals(CacheType.TRADITIONAL, cache.cacheType()) + assertEquals("traditional", cache.cacheTypeCode()) + } + + @Test + fun aClientDefinedCacheTypeSurvivesAsItsCode() { + // "Cache types are determined by individual clients" — an unknown type must not read as + // `traditional`, which would hide a puzzle cache behind a walk-up label. + val cache = listing(arrayOf("t", "earthcache")) + + assertNull(cache.cacheType()) + assertEquals("earthcache", cache.cacheTypeCode()) + } + + @Test + fun archivedIsALifecycleMarkerNotACacheType() { + // `t` carries both on this kind. Reading `archived` as the type would make every retired + // cache an unknown type, and reading the type as a lifecycle state would un-retire it. + val cache = listing(arrayOf("t", "mystery"), arrayOf("t", "archived")) + + assertTrue(cache.isArchived()) + assertEquals(CacheType.MYSTERY, cache.cacheType()) + } + + @Test + fun aCacheWithNoArchivedTagIsNotArchived() { + assertFalse(listing(arrayOf("t", "traditional")).isArchived()) + } + + @Test + fun difficultyAndTerrainRejectValuesOutsideOneToFive() { + assertNull(listing(arrayOf("D", "0")).difficulty()) + assertNull(listing(arrayOf("D", "6")).difficulty()) + assertNull(listing(arrayOf("D", "")).difficulty()) + assertNull(listing(arrayOf("D", "hard")).difficulty()) + assertNull(listing(arrayOf("T", "-1")).terrain()) + assertEquals(5, listing(arrayOf("D", "5")).difficulty()) + assertEquals(1, listing(arrayOf("T", "1")).terrain()) + } + + @Test + fun anUnknownSizeIsNotSilentlyACategory() { + val cache = listing(arrayOf("S", "nano")) + + assertNull(cache.cacheSize()) + assertEquals("nano", cache.cacheSizeCode()) + } + + @Test + fun onlyOneModifierPerCategoryCountsAndTheFirstWins() { + // NIP-CC rule 2. Both values here are CLAIM_SEMANTICS-shaped in a future where more + // exist; today only one category has two candidates, so the rule is exercised with + // a repeat. + val cache = listing(arrayOf("n", "first-to-find"), arrayOf("n", "first-to-find"), arrayOf("n", "art")) + + assertEquals(2, cache.typeModifiers().size) + } + + @Test + fun anUnknownModifierIsIgnoredRatherThanFatal() { + // NIP-CC rule 4, the forward-compatibility rule: a listing using a modifier from a future + // revision must still parse as a cache. + val cache = listing(*basicCacheTags(), arrayOf("n", "time-limited"), arrayOf("n", "art")) + + assertTrue(cache.isWellFormed()) + assertEquals(mapOf(TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART), cache.typeModifiers()) + assertEquals(listOf("time-limited", "art"), cache.typeModifierCodes()) + } + + @Test + fun theFTagIsOnlyHonouredOnAFirstToFindCache() { + // "Only valid when the treasure carries the `first-to-find` `n` modifier." Honouring it + // anywhere else invents an exclusive claim on a cache that never had one. + val winner = "b".repeat(64) + + assertNull(listing(arrayOf("F", winner)).firstToFindWinner()) + assertEquals(winner, listing(arrayOf("n", "first-to-find"), arrayOf("F", winner)).firstToFindWinner()) + } + + @Test + fun aMalformedPubKeyIsNotAVerificationKey() { + assertNull(listing(arrayOf("verification", "not-hex")).verificationKey()) + assertNull(listing(arrayOf("verification", "")).verificationKey()) + // isHex64 does not check the length itself; a longer string must still be rejected. + assertNull(listing(arrayOf("verification", "a".repeat(70))).verificationKey()) + assertEquals(verificationKey, listing(arrayOf("verification", verificationKey)).verificationKey()) + } + + @Test + fun aListingMissingARequiredTagIsNotWellFormed() { + GeocacheListingEvent.REQUIRED_FIELDS.forEach { missing -> + val tags = basicCacheTags().filterNot { it[0] == missing }.toTypedArray() + assertFalse(listing(*tags).isWellFormed(), "a listing without `$missing` should not be well formed") + } + } + + @Test + fun theAddressIsTheKindPubkeyAndDTag() { + val cache = listing(*basicCacheTags()) + + assertEquals("37516:$owner:first-treasure-1748619568668", cache.address().toValue()) + } + + @Test + fun aListingRoundTripsThroughJson() { + val cache = listing(*basicCacheTags()) + val reparsed = Event.fromJson(cache.toJson()) + + assertTrue(reparsed is GeocacheListingEvent) + assertEquals("First Treasure", reparsed.cacheName()) + assertEquals(CacheSize.SMALL, reparsed.cacheSize()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingRevisionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingRevisionTest.kt new file mode 100644 index 0000000000..99987a3a07 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheListingRevisionTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingRevision +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The owner-side lifecycle: archiving a cache and locking in its first finder. + * + * Both republish at the same address, so the thing worth testing is not that the new tag is + * present — it is that nothing else went missing. A real listing carries tags this library + * models nothing for, and a revision that rebuilt from parsed fields would drop them silently. + */ +class GeocacheListingRevisionTest { + private val owner = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val winner = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + + /** + * A listing carrying two tags Quartz has no model for: `client` and `expiration`. Both are + * real — every Treasures listing on the network carries `client`, and Lightning Piggy stamps + * `expiration` on all of them. + */ + private fun listing(extra: List> = emptyList()): GeocacheListingEvent { + val tags = + ( + listOf( + arrayOf("d", "treasure-troll-trunk"), + arrayOf("name", "Treasure Troll's Trunk"), + arrayOf("g", "u4xsu6ryb"), + arrayOf("g", "u4x"), + arrayOf("D", "3"), + arrayOf("T", "2"), + arrayOf("S", "regular"), + arrayOf("t", "traditional"), + arrayOf("n", "first-to-find"), + ) + extra + ).toTypedArray() + + return GeocacheListingEvent("id", owner, 1_748_619_568L, tags, "A trunk in the woods", "sig") + } + + @Test + fun archivingAddsArchivedWithoutLosingTheCacheType() { + val revision = GeocacheListingRevision.archived(listing(), createdAt = 1_748_619_600L) + val reparsed = GeocacheListingEvent("id", owner, revision.createdAt, revision.tags, revision.content, "sig") + + assertTrue(reparsed.isArchived()) + // The spec puts `archived` alongside the type rather than replacing it. A traditional + // cache that gets archived is still a traditional cache. + assertEquals(CacheType.TRADITIONAL, reparsed.cacheType()) + assertEquals("Treasure Troll's Trunk", reparsed.cacheName()) + assertEquals(GeocacheListingEvent.KIND, revision.kind) + } + + @Test + fun archivingTwiceDoesNotDuplicateTheTag() { + val once = GeocacheListingRevision.archived(listing()) + val onceEvent = GeocacheListingEvent("id", owner, once.createdAt, once.tags, once.content, "sig") + val twice = GeocacheListingRevision.archived(onceEvent) + + assertEquals(1, twice.tags.count { it.size > 1 && it[0] == "t" && it[1] == "archived" }) + } + + @Test + fun lockingInTheWinnerReplacesAnyEarlierClaim() { + val earlier = listing(listOf(arrayOf("F", owner))) + val revision = GeocacheListingRevision.withFirstToFindWinner(earlier, winner) + val reparsed = GeocacheListingEvent("id", owner, revision.createdAt, revision.tags, revision.content, "sig") + + // `F` is authoritative and permanent; two winners is not a state the spec can answer. + assertEquals(1, revision.tags.count { it.isNotEmpty() && it[0] == "F" }) + assertEquals(winner, reparsed.firstToFindWinner()) + } + + @Test + fun aRevisionKeepsTagsQuartzDoesNotModel() { + val withForeignTags = + listing( + listOf( + arrayOf("client", "Treasures"), + arrayOf("expiration", "1817364570"), + ), + ) + + val revision = GeocacheListingRevision.archived(withForeignTags) + + // The whole reason these helpers copy the tag array instead of rebuilding from fields: + // an owner must not lose data by pressing Archive in a client that models less than the + // one they published from. + assertTrue(revision.tags.any { it.size > 1 && it[0] == "client" && it[1] == "Treasures" }) + assertTrue(revision.tags.any { it.size > 1 && it[0] == "expiration" && it[1] == "1817364570" }) + } + + @Test + fun aRevisionKeepsTheWholeGeohashLadder() { + val revision = GeocacheListingRevision.archived(listing()) + val reparsed = GeocacheListingEvent("id", owner, revision.createdAt, revision.tags, revision.content, "sig") + + assertEquals(listOf("u4xsu6ryb", "u4x"), reparsed.geohashes()) + } + + @Test + fun theAddressSurvivesSoTheRevisionReplacesRatherThanForks() { + val original = listing() + val revision = GeocacheListingRevision.archived(original) + val reparsed = GeocacheListingEvent("id", owner, revision.createdAt, revision.tags, revision.content, "sig") + + assertEquals(original.address().toValue(), reparsed.address().toValue()) + assertNull(reparsed.firstToFindWinner()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheLogsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheLogsTest.kt new file mode 100644 index 0000000000..cbf0d7ba57 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheLogsTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment +import com.vitorpamplona.quartz.nipCCGeocaching.comment.declaredGeocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.comment.geocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Kind 7516 found logs, and the kind 1111 comments that carry everything else. */ +class GeocacheLogsTest { + private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd" + private val finder = "a".repeat(64) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "first-treasure-1748619568668") + + private val listing = + GeocacheListingEvent( + "9".repeat(64), + owner, + 1_748_619_568L, + arrayOf( + arrayOf("d", cacheAddress.dTag), + arrayOf("name", "First Treasure"), + arrayOf("g", "u4xsu6ryb"), + arrayOf("D", "1"), + arrayOf("T", "1"), + arrayOf("S", "small"), + ), + "", + "sig", + ) + + private fun Array>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] } + + @Test + fun parsesTheSpecsFoundLogExample() { + val log = + GeocacheFoundLogEvent( + "1".repeat(64), + finder, + 1_748_619_700L, + arrayOf(arrayOf("a", "37516:$owner:first-treasure-1748619568668")), + "Found it! Great hiding spot.", + "sig", + ) + + assertEquals(cacheAddress, log.geocache()) + assertEquals("37516:$owner:first-treasure-1748619568668", log.geocacheId()) + assertEquals(listOf("37516:$owner:first-treasure-1748619568668"), log.linkedAddressIds()) + assertTrue(!log.hasVerificationAttached()) + } + + @Test + fun aFoundLogBuiltFromAHintCarriesTheRelay() { + val template = GeocacheFoundLogEvent.build("Found it!", EventHintBundle(listing, relay), createdAt = 1L) + + assertEquals(GeocacheFoundLogEvent.KIND, template.kind) + assertEquals("Found it!", template.content) + assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a")) + assertEquals(listOf(relay.url), template.tags.first { it[0] == "a" }.drop(2)) + } + + @Test + fun aLogPointingAtSomethingThatIsNotACacheHasNoCache() { + // The `a` tag is whatever its author wrote. Resolving it unfiltered files a "Found it!" + // under an unrelated addressable — an article, a calendar event — and hands a caller a + // note of the wrong type to cast. + listOf( + "30023:$owner:an-article", + "31923:$owner:a-calendar-slot", + "30000:$owner:a-people-list", + ).forEach { coordinate -> + val log = + GeocacheFoundLogEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("a", coordinate)), "Found it!", "sig") + + assertNull(log.geocache(), "<$coordinate> should not resolve as a geocache") + assertNull(log.geocacheId()) + } + } + + @Test + fun theUndefinedLegacyListingKindStillResolves() { + // NIP-CC references 37515 without defining it; a log naming one is still about a cache. + val log = + GeocacheFoundLogEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("a", "37515:$owner:old-cache")), "Found it!", "sig") + + assertEquals("old-cache", log.geocache()?.dTag) + } + + @Test + fun theCheapVerificationGateAgreesWithTheParsedOne() { + // hasVerificationAttached() reads tag names so a feed does not run the JSON parser on + // every recomposition. It must still answer the same question the parse does for a + // well-formed payload, and must not claim proof for a tag with an empty value. + val withProof = + GeocacheFoundLogEvent( + "1".repeat(64), + finder, + 1L, + arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", "{\"anything\":1}")), + "Found it!", + "sig", + ) + val withoutProof = + GeocacheFoundLogEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("a", cacheAddress.toValue())), "Found it!", "sig") + val emptyProof = + GeocacheFoundLogEvent( + "1".repeat(64), + finder, + 1L, + arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", "")), + "Found it!", + "sig", + ) + + assertTrue(withProof.hasVerificationAttached()) + assertTrue(!withoutProof.hasVerificationAttached()) + assertTrue(!emptyProof.hasVerificationAttached()) + } + + @Test + fun aFoundLogIsIndexedByItsMessage() { + val log = GeocacheFoundLogEvent("1".repeat(64), finder, 1L, emptyArray(), "Great hiding spot", "sig") + + assertEquals("Great hiding spot", log.indexableContent()) + } + + @Test + fun aDnfCommentCarriesTheRootAndParentTheSpecShows() { + // NIP-CC's DNF example: the listing is both root and parent, so A/K/P and a/k/p all + // point at the cache. + val template = GeocacheLogComment.didNotFind("Searched for 30 minutes.", EventHintBundle(listing), createdAt = 1L) + + assertEquals(CommentEvent.KIND, template.kind) + assertEquals(listOf(cacheAddress.toValue()), template.tags.values("A")) + assertEquals(listOf("37516"), template.tags.values("K")) + assertEquals(listOf(owner), template.tags.values("P")) + assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a")) + assertEquals(listOf("37516"), template.tags.values("k")) + assertEquals(listOf(owner), template.tags.values("p")) + assertEquals(listOf("dnf"), template.tags.values("t")) + } + + @Test + fun theLogTypeReadsBackOffTheComment() { + val template = GeocacheLogComment.needsMaintenance("Cache is soaked.", EventHintBundle(listing), createdAt = 1L) + val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig") + + assertEquals(GeocacheLogType.MAINTENANCE, comment.tags.geocacheLogType()) + } + + @Test + fun aCommentWithNoTypeIsANote() { + // "If no `t` tag is present, the comment is assumed to be a general note." + val comment = CommentEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("A", cacheAddress.toValue())), "nice spot", "sig") + + assertEquals(GeocacheLogType.NOTE, comment.tags.geocacheLogType()) + assertNull(comment.tags.declaredGeocacheLogType()) + } + + @Test + fun aHashtagIsNotMistakenForALogType() { + // `t` is NIP-01's hashtag tag as well. Only the four defined codes parse, so a topic + // falls through to the one that follows it. + val comment = + CommentEvent( + "1".repeat(64), + finder, + 1L, + arrayOf(arrayOf("t", "geocaching"), arrayOf("t", "hiking"), arrayOf("t", "dnf")), + "", + "sig", + ) + + assertEquals(GeocacheLogType.DNF, comment.tags.geocacheLogType()) + } + + @Test + fun addingALogTypeDoesNotWipeTheAuthorsHashtags() { + val template = + GeocacheLogComment.note("nice spot", EventHintBundle(listing), createdAt = 1L) { + add(arrayOf("t", "geocaching")) + } + + assertEquals(listOf("note", "geocaching"), template.tags.values("t")) + } + + @Test + fun everyDefinedLogTypeRoundTrips() { + GeocacheLogType.entries.forEach { type -> + val template = GeocacheLogComment.build("log", EventHintBundle(listing), type, createdAt = 1L) + val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig") + + assertEquals(type, comment.tags.geocacheLogType()) + } + } + + @Test + fun anOwnerCanRetireACacheThroughTheCommentVocabularyToo() { + // NIP-CC lets owners retire a cache with an `archived` log type, keeping its history. + val template = GeocacheLogComment.build("Retiring this one.", EventHintBundle(listing), GeocacheLogType.ARCHIVED, createdAt = 1L) + + assertEquals(listOf("archived"), template.tags.values("t")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheVerificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheVerificationTest.kt new file mode 100644 index 0000000000..805427dd90 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/GeocacheVerificationTest.kt @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator +import com.vitorpamplona.quartz.nipCCGeocaching.verification.VerificationFailure +import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag +import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Kind 7517 and the validation NIP-CC puts around it. + * + * Each negative case here is a replay or a forgery that a missing check would let through, so + * they are written as the attack rather than as "returns null". + */ +class GeocacheVerificationTest { + private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd" + + // The keypair that lives at the cache, behind the QR code. + private val cacheKey = NostrSignerInternal(KeyPair()) + private val someoneElsesCacheKey = NostrSignerInternal(KeyPair()) + + private val finder = NostrSignerInternal(KeyPair()) + private val otherFinder = NostrSignerInternal(KeyPair()) + + private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "verified-treasure-1748619568669") + private val otherCacheAddress = Address(GeocacheListingEvent.KIND, owner, "some-other-treasure") + + private fun listing(verificationKey: String? = cacheKey.pubKey) = + GeocacheListingEvent( + "id", + owner, + 1_748_619_568L, + buildList { + add(arrayOf("d", cacheAddress.dTag)) + add(arrayOf("name", "Verified Treasure")) + add(arrayOf("g", "u4xsu6ryb")) + add(arrayOf("D", "3")) + add(arrayOf("T", "2")) + add(arrayOf("S", "small")) + verificationKey?.let { add(arrayOf("verification", it)) } + }.toTypedArray(), + "", + "sig", + ) + + private suspend fun verification( + signer: NostrSignerInternal = cacheKey, + forFinder: String = finder.pubKey, + atCache: Address = cacheAddress, + ) = signer.sign(GeocacheVerificationEvent.build(forFinder, atCache, createdAt = 1_748_619_600L)) + + private fun foundLog( + author: String, + verification: GeocacheVerificationEvent?, + cache: Address = cacheAddress, + createdAt: Long = 1_748_619_700L, + id: String = "1".repeat(64), + ) = GeocacheFoundLogEvent( + id, + author, + createdAt, + buildList { + add(arrayOf("a", cache.toValue())) + verification?.let { add(arrayOf("verification", it.toJson())) } + }.toTypedArray(), + "Found it!", + "sig", + ) + + @Test + fun theKindsAreRegisteredWithTheEventFactory() { + assertTrue(EventFactory.isKnownKind(GeocacheVerificationEvent.KIND)) + assertTrue(EventFactory.isKnownKind(GeocacheFoundLogEvent.KIND)) + } + + @Test + fun theContentIsTheStaticStringTheSpecMandates() { + val pubKey = "a".repeat(64) + + assertEquals("Geocache verification for ${NPub.create(pubKey)}", GeocacheVerificationEvent.contentFor(pubKey)) + assertTrue(GeocacheVerificationEvent.contentFor(pubKey).startsWith("Geocache verification for npub1")) + } + + @Test + fun theATagIsTheFinderAndAnNaddrJoinedByAColon() = + runTest { + val event = verification() + val raw = event.tags.first { it[0] == "a" }[1] + + assertEquals("${finder.pubKey}:", raw.substring(0, 65)) + assertTrue(raw.substring(65).startsWith("naddr1")) + assertEquals(cacheAddress, NAddress.parse(raw.substring(65))?.address()) + } + + @Test + fun theCompositeATagIsNotSomethingAddressParseCanRead() = + runTest { + // The reason FinderCacheTag exists. A generic `a`-tag reader gets nothing from a 7517 + // (and logs a warning on the way out), so the package parses it itself. + val raw = verification().tags.first { it[0] == "a" }[1] + + assertNull(Address.parse(raw)) + assertNotNull(FinderCacheTag.parse(arrayOf("a", raw))) + } + + @Test + fun readingAlsoAcceptsThePlainAddressFormOfTheCacheHalf() { + // Writing always emits the naddr NIP-CC specifies; reading tolerates `kind:pubkey:d` + // because Address.parse handles both and the 64-hex finder keeps the two unambiguous. + val tag = arrayOf("a", "${finder.pubKey}:${cacheAddress.toValue()}") + val parsed = FinderCacheTag.parse(tag) + + assertEquals(finder.pubKey, parsed?.finderPubKey) + assertEquals(cacheAddress, parsed?.cache) + } + + @Test + fun aBareNip01AddressIsNotAFinderCachePair() { + // `37516` on the left is not a 64-char pubkey, so there is no way to mistake one for the + // other and no way to end up with a verification attributed to nobody. + assertNull(FinderCacheTag.parse(arrayOf("a", cacheAddress.toValue()))) + assertNull(FinderCacheTag.parse(arrayOf("a", finder.pubKey))) + assertNull(FinderCacheTag.parse(arrayOf("a", ":${cacheAddress.toValue()}"))) + assertNull(FinderCacheTag.parse(arrayOf("a", "${finder.pubKey}:not-an-address"))) + } + + @Test + fun aWellFormedVerificationValidates() = + runTest { + assertNull(GeocacheVerificationValidator.validate(verification(), listing(), finder.pubKey)) + } + + @Test + fun aVerificationSignedByAnyOtherKeyIsRejected() = + runTest { + // Without the signer check, anyone signs their own "verification" and every cache in + // the world is verified by whoever wants to claim it. + val forged = verification(signer = someoneElsesCacheKey) + + assertEquals( + VerificationFailure.WRONG_SIGNER, + GeocacheVerificationValidator.validate(forged, listing(), finder.pubKey), + ) + } + + @Test + fun aVerificationIssuedToSomeoneElseCannotBeReplayedIntoYourLog() = + runTest { + // This is the check that makes the proof about a person. Lift a real verification out + // of another finder's log, paste it into yours, and without it you are verified. + val theirs = verification(forFinder = otherFinder.pubKey) + + assertEquals( + VerificationFailure.FINDER_MISMATCH, + GeocacheVerificationValidator.validate(theirs, listing(), finder.pubKey), + ) + } + + @Test + fun aVerificationEarnedAtAnotherCacheCannotBeReplayedHere() = + runTest { + val elsewhere = verification(atCache = otherCacheAddress) + + assertEquals( + VerificationFailure.CACHE_MISMATCH, + GeocacheVerificationValidator.validate(elsewhere, listing(), finder.pubKey), + ) + } + + @Test + fun aTamperedSignatureIsRejected() = + runTest { + val real = verification() + val tampered = + GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, real.content, "0".repeat(128)) + + assertEquals( + VerificationFailure.BAD_SIGNATURE, + GeocacheVerificationValidator.validate(tampered, listing(), finder.pubKey), + ) + } + + @Test + fun aRewrittenContentIsRejected() = + runTest { + val real = verification() + val rewritten = + GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, "Geocache verification for someone", real.sig) + + assertEquals( + VerificationFailure.CONTENT_MISMATCH, + GeocacheVerificationValidator.validate(rewritten, listing(), finder.pubKey), + ) + } + + @Test + fun aCacheWithoutAVerificationKeyCannotVerifyAnything() = + runTest { + assertEquals( + VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY, + GeocacheVerificationValidator.validate(verification(), listing(verificationKey = null), finder.pubKey), + ) + } + + @Test + fun aFoundLogValidatesAgainstItsOwnAuthor() = + runTest { + val log = foundLog(finder.pubKey, verification()) + + assertNull(GeocacheVerificationValidator.validate(log, listing())) + assertTrue(GeocacheVerificationValidator.isValid(log, listing())) + } + + @Test + fun aFoundLogCarryingSomeoneElsesVerificationDoesNotValidate() = + runTest { + // The same replay as above, entered through the log rather than the raw event: the + // author is taken from the log, never from the verification's own `a` tag. + val log = foundLog(otherFinder.pubKey, verification(forFinder = finder.pubKey)) + + assertEquals(VerificationFailure.FINDER_MISMATCH, GeocacheVerificationValidator.validate(log, listing())) + } + + @Test + fun aLogWithNoVerificationSaysSoRatherThanFailingSomewhereElse() { + val log = foundLog(finder.pubKey, null) + + assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing())) + } + + @Test + fun aGarbageVerificationPayloadIsNullRatherThanAnException() { + // The payload is whatever a stranger put in a public event. A parse failure that threw + // would take the feed rendering the log down with it. + listOf("", "not json", "{", "[1,2,3]", """{"kind":"seven"}""").forEach { payload -> + val log = + GeocacheFoundLogEvent( + "1".repeat(64), + finder.pubKey, + 1L, + arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", payload)), + "Found it!", + "sig", + ) + + assertNull(log.embeddedVerification(), "payload <$payload> should not parse") + assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing())) + } + } + + @Test + fun aVerificationOfAnotherKindEmbeddedInALogIsNotAVerification() = + runTest { + // Well-formed JSON for a real event of the wrong kind must not be read as a 7517. + val notAVerification = finder.sign(GeocacheFoundLogEvent.build("hi", cacheAddress)) + val log = + GeocacheFoundLogEvent( + "1".repeat(64), + finder.pubKey, + 1L, + arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", notAVerification.toJson())), + "Found it!", + "sig", + ) + + assertNull(log.embeddedVerification()) + } + + @Test + fun anEmbeddedVerificationSurvivesTheJsonRoundTrip() = + runTest { + val original = verification() + val template = GeocacheFoundLogEvent.build("Found it!", cacheAddress, verification = original) + val log = finder.sign(template) + val reparsed = Event.fromJson(log.toJson()) as GeocacheFoundLogEvent + + assertEquals(original.id, reparsed.embeddedVerification()?.id) + assertNull(GeocacheVerificationValidator.validate(reparsed, listing())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/NipCCInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/NipCCInteropTest.kt new file mode 100644 index 0000000000..1198081422 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/NipCCInteropTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipCCGeocaching + +import com.vitorpamplona.quartz.TestResourceLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent +import com.vitorpamplona.quartz.nipCCGeocaching.listing.HintObfuscation +import com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13 +import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier +import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Our parsers against events other people actually published. + * + * The corpus in `nipcc.interop.json` was captured from relay.damus.io, nos.lol, + * relay.primal.net and nostr.wine, and is pinned rather than re-fetched so the suite stays + * offline and deterministic. It was picked to cover what the wild does and the spec's examples + * do not: an owner-archived cache, a locked-in `F` winner, an `art` modifier, a cache type no + * version of this code knows, tags we ignore entirely, both hint conventions, and a found log + * carrying a real embedded kind 7517. + * + * The reason this file exists rather than more hand-written fixtures: reading NIP-CC's tag table + * literally produced a `hint` implementation that was backwards against every publisher on the + * network, and no amount of testing against the spec's own examples would have caught it. + */ +class NipCCInteropTest { + private val corpus = Json.parseToJsonElement(TestResourceLoader().loadString("nipcc.interop.json")).jsonObject + + private fun eventsIn(field: String) = + corpus[field]!!.jsonArray.map { + Event.fromJson(it.toString()) to it.jsonObject["_why"]!!.toString().trim('"') + } + + private val listings get() = eventsIn("listings") + private val foundLogs get() = eventsIn("foundLogs") + + @Test + fun everyCapturedListingParsesAsAGeocache() { + listings.forEach { (event, why) -> + assertTrue(event is GeocacheListingEvent, "$why: kind ${event.kind} did not parse as a listing") + assertTrue(event.isWellFormed(), "$why: a real, published cache failed isWellFormed()") + assertNotNull(event.cacheName(), "$why: no name") + assertNotNull(event.location(), "$why: no geohash") + assertNotNull(event.difficulty(), "$why: D did not parse") + assertNotNull(event.terrain(), "$why: T did not parse") + } + } + + @Test + fun theGeohashLadderInTheWildIsExactlyTheBandWePublish() { + // Every listing sampled carried `g` at precisions 3 through 9 — the same band + // GeocacheGeohash.ladder emits. A reader that only looked at one precision, or a writer + // that emitted 1..9, would be the odd one out. + listings.forEach { (event, why) -> + val lengths = (event as GeocacheListingEvent).geohashes().map { it.length }.sorted() + assertEquals((3..9).toList(), lengths, "$why: unexpected geohash ladder") + } + } + + @Test + fun noCapturedCacheShowsItsHintBeforeTheReaderAsks() { + // The whole point of the hint mechanism, checked against real publishers rather than + // against an assumed convention. The corpus deliberately contains both: this NIP is + // self-contradictory about whether `hint` is plaintext or ROT13, and the network split + // roughly in half along author lines. Either fixed choice spoils about half of these. + val hinted = + listings.mapNotNull { (event, why) -> + (event as GeocacheListingEvent).hintOnWire()?.let { Triple(event, it, why) } + } + assertTrue(hinted.isNotEmpty(), "the corpus should carry at least one hinted cache") + + hinted.forEach { (event, onWire, why) -> + val hidden = event.hintHidden()!! + val revealed = event.hintRevealed()!! + + // Only ever the two rotations of what was published — never invented text. + assertEquals(setOf(onWire, rot13(onWire)), setOf(hidden, revealed), "$why") + + // And the one shown unasked is the one that reads less like prose. + assertTrue( + HintObfuscation.englishScore(hidden) <= HintObfuscation.englishScore(revealed), + "$why: the hidden form <$hidden> reads more like English than the revealed one <$revealed>", + ) + } + } + + @Test + fun theCorpusReallyDoesContainBothHintConventions() { + // Guards the test above from quietly becoming vacuous: if a future corpus refresh + // happened to capture only one convention, the interesting case would stop being + // covered and nothing else would say so. + val onWire = listings.mapNotNull { (event, _) -> (event as GeocacheListingEvent).hintOnWire() } + val prose = onWire.count { HintObfuscation.isProse(it) } + + assertTrue(prose > 0, "no plaintext-on-wire hint in the corpus — refresh it from relays") + assertTrue(prose < onWire.size, "no rot13-on-wire hint in the corpus — refresh it from relays") + } + + @Test + fun anOwnerArchivedCacheReadsAsArchivedWithItsTypeIntact() { + val (event, why) = listings.first { it.second.contains("archived") } + val cache = event as GeocacheListingEvent + + assertTrue(cache.isArchived(), "$why: an archived cache did not read as archived") + // `t` carries both meanings; `archived` must not have eaten the cache type. + assertTrue(cache.cacheTypeCode() != "archived", "$why: the archived marker was read as the cache type") + } + + @Test + fun aRealFirstToFindLockInIsAttributedToItsFTag() { + val (event, why) = listings.first { it.second.contains("first-to-find") } + val cache = event as GeocacheListingEvent + + assertTrue(cache.isFirstToFind(), "$why: not read as first-to-find") + assertNotNull(cache.firstToFindWinner(), "$why: the published F winner did not parse") + assertEquals(64, cache.firstToFindWinner()!!.length) + } + + @Test + fun anArtCacheKeepsBothOfItsModifiers() { + val (event, why) = listings.first { it.second.contains("art") } + + assertTrue((event as GeocacheListingEvent).hasTypeModifier(TypeModifier.ART), "$why: the art modifier was dropped") + } + + @Test + fun aCacheTypeWeDoNotKnowSurvivesAsItsCode() { + // Forward compatibility, checked against a type a real client invented rather than one + // this test made up. + val (event, why) = listings.first { it.second.contains("client-defined") } + val cache = event as GeocacheListingEvent + + assertNull(cache.cacheType(), "$why: an unknown type resolved to a known one") + assertTrue(cache.cacheTypeCode().isNotEmpty(), "$why: the raw type code was lost") + assertTrue(cache.isWellFormed(), "$why: an unknown type should not make the cache unreadable") + } + + @Test + fun tagsWeDoNotModelDoNotDisturbTheOnesWeDo() { + // Real listings carry `client`, `expiration`, NIP-32 `L`/`l` labels, `content-warning`, + // and payout hints this package knows nothing about. + val (event, why) = listings.first { it.second.contains("ignore") } + val cache = event as GeocacheListingEvent + + assertTrue(cache.isWellFormed(), "$why: unmodelled tags broke the cache") + assertNotNull(cache.cacheSize(), "$why: size did not parse") + } + + @Test + fun aListingWithNoTypeTagDefaultsToTraditional() { + val (event, why) = listings.first { it.second.contains("no t tag") } + + assertEquals("traditional", (event as GeocacheListingEvent).cacheTypeCode(), "$why") + } + + @Test + fun everyCapturedFoundLogResolvesToTheCacheItNames() { + foundLogs.forEach { (event, why) -> + assertTrue(event is GeocacheFoundLogEvent, "$why: did not parse as a found log") + val cache = event.geocache() + assertNotNull(cache, "$why: the `a` tag did not resolve") + assertEquals(GeocacheListingEvent.KIND, cache.kind, "$why") + } + } + + @Test + fun aRealEmbeddedVerificationParsesAndNamesItsFinder() { + // The composite `a` tag — `:` — is the shape no generic NIP-01 reader + // handles, so this is the one worth checking against a real payload rather than one we + // assembled ourselves. + val (event, why) = foundLogs.first { it.second.contains("embedded") } + val log = event as GeocacheFoundLogEvent + + assertTrue(log.hasVerificationAttached(), "$why: the cheap gate missed a real payload") + + val verification = log.embeddedVerification() + assertNotNull(verification, "$why: a real embedded 7517 failed to parse") + assertEquals(GeocacheVerificationEvent.KIND, verification.kind) + + val claim = verification.finderCache() + assertNotNull(claim, "$why: the composite `a` tag failed to parse") + assertEquals(log.pubKey, claim.finderPubKey, "$why: the verification names a different finder than the log's author") + assertEquals(log.geocache(), claim.cache, "$why: the verification names a different cache than the log") + assertTrue(verification.hasExpectedContent(), "$why: content did not match the static format") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/UriParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/UriParserTest.kt new file mode 100644 index 0000000000..a76776ad7f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/UriParserTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The contract every actual of [UriParser] has to keep. + * + * The interesting case is the *opaque* URI -- a scheme followed by anything but `/`. The JVM and + * Android actual delegates to `java.net.URI`, which declares such a URI to have no query at all + * and folds `dlnwc?value=...` into one scheme-specific part, while the hand-rolled actuals look + * for `?` wherever it sits. That disagreement is what made a wallet-connect deep link report + * "that uri was invalid" on Android while the `//` spelling of the same link worked. + */ +class UriParserTest { + private val nwc = "nostr+walletconnect://abc?relay=wss://r.example&secret=s1" + + @Test + fun readsTheQueryOfAnOpaqueUri() { + val parser = UriParser("amethyst+walletconnect:dlnwc?value=${encode(nwc)}") + + assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull()) + } + + /** The spelling that already worked, so the rescue above cannot have broken it. */ + @Test + fun readsTheQueryOfAHierarchicalUri() { + val parser = UriParser("amethyst+walletconnect://dlnwc?value=${encode(nwc)}") + + assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull()) + } + + /** No scheme at all, which is how the callback arrives from some wallets. */ + @Test + fun readsTheQueryOfASchemelessUri() { + val parser = UriParser("dlnwc?value=${encode(nwc)}") + + assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull()) + } + + @Test + fun opaqueUriWithoutAQueryHasNoParameters() { + val parser = UriParser("cashu:sometoken") + + assertNull(parser.getQueryParameter("value")) + assertEquals(emptySet(), parser.queryParameterNames()) + } + + @Test + fun namesEveryParameterOfAnOpaqueUri() { + val parser = UriParser("bunker:pubkeyhex?relay=wss%3A%2F%2Fr.example&secret=s1") + + assertEquals(setOf("relay", "secret"), parser.queryParameterNames()) + assertEquals("wss://r.example", parser.getQueryParameter("relay")?.firstOrNull()) + assertEquals("s1", parser.getQueryParameter("secret")?.firstOrNull()) + } + + /** A fragment is parsed off an opaque URI by every actual already; keep it that way. */ + @Test + fun keepsTheFragmentOfAnOpaqueUri() { + val parser = UriParser("scheme:thing?a=1#b=2") + + assertEquals("1", parser.getQueryParameter("a")?.firstOrNull()) + assertEquals(mapOf("b" to "2"), parser.fragments()) + } + + private fun encode(value: String) = + value + .replace("%", "%25") + .replace("+", "%2B") + .replace(":", "%3A") + .replace("/", "%2F") + .replace("?", "%3F") + .replace("&", "%26") + .replace("=", "%3D") +} diff --git a/quartz/src/commonTest/resources/nipcc.interop.json b/quartz/src/commonTest/resources/nipcc.interop.json new file mode 100644 index 0000000000..834326d6c0 --- /dev/null +++ b/quartz/src/commonTest/resources/nipcc.interop.json @@ -0,0 +1,818 @@ +{ + "note": "Real NIP-CC events captured from relay.damus.io / nos.lol / relay.primal.net / nostr.wine on 2026-09-18.", + "listings": [ + { + "id": "d81904a39ac15990f062e48d4715942aa16a6b506aebb38722726b4e9296cf7d", + "pubkey": "86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47", + "created_at": 1780690677, + "kind": 37516, + "tags": [ + [ + "d", + "97a5cc" + ], + [ + "name", + "Treasure Troll's Trunk" + ], + [ + "D", + "1" + ], + [ + "T", + "1" + ], + [ + "S", + "regular" + ], + [ + "g", + "u4x" + ], + [ + "g", + "u4xs" + ], + [ + "g", + "u4xsu" + ], + [ + "g", + "u4xsug" + ], + [ + "g", + "u4xsugk" + ], + [ + "g", + "u4xsugkq" + ], + [ + "g", + "u4xsugkq7" + ], + [ + "n", + "first-to-find" + ], + [ + "n", + "art" + ], + [ + "F", + "271bffd30e11001c7909069742453e5ee37ce636d7fdd1f392a4c1d2cc6934ad" + ], + [ + "image", + "https://blossom.ditto.pub/c084d5170a998433b5843b6648a90e250924aca8222edda88d29bbef5047dbc5.jpeg" + ], + [ + "r", + "wss://relay.damus.io" + ], + [ + "verification", + "f4bcedc64f28d195fb35d429b592533d9c6d76215f3d2bb41ad3300f2b57525c" + ], + [ + "t", + "archived" + ] + ], + "content": "Within the hollow of the trunk, the treasure troll protects itself from the elements.", + "sig": "900b28c08f00a33b045fe73d4b483c59d8e683d94cd8d41fdbaca03cdd2d89f3c126d89e9a8a73ef2bf47aeb2d57cba7b3b8bb32b76a985320a092cee10ff720", + "_why": "first-to-find cache with a locked-in F winner" + }, + { + "id": "e0c08e8c7e92fbbab2677e15671bb58f35c2227f1e6c89505668a938e32f693d", + "pubkey": "7324e05a946c66a7818fd0c0aca80b60fb1e95fde32357ef1a2087303dd0185e", + "created_at": 1781372711, + "kind": 37516, + "tags": [ + [ + "d", + "83e8cb" + ], + [ + "name", + "Art under the tree" + ], + [ + "D", + "2" + ], + [ + "T", + "1" + ], + [ + "S", + "regular" + ], + [ + "g", + "u3q" + ], + [ + "g", + "u3qc" + ], + [ + "g", + "u3qcn" + ], + [ + "g", + "u3qcnp" + ], + [ + "g", + "u3qcnpp" + ], + [ + "g", + "u3qcnppv" + ], + [ + "g", + "u3qcnppvp" + ], + [ + "n", + "art" + ], + [ + "hint", + "Under the bench." + ], + [ + "image", + "https://blossom.ditto.pub/b8d8cd48e153c917161ef262d29ea45988985bb77f91a52ee987cceddf4dcb16.jpeg" + ], + [ + "r", + "wss://relay.ditto.pub/" + ], + [ + "r", + "wss://relay.damus.io/" + ], + [ + "r", + "wss://relay.dreamith.to/" + ], + [ + "r", + "wss://relay.primal.net" + ], + [ + "verification", + "c179650c38efd1cdab64450a9fbfdf9748c9bb336ac08f7298a9726b70768824" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "Fine art underneath a tree.", + "sig": "fba48484750dfa3ed10ec2672d7cddc70f42fdf12f998f9c9557361c1602654a7409eec44413a46d9be7bf201de43f6daee7626e653432a409e10df9d365743e", + "_why": "art-modifier cache" + }, + { + "id": "fb0f92191f6b4c220678854e77c170bc7065930000d39574149cbf25559d2c55", + "pubkey": "86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47", + "created_at": 1781282882, + "kind": 37516, + "tags": [ + [ + "d", + "f717fb" + ], + [ + "name", + "Fire Ant March" + ], + [ + "D", + "1" + ], + [ + "T", + "1" + ], + [ + "S", + "small" + ], + [ + "g", + "9v6" + ], + [ + "g", + "9v6k" + ], + [ + "g", + "9v6kp" + ], + [ + "g", + "9v6kps" + ], + [ + "g", + "9v6kpsb" + ], + [ + "g", + "9v6kpsbg" + ], + [ + "g", + "9v6kpsbgh" + ], + [ + "hint", + "Under an ant sculpture." + ], + [ + "image", + "https://blossom.dreamith.to/6c0e2751070aa4d772788df71b58b3036b7f5ec46def7178f52d52453baaa405.jpeg" + ], + [ + "r", + "wss://relay.damus.io" + ], + [ + "verification", + "536643065c699b2a45d34c8ee905c857d73831da74f912179a694af15bd76428" + ], + [ + "t", + "archived" + ] + ], + "content": "The fire ants march onward into battle, but one of the convoy must carry their treasure, no?", + "sig": "28e536d4d4bb883c956e6d277eccd128135aefe2dafa299a8a794c0f644190607322990bf231538600c1747cc8f910945529ff5737745050cff646517f8d5aaf", + "_why": "owner-archived cache" + }, + { + "id": "f20c094111a16f3c283f13c73499d2127a560365117de5b1b3b82018ca6dfcb1", + "pubkey": "6d7f8814826e6dde3cf83461bb72bb19a1f59c6358f6aa82e9e4dcd8af9757d8", + "created_at": 1788533854, + "kind": 37516, + "tags": [ + [ + "d", + "15985f" + ], + [ + "name", + "\u20bfAMO21 2026-09-26" + ], + [ + "D", + "1" + ], + [ + "T", + "2" + ], + [ + "S", + "small" + ], + [ + "g", + "u1x" + ], + [ + "g", + "u1x0" + ], + [ + "g", + "u1x0j" + ], + [ + "g", + "u1x0jf" + ], + [ + "g", + "u1x0jf5" + ], + [ + "g", + "u1x0jf5m" + ], + [ + "g", + "u1x0jf5m7" + ], + [ + "hint", + "\u20bfaum" + ], + [ + "image", + "https://blossom.ditto.pub/d913441a4225d507945e7205789c8e7b01a7b9ff1207530c5f32f8c893ea9a37.jpeg" + ], + [ + "image", + "https://blossom.ditto.pub/77ed6e1e11cbdff542f5fa71aed92231f07b7aa99bedb4bae0f2b13b5b7b857b.jpeg" + ], + [ + "content-warning", + "\u20bfaum" + ], + [ + "r", + "wss://relay.ditto.pub/" + ], + [ + "r", + "wss://relay.dreamith.to/" + ], + [ + "r", + "wss://nostr.einundzwanzig.space" + ], + [ + "r", + "wss://nos.lol" + ], + [ + "verification", + "430eeae259cb05d9691a32e8da1e4536bef985ee341671399652d69d2749ce49" + ], + [ + "t", + "hidden" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "Bitcoin am Ottisee\nNur am Samstag, 26. September 2026\n\nEin Tag von Plebs f\u00fcr Plebs: Austausch, Vortr\u00e4ge und Workshops rund um Bitcoin \u2013 zwischen Gem\u00fcsebeeten, Wiese und See. Offen f\u00fcr alle, vom Einsteiger bis zum alten Hasen.", + "sig": "248f9b1ef7fe8e0fc0ebaf7968b491e6472369f5478ca8ee7093523472a05bbe71a68d4e5529521e1e66dc545b87f1eb8c1bb244f4cd03b0fa35c173874fc426", + "_why": "client-defined cache type we do not know" + }, + { + "id": "f20c094111a16f3c283f13c73499d2127a560365117de5b1b3b82018ca6dfcb1", + "pubkey": "6d7f8814826e6dde3cf83461bb72bb19a1f59c6358f6aa82e9e4dcd8af9757d8", + "created_at": 1788533854, + "kind": 37516, + "tags": [ + [ + "d", + "15985f" + ], + [ + "name", + "\u20bfAMO21 2026-09-26" + ], + [ + "D", + "1" + ], + [ + "T", + "2" + ], + [ + "S", + "small" + ], + [ + "g", + "u1x" + ], + [ + "g", + "u1x0" + ], + [ + "g", + "u1x0j" + ], + [ + "g", + "u1x0jf" + ], + [ + "g", + "u1x0jf5" + ], + [ + "g", + "u1x0jf5m" + ], + [ + "g", + "u1x0jf5m7" + ], + [ + "hint", + "\u20bfaum" + ], + [ + "image", + "https://blossom.ditto.pub/d913441a4225d507945e7205789c8e7b01a7b9ff1207530c5f32f8c893ea9a37.jpeg" + ], + [ + "image", + "https://blossom.ditto.pub/77ed6e1e11cbdff542f5fa71aed92231f07b7aa99bedb4bae0f2b13b5b7b857b.jpeg" + ], + [ + "content-warning", + "\u20bfaum" + ], + [ + "r", + "wss://relay.ditto.pub/" + ], + [ + "r", + "wss://relay.dreamith.to/" + ], + [ + "r", + "wss://nostr.einundzwanzig.space" + ], + [ + "r", + "wss://nos.lol" + ], + [ + "verification", + "430eeae259cb05d9691a32e8da1e4536bef985ee341671399652d69d2749ce49" + ], + [ + "t", + "hidden" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "Bitcoin am Ottisee\nNur am Samstag, 26. September 2026\n\nEin Tag von Plebs f\u00fcr Plebs: Austausch, Vortr\u00e4ge und Workshops rund um Bitcoin \u2013 zwischen Gem\u00fcsebeeten, Wiese und See. Offen f\u00fcr alle, vom Einsteiger bis zum alten Hasen.", + "sig": "248f9b1ef7fe8e0fc0ebaf7968b491e6472369f5478ca8ee7093523472a05bbe71a68d4e5529521e1e66dc545b87f1eb8c1bb244f4cd03b0fa35c173874fc426", + "_why": "verified cache carrying a hint" + }, + { + "id": "f20c094111a16f3c283f13c73499d2127a560365117de5b1b3b82018ca6dfcb1", + "pubkey": "6d7f8814826e6dde3cf83461bb72bb19a1f59c6358f6aa82e9e4dcd8af9757d8", + "created_at": 1788533854, + "kind": 37516, + "tags": [ + [ + "d", + "15985f" + ], + [ + "name", + "\u20bfAMO21 2026-09-26" + ], + [ + "D", + "1" + ], + [ + "T", + "2" + ], + [ + "S", + "small" + ], + [ + "g", + "u1x" + ], + [ + "g", + "u1x0" + ], + [ + "g", + "u1x0j" + ], + [ + "g", + "u1x0jf" + ], + [ + "g", + "u1x0jf5" + ], + [ + "g", + "u1x0jf5m" + ], + [ + "g", + "u1x0jf5m7" + ], + [ + "hint", + "\u20bfaum" + ], + [ + "image", + "https://blossom.ditto.pub/d913441a4225d507945e7205789c8e7b01a7b9ff1207530c5f32f8c893ea9a37.jpeg" + ], + [ + "image", + "https://blossom.ditto.pub/77ed6e1e11cbdff542f5fa71aed92231f07b7aa99bedb4bae0f2b13b5b7b857b.jpeg" + ], + [ + "content-warning", + "\u20bfaum" + ], + [ + "r", + "wss://relay.ditto.pub/" + ], + [ + "r", + "wss://relay.dreamith.to/" + ], + [ + "r", + "wss://nostr.einundzwanzig.space" + ], + [ + "r", + "wss://nos.lol" + ], + [ + "verification", + "430eeae259cb05d9691a32e8da1e4536bef985ee341671399652d69d2749ce49" + ], + [ + "t", + "hidden" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "Bitcoin am Ottisee\nNur am Samstag, 26. September 2026\n\nEin Tag von Plebs f\u00fcr Plebs: Austausch, Vortr\u00e4ge und Workshops rund um Bitcoin \u2013 zwischen Gem\u00fcsebeeten, Wiese und See. Offen f\u00fcr alle, vom Einsteiger bis zum alten Hasen.", + "sig": "248f9b1ef7fe8e0fc0ebaf7968b491e6472369f5478ca8ee7093523472a05bbe71a68d4e5529521e1e66dc545b87f1eb8c1bb244f4cd03b0fa35c173874fc426", + "_why": "cache carrying tags we ignore" + }, + { + "id": "99f195b83702dd7e86802ec7b8c48d1bda624f715236c004417d3534b8be48ec", + "pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "created_at": 1781637711, + "kind": 37516, + "tags": [ + [ + "d", + "7e155c" + ], + [ + "name", + "Decentralized Geo" + ], + [ + "D", + "1" + ], + [ + "T", + "1" + ], + [ + "S", + "small" + ], + [ + "g", + "dr0" + ], + [ + "g", + "dr0m" + ], + [ + "g", + "dr0mx" + ], + [ + "g", + "dr0mxr" + ], + [ + "g", + "dr0mxrd" + ], + [ + "g", + "dr0mxrdb" + ], + [ + "g", + "dr0mxrdbk" + ], + [ + "hint", + "Small" + ], + [ + "image", + "https://nostr-relay.derekross.me/29ed5f4b383750d879ccec07b2ab90b1062edf2e91c5e38a5b1fc0227a121d59.jpg" + ], + [ + "r", + "wss://relay.ditto.pub/" + ], + [ + "r", + "wss://relay.dreamith.to/" + ], + [ + "verification", + "a6390e089e152c3d9139f0326edcc6d1a3d81a148231c2d89456d8c4f7774117" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "In these trees you will find centralized and decentralized geocaches. One is superior to the other. Log both for ultimate fun and rewards.", + "sig": "a2831c683f413619b2db6363df49d186531ce829287806f930accb63b447986ef47f6c2c4378a278e1ccd0c44c9e1610f762e1f0ea13eea7d21bfd06b9a1544e", + "_why": "cache with no t tag (defaults to traditional)" + }, + { + "id": "0cd6948475174689fb5eae2ee005fe038acf6132fd5e3975873648ea638a8a71", + "pubkey": "0448b3908abf0abf0d4ab70d7ff5b142b1877e0fbb5713b7dc465d941ddcd18e", + "created_at": 1785828570, + "kind": 37516, + "tags": [ + [ + "client", + "Lightning Piggy" + ], + [ + "d", + "piggy_msec73rj_zog9dx" + ], + [ + "name", + "Hammershus Castle Ruin - Vestfl\u00f8jen" + ], + [ + "g", + "u3f" + ], + [ + "g", + "u3fd" + ], + [ + "g", + "u3fd2" + ], + [ + "g", + "u3fd2y" + ], + [ + "g", + "u3fd2y0" + ], + [ + "g", + "u3fd2y01" + ], + [ + "g", + "u3fd2y01x" + ], + [ + "D", + "2" + ], + [ + "T", + "2" + ], + [ + "S", + "micro" + ], + [ + "t", + "traditional" + ], + [ + "hint", + "Ba gur Jrfgjvat vasbezngvba obneq" + ], + [ + "image", + "https://blossom.primal.net/93845b182ceb4cf4b2dcbf55c4eaa53b2ed8fcc4838c03e3b038c29b5889955e.jpg" + ], + [ + "L", + "com.lightningpiggy.app" + ], + [ + "l", + "payout-lnurl-w", + "com.lightningpiggy.app" + ], + [ + "wait", + "10800" + ], + [ + "uses", + "3" + ], + [ + "amount", + "2100" + ], + [ + "expiration", + "1817364570" + ] + ], + "content": "The old beautiful Hammershus Castle Ruin in the Westwing", + "sig": "c767f78b8c6dcaba2fe930fc4d6f91241bc2c34ee17c1b1be35ac8d899a6b0d604a72a6853989dac71c6e3e568ba85d21e81a067eb37305a9ecfa367a7b6691b", + "_why": "cache whose hint is published rot13'd" + } + ], + "foundLogs": [ + { + "id": "04ce2417e9e0ccc2b84d04bd21fa12fafe2dde4ec9e80b21913a6daa82592b38", + "pubkey": "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd", + "created_at": 1777841899, + "kind": 7516, + "tags": [ + [ + "a", + "37516:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:f0badd" + ], + [ + "verification", + "{\"kind\":7517,\"content\":\"Geocache verification for npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p\",\"tags\":[[\"a\",\"0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd:naddr1qvzqqqyj3spzppscgyy746fhmrt0nq955z6xmf80pkvrat0yq0hpknqtd00z8z68qqrxvvrzv9jxg2dmuwy\"]],\"created_at\":1777841899,\"pubkey\":\"a1b9eba92fff5a6a9a97c309cca5422839e268d6d4b4225d4d8d733e1258f320\",\"id\":\"923668508d69db41ee05221c09b0b2d2232a4a19fc3164caeb6604bd7749f81c\",\"sig\":\"cadba4dc128b65e2dc798ac13bd2661de5a6938d2904ce14cdf65a70b122005247ba732dfb33f190ccfbfd40d003861ac62b173abe8c1ba8f1d3403d82f73c52\"}" + ], + [ + "client", + "Treasures", + "31990:86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47:cgdgkgvtgnb", + "wss://relay.ditto.pub" + ] + ], + "content": "\u2764", + "sig": "f41702b5ebaf2a2e8c0cb51cd460cfecf5150c45e6a113df4cbbee1f5eed8d0f05788b5876c9725d60620ed9fdc8925a5924115f7c1f01b0b6698001f2ce21e2", + "_why": "found log with an embedded 7517" + }, + { + "id": "e2a1aaab3c36f4d69e7e06a75b505a8cb642ba3c8785ccf7dc395590e8158b22", + "pubkey": "cf6f8d9eb960c3ed2d233967020ec3efda289d37f55e71d32dc15d7b429ab1bc", + "created_at": 1786195552, + "kind": 7516, + "tags": [ + [ + "client", + "Lightning Piggy" + ], + [ + "a", + "37516:2093baa8621c5b255e8f4fc2c6fdfc10d8a5598a25517664efaba860735f1030:874497" + ], + [ + "image", + "https://blossom.primal.net/7bb72ad9a51e76a914c756d82cf3e1fb0011c0f439ca2d832c0b61dabc8ef03c.jpg" + ] + ], + "content": "Found competing treasure:))", + "sig": "c1ef86cfe0cd1f49c6204e172a438b1c652a7139fd9f9ece25490cf0cdb0d4fe09b15c549696f2ad9220dbbdf0d1d98012c05ad3d7c745b581d3c944d3f76bda", + "_why": "plain found log" + } + ] +} \ No newline at end of file diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt index 89a3287d63..78f12ebe8e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt @@ -20,18 +20,32 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.RuntimeJsonMappingException +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.SerializationException actual object OptimizedJsonMapper { actual fun fromJson(json: String): Event = try { JacksonMapper.fromJson(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } @@ -40,57 +54,97 @@ actual object OptimizedJsonMapper { actual fun fromJsonToMessage(json: String): Message = try { JacksonMapper.fromJsonToMessage(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun fromJsonToCommand(json: String): Command = try { JacksonMapper.fromJsonToCommand(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun fromJsonToTagArray(json: String): Array> = try { JacksonMapper.fromJsonToTagArray(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun fromJsonToRumor(json: String): Rumor = try { JacksonMapper.fromJsonToRumor(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun fromJsonToEventTemplate(json: String): EventTemplate = try { JacksonMapper.fromJsonToEventTemplate(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun fromJsonToEventList(json: String): List = try { JacksonMapper.fromJsonToEventList(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { + } catch (e: JsonParseException) { throw IllegalArgumentException(e.message, e) } actual fun toJson(tags: Array>): String = JacksonMapper.toJson(tags) + /** + * NIP-47 and CLINK read and write through kotlinx on every target, including this + * one. Two reasons, in order of importance: + * + * 1. These are the only types Jackson bound REFLECTIVELY here — the hand-written + * deserializers dispatched to the concrete classes with `treeToValue`. That made + * ~106 classes' field names load-bearing under R8 and cost two blanket keep rules. + * The kotlinx serializers name every field as a string literal, so nothing has to + * be kept. + * 2. Wallets and CLINK peers are other people's software. The kotlinx path reads a + * field of the wrong shape as absent instead of failing the message (see + * nip01Core.kotlinSerialization.LenientJson); Jackson raised + * MismatchedInputException and lost the whole response over one bad field. + * + * Everything else stays on Jackson, which is faster on the event hot path and is + * already non-reflective there. + */ actual inline fun fromJsonTo(json: String): T = - try { - JacksonMapper.fromJsonTo(json) - } catch (e: com.fasterxml.jackson.core.JsonParseException) { - throw IllegalArgumentException(e.message, e) - } catch (e: com.fasterxml.jackson.core.JsonProcessingException) { - throw IllegalArgumentException(e.message, e) - } catch (e: RuntimeJsonMappingException) { - throw IllegalArgumentException(e.message, e) + when (T::class) { + Request::class, Response::class, Notification::class, + OfferRequest::class, OfferResponse::class, OfferReceipt::class, + DebitRequest::class, DebitResponse::class, + ManageRequest::class, ManageResponse::class, + -> + try { + KotlinSerializationMapper.fromJsonTo(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } + + else -> + try { + JacksonMapper.fromJsonTo(json) + } catch (e: JsonParseException) { + throw IllegalArgumentException(e.message, e) + } catch (e: JsonProcessingException) { + throw IllegalArgumentException(e.message, e) + } catch (e: RuntimeJsonMappingException) { + throw IllegalArgumentException(e.message, e) + } } - actual fun toJson(value: OptimizedSerializable): String = JacksonMapper.toJson(value) + actual fun toJson(value: OptimizedSerializable): String = + when (value) { + is Request, is Response, is Notification, + is OfferRequest, is OfferResponse, is OfferReceipt, + is DebitRequest, is DebitResponse, + is ManageRequest, is ManageResponse, + -> KotlinSerializationMapper.toJson(value) + + else -> JacksonMapper.toJson(value) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 0c4365031e..c02d6577c3 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -23,12 +23,10 @@ package com.vitorpamplona.quartz.nip01Core.jackson import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonTypeRef -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.core.RawJson @@ -53,39 +51,18 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer import java.io.InputStream +import kotlin.reflect.KClass class JacksonMapper { companion object { val defaultPrettyPrinter = InliningTagArrayPrettyPrinter() val mapper = - jacksonObjectMapper() + ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, false) .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, false) @@ -114,31 +91,6 @@ class JacksonMapper { // nip 59 .addSerializer(Rumor::class.java, RumorSerializer()) .addDeserializer(Rumor::class.java, RumorDeserializer()) - // nip 47 - .addSerializer(Response::class.java, ResponseSerializer()) - .addDeserializer(Response::class.java, ResponseDeserializer()) - .addSerializer(Request::class.java, RequestSerializer()) - .addDeserializer(Request::class.java, RequestDeserializer()) - .addSerializer(Notification::class.java, NotificationSerializer()) - .addDeserializer(Notification::class.java, NotificationDeserializer()) - // NIP-47's optional params are OMITTED when null — see OmitNullsMixin. - // Matches what the kotlinx backend has always done. - .setMixInAnnotation(PayInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(PayParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(ReceiveParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(PayKeysendParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(MakeInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(LookupInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(ListTransactionsParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(MakeHoldInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(CancelHoldInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(SettleHoldInvoiceParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(SignMessageParams::class.java, OmitNullsMixin::class.java) - .setMixInAnnotation(CreateConnectionParams::class.java, OmitNullsMixin::class.java) - // NESTED, and the only params field that is not a primitive or an - // already-registered type: a TlvRecord inside pay_keysend's - // `tlv_records` has two independently optional fields of its own. - .setMixInAnnotation(TlvRecord::class.java, OmitNullsMixin::class.java) // nip 46 .addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer()) .addSerializer(BunkerRequest::class.java, BunkerRequestSerializer()) @@ -149,14 +101,28 @@ class JacksonMapper { /** * Shortcuts + * + * Built from Class objects, NOT from a TypeReference. A TypeReference + * reads its type argument back off the anonymous subclass's generic + * superclass, and R8 in full mode does not keep that -- not with + * `-keepattributes Signature`, and not with a `-keep` on the subclasses + * either (both were tried on device). It failed here, in , with + * + * IllegalArgumentException: Internal error: TypeReference constructed + * without actual type information + * + * and a failed is permanent: every later touch of this class + * throws NoClassDefFoundError, so the release build could not hash or sign + * a single event. TypeFactory takes the Class objects directly, so there is + * nothing for R8 to erase. */ - val eventTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) - val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) - val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) - val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) - val eventListTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) - val messageTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) - val commandTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) + val eventTypeInstance: JavaType = mapper.typeFactory.constructType(Event::class.java) + val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(Array>::class.java) + val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(Rumor::class.java) + val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructParametricType(EventTemplate::class.java, Event::class.java) + val eventListTypeInstance: JavaType = mapper.typeFactory.constructCollectionType(List::class.java, Event::class.java) + val messageTypeInstance: JavaType = mapper.typeFactory.constructType(Message::class.java) + val commandTypeInstance: JavaType = mapper.typeFactory.constructType(Command::class.java) fun fromJson(json: String): Event = mapper.readValue(json, eventTypeInstance) @@ -172,9 +138,118 @@ class JacksonMapper { fun fromJsonToEventList(json: String): List = mapper.readValue(json, eventListTypeInstance) - inline fun fromJsonTo(json: String): T = mapper.readValue(json) + /** + * The types this mapper has a registered deserializer for, by EXACT class. + * + * Exact is right here: `SimpleModule.addDeserializer(Event::class)` binds that + * class alone — Jackson's SimpleDeserializers does not walk up a hierarchy on + * the read side — so a subclass passed to [fromJsonTo] really would be + * unbound. The serializer side is the mirror image; see [checkSerializable]. + * + * [fromJsonTo] is generic, so nothing in the type system stops a new + * [OptimizedSerializable] being passed to it. Jackson would answer by binding + * that type REFLECTIVELY off its Kotlin constructor parameter names — which + * works in debug, and in a release build writes obfuscated one-letter JSON keys + * onto the wire. That is how NIP-47 and CLINK ended up needing a package keep + * rule each, and the symptom only ever shows up in production. + * + * So the fallback is closed: an unregistered type fails here, loudly, on the + * first call. Register a StdSerializer/StdDeserializer pair below, or route the + * type at kotlinx in OptimizedJsonMapper the way NIP-47 and CLINK are. + */ + @PublishedApi + internal val registered: Set> = + setOf( + Event::class, + Filter::class, + Message::class, + Command::class, + TagArray::class, + EventTemplate::class, + Rumor::class, + BunkerMessage::class, + BunkerRequest::class, + BunkerResponse::class, + ) - inline fun fromJsonTo(json: InputStream): T = mapper.readValue(json) + @PublishedApi + internal fun checkRegistered(type: KClass<*>) { + if (type !in registered) { + throw IllegalArgumentException( + "No Jackson deserializer is registered for $type, so Jackson would bind it " + + "reflectively and emit obfuscated field names in a release build. Register " + + "one in JacksonMapper, or route the type at KotlinSerializationMapper in " + + "OptimizedJsonMapper.", + ) + } + } + + /** + * The write-side twin of [checkRegistered], and the reason it is an `is` chain + * rather than a set: Jackson's SimpleSerializers DOES walk the hierarchy, so + * the serializer registered for [Event] serves every event kind and [Command]'s + * serves NegMsgMessage. Comparing exact classes here would reject all of them. + * + * Without this, [toJson] has the same hole [fromJsonTo] had. Hand it an + * [OptimizedSerializable] with no registered serializer and Jackson falls back + * to bean introspection, naming each field after its getter — names R8 renames, + * so a release build writes `{"a":…}` onto the wire while the debug build looks + * perfect. + * + * [BunkerRequest] and [BunkerResponse] are named instead of their [BunkerMessage] + * parent on purpose: the parent has no serializer of its own, so a third subclass + * should fail here rather than quietly bean-serialize. + * + * Two OptimizedSerializables are deliberately absent, and both would throw if + * they ever arrived here: CLINK's SatRange, which is only ever written as a + * field of an Offer/Debit/Manage message by that message's kotlinx serializer, + * and NIP-55's IntentResult, which goes through JsonMapperNip55 and its own + * registered serializer. Neither reaches this mapper today. + */ + private fun checkSerializable(value: OptimizedSerializable) { + val hasSerializer = + value is Event || + value is Filter || + value is Message || + value is Command || + value is EventTemplate<*> || + value is Rumor || + value is BunkerRequest || + value is BunkerResponse + + if (!hasSerializer) { + throw IllegalArgumentException( + "No Jackson serializer is registered for ${value::class}, so Jackson would " + + "serialize it reflectively and emit obfuscated field names in a release " + + "build. Register one in JacksonMapper, or route the type at " + + "KotlinSerializationMapper in OptimizedJsonMapper.", + ) + } + } + + /** + * `T::class.java`, not a TypeReference — the same reason the JavaTypes above + * are built from Class objects. A TypeReference reads its type argument back + * off the anonymous subclass's generic superclass, which R8 in full mode does + * not keep, and it throws "TypeReference constructed without actual type + * information" on device. This path reaches it through the NIP-46 bunker + * (NostrConnectEvent, RemoteSignerManager, NostrConnectLoginUseCase), which + * needs a remote signer to exercise and so survived the first device run. + * + * Erasure costs nothing here: [checkRegistered] has already limited T to the + * ten registered classes, and each one is answered by a StdDeserializer + * registered against that exact Class. Jackson never has to infer a type + * argument, so there is none to lose. + */ + inline fun fromJsonTo(json: String): T { + checkRegistered(T::class) + return mapper.readValue(json, T::class.java) + } + + inline fun fromJsonTo(json: InputStream): T { + checkRegistered(T::class) + return mapper.readValue(json, T::class.java) + } fun toJson(event: Event): String = EventManualSerializer.toJson(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content, event.sig) @@ -207,7 +282,10 @@ class JacksonMapper { fun toJson(event: ObjectNode?): String = mapper.writeValueAsString(event) - fun toJson(value: OptimizedSerializable): String = mapper.writeValueAsString(value) + fun toJson(value: OptimizedSerializable): String { + checkSerializable(value) + return mapper.writeValueAsString(value) + } fun toJson(tags: TagArray): String = mapper.writeValueAsString(tags) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt deleted file mode 100644 index 70535bb019..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification -import com.vitorpamplona.quartz.utils.asTextOrNull - -class NotificationDeserializer : StdDeserializer(Notification::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Notification? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val notificationType = jsonObject.get("notification_type")?.asTextOrNull() - - return when (notificationType) { - NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java) - NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java) - NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java) - else -> null - } - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt deleted file mode 100644 index 6d7376fbfe..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.ser.std.StdSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification - -class NotificationSerializer : StdSerializer(Notification::class.java) { - override fun serialize( - value: Notification, - gen: JsonGenerator, - provider: SerializerProvider, - ) { - gen.writeStartObject() - gen.writeStringField("notification_type", value.notification_type) - when (value) { - is PaymentReceivedNotification -> { - if (value.notification != null) { - gen.writeObjectField("notification", value.notification) - } - } - - is PaymentSentNotification -> { - if (value.notification != null) { - gen.writeObjectField("notification", value.notification) - } - } - - is HoldInvoiceAcceptedNotification -> { - if (value.notification != null) { - gen.writeObjectField("notification", value.notification) - } - } - } - gen.writeEndObject() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt deleted file mode 100644 index 27ec2cc38f..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod -import com.vitorpamplona.quartz.utils.asTextOrNull - -class RequestDeserializer : StdDeserializer(Request::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Request? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val method = jsonObject.get("method")?.asTextOrNull() - - return when (method) { - NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) - NwcMethod.PAY -> jp.codec.treeToValue(jsonObject, PayMethod::class.java) - NwcMethod.RECEIVE -> jp.codec.treeToValue(jsonObject, ReceiveMethod::class.java) - NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java) - NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java) - NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java) - NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java) - NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java) - NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java) - NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java) - NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java) - NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::class.java) - NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java) - NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java) - NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java) - else -> null - } - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt deleted file mode 100644 index 8bb35de4f3..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.ser.std.StdSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod - -class RequestSerializer : StdSerializer(Request::class.java) { - override fun serialize( - value: Request, - gen: JsonGenerator, - provider: SerializerProvider, - ) { - gen.writeStartObject() - if (value.method != null) { - gen.writeStringField("method", value.method) - } - when (value) { - is PayInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is PayMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is ReceiveMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is PayKeysendMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is MakeInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is LookupInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is ListTransactionsMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is MakeHoldInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is CancelHoldInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is SettleHoldInvoiceMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is SignMessageMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - is CreateConnectionMethod -> { - if (value.params != null) { - gen.writeObjectField("params", value.params) - } - } - - // Parameterless: `method` alone is the whole request. Spelled out rather - // than left to fall through, because Request is sealed and the compiler - // now makes every method state which of the two shapes it is. - is GetBalanceMethod, - is GetBudgetMethod, - is GetInfoMethod, - -> Unit - } - gen.writeEndObject() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt deleted file mode 100644 index 8c503e7140..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse -import com.vitorpamplona.quartz.utils.asTextOrNull - -class ResponseDeserializer : StdDeserializer(Response::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Response? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val resultType = jsonObject.get("result_type")?.asTextOrNull() - val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull - val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull - - if (hasError) { - return when (resultType) { - NwcMethod.PAY_INVOICE -> { - jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - - else -> { - val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java) - NwcErrorResponse(resultType ?: "", error) - } - } - } - - if (hasResult || resultType != null) { - return when (resultType) { - NwcMethod.PAY_INVOICE -> { - jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - - NwcMethod.PAY -> { - jp.codec.treeToValue(jsonObject, PaySuccessResponse::class.java) - } - - NwcMethod.RECEIVE -> { - jp.codec.treeToValue(jsonObject, ReceiveSuccessResponse::class.java) - } - - NwcMethod.PAY_KEYSEND -> { - jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) - } - - NwcMethod.MAKE_INVOICE -> { - jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java) - } - - NwcMethod.LOOKUP_INVOICE -> { - jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java) - } - - NwcMethod.LIST_TRANSACTIONS -> { - jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) - } - - NwcMethod.GET_BALANCE -> { - jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) - } - - NwcMethod.GET_INFO -> { - jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) - } - - NwcMethod.GET_BUDGET -> { - jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) - } - - NwcMethod.SIGN_MESSAGE -> { - jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) - } - - NwcMethod.CREATE_CONNECTION -> { - jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java) - } - - NwcMethod.MAKE_HOLD_INVOICE -> { - jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) - } - - NwcMethod.CANCEL_HOLD_INVOICE -> { - jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) - } - - NwcMethod.SETTLE_HOLD_INVOICE -> { - jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) - } - - else -> { - // tries to guess for backward compatibility - if (jsonObject.get("result")?.get("preimage") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - null - } - } - } - - return null - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt deleted file mode 100644 index 99bc7613b8..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip47WalletConnect.jackson - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.ser.std.StdSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse - -class ResponseSerializer : StdSerializer(Response::class.java) { - override fun serialize( - value: Response, - gen: JsonGenerator, - provider: SerializerProvider, - ) { - gen.writeStartObject() - if (value.resultType.isNotEmpty()) { - gen.writeStringField("result_type", value.resultType) - } - when (value) { - is NwcErrorResponse -> { - if (value.error != null) { - gen.writeObjectField("error", value.error) - } - } - - is PayInvoiceErrorResponse -> { - if (value.error != null) { - gen.writeObjectField("error", value.error) - } - } - - is PayInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is PaySuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is ReceiveSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is PayKeysendSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is MakeInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is LookupInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is ListTransactionsSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is GetBalanceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is GetInfoSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is MakeHoldInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is CancelHoldInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is SettleHoldInvoiceSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is GetBudgetSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is SignMessageSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - - is CreateConnectionSuccessResponse -> { - if (value.result != null) { - gen.writeObjectField("result", value.result) - } - } - } - gen.writeEndObject() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt index 72057834ef..a961dbb53f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt @@ -28,8 +28,28 @@ actual class UriParser actual constructor( ) { private val myUri = URI.create(uri) + /** + * The query string, including the one [java.net.URI] refuses to find. + * + * A URI whose scheme is followed by anything other than `/` is *opaque* to `java.net.URI`: + * `amethyst+walletconnect:dlnwc?value=...` has no query at all as far as it is concerned, and + * the whole `dlnwc?value=...` is one scheme-specific part. Every other actual of this class + * looks for `?` wherever it sits, so on JVM and Android alone the parameters of an opaque URI + * silently vanished -- which is how a perfectly valid wallet-connect deep link came back as + * "that uri was invalid", while the `//` spelling of the very same link worked. + * + * The fragment needs no such rescue: `java.net.URI` does parse `#...` off an opaque URI. + */ + private val rawQuery: String? = + myUri.rawQuery + ?: myUri + .takeIf { it.isOpaque } + ?.rawSchemeSpecificPart + ?.substringAfter('?', "") + ?.ifBlank { null } + private val queryParameters: Map> by lazy { - myUri.rawQuery?.ifBlank { null }?.let { query -> + rawQuery?.ifBlank { null }?.let { query -> val params = mutableMapOf>() query.split('&').forEach { paramValue -> diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt index 40a25246dc..2cf75ec219 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt @@ -33,16 +33,20 @@ import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Exercises the kotlinx-serialization path for the CLINK DTOs — the code path that - * native targets (iOS) use via `OptimizedJsonMapper` — and cross-checks it against - * Jackson (the JVM/Android path) so both backends agree on the wire shapes. + * Exercises the kotlinx-serialization path for the CLINK DTOs. + * + * It used to cross-check kotlinx against Jackson, because JVM/Android bound these + * reflectively while native used kotlinx. There is one backend now — [OptimizedJsonMapper] + * routes CLINK at kotlinx on every target — so the cross-check is instead that the + * serializers agree with the PLATFORM ENTRY POINT the app actually calls, which is what + * catches a routing mistake in OptimizedJsonMapper.jvmAndroid. */ class ClinkKotlinSerializationTest { @Test @@ -70,10 +74,10 @@ class ClinkKotlinSerializationTest { assertEquals("A coffee", parsed.description) // Jackson-serialized payload parses with kotlinx and vice versa. - val fromJackson = KotlinSerializationMapper.fromJsonTo(JacksonMapper.toJson(request)) - assertEquals("coffee", fromJackson.offer) - val jacksonParsed = JacksonMapper.fromJsonTo(kotlinJson) - assertEquals(21000L, jacksonParsed.amount_sats) + val fromPlatform = KotlinSerializationMapper.fromJsonTo(OptimizedJsonMapper.toJson(request)) + assertEquals("coffee", fromPlatform.offer) + val platformParsed = OptimizedJsonMapper.fromJsonTo(kotlinJson) + assertEquals(21000L, platformParsed.amount_sats) } @Test @@ -160,8 +164,8 @@ class ClinkKotlinSerializationTest { assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) // Jackson reads the kotlinx output identically. - val jacksonParsed = JacksonMapper.fromJsonTo(json) - assertEquals("Coffee", jacksonParsed.offer?.fields?.label) + val platformParsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("Coffee", platformParsed.offer?.fields?.label) } @Test @@ -194,7 +198,7 @@ class ClinkKotlinSerializationTest { assertEquals("o1", parsed.details?.first()?.id) assertEquals(1500L, parsed.details?.first()?.price_sats) - val jacksonParsed = JacksonMapper.fromJsonTo(json) - assertEquals("noffer1...", jacksonParsed.details?.first()?.noffer) + val platformParsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("noffer1...", platformParsed.details?.first()?.noffer) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt index be489b5aae..df327e132d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.quartz.nip01Core.jackson -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import kotlin.test.Test import kotlin.test.assertEquals class InliningTagArrayPrettyPrinterTest { val mapper = - jacksonObjectMapper().apply { + ObjectMapper().apply { setDefaultPrettyPrinter(InliningTagArrayPrettyPrinter()) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonSerializerGuardTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonSerializerGuardTest.kt new file mode 100644 index 0000000000..8c01e0e83e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonSerializerGuardTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * [JacksonMapper.toJson] refuses a type it has no registered serializer for. + * + * Bean introspection would otherwise name every field after its getter, and R8 + * renames getters — so the JSON is correct in debug and one-letter garbage in a + * release build. The failure has to happen here, on the first call, or it happens + * on a user's device. + */ +class JacksonSerializerGuardTest { + private class NotRegistered( + val someField: String = "value", + ) : OptimizedSerializable + + @Test + fun unregisteredTypeIsRefused() { + val e = assertFailsWith { JacksonMapper.toJson(NotRegistered()) } + assertTrue(e.message!!.contains("No Jackson serializer is registered"), e.message!!) + } + + /** + * The guard is an `is` chain because Jackson's SimpleSerializers walks the + * hierarchy: CLOSE has no serializer of its own, it rides Command's. An + * exact-class check would reject it — and with it every relay command the + * client sends. + */ + @Test + fun subclassesOfARegisteredRootStillSerialize() { + assertEquals("""["CLOSE","sub-1"]""", JacksonMapper.toJson(CloseCmd("sub-1"))) + } + + @Test + fun registeredRootsStillSerialize() { + assertEquals("""{"kinds":[1]}""", JacksonMapper.toJson(Filter(kinds = listOf(1)))) + assertTrue(JacksonMapper.toJson(BunkerRequest("id-1", "connect", arrayOf("a"))).contains("\"connect\"")) + } +} diff --git a/quartz/src/jvmTest/resources/indexable-content.golden b/quartz/src/jvmTest/resources/indexable-content.golden index 379c24a8c3..a0b62cc642 100644 --- a/quartz/src/jvmTest/resources/indexable-content.golden +++ b/quartz/src/jvmTest/resources/indexable-content.golden @@ -48,6 +48,7 @@ 5302 5303 6969 The content body. +7516 The content body. 8333 The content body. 9002 The Name\nThe About\nhashtag1\nhashtag2 9041 The Summary\nThe content body. @@ -128,6 +129,8 @@ 35128 The Title\nThe Description 35129 The Title\nThe Description 36787 The Title\nThe content body. +37516 The Name\nThe content body. +37517 The Title\nThe Description\nThe content body. 38000 The content body. 38192 The Alt\nThe Title 38383 The Name diff --git a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriter.kt b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriter.kt index 585d17c3e6..eba66bd774 100644 --- a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriter.kt +++ b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriter.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quic.interop import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quic.connection.EncryptionLevel import com.vitorpamplona.quic.observability.QlogObserver import java.io.BufferedWriter @@ -295,7 +294,7 @@ class QlogWriter( } companion object { - private val DEFAULT_MAPPER: ObjectMapper = jacksonObjectMapper() + private val DEFAULT_MAPPER: ObjectMapper = ObjectMapper() private fun packetTypeFor(level: EncryptionLevel): String = when (level) { diff --git a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriterTest.kt b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriterTest.kt index 72f5d13845..f6e20de0f1 100644 --- a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriterTest.kt +++ b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/QlogWriterTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quic.interop -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quic.connection.EncryptionLevel import org.junit.Test import java.io.File @@ -73,7 +73,7 @@ class QlogWriterTest { val lines = tmp.readLines().filter { it.isNotBlank() } assertTrue(lines.size >= 12, "expected >= 12 lines (header + at least 11 events) but got ${lines.size}") - val mapper = jacksonObjectMapper() + val mapper = ObjectMapper() // Line 1: qlog header. val header = mapper.readTree(lines[0]) @@ -128,7 +128,7 @@ class QlogWriterTest { w.onAlpnNegotiated("h3") } val lines = tmp.readLines().filter { it.isNotBlank() } - val mapper = jacksonObjectMapper() + val mapper = ObjectMapper() val event = mapper.readTree(lines[1]) assertEquals(50L, event.get("time").asLong(), "time must be relative to constructor (1050 - 1000)") } @@ -152,7 +152,7 @@ class QlogWriterTest { QlogWriter(tmp, odcidHex = "00").use { w -> w.onPacketSent(EncryptionLevel.INITIAL, 0, 1200, emptyList()) } - val mapper = jacksonObjectMapper() + val mapper = ObjectMapper() val lines = tmp.readLines().filter { it.isNotBlank() } val frames = mapper.readTree(lines[1]).get("data").get("frames") assertTrue(frames.isArray, "frames must be an array even when empty") diff --git a/scripts/retrace.sh b/scripts/retrace.sh new file mode 100755 index 0000000000..329b992305 --- /dev/null +++ b/scripts/retrace.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# +# Turn an obfuscated Amethyst crash report back into real class, method, file +# and line names. +# +# scripts/retrace.sh report.txt # figures out the release by itself +# pbpaste | scripts/retrace.sh # ... or straight off the clipboard +# +# A report produced by ReportAssembler names its own build on line 1: +# +# java.lang.IllegalStateException: 1.16.0-PLAY +# +# so that is all this needs to fetch the right mapping from the matching GitHub +# Release (amethyst-googleplay-mapping-v1.16.0.txt.gz) and cache it. +# +# If you only have a bare stack trace with no such header, name the build: +# +# scripts/retrace.sh --release v1.16.0 --flavor play trace.txt +# +# ... or point at a mapping yourself, e.g. for a build you made locally: +# +# scripts/retrace.sh amethyst/build/outputs/mapping/playRelease/mapping.txt trace.txt +# +# Mappings are never guessed at. Every frame of an obfuscated trace carries +# `r8-map-id-`, which is the `pg_map_id` of the one mapping that produced +# that build, so the mapping is checked against the report before any output is +# printed — a wrong mapping produces plausible, wrong answers, which is worse +# than no answer. --force overrides. +# +# The R8 that does the work is fetched at the version recorded in the mapping's +# own header, so there is no tooling to pin and this keeps working across AGP +# bumps. +# +# Everything is cached under ~/.cache/amethyst-retrace/, and that cache is not +# small: a mapping is ~29 MB compressed and ~500 MB expanded, per release you +# retrace. `rm -rf ~/.cache/amethyst-retrace` whenever you want it back; the +# next run re-downloads. +set -euo pipefail + +REPO="${AMETHYST_REPO:-vitorpamplona/amethyst}" +CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/amethyst-retrace" + +MAPPING="" +RELEASE="" +FLAVOR="" +REPORT="" +FORCE=0 + +die() { echo "error: $*" >&2; exit 2; } + +usage() { + sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-2}" +} + +# The first 4 KiB of a mapping, which is where R8 puts its whole header. Read +# once, into a variable: `sed ... "$MAPPING" | head -1` would stream all ~500 MB +# looking for a line that is always in the first hundred bytes, and would also +# leave the pipeline's status at head's SIGPIPE. +mapping_header() { + case "$1" in + *.gz) gunzip -c "$1" 2>/dev/null | head -c 4096 || true ;; + *) head -c 4096 "$1" 2>/dev/null || true ;; + esac +} + +# A mapping file, or the report? Decide by content, not by extension, so both +# documented argument orders keep working. +# +# Deliberately NOT `... | grep -q`: `grep -q` exits at the first match, the +# producer takes SIGPIPE, and `set -o pipefail` then reports the whole pipeline +# as failed — so a real mapping.txt.gz was classified as a report and the actual +# report came back as "unexpected argument". +looks_like_mapping() { + [ -f "$1" ] || return 1 + case "$1" in + *.prt) return 0 ;; + esac + case "$(mapping_header "$1")" in + "# compiler"*) return 0 ;; + *) return 1 ;; + esac +} + +while [ $# -gt 0 ]; do + case "$1" in + --release) RELEASE="${2:-}"; shift 2 ;; + --flavor) FLAVOR="${2:-}"; shift 2 ;; + --mapping) MAPPING="${2:-}"; shift 2 ;; + --force) FORCE=1; shift ;; + -h|--help) usage 0 ;; + -*) die "unknown option $1 (try --help)" ;; + *) + if [ -z "$MAPPING" ] && [ -z "$RELEASE" ] && looks_like_mapping "$1"; then + MAPPING="$1" + elif [ -z "$REPORT" ]; then + REPORT="$1" + else + die "unexpected argument $1" + fi + shift ;; + esac +done + +mkdir -p "$CACHE" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# ---- materialise the report ------------------------------------------------ +# Always to a file: we have to read it twice (header, map id) before retracing, +# and that is not possible on a pipe. +REPORT_FILE="$TMP/report.txt" +if [ -n "$REPORT" ]; then + [ -f "$REPORT" ] || die "no such file: $REPORT" + cp "$REPORT" "$REPORT_FILE" +else + [ -t 0 ] && echo "reading the report from stdin (ctrl-D when done) ..." >&2 + cat > "$REPORT_FILE" +fi +[ -s "$REPORT_FILE" ] || die "the report is empty" + +# Retrace only rewrites frames it recognises, and it recognises them by the +# leading `at`. Reports from Amethyst builds before the ReportAssembler fix +# wrote bare " com.foo.Bar.baz(File.kt:12)" lines, which would otherwise be +# passed through still obfuscated and look like a mapping problem. Put the `at` +# back on any frame-shaped line that is missing it. Anything that is not +# (:) is left exactly as it is. +sed -E 's/^([[:space:]]+)([A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z0-9_$<>]+)+\([^()]*:[0-9]+\))[[:space:]]*$/\1at \2/' \ + "$REPORT_FILE" > "$TMP/normalised.txt" +mv "$TMP/normalised.txt" "$REPORT_FILE" + +# ---- work out which mapping -------------------------------------------------- +if [ -z "$MAPPING" ]; then + if [ -z "$RELEASE" ]; then + # --auto: parse ": -" off line 1. + header="$(head -1 "$REPORT_FILE" | tr -d '\r')" + case "$header" in + *": "*) ;; + *) die "line 1 is not an Amethyst crash-report header, so the build is unknown. + Pass --release [--flavor play|fdroid], or a mapping file." ;; + esac + vf="${header#*: }" + vf="$(echo "$vf" | tr -d '[:space:]')" + [ -n "$vf" ] || die "line 1 has no '-' after the exception name." + parsed_flavor="${vf##*-}" + version="${vf%-*}" + [ -n "$parsed_flavor" ] && [ -n "$version" ] && [ "$version" != "$vf" ] \ + || die "cannot read '-' out of line 1: $header" + [ -n "$FLAVOR" ] || FLAVOR="$parsed_flavor" + case "$version" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) die "line 1 reports version '$version', which is not a release version." ;; + esac + # generateVersionName() appends the git branch on non-main builds, so a + # dev build reads e.g. 1.16.0-my-branch-PLAY. There is no release for it. + case "$version" in + *[!0-9.]*) die "version '$version' carries a branch suffix, so this is a local/CI + build, not a release — its mapping was never published. Retrace against + that build's own amethyst/build/outputs/mapping//mapping.txt." ;; + esac + RELEASE="v$version" + fi + + case "$RELEASE" in v*) ;; *) RELEASE="v$RELEASE" ;; esac + + case "$(echo "${FLAVOR:-play}" | tr '[:upper:]' '[:lower:]')" in + play|googleplay) channel=googleplay ;; + fdroid) channel=fdroid ;; + *) die "unknown flavor '$FLAVOR' (expected play or fdroid)" ;; + esac + + asset="amethyst-${channel}-mapping-${RELEASE}.txt.gz" + MAPPING="$CACHE/$asset" + if [ ! -s "$MAPPING" ]; then + url="https://github.com/${REPO}/releases/download/${RELEASE}/${asset}" + echo "fetching $asset ..." >&2 + curl -fsSL -o "$MAPPING.tmp" "$url" || { + rm -f "$MAPPING.tmp" + die "could not download $url + Either that release predates mapping publication (builds up to v1.16.0 + were not obfuscated — read those traces as-is), or the flavor is wrong. + Assets: gh release view $RELEASE --json assets --jq '.assets[].name'" + } + mv "$MAPPING.tmp" "$MAPPING" + fi +fi + +[ -f "$MAPPING" ] || die "no such mapping: $MAPPING" +# Keep the name the user actually gave us for error messages — once a .gz is +# expanded, $MAPPING points at a cache path they never typed. +MAPPING_LABEL="$(basename "$MAPPING")" + +# ---- decompress if needed -------------------------------------------------- +PARTITION=0 +case "$MAPPING" in + *.prt) PARTITION=1 ;; + *.gz) + # Keyed on the full source path, not just the basename: two different + # mappings are both called mapping.txt.gz if you gzip a playRelease and + # an fdroidRelease by hand. (The map-id check below would catch the mixup + # anyway, but "wrong release" is a much clearer error than a stale cache.) + key="$(printf '%s' "$(cd "$(dirname "$MAPPING")" && pwd)/$(basename "$MAPPING")" | cksum | cut -d' ' -f1)" + plain="$CACHE/$(basename "${MAPPING%.gz}").$key" + if [ ! -s "$plain" ] || [ "$MAPPING" -nt "$plain" ]; then + echo "decompressing $(basename "$MAPPING") ..." >&2 + gunzip -c "$MAPPING" > "$plain" + fi + MAPPING="$plain" + ;; +esac + +# ---- make sure this mapping really built this report ------------------------ +if [ "$PARTITION" -eq 0 ]; then + HEADER="$(mapping_header "$MAPPING")" + trace_id="$(grep -om1 'r8-map-id-[0-9a-f]\{16,\}' "$REPORT_FILE" | sed 's/^r8-map-id-//' || true)" + map_id="$(printf '%s\n' "$HEADER" | sed -n 's/^# pg_map_id: *//p' | head -1 || true)" + if [ -z "$trace_id" ]; then + echo "note: no r8-map-id in the report (an un-obfuscated build, or a trimmed" >&2 + echo " trace) — cannot confirm the mapping matches." >&2 + elif [ "$trace_id" != "$map_id" ]; then + msg="this mapping did not build this report. + report: $trace_id + mapping: $map_id ($MAPPING_LABEL) + Retracing anyway yields wrong names that look right. Check the release + tag and the flavor (play vs fdroid are separate R8 runs)." + [ "$FORCE" -eq 1 ] && echo "warning: $msg" >&2 || die "$msg" + fi +fi + +# ---- fetch the R8 that wrote it --------------------------------------------- +if [ "$PARTITION" -eq 1 ]; then + R8_VERSION="${R8_VERSION:-$(ls "$CACHE"/r8-*.jar 2>/dev/null | sed 's/.*r8-\(.*\)\.jar/\1/' | sort -V | tail -1 || true)}" + [ -n "$R8_VERSION" ] || die "a .prt partition map does not expose its R8 version. + Set R8_VERSION= (the 'compiler_version' of the matching + mapping.txt), or retrace against the .txt.gz instead." +else + R8_VERSION="$(printf '%s\n' "${HEADER:-$(mapping_header "$MAPPING")}" | sed -n 's/^# compiler_version: *//p' | head -1 || true)" + [ -n "$R8_VERSION" ] || die "no '# compiler_version:' header in $MAPPING — not an R8 mapping?" +fi + +R8_JAR="$CACHE/r8-${R8_VERSION}.jar" +if [ ! -s "$R8_JAR" ]; then + echo "fetching R8 ${R8_VERSION} ..." >&2 + curl -fsSL -o "$R8_JAR.tmp" \ + "https://maven.google.com/com/android/tools/r8/${R8_VERSION}/r8-${R8_VERSION}.jar" \ + || { rm -f "$R8_JAR.tmp"; die "could not download R8 ${R8_VERSION} from Google's Maven"; } + mv "$R8_JAR.tmp" "$R8_JAR" +fi + +# ---- retrace --------------------------------------------------------------- +# Not `exec`: exec replaces this shell, so the EXIT trap never runs and $TMP is +# left behind on every single invocation. +if [ "$PARTITION" -eq 1 ]; then + java -cp "$R8_JAR" com.android.tools.r8.retrace.Retrace \ + --partition-map "$MAPPING" "$REPORT_FILE" +else + java -cp "$R8_JAR" com.android.tools.r8.retrace.Retrace \ + "$MAPPING" "$REPORT_FILE" +fi diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 492724db5e..bf29ccabb1 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -8,8 +8,8 @@ JNI wrapper built directly from Arti source. | | Guardian Project AAR | Custom build | |---|---|---| -| **Size** | ~140MB | ~11MB | -| **16KB pages** | No | Yes (rustc aligns Android targets to 16 KiB) | +| **Size** | ~140MB | ~22MB for all four ABIs — 4-6MB in the APK a device installs | +| **16KB pages** | No | Yes on the 64-bit ABIs (rustc aligns them to 16 KiB) | | **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) | | **Version** | Behind | Pinned to latest (see [`ARTI_VERSION`](ARTI_VERSION)) | @@ -49,6 +49,24 @@ committed binary wasn't tampered with. **Five** things have to be fixed: > pinned output was verified with. `cargo-ndk` only wraps the NDK, so a mismatch > is a warning rather than an error — but it is the next thing to check if your > rebuild does not match. +> +> **The app build reads this pin too.** `amethyst/build.gradle.kts` sets +> `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP runs the NDK's +> `llvm-strip` over every native library it packages — the toolchain that strips +> a library is as much an APK input as the one that compiled it. Unset, +> `ndkVersion` follows AGP's own default (r28 on AGP 9.4.0) and moves with every +> AGP bump. So bumping this file changes what packagers need installed, not only +> what rebuilders need: bump it, rebuild the `.so`, and commit both. +> +> **`libarti_android.so` skips that strip step.** The release profile here +> already strips it — no `.symtab`, no `.debug_*` — so `llvm-strip +> --strip-unneeded` has nothing to remove and only rebuilds `.comment`, the +> section carrying the rustc/clang/lld stamps (273 bytes change on arm64-v8a). +> `packaging.jniLibs.keepDebugSymbols` in `amethyst/build.gradle.kts` therefore +> excludes it, which costs no APK size and means the library inside a built APK +> is byte-identical to the one committed in `src/main/jniLibs/`: `unzip -p +> app.apk lib/arm64-v8a/libarti_android.so | sha256sum` can be checked straight +> against the file this script reproduces. `repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0` and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized @@ -71,8 +89,9 @@ release profile in `Cargo.toml` (`lto`, `codegen-units = 1`, `strip`, From `tools/arti-build/`, the helper builds twice from clean and diffs the output: ```bash -./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +./verify-reproducible.sh # all four shipped ABIs ./verify-reproducible.sh --release # arm64-v8a only (faster) +./verify-reproducible.sh --target=armv7-linux-androideabi # one ABI ``` It prints `✅ REPRODUCIBLE` when two clean builds produce identical bytes, then @@ -90,8 +109,12 @@ repo is checked out. 2. **Android targets** ```bash - rustup target add aarch64-linux-android x86_64-linux-android + rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` + One per ABI the APK is split for. They are also listed in + `rust-toolchain.toml`, so a first invocation of the build scripts installs + whatever is missing. 3. **cargo-ndk** — the release the pinned output was verified with: ```bash @@ -120,12 +143,19 @@ repo is checked out. ```bash cd tools/arti-build -# Build for all targets (arm64 + x86_64) +# Build every shipped ABI (arm64-v8a, x86_64, armeabi-v7a, x86) ./build-arti.sh -# Build arm64 only (for release APKs) +# Build arm64-v8a only — a fast local loop, NOT enough to cut a release: +# the APK splits ship four ABIs and each one needs its own libarti_android.so ./build-arti.sh --release +# Build a single ABI without touching the other committed .so files +./build-arti.sh --target=armv7-linux-androideabi + +# Print the jniLibs ABI dirs a given invocation would write, then exit +./build-arti.sh --print-abis --release # -> arm64-v8a + # Clean rebuild from scratch ./build-arti.sh --clean ``` @@ -135,7 +165,7 @@ The script will: 2. Check out the version pinned in `ARTI_VERSION` 3. Copy the JNI wrapper into the source tree 4. Compile with `cargo-ndk` for each target architecture -5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64}/` +5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64,armeabi-v7a,x86}/` 6. Verify JNI symbols are exported correctly ## Output @@ -144,10 +174,35 @@ The script will: amethyst/src/main/jniLibs/ ├── arm64-v8a/ │ └── libarti_android.so (~5-6 MB) -└── x86_64/ - └── libarti_android.so (~6-7 MB, emulator support) +├── x86_64/ +│ └── libarti_android.so (~6-7 MB, emulator support) +├── armeabi-v7a/ +│ └── libarti_android.so (~3-4 MB, 32-bit ARM devices) +└── x86/ + └── libarti_android.so (~6 MB, 32-bit x86 images) ``` +**One per ABI split, always.** `amethyst/build.gradle.kts` splits the APK four +ways and `create-release.yml` publishes all four, so an ABI missing from this +tree ships an APK that is complete except for Arti: the dependencies' native +libraries are all there (secp256k1's JNI, for one, ships every ABI), the app +installs and runs, and Tor alone is dead for that install. `System.loadLibrary` +throws, `TorManager`'s status flow swallows the error, and Tor reports Off +forever. With the defaults (`TorType.INTERNAL`, DM relays and unknown relays +routed over Tor) those relays then dial a SOCKS port nothing listens on and +never connect — so the ABI loses its DMs too, not just Tor. + +The ABI list therefore lives in three places that must agree: `splits.abi` in +`amethyst/build.gradle.kts`, `targets` in `rust-toolchain.toml`, and `TARGETS` +in `build-arti.sh`. (`verify-reproducible.sh` has no copy of its own — it asks +`build-arti.sh --print-abis`, so it can never hash a different set than the one +it just rebuilt.) The `verifyNativeAbis` Gradle task, wired into `preBuild`, fails +the build when an ABI split is missing any committed native library +(`libarti_android.so`, `libzxingcpp_android.so`) **or** has one that is +not an ELF of that architecture — a truncated file or arm64's library copied +into `x86/` loads as nothing on device, exactly like a missing one, and unlike a +missing one it looks fine in `git status`. + ## Verifying 16KB page alignment Google Play requires 16KB page-aligned native libraries. Verify with: @@ -160,6 +215,11 @@ The first LOAD segment alignment should be `0x4000` (16384 bytes). This comes from rustc's Android target spec (`max-page-size=16384`), not from the NDK, so it holds for every NDK revision we could build with. +The requirement is a 64-bit one — 16 KB pages exist only on 64-bit Android +devices — so it applies to `arm64-v8a` and `x86_64`. The 32-bit libraries +(`armeabi-v7a`, `x86`) link at the 4 KB alignment their targets specify +(`0x1000`), which is correct for them and not a regression to fix. + ## Checking which toolchain built a `.so` The shipped binaries say so themselves — useful when a rebuild does not match, or @@ -308,7 +368,8 @@ fatal. ### Rust targets not installed ```bash -rustup target add aarch64-linux-android x86_64-linux-android +rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` ### Build fails with dependency errors diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index babc4e48cc..438fac14ee 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -4,15 +4,26 @@ # # Prerequisites: # - Rust toolchain: rustup, cargo -# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android +# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android \ +# armv7-linux-androideabi i686-linux-android # - cargo-ndk: cargo install cargo-ndk # - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION # (sdkmanager "ndk;") — see README.md -> "Reproducible builds" # # Usage: -# ./build-arti.sh # Build for all targets (arm64 + x86_64) -# ./build-arti.sh --release # Build arm64 only (for release) +# ./build-arti.sh # Build every shipped ABI (all four) +# ./build-arti.sh --release # arm64-v8a only (faster; NOT enough to cut a +# # release — the APK splits ship four ABIs) +# ./build-arti.sh --target= +# # build just this target, repeatable. Use it to +# # add one ABI without rewriting the other .so +# # files already committed under jniLibs/. # ./build-arti.sh --clean # Clean and rebuild +# ./build-arti.sh --print-abis # print the jniLibs ABI dirs this invocation +# # would write (honours --release/--target=), +# # then exit. This is how verify-reproducible.sh +# # learns the ABI list instead of keeping its +# # own copy of it. # set -euo pipefail @@ -56,26 +67,42 @@ OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs" LIB_NAME="libarti_android.so" MIN_SDK_VERSION=26 -# Default targets -TARGETS=("aarch64-linux-android" "x86_64-linux-android") -RELEASE_ONLY=false +# Default targets: one per ABI the APK is split for (amethyst/build.gradle.kts -> +# splits.abi). They must stay in sync — an ABI that gets an APK split but no +# libarti_android.so produces an install where every other native library is +# present, so the app looks healthy right up to the moment it tries to reach Tor, +# and then fails silently for the lifetime of that install. +TARGETS=("aarch64-linux-android" "x86_64-linux-android" "armv7-linux-androideabi" "i686-linux-android") CLEAN=false REGEN_LOCK=false +# Collects --target= selections; replaces TARGETS wholesale once any is given. +# A space-separated string, not an array: macOS still ships bash 3.2, where +# `${#empty_array[@]}` under `set -u` is an unbound-variable error. Target +# triples never contain spaces, so word splitting is exact here. +SELECTED_TARGETS="" +PRINT_ABIS=false # Parse arguments for arg in "$@"; do case $arg in - --release) RELEASE_ONLY=true; TARGETS=("aarch64-linux-android") ;; + --release) TARGETS=("aarch64-linux-android") ;; + --target=*) SELECTED_TARGETS="$SELECTED_TARGETS ${arg#--target=}" ;; + --print-abis) PRINT_ABIS=true ;; --clean) CLEAN=true ;; # Refresh the committed Cargo.lock from the pinned Arti tag, then exit # (no compile — needs only git + cargo, not the NDK). Use after bumping # ARTI_VERSION / Cargo.toml; the normal build is --locked and will fail # until the lock is regenerated and committed. --regen-lock) REGEN_LOCK=true; CLEAN=true ;; - --help) echo "Usage: $0 [--release] [--clean] [--regen-lock] [--help]"; exit 0 ;; + --help) echo "Usage: $0 [--release] [--target=]... [--clean] [--regen-lock] [--print-abis] [--help]"; exit 0 ;; esac done +if [ -n "$SELECTED_TARGETS" ]; then + # shellcheck disable=SC2206 # deliberate word splitting; see SELECTED_TARGETS + TARGETS=($SELECTED_TARGETS) +fi + print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; } print_success() { echo -e "${GREEN}✓ $1${NC}"; } print_error() { echo -e "${RED}✗ $1${NC}"; } @@ -254,12 +281,16 @@ PATCH # ============================================================================ # Android ABI directory (as laid out under jniLibs/) for a Rust target triple. +# Unknown triples are fatal rather than empty: an empty answer would make the +# caller write "$OUTPUT_DIR/" — i.e. drop the .so loose in jniLibs/, where no ABI +# picks it up — and both verification loops would skip it without a word. abi_dir_for() { case "$1" in aarch64-linux-android) echo "arm64-v8a" ;; x86_64-linux-android) echo "x86_64" ;; armv7-linux-androideabi) echo "armeabi-v7a" ;; i686-linux-android) echo "x86" ;; + *) print_error "Unknown Rust target '$1' — no jniLibs ABI maps to it" >&2; exit 1 ;; esac } @@ -395,6 +426,16 @@ verify_ndk_stamp() { # ============================================================================ main() { + # Answer --print-abis before any other output, so the caller gets exactly the + # ABI directory names on stdout and nothing else. Runs here rather than in the + # argument loop because abi_dir_for is not defined yet at that point. + if [ "$PRINT_ABIS" = true ]; then + for target in "${TARGETS[@]}"; do + abi_dir_for "$target" + done + exit 0 + fi + echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}" # --regen-lock only needs git + cargo, not the NDK/cargo-ndk toolchain. diff --git a/tools/arti-build/rust-toolchain.toml b/tools/arti-build/rust-toolchain.toml index cfb3a8367d..356afb295c 100644 --- a/tools/arti-build/rust-toolchain.toml +++ b/tools/arti-build/rust-toolchain.toml @@ -10,10 +10,14 @@ channel = "1.98.1" profile = "minimal" components = ["rustc", "cargo", "rust-std"] -# Only the two ABIs we actually ship libarti_android.so for (see jniLibs/). If -# build-arti.sh is extended to armv7/i686, add them here too — check_prerequisites -# also adds any missing target on the fly. +# One per ABI the APK is split for (see amethyst/build.gradle.kts -> splits.abi): +# every split that ships native code ships libarti_android.so too, or Tor is dead +# on that ABI for the life of the install. The `verifyArtiAbis` Gradle task holds +# the two lists together. check_prerequisites also adds any missing target on the +# fly. targets = [ "aarch64-linux-android", "x86_64-linux-android", + "armv7-linux-androideabi", + "i686-linux-android", ] diff --git a/tools/arti-build/verify-reproducible.sh b/tools/arti-build/verify-reproducible.sh index d9bc45d9e7..a1539dae67 100755 --- a/tools/arti-build/verify-reproducible.sh +++ b/tools/arti-build/verify-reproducible.sh @@ -9,8 +9,10 @@ # "Reproducible builds". # # Usage: -# ./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +# ./verify-reproducible.sh # all four shipped ABIs # ./verify-reproducible.sh --release # arm64-v8a only (faster) +# ./verify-reproducible.sh --target=armv7-linux-androideabi +# # one ABI, by Rust target triple # # Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, and the exact # Android NDK revision pinned in ANDROID_NDK_VERSION — a different revision is @@ -33,10 +35,14 @@ sha256() { # x86_64 library: that build never touches it, so the untouched file hashed # identically in both runs and the script reported the whole tree reproducible # and matching the commit. -ABIS="arm64-v8a x86_64" -for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do - [ "$arg" = "--release" ] && ABIS="arm64-v8a" -done +# +# Asked of build-arti.sh with the very flags it is about to receive, rather than +# kept as a second copy of the ABI list here. A local copy would drift the moment +# an ABI is added: this script would rebuild it twice, hash neither, and still +# print ✅ REPRODUCIBLE — the same false pass the paragraph above describes. +# Under `set -e` a failing --print-abis (e.g. an unknown triple) aborts here. +ABIS="$("$SCRIPT_DIR/build-arti.sh" --print-abis ${PASSTHRU[@]+"${PASSTHRU[@]}"} | tr '\n' ' ')" +ABIS="${ABIS% }" # sha256 of each built .so, keyed by ABI dir (relative paths → stable keys). hashes() { diff --git a/tools/r8-verify/reflection-contract.txt b/tools/r8-verify/reflection-contract.txt new file mode 100644 index 0000000000..4d04998fb8 --- /dev/null +++ b/tools/r8-verify/reflection-contract.txt @@ -0,0 +1,160 @@ +# What must survive R8 with its ORIGINAL name, and why. +# +# R8 cannot see reflection. Every line here is a place where something outside +# the DEX — a .so, a manifest attribute, a JSON file on disk, WorkManager's +# database, a DataStore value written by an older install — looks a name up at +# runtime. Rename or remove it and the app compiles, ships, and fails only on a +# user's device. +# +# `verify_reflection_contract.py` checks each line against a release build's +# mapping.txt, so a keep rule that silently stops matching (a moved class, a +# renamed package, a deleted rule) fails the release instead of the user. +# +# Adding reflection? Add the keep rule in amethyst/proguard-rules.pro AND a line +# here. A rule with no line here is unverified; a line here with no rule fails. +# +# CAN THE REFLECTION ITSELF BE REMOVED? +# Reviewed entry by entry. A keep rule you do not need is better than one you +# verify, so the standing answer per mechanism: +# +# JNI class+method names IRREDUCIBLE. `Java__` is +# compiled into libarti_android.so. Costs us +# nothing anyway — AGP's default +# `native ` rule covers it. +# Rust -> Kotlin callback Removable only by inverting the flow (Kotlin +# polls a native queue instead of Rust pushing +# by GetMethodID). One interface, one method — +# not obviously worth the threading change. +# Cast OptionsProvider IRREDUCIBLE. Play Services reads the class +# name out of a manifest value and +# Class.forName()s it. Google's API, not ours. +# WorkManager workers ALREADY GONE. androidx.work ships the keep +# itself; our duplicate rule was deleted and +# these entries now verify the library's. +# Jackson NWC + CLINK DONE — both package keeps deleted. The types +# route at the hand-written kotlinx serializers +# on every target now, and the Jackson +# (de)serializers for them are gone. +# Class name as a value REMOVED. requireInProcessSigner() compared +# `signer::class.qualifiedName` against the FQN +# of NostrSignerExternal. R8 renames that class, +# so the branch never ran in a release build. It +# is a plain `is` check now — nothing to keep. +# Platform-class reflection IRREDUCIBLE AND FREE. quic's +# JdkCertificateValidator probes the JDK/Android +# trust manager for the 3-arg +# checkServerTrusted(chain, authType, host). +# That class ships in the platform, not in our +# DEX, so R8 never renames it — no rule needed. +# libscrypt / NetCipher / GONE. Their keep rules matched zero classes: +# LazySodium / JNA libsodium is a pure-Kotlin implementation now +# and Tor runs through arti. Rules deleted. +# Jackson on-disk stores REMOVABLE. Plain data classes; @Serializable +# + kotlinx gives compile-time literal names. +# App-private files, so no interop risk. +# Enum constants REMOVABLE WITH NO MIGRATION. Give each +# persisted enum an explicit `val code: String` +# and store that instead of `.name`. Set the +# codes equal to today's constant names and +# every existing DataStore value keeps working, +# while the literal is untouchable by R8. That +# deletes the one blanket rule left +# (`-keepclassmembers enum *`), which today +# blocks enum unboxing across all 707 enums. +# +# Format — one per line, `#` comments to end of line: +# [@play|@fdroid] optional leading scope: check this line only on +# that flavor. Play-only code is absent from the +# F-Droid APK, and "absent" reads as "R8 deleted +# it" — an unscoped line would fail that release. +# class class must keep its exact name +# method ... those methods must keep their names +# fields class name AND every field name preserved +# enum ... those constants keep their names (class may be renamed) + +# --- JNI: the symbol name Java__ lives in libarti_android.so --- +class com.vitorpamplona.amethyst.ui.tor.ArtiNative +# Rust calls back by name: GetMethodID("onLogLine") in tools/arti-build/src/lib.rs +method com.vitorpamplona.amethyst.ui.tor.ArtiLogCallback onLogLine + +# zxing-cpp's JNI half, vendored into amethyst/src/main/java/zxingcpp. The .so +# exports Java_zxingcpp_BarcodeReader_readYBuffer, so the class name and both +# native method names are the lookup. AGP's default +# `-keepclasseswithmembernames class * { native ; }` should cover this on +# its own and the explicit `-keep class zxingcpp.**` is belt-and-braces; these +# lines are what proves at least one of them still matches. A rename fails +# silently — no build error, no warning, the QR scanner just never starts. +class zxingcpp.BarcodeReader +method zxingcpp.BarcodeReader readYBuffer readBitmap + +# --- Named from outside the DEX ---------------------------------------------- +# No keep rule of ours backs the three workers below: androidx.work's own +# consumer rules do. They stay listed so that if the library ever drops them, +# the release fails here instead of on a user's phone. +# in the play manifest; the Cast framework +# Class.forName()s it. AGP generates keeps for component android:name, not for +# meta-data values, so nothing but an explicit rule protects this one. +@play class com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider +# WorkManager stores the class name in its own DB and instantiates it by name on +# a later process start — including after an update that reshuffled the mapping. +class com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker +class com.vitorpamplona.amethyst.service.notifications.NotificationCatchUpWorker +class com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker + +# The AppFunctions bridge is kept whole by a package rule, so this asserts the +# rule still matches something. androidx.appfunctions reflects over the generated +# inventories and @AppFunctionSerializable types; play-only source set. +@play class com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions + +# --- Jackson reflective data binding: field names ARE the wire format --------- +# NIP-47 and CLINK used to be listed here. They are not data-bound reflectively +# any more — OptimizedJsonMapper routes them at kotlinx serializers that write +# every field name as a string literal — so there is no rule to verify. + + +# --- Enums used as navigation-route arguments: the CLASS name is the lookup --- +# androidx.navigation resolves an enum route argument by fully-qualified class +# name (NavTypeConverter calls Class.forName on the serial name). Renaming the +# class throws while the nav graph is being built, so the app cannot get past +# login at all -- release-only, and total. +# +# These need the CLASS pinned, which the `-keepclassmembers enum *` rule below +# deliberately does not do: it keeps constant names and lets the class be +# renamed. Add a line here whenever an enum becomes a route argument. +class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab +class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType +class com.vitorpamplona.amethyst.ui.navigation.routes.GeocacheTab + + +# --- Enum constants persisted as strings ------------------------------------- +# The preference stores write `enum.name` into DataStore and read it back with +# valueOf(). A renamed constant resets that setting for every existing user on +# the first launch after the update — silently, and only in a release build. +# The enum CLASS is still renamed; only the constant names are pinned. +enum com.vitorpamplona.amethyst.commons.tor.TorType INTERNAL + +# Not a preference: these constant names ARE the NIP-47 wire strings. The +# response parser does NwcErrorCode.valueOf(json["code"]) on a string another +# wallet wrote, so a rename turns every typed error into a null code and the +# UI loses the reason a payment failed. Falls back quietly (try/catch), which +# is exactly why it would never be noticed. +enum com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode RATE_LIMITED NOT_IMPLEMENTED INSUFFICIENT_BALANCE PAYMENT_FAILED QUOTA_EXCEEDED RESTRICTED UNAUTHORIZED INTERNAL UNSUPPORTED_ENCRYPTION BAD_REQUEST NOT_FOUND EXPIRED UNSUPPORTED_PAYMENT_INSTRUCTION UNSUPPORTED_NETWORK OTHER + +enum com.vitorpamplona.amethyst.model.ThemeType SYSTEM LIGHT DARK +enum com.vitorpamplona.amethyst.model.BooleanType ALWAYS NEVER +enum com.vitorpamplona.amethyst.model.ConnectivityType ALWAYS NEVER +enum com.vitorpamplona.amethyst.model.FeatureSetType SIMPLIFIED +enum com.vitorpamplona.amethyst.model.FontFamilyType SYSTEM +enum com.vitorpamplona.amethyst.model.FontSizeType NORMAL +enum com.vitorpamplona.amethyst.model.AccentColorType PURPLE +enum com.vitorpamplona.amethyst.model.ProfileGalleryType CLASSIC +enum com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +enum com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +enum com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ChannelExpand +# Still listed after ScheduledPost moved to kotlinx: this one is also the on-disk +# format of the scheduled-post file, and a plain @Serializable enum is not obviously +# immune — kotlinx builds its descriptor from the entries, and it has not been proven +# here that those names are literals rather than Enum.name read at runtime. Keeping +# the constants costs nothing (the blanket enum rule already provides them) and the +# failure mode — every queued post's status unreadable — is not worth guessing at. +enum com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus PENDING PUBLISHING SENT FAILED CANCELLED diff --git a/tools/r8-verify/verify_reflection_contract.py b/tools/r8-verify/verify_reflection_contract.py new file mode 100755 index 0000000000..80b3564b11 --- /dev/null +++ b/tools/r8-verify/verify_reflection_contract.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Check a release build's R8 output against tools/r8-verify/reflection-contract.txt. + + python3 tools/r8-verify/verify_reflection_contract.py [contract] + + is amethyst/build/outputs/mapping// — BOTH mapping.txt +and usage.txt are read, because neither is sufficient alone: + + * mapping.txt records only what CHANGED. A class kept intact by + `-keep ... { *; }` appears with an empty body, and an unrenamed member has + no line at all. Absence there means "preserved", not "missing". + * usage.txt is the other half: what R8 deleted. A constant that was shrunk + away leaves no trace in mapping.txt, so removals are only visible here. + +Preserved therefore means: present in mapping.txt under its own name, and not +listed in usage.txt. + +R8 cannot see reflection, so a keep rule that stops matching — a class moved to +another package, a rule deleted, a DTO renamed — fails silently: the build is +green, the APK ships, and the break surfaces on a user's device as a +ClassNotFoundException, an unreadable settings file, or JSON with one-letter +property names. The contract lists what must survive; this asserts it against +what R8 actually emitted. + +Exits 0 when every entry holds, 1 otherwise, naming each failure and the keep +rule that should have covered it. +""" +import re +import sys + +CLASS_LINE = re.compile(r"^(?P[^\s]+) -> (?P[^\s:]+):") +# " -> " (field) / " [1:2:] (args) -> " (method) +MEMBER_LINE = re.compile( + r"^\s+(?:\d+:\d+:)?[^\s]+\s+(?P[^\s(]+)(?P\([^)]*\))?\s*->\s*(?P[^\s]+)\s*$" +) + + +FLAVORS = ("play", "fdroid") + + +def parse_contract(path): + """Read the contract. A line may open with @ to scope it. + + Play-only code (the Cast provider, the AppFunctions bridge) is not compiled + into the F-Droid APK at all, so an unscoped entry for it would read as + "R8 deleted this" on that variant and fail a release the moment CI checked + both. `@play class ...` limits the check to the variant that has the class. + """ + entries = [] + with open(path, encoding="utf-8") as fh: + for lineno, raw in enumerate(fh, 1): + line = raw.split("#", 1)[0].strip() + if not line: + continue + parts = line.split() + scope = None + if parts[0].startswith("@"): + scope = parts[0][1:] + if scope not in FLAVORS: + sys.exit(f"{path}:{lineno}: unknown flavor {scope!r}; " + f"expected one of {', '.join(FLAVORS)}") + parts = parts[1:] + if len(parts) < 2: + sys.exit(f"{path}:{lineno}: expected ' [names...]'") + kind, fqn, rest = parts[0], parts[1], parts[2:] + if kind not in ("class", "method", "fields", "enum"): + sys.exit(f"{path}:{lineno}: unknown check kind {kind!r}") + entries.append((kind, fqn, rest, lineno, scope)) + return entries + + +def flavor_of(mapping_dir): + """playRelease -> play, fdroidRelease -> fdroid, anything else -> None. + + None means "check everything": an unrecognised directory must not silently + skip entries. + """ + variant = mapping_dir.rsplit("/", 1)[-1] + for flavor in FLAVORS: + if variant.startswith(flavor): + return flavor + return None + + +def consistency_check(mapping_path, usage_path): + """Are these two files from the SAME R8 run? + + mapping.txt is written later than the rest of the mapping directory (R8 + emits it during packaging, not at minify time), so a stale one survives a + rebuild that only got as far as minifying — leaving a directory whose + usage.txt describes one build and whose mapping.txt describes another. The + checks below would then read a coherent-looking mix and report nonsense. + + The invariant: a class R8 deleted cannot also be a class R8 named. Any class + that usage.txt lists as removed while mapping.txt shows it live under a real + name means the two files disagree about what was built. + """ + removed = set() + with open(usage_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if line[:1].isspace() or not line.strip(): + continue + name = line.strip() + if not name.endswith(":"): + removed.add(name) + clashes = [] + with open(mapping_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if line.startswith("#") or line[:1].isspace(): + continue + m = CLASS_LINE.match(line) + if ( + m + and not m.group("obf").startswith("R8$$REMOVED$$") + and m.group("orig") in removed + ): + clashes.append(m.group("orig")) + if len(clashes) > 20: + break + return clashes + + +def collect_removed(usage_path, wanted): + """usage.txt: `com.foo.Bar` alone = class deleted; `com.foo.Bar:` + indented + signatures = those members deleted.""" + gone_classes, gone_members, current = set(), {}, None + with open(usage_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if not line.strip(): + continue + if not line[:1].isspace(): + name = line.strip() + current = None + if name.endswith(":"): + name = name[:-1] + if name in wanted: + current = name + gone_members.setdefault(name, []) + elif name in wanted: + gone_classes.add(name) + continue + if current is not None: + gone_members[current].append(line.strip()) + return gone_classes, gone_members + + +def collect(mapping_path, wanted): + """Stream the mapping (it is ~500 MB) and keep only the blocks we asked for.""" + blocks, current = {}, None + with open(mapping_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if line.startswith("#"): + continue + if not line[:1].isspace(): + m = CLASS_LINE.match(line) + current = None + if m and m.group("orig") in wanted: + current = m.group("orig") + blocks[current] = {"obf": m.group("obf"), "fields": {}, "methods": {}} + continue + if current is None: + continue + m = MEMBER_LINE.match(line) + if not m: + continue + bucket = "methods" if m.group("args") else "fields" + blocks[current][bucket].setdefault(m.group("name"), m.group("new")) + return blocks + + +def main(): + if not 2 <= len(sys.argv) <= 3: + sys.exit(__doc__) + mapping_dir = sys.argv[1].rstrip("/") + mapping_path = mapping_dir + "/mapping.txt" + usage_path = mapping_dir + "/usage.txt" + for required in (mapping_path, usage_path): + try: + open(required).close() + except OSError as exc: + sys.exit(f"cannot read {required}: {exc}\n" + "Point this at amethyst/build/outputs/mapping// from a " + "minified build.") + contract_path = ( + sys.argv[2] + if len(sys.argv) == 3 + else __file__.rsplit("/", 1)[0] + "/reflection-contract.txt" + ) + + clashes = consistency_check(mapping_path, usage_path) + if clashes: + print( + f"{mapping_dir} is not one build: {len(clashes)}+ classes are listed as\n" + f"removed in usage.txt yet named in mapping.txt, e.g.\n " + + "\n ".join(clashes[:3]) + + "\n\nmapping.txt is written during packaging, so a minify-only rebuild leaves\n" + "the previous one behind. Re-run a full assemble/bundle for this variant\n" + "and check again — the results from this directory would be meaningless.", + file=sys.stderr, + ) + return 1 + + entries = parse_contract(contract_path) + flavor = flavor_of(mapping_dir) + in_scope = [e for e in entries if e[4] is None or e[4] == flavor] + skipped = len(entries) - len(in_scope) + entries = in_scope + wanted = {fqn for _, fqn, _, _, _ in entries} + blocks = collect(mapping_path, wanted) + gone_classes, gone_members = collect_removed(usage_path, wanted) + + def removed_member(fqn, name): + """usage.txt prints whole signatures; match the member name inside one.""" + for sig in gone_members.get(fqn, ()): + head = sig.split("(", 1)[0] + if head.split()[-1:] == [name] or head.endswith(" " + name): + return True + return False + + failures = [] + + def fail(lineno, fqn, msg): + failures.append(f" {contract_path}:{lineno} {fqn}\n {msg}") + + for kind, fqn, rest, lineno, _scope in entries: + if fqn in gone_classes: + fail(lineno, fqn, "deleted by R8 (listed in usage.txt) — nothing keeps it.") + continue + blk = blocks.get(fqn) + if blk is None: + fail(lineno, fqn, "absent from the mapping entirely — R8 removed it, or it " + "was renamed/moved in source and the contract is stale.") + continue + if blk["obf"].startswith("R8$$REMOVED$$"): + fail(lineno, fqn, "shrunk away by R8; nothing keeps it any more.") + continue + + if kind in ("class", "method", "fields") and blk["obf"] != fqn: + fail(lineno, fqn, f"renamed to {blk['obf']} — it is looked up by name at runtime.") + continue + + # From here on, a member with NO mapping line kept its name. Only an + # explicit rename, or an entry in usage.txt, is a failure. + if kind == "method": + for name in rest: + if removed_member(fqn, name): + fail(lineno, fqn, f"method {name}() was deleted by R8.") + elif blk["methods"].get(name, name) != name: + fail(lineno, fqn, f"method {name}() renamed to {blk['methods'][name]}().") + elif kind == "fields": + renamed = sorted(n for n, new in blk["fields"].items() if n != new) + if renamed: + shown = ", ".join(renamed[:6]) + ("…" if len(renamed) > 6 else "") + fail(lineno, fqn, f"{len(renamed)} field(s) renamed ({shown}) — these names " + "are the JSON/wire format.") + dropped = [s for s in gone_members.get(fqn, ()) if "(" not in s] + if dropped: + fail(lineno, fqn, f"{len(dropped)} field(s) deleted by R8: {', '.join(dropped[:4])}") + elif kind == "enum": + for const in rest: + if removed_member(fqn, const): + fail(lineno, fqn, f"constant {const} was deleted; valueOf(\"{const}\") " + "throws on a value already on disk.") + elif blk["fields"].get(const, const) != const: + fail(lineno, fqn, f"constant {const} renamed to {blk['fields'][const]}; " + "every stored value of it becomes unreadable.") + + checked = len(entries) + if failures: + print(f"R8 reflection contract: {len(failures)} of {checked} checks FAILED\n", file=sys.stderr) + print("\n".join(failures), file=sys.stderr) + print("\nFix the keep rule in amethyst/proguard-rules.pro (or quartz/consumer-rules.pro)," + "\nor update the contract if the code genuinely moved.", file=sys.stderr) + return 1 + note = f" ({skipped} skipped: not in the {flavor} flavor)" if skipped else "" + print(f"R8 reflection contract: all {checked} checks passed against {mapping_path}{note}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/zxing-cpp-build/.gitignore b/tools/zxing-cpp-build/.gitignore new file mode 100644 index 0000000000..224628a20f --- /dev/null +++ b/tools/zxing-cpp-build/.gitignore @@ -0,0 +1,2 @@ +.zxing-cpp-source/ +build/ diff --git a/tools/zxing-cpp-build/README.md b/tools/zxing-cpp-build/README.md new file mode 100644 index 0000000000..48d1426725 --- /dev/null +++ b/tools/zxing-cpp-build/README.md @@ -0,0 +1,108 @@ +# zxing-cpp Android Build Tools + +Custom-built [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) native library for Amethyst's QR +scanner. This replaces the prebuilt `io.github.zxing-cpp:android` AAR with the same library built +from source, the way [`tools/arti-build`](../arti-build) replaces the Guardian Project AAR. + +## Why custom build? + +Not for size — the published AAR's libraries are already stripped, and ours come out only slightly +smaller. **For verifiability.** + +The AAR ships four `.so` files built by a third party on toolchains we cannot see, and nothing in +this repository could check them. A QR scanner is a thing you point at a stranger's phone, and the +binary that parses whatever comes back was one we took on trust. Amethyst already holds +`libarti_android.so` to a reproducible standard; there was no principled reason for the barcode +decoder to be exempt. + +Building it ourselves also means F-Droid, Zapstore or any auditor can rebuild from this tag and +confirm the committed bytes, instead of being asked to trust a Maven artifact. + +## Quick start + +Pre-built `.so` files are committed to `amethyst/src/main/jniLibs/`. You only need to rebuild to +verify the binaries, bump the zxing-cpp version, or change the build flags. + +```bash +./build-zxingcpp.sh # every ABI +./build-zxingcpp.sh --abi arm64-v8a # one ABI, much faster +./verify-reproducible.sh # build twice, compare bytes, compare against the commit +``` + +Prerequisites: `git`, `cmake`, `ninja`, and the exact NDK revision in +[`tools/arti-build/ANDROID_NDK_VERSION`](../arti-build/ANDROID_NDK_VERSION). That is the +repo's single NDK pin, not a copy: `build-zxingcpp.sh`, `build-arti.sh` and `:amethyst`'s +`ndkVersion` all read that one file, so one NDK install serves every native build and the +three can never drift apart. + +## Reproducible builds + +Five things have to be fixed, and each is: + +| Source of non-determinism | Pinned by | +|---|---| +| compiler + linker version | [`tools/arti-build/ANDROID_NDK_VERSION`](../arti-build/ANDROID_NDK_VERSION); `build-zxingcpp.sh` refuses any other revision | +| upstream source | [`ZXING_CPP_VERSION`](ZXING_CPP_VERSION), cloned at that tag and nothing else | +| absolute paths baked into `__FILE__`, assertions, debug records | `-ffile-prefix-map` / `-fdebug-prefix-map` in [`repro-env.sh`](repro-env.sh) | +| timestamps | `SOURCE_DATE_EPOCH`, derived from the pinned tag's commit rather than from build time; `__DATE__`/`__TIME__` redacted | +| codegen/link ordering keyed on the real build path | canonical build path (`/tmp/amethyst-zxingcpp-build`) | + +> **Why the NDK is pinned.** clang compiles the C++ and lld links the `.so`, and both stamp their +> versions into the binary's `.comment` section. Swapping the NDK changes the bytes exactly as +> swapping compilers would. `build-zxingcpp.sh` reads each candidate directory's +> `source.properties` and keeps looking until it finds the pinned revision — no wildcards. Picking +> "some NDK" is precisely how arti's committed binaries ended up built by r25b while its README +> told everyone to install r27; that lesson is borrowed here rather than re-learned. + +> **Why the output is re-checked.** After each build the script decodes the library's own +> `.note.android.ident`, which records the min SDK and the NDK release and build number, and fails +> if they are not what was asked for. The revision gate checks the *input*; this checks the +> *output*, which is what catches a stale CMake cache or an overriding environment variable that +> slipped a different toolchain past the gate. + +Verified on this machine: two clean builds of `arm64-v8a` produced identical bytes +(`dec4397c…39dbf`), and `verify-reproducible.sh` confirmed they match the committed library. + +## What is built + +`wrappers/android/zxingcpp/src/main/cpp/CMakeLists.txt` from the pinned tag, which pulls in +zxing-cpp's `core/` and compiles one JNI file. Readers only (`ZXING_WRITERS=OFF`) — Amethyst +encodes its QR codes with ZXing core on the JVM, and never writes one natively. + +All four ABIs, unlike arti's two. Tor is an optional feature that can be absent; a QR scanner that +fails to load is a broken core feature, and the decoder this replaced was pure Java and worked +everywhere. Dropping an ABI would mean a silent regression on those devices unless the ZXing-Java +fallback behind `BarcodeDecoder` were wired up too. + +## The Kotlin half + +The AAR shipped the `zxingcpp.BarcodeReader` class alongside the `.so`, so dropping the AAR means +carrying that too: it is vendored verbatim at `amethyst/src/main/java/zxingcpp/BarcodeReader.kt`. + +Two things there are load-bearing and easy to break: + +- **The package and class name.** The native library exports + `Java_zxingcpp_BarcodeReader_readYBuffer`, so moving or renaming the class breaks the JNI lookup + at runtime with no build error. +- **`-keep class zxingcpp.** { *; }`** in `amethyst/proguard-rules.pro`. This used to arrive as + the AAR's consumer rule. Without it R8 renames the class in release builds and the scanner fails + to start — in release only, which is the worst place to find out. + +The vendored file keeps its upstream Apache-2.0 header and is excluded from spotless in the root +`build.gradle.kts`, so our MIT header is never stamped onto it. + +## Updating zxing-cpp + +1. Change [`ZXING_CPP_VERSION`](ZXING_CPP_VERSION) to the new tag. +2. Run `./build-zxingcpp.sh`. +3. Re-copy `BarcodeReader.kt` from the pinned clone + (`/tmp/amethyst-zxingcpp-build/.zxing-cpp-source/wrappers/android/zxingcpp/src/main/java/zxingcpp/`) + so the two halves cannot drift — a Kotlin wrapper from one version against a `.so` from another + fails at the JNI boundary, not at compile time. +4. Run `./verify-reproducible.sh` and commit the `.so` files with the hash it reports. + +## Licensing + +zxing-cpp is Apache-2.0 — permissive, so linking it into Amethyst's MIT-licensed artifacts is +fine, and no linking-exception question arises. The vendored Kotlin file carries its upstream +`SPDX-License-Identifier: Apache-2.0` and copyright line unchanged. diff --git a/tools/zxing-cpp-build/ZXING_CPP_VERSION b/tools/zxing-cpp-build/ZXING_CPP_VERSION new file mode 100644 index 0000000000..903cd9f2a0 --- /dev/null +++ b/tools/zxing-cpp-build/ZXING_CPP_VERSION @@ -0,0 +1 @@ +v3.1.1 diff --git a/tools/zxing-cpp-build/build-zxingcpp.sh b/tools/zxing-cpp-build/build-zxingcpp.sh new file mode 100755 index 0000000000..50b45f9df6 --- /dev/null +++ b/tools/zxing-cpp-build/build-zxingcpp.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# +# Build libzxingcpp_android.so from zxing-cpp source, reproducibly. +# +# Replaces the prebuilt io.github.zxing-cpp:android AAR, whose four .so files +# were built by a third party on unknown toolchains and which nothing in this +# tree could verify. Same standard tools/arti-build holds libarti_android.so +# to: pinned NDK, pinned source, canonical build path, verifiable bytes. +# +# Usage: +# ./build-zxingcpp.sh # every ABI +# ./build-zxingcpp.sh --abi arm64-v8a # one ABI (faster) +# ./build-zxingcpp.sh --out DIR # write .so somewhere else (verify uses this) +# +# Prerequisites: git, cmake, ninja, and the exact NDK revision in +# tools/arti-build/ANDROID_NDK_VERSION. Any other revision is refused — it +# would change the output bytes, which is the whole point. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ZXING_VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ZXING_CPP_VERSION")" +# One pin for the whole repo, deliberately not a copy of our own. :amethyst +# already reads this same file for `ndkVersion` (the NDK that strips whatever +# lands in src/main/jniLibs), so a second copy here could only ever be a way +# to disagree with the toolchain that packages the .so we produce — the exact +# byte-level drift the pin exists to prevent. Bumping it rebuilds both +# libarti_android.so and libzxingcpp_android.so, which is correct: they must +# be built and stripped by one NDK. +NDK_VERSION="$(tr -d '[:space:]' < "$PROJECT_ROOT/tools/arti-build/ANDROID_NDK_VERSION")" + +# Canonical build path. Codegen and link ordering can key on the real build +# directory even with path remapping in place, so everyone builds here or +# nobody's bytes match. Overriding it changes the output; only do that if you +# do not care about matching the published .so. +BUILD_ROOT="${ZXING_REPRO_DIR:-/tmp/amethyst-zxingcpp-build}" +SOURCE_DIR="$BUILD_ROOT/.zxing-cpp-source" +OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs" +LIB_NAME="libzxingcpp_android.so" +MIN_SDK_VERSION=26 + +# Every ABI the app splits on. A QR scanner that does not load is a broken core +# feature — the decoder this replaced was pure Java and worked everywhere — so +# every split gets one, and :amethyst's verifyNativeAbis fails the build if one +# is missing or built for the wrong architecture. +ABIS=(arm64-v8a armeabi-v7a x86 x86_64) + +while [ $# -gt 0 ]; do + case "$1" in + --abi) ABIS=("$2"); shift 2 ;; + --out) OUTPUT_DIR="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +info() { printf '\033[0;34m==>\033[0m %s\n' "$1"; } +ok() { printf '\033[0;32m ok\033[0m %s\n' "$1"; } +fail() { printf '\033[0;31merror:\033[0m %s\n' "$1" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# NDK: find the pinned revision, refuse anything else +# --------------------------------------------------------------------------- + +ndk_revision() { + sed -n 's/^Pkg\.Revision *= *//p' "$1/source.properties" 2>/dev/null | tr -d '[:space:]' || true +} + +find_ndk() { + # No wildcards. An exported ANDROID_NDK_HOME is only a hint: CI images and + # IDE installs routinely point it at a bundled NDK that is not ours, so each + # candidate is checked against source.properties and skipped when it does + # not match. Picking "some NDK" is exactly how arti's committed binaries + # ended up built by r25b while its docs asked for r27. + local candidate revision rejected="" + for candidate in \ + "${ANDROID_NDK_HOME:-}" \ + "${ANDROID_NDK_ROOT:-}" \ + "${ANDROID_HOME:-}/ndk/$NDK_VERSION" \ + "${ANDROID_SDK_ROOT:-}/ndk/$NDK_VERSION" \ + "${HOME:-}/Android/Sdk/ndk/$NDK_VERSION" \ + "${HOME:-}/Library/Android/sdk/ndk/$NDK_VERSION" \ + "/usr/local/lib/android/sdk/ndk/$NDK_VERSION"; do + [ -n "$candidate" ] || continue + [ -d "$candidate" ] || continue + revision="$(ndk_revision "$candidate")" + if [ "$revision" = "$NDK_VERSION" ]; then + echo "$candidate" + return 0 + fi + [ -n "$revision" ] && rejected="$rejected\n $candidate (r$revision)" + done + + printf 'error: NDK %s not found.\n' "$NDK_VERSION" >&2 + [ -n "$rejected" ] && printf ' rejected:%b\n' "$rejected" >&2 + printf ' install it with: sdkmanager "ndk;%s"\n' "$NDK_VERSION" >&2 + return 1 +} + +# An ELF tool, preferring the pinned NDK's own llvm-* copy so the post-build +# checks work on hosts without binutils (notably macOS). +ndk_tool() { + local name="$1" candidate + for candidate in "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/"llvm-$name"; do + [ -x "$candidate" ] && { echo "$candidate"; return 0; } + done + command -v "$name" 2>/dev/null && return 0 + return 1 +} + +# --------------------------------------------------------------------------- +# Source +# --------------------------------------------------------------------------- + +fetch_source() { + if [ ! -d "$SOURCE_DIR/.git" ]; then + info "Cloning zxing-cpp $ZXING_VERSION" + rm -rf "$SOURCE_DIR" + git clone --depth 1 --branch "$ZXING_VERSION" \ + https://github.com/zxing-cpp/zxing-cpp.git "$SOURCE_DIR" >/dev/null 2>&1 \ + || fail "could not clone zxing-cpp at $ZXING_VERSION" + else + info "Updating clone to $ZXING_VERSION" + git -C "$SOURCE_DIR" fetch --depth 1 origin tag "$ZXING_VERSION" >/dev/null 2>&1 || true + git -C "$SOURCE_DIR" checkout -q "$ZXING_VERSION" + fi + + local head + head="$(git -C "$SOURCE_DIR" rev-parse HEAD)" + ok "zxing-cpp $ZXING_VERSION at $head" +} + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +build_abi() { + local abi="$1" + local build_dir="$BUILD_ROOT/build/$abi" + + info "Building $abi" + rm -rf "$build_dir" + mkdir -p "$build_dir" + + # The wrapper's own CMakeLists pulls in core/ and sets the flags upstream + # cares about (readers only, hidden visibility, --exclude-libs). Everything + # added here is either reproducibility or the release/strip settings the + # published AAR applies at packaging time rather than in CMake. + cmake -S "$SOURCE_DIR/wrappers/android/zxingcpp/src/main/cpp" -B "$build_dir" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI="$abi" \ + -DANDROID_PLATFORM="android-$MIN_SDK_VERSION" \ + -DANDROID_ARM_NEON=ON \ + -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DZXING_WRITERS=OFF \ + -DZXING_READERS=ON \ + -DZXING_UNIT_TESTS=OFF \ + -DCMAKE_C_FLAGS="$ZXING_REPRO_CFLAGS" \ + -DCMAKE_CXX_FLAGS="$ZXING_REPRO_CFLAGS" \ + -DCMAKE_SHARED_LINKER_FLAGS="$ZXING_REPRO_LDFLAGS" \ + >/dev/null || fail "cmake configure failed for $abi" + + cmake --build "$build_dir" --target zxingcpp_android >/dev/null \ + || fail "build failed for $abi" + + local built="$build_dir/$LIB_NAME" + [ -f "$built" ] || fail "$LIB_NAME not produced for $abi" + + # Strip: the published AAR ships stripped libraries (AGP strips at packaging + # time), so an unstripped one would differ from what the app used to carry + # for no benefit — debug info in a shipped .so helps nobody here. + local strip_tool + strip_tool="$(ndk_tool strip)" || fail "no strip tool found" + "$strip_tool" --strip-unneeded "$built" + + mkdir -p "$OUTPUT_DIR/$abi" + cp "$built" "$OUTPUT_DIR/$abi/$LIB_NAME" + + verify_abi "$abi" "$OUTPUT_DIR/$abi/$LIB_NAME" +} + +# Re-check the NDK stamp in the output rather than trusting that the right NDK +# was selected: this is what catches a toolchain that was picked up despite the +# revision gate (a stale CMake cache, an overriding environment variable). +verify_abi() { + local abi="$1" lib="$2" + local readelf + readelf="$(ndk_tool readelf)" || { ok "$abi (no readelf; stamp not checked)"; return 0; } + + # .note.android.ident records the min SDK as a little-endian word followed by the NDK's + # release name and build number as NUL-padded strings, e.g. `1a 00 00 00 r30 16248370`. + # readelf prints it as raw hex, so the strings are recovered from that rather than grepped + # for directly -- min SDK 26 is the byte 0x1a and never appears as the text "26". + local hex ascii + hex="$("$readelf" --notes "$lib" 2>/dev/null | sed -n 's/.*description data: *//p' | head -1)" + [ -n "$hex" ] || fail "$abi: no .note.android.ident -- not an Android library?" + + ascii="$(printf '%s' "$hex" | tr -d ' ' | sed 's/../\\x&/g' | xargs -0 printf 2>/dev/null | tr -c '[:print:]' ' ')" + + # The build number is everything after the last dot of the pinned revision. + local ndk_build_number="${NDK_VERSION##*.}" + printf '%s' "$ascii" | grep -q "$ndk_build_number" \ + || fail "$abi: built by the wrong NDK -- .note.android.ident has no build number $ndk_build_number (got: $ascii)" + + # First word, little-endian, is the min SDK the library targets. + local min_sdk_hex min_sdk + min_sdk_hex="$(printf '%s' "$hex" | awk '{printf "%s%s%s%s", $4, $3, $2, $1}')" + min_sdk=$((16#$min_sdk_hex)) + [ "$min_sdk" = "$MIN_SDK_VERSION" ] \ + || fail "$abi: targets minSdk $min_sdk, expected $MIN_SDK_VERSION" + + local size + size="$(wc -c < "$lib" | tr -d '[:space:]')" + ok "$abi minSdk $min_sdk NDK $ndk_build_number ${size} bytes" +} + +# --------------------------------------------------------------------------- + +main() { + command -v cmake >/dev/null || fail "cmake not found" + command -v ninja >/dev/null || fail "ninja not found" + command -v git >/dev/null || fail "git not found" + + ANDROID_NDK_HOME="$(find_ndk)" || exit 1 + export ANDROID_NDK_HOME + ok "NDK $NDK_VERSION at $ANDROID_NDK_HOME" + + mkdir -p "$BUILD_ROOT" + fetch_source + + # shellcheck source=repro-env.sh + . "$SCRIPT_DIR/repro-env.sh" + ok "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-unset}" + + for abi in "${ABIS[@]}"; do + build_abi "$abi" + done + + info "Done. Libraries in $OUTPUT_DIR" +} + +main "$@" diff --git a/tools/zxing-cpp-build/repro-env.sh b/tools/zxing-cpp-build/repro-env.sh new file mode 100644 index 0000000000..6bdfe9deaa --- /dev/null +++ b/tools/zxing-cpp-build/repro-env.sh @@ -0,0 +1,52 @@ +# Deterministic build environment for libzxingcpp_android.so. +# +# Sourced by build-zxingcpp.sh so every build path stays in lockstep. Makes the +# native library byte-for-byte reproducible, which is what lets F-Droid, +# Zapstore or any third party rebuild the shipped .so from this tag and confirm +# it matches — the same bar tools/arti-build holds libarti_android.so to. +# +# The caller must already have set: +# SCRIPT_DIR — tools/zxing-cpp-build +# SOURCE_DIR — the zxing-cpp clone (.zxing-cpp-source) +# BUILD_ROOT — the canonical build root +# +# What makes a C++ shared library non-reproducible, and the fix for each: +# 1. Compiler + linker version -> the pinned NDK (ANDROID_NDK_VERSION). clang +# and lld stamp their versions into .comment exactly as rustc does. +# 2. Upstream source -> pinned git tag (ZXING_CPP_VERSION), cloned +# at that tag and nothing else. +# 3. Absolute paths baked into __FILE__, assertions and debug records +# -> -ffile-prefix-map rewrites them to stable +# virtual paths, so two machines with different checkout dirs agree. +# 4. Timestamps -> SOURCE_DATE_EPOCH, derived from the pinned +# tag's commit rather than from when the build happens. +# 5. Archive metadata -> ar writes mtimes/uids into static archives; +# the D (deterministic) flag zeroes them. The NDK's llvm-ar defaults to D, +# but it is set explicitly so a host ar cannot change the answer. + +# Rewrite every host-specific absolute prefix the compiler would otherwise bake +# into the binary. Both flags are needed: -ffile-prefix-map covers __FILE__ and +# debug info, -fdebug-prefix-map is kept for older clangs that ignore the first. +REPRO_CFLAGS="-ffile-prefix-map=${SOURCE_DIR}=/zxing-cpp" +REPRO_CFLAGS="${REPRO_CFLAGS} -ffile-prefix-map=${BUILD_ROOT}=/build" +REPRO_CFLAGS="${REPRO_CFLAGS} -fdebug-prefix-map=${SOURCE_DIR}=/zxing-cpp" +REPRO_CFLAGS="${REPRO_CFLAGS} -fdebug-prefix-map=${BUILD_ROOT}=/build" + +# No __DATE__/__TIME__ anywhere in the output, whatever upstream does with them. +REPRO_CFLAGS="${REPRO_CFLAGS} -Wno-builtin-macro-redefined -D__DATE__=\"redacted\" -D__TIME__=\"redacted\"" + +export ZXING_REPRO_CFLAGS="${REPRO_CFLAGS}" + +# lld's default build-id is a hash of the content, so it is already a function +# of the input bytes — but pin it rather than inherit whatever the NDK's +# default becomes. A content hash also means a matching rebuild keeps the same +# BuildID, which is what an auditor compares. +export ZXING_REPRO_LDFLAGS="-Wl,--build-id=sha1" + +# Pin SOURCE_DATE_EPOCH to the commit the tag points at: deterministic for a +# given ZXING_CPP_VERSION, and independent of when the build actually runs. +if [ -d "${SOURCE_DIR}/.git" ]; then + _epoch="$(git -C "${SOURCE_DIR}" log -1 --format=%ct 2>/dev/null || true)" + [ -n "${_epoch}" ] && export SOURCE_DATE_EPOCH="${_epoch}" + unset _epoch +fi diff --git a/tools/zxing-cpp-build/verify-reproducible.sh b/tools/zxing-cpp-build/verify-reproducible.sh new file mode 100755 index 0000000000..3a3741ca50 --- /dev/null +++ b/tools/zxing-cpp-build/verify-reproducible.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Verify that libzxingcpp_android.so builds reproducibly. +# +# Builds the native library twice from a clean state into scratch directories +# and confirms the two outputs are byte-for-byte identical, then compares them +# against what is committed under jniLibs. Both builds compile in the canonical +# path, so a match here means any checkout -- ours, F-Droid's, an auditor's -- +# produces the same bytes. See README.md -> "Reproducible builds". +# +# Usage: +# ./verify-reproducible.sh # every ABI +# ./verify-reproducible.sh --abi arm64-v8a # one ABI (faster) +# +# Exit 0 = reproducible and matching the commit, exit 1 = they differ. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +JNILIBS="$PROJECT_ROOT/amethyst/src/main/jniLibs" +LIB_NAME="libzxingcpp_android.so" + +PASSTHRU=("$@") + +# Only the ABIs this run actually rebuilds. Hashing the whole tree would let an +# untouched ABI hash identically in both runs and report the lot reproducible -- +# the same trap tools/arti-build hit and documents. +ABIS=(arm64-v8a armeabi-v7a x86 x86_64) +for ((i = 0; i < ${#PASSTHRU[@]}; i++)); do + [ "${PASSTHRU[$i]}" = "--abi" ] && ABIS=("${PASSTHRU[$((i + 1))]}") +done + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi +} + +hashes_in() { + local dir="$1" abi + ( cd "$dir" && for abi in "${ABIS[@]}"; do + [ -f "$abi/$LIB_NAME" ] && sha256 "$abi/$LIB_NAME" + done ) +} + +RUN_A="$(mktemp -d -t zxingcpp-verify-a.XXXXXX)" +RUN_B="$(mktemp -d -t zxingcpp-verify-b.XXXXXX)" +trap 'rm -rf "$RUN_A" "$RUN_B"' EXIT + +printf '==> Build 1 of 2\n' +"$SCRIPT_DIR/build-zxingcpp.sh" --out "$RUN_A" ${PASSTHRU[@]+"${PASSTHRU[@]}"} >/dev/null + +printf '==> Build 2 of 2\n' +"$SCRIPT_DIR/build-zxingcpp.sh" --out "$RUN_B" ${PASSTHRU[@]+"${PASSTHRU[@]}"} >/dev/null + +A="$(hashes_in "$RUN_A")" +B="$(hashes_in "$RUN_B")" + +if [ "$A" != "$B" ]; then + printf '\nNOT REPRODUCIBLE -- the two builds differ:\n' + diff <(printf '%s\n' "$A") <(printf '%s\n' "$B") || true + exit 1 +fi + +printf '\nReproducible: two builds produced identical bytes.\n' +printf '%s\n' "$A" | sed 's/^/ /' + +# A reproducible build that does not match the commit is still a problem: it +# means the shipped library came from something other than this tag. +COMMITTED="$(hashes_in "$JNILIBS")" +if [ "$COMMITTED" != "$A" ]; then + printf '\nWARNING: the committed libraries do NOT match this build.\n' + diff <(printf '%s\n' "$COMMITTED") <(printf '%s\n' "$A") || true + printf '\nRun build-zxingcpp.sh and commit the result.\n' + exit 1 +fi + +printf '\nCommitted libraries match.\n'