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/.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/BUILDING.md b/BUILDING.md index 1a67b65b82..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,34 +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. -The NDK half of that Arti pin does reach the ordinary Android build, though. -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. `libarti_android.so` -is then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`): -the Cargo release profile already stripped it, and llvm-strip would only rewrite -its `.comment` stamps, 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 +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)"`. -> **One Arti library per ABI split.** The APK is split four ways (`arm64-v8a`, -> `x86_64`, `armeabi-v7a`, `x86`) and every split needs its own -> `libarti_android.so`; a split without one installs and runs with Tor silently -> unavailable. The `verifyArtiAbis` Gradle task fails the build if the two lists -> drift, and names the `build-arti.sh --target=…` to run. +> **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. --- diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index f33448ec6c..1f432394be 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -67,14 +67,27 @@ afterEvaluate { } } -// Every ABI we split the APK for, and therefore every ABI that needs its own -// libarti_android.so under src/main/jniLibs/ (see tools/arti-build/). The two -// 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. `verifyArtiAbis` below keeps them in step. +// 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 = @@ -92,10 +105,14 @@ android { // libraries") — three different APKs from the same source, which is // exactly what F-Droid's rebuild verification cannot have. // - // Read straight from the Arti 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) moves the packaging toolchain with - // it. See tools/arti-build/README.md → "Reproducible builds". + // 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")) @@ -341,6 +358,17 @@ android { // 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" } } @@ -365,6 +393,12 @@ 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()) } } } } @@ -375,20 +409,20 @@ android { // 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 verifyArtiAbis = - tasks.register("verifyArtiAbis") { +val verifyNativeAbis = + tasks.register("verifyNativeAbis") { group = "verification" - description = "Checks that every ABI in the APK splits has a libarti_android.so for that architecture." + 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 - // Per ABI: the Rust target triple (so a failure names the exact build - // command) and the ELF identity the 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 dead Tor this task exists - // to prevent — and unlike a missing file, those look fine in git. + 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), @@ -399,48 +433,51 @@ val verifyArtiAbis = doLast { val bitness = mapOf(1 to "32-bit", 2 to "64-bit") - val problems = mutableListOf>() + val problems = mutableListOf>() - abis.forEach { abi -> - val lib = File(jniLibs, "$abi/libarti_android.so") - val want = expected[abi] - val header = ByteArray(20) - val read = if (lib.isFile) lib.inputStream().use { it.read(header) } else -1 + 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 libarti_android.so" - 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 + 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 += abi to problem + if (problem != null) problems += Triple(libName, abi, problem) + } } if (problems.isNotEmpty()) { throw GradleException( buildString { - appendLine("libarti_android.so is missing or wrong for ${problems.size} ABI split(s):") - problems.forEach { (abi, problem) -> appendLine(" $abi: $problem") } - appendLine("Those APK splits would install with Tor permanently unavailable.") - appendLine("Rebuild them (tools/arti-build/README.md):") - problems.forEach { (abi, _) -> + 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 ?: "" - appendLine(" ./tools/arti-build/build-arti.sh --target=$triple") + val rebuild = libs[libName]?.invoke(abi, triple) ?: "" + appendLine(" $rebuild") } append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.") }, @@ -449,7 +486,9 @@ val verifyArtiAbis = } } -tasks.named("preBuild") { dependsOn(verifyArtiAbis) } +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 @@ -634,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 99de8a27e3..3e4b2d898d 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -135,6 +135,16 @@ # secp256k1's JNI layer resolves these from native code. -keep class fr.acinq.secp256k1.** { *; } +# 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.** { *; } + # 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 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/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/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/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/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/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/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 0cdb97a43e..a58b51ed2b 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -199,6 +199,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 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ddf59c670..0aa82c3a96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,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" @@ -254,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/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/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/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/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/tools/arti-build/README.md b/tools/arti-build/README.md index 9d9987a064..bf29ccabb1 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -196,8 +196,9 @@ 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 `verifyArtiAbis` Gradle task, wired into `preBuild`, fails -the build when an ABI split has no `libarti_android.so` **or** has one that is +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`. 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'