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/.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 7d323ba5ab..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. diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 202e9a2418..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 @@ -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 d1b79c98ff..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 = @@ -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/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..3e4b2d898d 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -1,71 +1,244 @@ -# 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 { *; } 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/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 39b9db024d..76e61e9805 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter @@ -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-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index e2e8eb7371..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… @@ -2392,4 +2395,5 @@ Health Connect és Amethyst + Fitnesz diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 7963cbf16c..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… @@ -2610,4 +2613,5 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Health Connect i Amethyst + Moja kondycja diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e4e904cf7b..db5d7c8a76 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1670,6 +1670,9 @@ New Picture New Short New Video + Scan QR code + Scan QR + Scan a QR code New Highlight Highlighted text Add your thoughts… 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/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/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/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/workouts/health/WorkoutMergerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt index 33d5732a1a..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 @@ -226,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..67425d5405 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,74 @@ 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 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/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/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/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/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/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/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/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/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/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/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt index a99e9bf58d..f06a970de3 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt @@ -80,4 +80,22 @@ data class DetectedWorkout( * 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 index d6cb6c6d08..ee4061bb85 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt @@ -21,6 +21,8 @@ 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 /** @@ -47,9 +49,16 @@ object TrainingLog { * 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 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. + * 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 — @@ -60,21 +69,33 @@ object TrainingLog { healthConnect: List, published: List, ): List { - val flagged = - healthConnect.map { recorded -> - if (published.any { recorded.isProbablySameWorkoutAs(it) }) { - recorded.copy(alreadyPublished = true) - } else { - recorded + 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) } } + } - val deduped = - published.filterNot { candidate -> - healthConnect.any { it.isProbablySameWorkoutAs(candidate) } - } + // 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 (flagged + deduped).sortedByDescending { it.startTimeEpochSeconds } + return merged.sortedByDescending { it.startTimeEpochSeconds } } private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean = 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/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/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-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index e59b387b4d..ab2b7fcfb0 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -186,7 +186,37 @@ %1$d घटनाएँ परिचय ध्वजचित्र - क्यूआर॰ क्रमचित्र के अभिमुख करें + क्यूआर॰ चित्र अक्षरराशि के अभिमुख करें + अमेथिस्ट को चित्रग्राहक चाहिए क्यूआर॰ चित्र अक्षरराशि परखने के लिए। कुछ भी अभिलेखित अथवा आरोहित नहीं। चित्र का विस्तारण यन्त्र पर किया जाएगा तथा हटा दिया जाएगा। + चित्रग्राहक अनुमति + चित्रग्राहक अभिगमन निष्क्रिय कर दिया गया अमेथिस्ट के लिए। आप इसे पुनःसक्रिय कर सकते हैं आण्ड्रोइड स्थापना विकल्पों में। + स्थापना विकल्प खोलें + क्यूआर॰ विस्तारक इस यन्त्र पर आरम्भ नहीं किया जा सका। तो भी आप अक्षरराशि को लेख के रूप में चिपका सकते हैं। + प्रकाशस्रोत सक्रिय करें + प्रकाशस्रोत निष्क्रिय करें + अन्धकारमय है। प्रकाशस्रोत सक्रिय करके प्रयास करें + चित्र परखें + चिपकाएँ + उस चित्र में से कोई क्यूआर॰ चित्र अक्षरराशि प्राप्त नहीं + टाँकाफलक में कुछ भी नहीं परखने के लिए + अक्षरराशि का चयन करें + %2$s में से %1$s भाग प्राप्त। आगे बढें + विस्तारण पुनःस्थापना + अमेथिस्ट इस अक्षरराशि को नहीं खोल सकता + इस अक्षरराशि में एक निजी कुंचिका अथवा जोडनेवाला रहस्य है। इसलिए इसकी विषयवस्तु यहाँ दिखाया नहीं जाता। अनुकूल पटल का उपयोग करें। + यह एक निजी कुंचिका है। प्रवेशांकन पटल पर चिपकाएँ। + यह एक धनकोष संयोजन है। इसे जोडें धनकोष स्थापना विकल्पों में। + यह एक दूरस्थ हस्ताक्षरकर्ता पता है। अमेथिस्ट इसे देता है अन्य क्रमकों को जिससे वे अमेथिस्ट से हस्ताक्षर अनुरोध कर सकते हैं। अमेथिस्ट इससे हस्ताक्षर करके प्रवेशांकन नहीं कर सकता। + यह एक क्रमक है जो आपके हस्ताक्षरकर्ता से जुडने का अनुरोध कर रहा है। इसे निप॰छियालीस हस्ताक्षरकर्ता पटल से खोलें। + यह एक लैटनिंग चालान है। + यह एक काशयू अक्षरराशि है। + यह एक जाल योजक है। + यह साधारण लेख है। नोस्टर सम्बन्धित अक्षरराशि नहीं। + यह नोस्टर सम्बन्धित अक्षरराशि है। पर इस पटल द्वारा नहीं खोला जा सकता। + योजक खोलें + अनुकृति + अनुकृत + पुनःपरखें क्यूआर॰ क्रमचित्र दिखाएँ परिचय चित्र आपका परिचय चित्र @@ -494,8 +524,8 @@ किसी भी संचारयन्त्र चित्रग्राहक के साथ परखें नोस्टर क्रमक के साथ परखें चित्र - क्यूआर॰ चित्र जिसमें इस टीका का एक जाल योजक समाविष्ट है - क्यूआर॰ चित्र जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है + क्यूआर॰ चित्र अक्षरराशि जिसमें इस टीका का एक जाल योजक समाविष्ट है + क्यूआर॰ चित्र अक्षरराशि जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है अंगुलचित्र छिपाया गया संवेदनशिल विषयवस्तु के कारण लेखक सूचक लेख की अनुकृति करें @@ -631,7 +661,7 @@ गति ऊँचाई वृद्धि ऊँचाई घटाव - उष्ममान + उष्ममानइकाई पद हृदय गति उच्चतम हृदय गति @@ -656,7 +686,7 @@ अमेथिस्ट क्या पढता है और क्यों व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। तथा पत्र का शीर्षक तथा कालावधि। दूरी। आप कितने दूर गए। धवन चालन चलन तरण की दूरी के रूप में दिखाया जाएगा। - सक्रिय तथा कुल उष्ममान। ऊर्जा जिसका व्यय व्यायाम से किया गया। सक्रिय उष्ममान उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममान विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। + सक्रिय तथा कुल उष्ममानइकाई। ऊर्जा जिसका व्यय व्यायाम से किया गया। सक्रिय उष्ममानइकाई उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममानइकाई विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। मानक परिमाण कि श्रम कितना कठिन था। पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। ऊँचाई वृद्धि। आप कितना ऊपर चढे। जो कि पृथकता है एक समतल चालन का एक पर्वतीय चालन से। @@ -666,8 +696,47 @@ आपके संचारयन्त्र से तब तक कुछ नहीं जाएगा जब तक आप एक सुझाव नहीं दबाते तथा पत्र का प्रकाशन आप स्वयम नहीं करते। अमेथिस्ट का कोई सेवासंगणक नहीं। पत्र उन नोस्टर पुनःप्रसारकों को जाएगा जिनको आपने समाकृत कर रखे हैं। यह सम्पूर्ण सुविधा विकल्पात्मक है। इसे निष्क्रिय करें स्थापना विकल्पों में सम्पादन पटल में। अथवा स्वास्थ्य संयोजन में अनुमतियाँ का निराकरण करें कभी भी। अमेथिस्ट के शेष भाग कार्य करते रहेंगे। सम्पूर्ण गोपनीयता नीति पढें + मेरा स्वास्थ्य + पिछले चार सप्ताह + आपके अभ्यास के अन्तिम चार पूर्ण सप्ताह। व्यायाम जो आपने प्रकाशित किए। तथा जो भी स्वास्थ्यसंयोजन इस संचारयन्त्र पर अभिलेखन करता है। अमेथिस्ट इसका साराम्श कर निकालेगा यन्त्र पर तथा कुछ भी प्रकाशित नहीं करेगा। + इस सप्ताह + पिछले सप्ताह की तुलना में + साप्ताहिक मध्यमान + व्यायाम + सक्रिय दिन + लगातार इतने दिन + समय + %1$dघं॰ %2$dमि॰ + %1$dमि॰ + %1$dसे॰ + दूरी + उष्ममानइकाई + पद + आरोहण + मध्य॰ हृ॰गति + उच्च॰ हृ॰गति + गतिविधि अनुसार + सर्वोत्तम प्रयास + दीर्घतम दूरी + दीर्घतम व्यायाम + बृहत्तम आरोहण + अधिकतम पद + उच्चतम हृदयस्पन्दन गति + निकटकाल व्यायाम + पिछले चार सप्ताह में कुछ भी अभिलेखित नहीं। व्यायाम जो आप प्रकाशित करते हैं तथा जो भी आपकी घडी अभिलेखित करती है सब यहाँ दिखेंगे। + व्यायाम के अभिलेख + स्वास्थ्यसंयोजन से जोड बनाएँ आपके साप्ताहिक संकलनों को देखने के लिए। तथा सप्ताहोत्तर सप्ताह प्रवृत्ति। तथा सर्वोत्तम प्रयासों को। तथा निरन्तार अभ्यास दिनों को। सब आपके संचारयन्त्र पर रहेगा। + स्वास्थ्यसंयोजन से जोड बनाएँ आपकी घडी द्वारा अभिलेखित व्यायामों को जोडने के लिए। हृदयस्पन्दन गति तथा पद तथा आरोहण युक्त। + संयोजन + व्यायाम बाँटें + स्पन्दनप्रतिमिनुट + सहस्रउष्ममानइकाई + सहस्रमीटर + मील + मीटर + पाद कोई नोस्टरस्थान प्राप्त नहीं अब तक। पुनःआवहन क्रमकाभ्यन्तर जालवीक्षक के लिए आण्ड्रोइड ग्यारह अथवा नवीनतर आवश्यक। @@ -1242,6 +1311,8 @@ + %1$02dमि॰%2$02dसे॰ + %1$dघं॰%2$02dमि॰%3$02dसे॰ हटाएँ अन्य चित्रग्राहक ध्‍वनि आह्वान @@ -1630,7 +1701,7 @@ यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या तो भी आरोहण करें। तो भी आरोहण करें पाण्डुलिपि सम्पादन - क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें + क्यूआर॰ चित्र अक्षरराशि के साथ प्रवेशांकन करें भेजें प्राप्त करें लेनदेन @@ -1648,6 +1719,7 @@ इस छलनी के अनुकूल कोई लेनदेन नहीं आवहन चालू… प्राप्त + ज्साप किया गया टीका खोलें भेजा गया नवीकरण सभी @@ -2091,8 +2163,8 @@ समापन दिनांक तथा समय चुनें %1$s में समाप्त समापन समय - क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ - क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ + क्यूआर॰ चित्र अक्षरराशि के रूप में एनपुब॰ को दिखाएँ + क्यूआर॰ चित्र अक्षरराशि के रूप में एनप्रोफैल॰ को दिखाएँ आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे। ये अच्छे विकल्प हैं :\n - auth.nostr1.com (शुल्करहित)\n - inbox.nostr.wine (सशुल्क)\n - relay.0xchat.com (शुल्करहित) @@ -2202,7 +2274,7 @@ भेजें उपयोगकर्ता नाम को ध्वनि के रूप में चलाएँ घुण्डी - क्यूआर॰ क्रमचित्र परखें + क्यूआर॰ चित्र अक्षरराशि परखें दिनांक का चयन करें पाण्डुलिपि पुरस्कार diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 45d0d54e19..df12c165f3 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 @@ -187,6 +187,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,8 +697,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. @@ -848,7 +917,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Ő @@ -1243,6 +1312,8 @@ + %1$02d:%2$02d + %1$d:%2$02d:%3$02d Eltüntetés Kameraváltás Hanghívás @@ -1649,6 +1720,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-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 87147c66cc..5f3375d1bc 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -197,6 +197,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,8 +707,47 @@ Ż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 + 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. @@ -1253,6 +1322,8 @@ + + Ignoruj Przełącz kamerę Połączenie głosowe @@ -1663,6 +1734,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/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index f714c424d4..3fecb3fbaa 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -342,6 +342,36 @@ 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. + Open link + Copy + Copied + Scan again Show QR Profile Picture Your Profile Picture @@ -829,6 +859,7 @@ 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 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/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/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/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 cfaf21993d..0aa82c3a96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt deleted file mode 100644 index fe331449b9..0000000000 --- a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ /dev/null @@ -1,30 +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 - -actual class TestResourceLoader { - actual fun loadString(file: String): String = - this@TestResourceLoader - .javaClass.classLoader!! - .getResourceAsStream(file) - .bufferedReader() - .use { it.readText() } -} 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/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/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt deleted file mode 100644 index 472972cb6e..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt +++ /dev/null @@ -1,48 +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.kotlinSerialization - -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -// 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 - } - } - - 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/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/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/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/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/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/nip01Core/jackson/OmitNullsMixin.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt deleted file mode 100644 index 17f4b976aa..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt +++ /dev/null @@ -1,42 +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.nip01Core.jackson - -import com.fasterxml.jackson.annotation.JsonInclude - -/** - * Applied to a reflectively-serialized DTO so Jackson OMITS a null field instead - * of writing it. - * - * 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. - * - * 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. - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -abstract class OmitNullsMixin 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/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/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..cad44c3656 --- /dev/null +++ b/tools/r8-verify/reflection-contract.txt @@ -0,0 +1,159 @@ +# 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 + + +# --- 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'