Merge branch 'main' into claude/festive-allen-1y5xav

Conflict in amethyst/proguard-rules.pro: both sides appended a keep rule to the
same region. Kept both.

main's `-keep class zxingcpp.** { *; }` is filed with the other JNI rules next to
secp256k1 rather than left where the merge put it, under this branch's
"Enums used as navigation-route ARGUMENTS" heading. Same mechanism as
ArtiNative/secp256k1 — a name-mangled symbol in a .so that R8 cannot see — so it
belongs in that section, and this file is organised by mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm
This commit is contained in:
Claude
2026-09-20 16:38:51 +00:00
124 changed files with 5874 additions and 2751 deletions
+13 -1
View File
@@ -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
```
+4
View File
@@ -1,4 +1,8 @@
{
"env": {
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
},
"hooks": {
"PreToolUse": [
{
+10
View File
@@ -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 <module>: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
)
+4 -3
View File
@@ -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
+31 -21
View File
@@ -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.
---
+95 -53
View File
@@ -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/<abi>/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<Pair<String, String>>()
val problems = mutableListOf<Triple<String, String, String>>()
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 ?: "<add the Rust target for $abi>"
appendLine(" ./tools/arti-build/build-arti.sh --target=$triple")
val rebuild = libs[libName]?.invoke(abi, triple) ?: "<no rebuild command recorded for $libName>"
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)
@@ -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<ScanResult>` and `decode(Bitmap, cropRect, rotation): List<ScanResult>`
— 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
```
+1
View File
@@ -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. |
+10
View File
@@ -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
@@ -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
1 blur 3 3
2 blur-heavy 0 3
3 clean 3 3
4 far-1.5px 0 3
5 far-2px 2 3
6 far-3px 3 3
7 glare 3 3
8 inverted 3 3
9 low-contrast 3 3
10 moire 3 3
11 noise 2 3
12 perspective 0 3
13 tilt15 2 3
14 tilt30 2 3
15 tilt45 2 3
16 very-low-contrast 0 3
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -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
1 clean-npub.png clean nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
2 blur-npub.png blur nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
3 blur-heavy-npub.png blur-heavy nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
4 tilt15-npub.png tilt15 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
5 tilt30-npub.png tilt30 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
6 tilt45-npub.png tilt45 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
7 perspective-npub.png perspective nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
8 low-contrast-npub.png low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
9 very-low-contrast-npub.png very-low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
10 inverted-npub.png inverted nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
11 glare-npub.png glare nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
12 noise-npub.png noise nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
13 moire-npub.png moire nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
14 far-3px-npub.png far-3px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
15 far-2px-npub.png far-2px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
16 far-1.5px-npub.png far-1.5px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
17 clean-nprofile.png clean nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
18 blur-nprofile.png blur nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
19 blur-heavy-nprofile.png blur-heavy nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
20 tilt15-nprofile.png tilt15 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
21 tilt30-nprofile.png tilt30 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
22 tilt45-nprofile.png tilt45 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
23 perspective-nprofile.png perspective nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
24 low-contrast-nprofile.png low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
25 very-low-contrast-nprofile.png very-low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
26 inverted-nprofile.png inverted nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
27 glare-nprofile.png glare nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
28 noise-nprofile.png noise nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
29 moire-nprofile.png moire nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
30 far-3px-nprofile.png far-3px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
31 far-2px-nprofile.png far-2px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
32 far-1.5px-nprofile.png far-1.5px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
33 clean-nevent.png clean nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
34 blur-nevent.png blur nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
35 blur-heavy-nevent.png blur-heavy nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
36 tilt15-nevent.png tilt15 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
37 tilt30-nevent.png tilt30 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
38 tilt45-nevent.png tilt45 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
39 perspective-nevent.png perspective nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
40 low-contrast-nevent.png low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
41 very-low-contrast-nevent.png very-low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
42 inverted-nevent.png inverted nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
43 glare-nevent.png glare nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
44 noise-nevent.png noise nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
45 moire-nevent.png moire nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
46 far-3px-nevent.png far-3px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
47 far-2px-nevent.png far-2px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
48 far-1.5px-nevent.png far-1.5px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -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<String>()
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<Fixture> =
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<String, Baseline> =
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"
}
}
+27 -6
View File
@@ -188,6 +188,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long-press the launcher icon to scan a QR code. The shortcut fires a VIEW intent
at nostr:scanqr, which the nostr-scheme filter below already catches, so it needs
no hardcoded targetPackage - debug and benchmark builds carry an applicationId
suffix that would break one. uriToRoute turns it into the scanner. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -377,6 +385,25 @@
</intent-filter>
</activity-alias>
<!-- "Scan QR code" share target: an image shared here is decoded rather than posted, which
is how most QR codes actually arrive - a screenshot or a photo someone sent you. SEND
only: a QR scan reads one code, so there is nothing sensible to do with a multi-select.
The android:name simple class ("ScanQrCodeAlias") is matched at runtime by
ShareIntentRouting.SCAN_QR_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ScanQrCodeAlias"
android:exported="true"
android:label="@string/share_target_scan_qr"
android:icon="@drawable/ic_qrcode"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_scan_qr">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
@@ -470,12 +497,6 @@
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
<activity
android:name=".ui.screen.loggedIn.nests.room.activity.NestActivity"
android:autoRemoveFromRecents="true"
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -52,6 +53,19 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
/**
* The pubkeys an `a`-tagging event addresses, read straight from the coordinates
* (`kind:pubkey:dTag`) rather than from whatever the local cache happens to hold.
*
* This is what lets [EventBroadcaster] route to an addressed author's inbox relays when the
* addressable itself was never cached on this device - a NIP-52 RSVP being the motivating case,
* since its `a` tag is the only thing tying it to the appointment's host.
*
* Unparseable coordinates are dropped; the result is deduplicated because an event may address
* several addressables by the same author (a calendar listing its own appointments).
*/
fun addressedAuthors(event: AddressHintProvider): Set<HexKey> = event.linkedAddressIds().mapNotNullTo(mutableSetOf()) { Address.parse(it)?.pubKeyHex }
/**
* The sign-and-publish choke point for an [Account]: computes the relay set an
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
@@ -232,6 +246,16 @@ class EventBroadcaster(
event.addressHints().forEach {
relayList.add(it.relay)
}
// An `a` coordinate names its own author, so the addressed user's inbox is reachable
// straight from the tag. Everything in the loop below is nested inside a cache
// lookup, so without this an event aimed at an addressable this device never cached
// - an RSVP to a calendar appointment that arrived as a bare reference, say - went
// only to the sender's own outbox and never to the author it was answering.
addressedAuthors(event).forEach { authorPubKey ->
relayList.addAll(computeRelayListForLinkedUser(authorPubKey))
}
event.linkedAddressIds().forEach { addressId ->
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
@@ -210,6 +210,21 @@ fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:")
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
/**
* The launcher shortcut (res/xml/shortcuts.xml) fires `amethyst:scanqr`, caught by MainActivity's
* existing scheme filter. A pseudo-uri rather than a hardcoded component, because debug and
* benchmark builds carry an applicationId suffix a hardcoded targetPackage would miss, and
* manifest placeholders are not substituted into resource XML.
*
* `nostr:scanqr` is still accepted so an older pinned shortcut keeps working, but the shortcut
* itself uses our own scheme: an implicit `nostr:` VIEW intent can be answered by any Nostr client
* installed alongside us.
*/
fun isScanQrRoute(uri: String) =
uri.equals("scanqr", true) ||
uri.equals("amethyst:scanqr", true) ||
uri.equals("nostr:scanqr", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
@@ -223,6 +238,9 @@ fun uriToRoute(
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isScanQrRoute(uri)) {
return Route.QRDisplay(account.signer.pubKey, startScanning = true)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
@@ -248,6 +248,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ScanQrImageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
@@ -568,7 +569,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.EmojiPackMetadataEdit> { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackSelection> { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav, it.startScanning) }
composableFromBottomArgs<Route.ScanQrImage> { ScanQrImageScreen(it.uri, accountViewModel, nav) }
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
@@ -1080,6 +1082,9 @@ private fun NavigateIfIntentRequested(
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
// Always re-runs: the route carries the image, so a second share of a different
// picture must decode that one rather than sit on the previous result.
ShareTarget.SCAN_QR -> Unit
}
// saves the intent to avoid processing again
@@ -1105,6 +1110,8 @@ private fun NavigateIfIntentRequested(
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
ShareTarget.SCAN_QR ->
attachments.firstOrNull()?.let { nav.newStack(Route.ScanQrImage(it.toString())) }
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1209,6 +1216,8 @@ private fun NavigateIfIntentRequested(
if (!consumesSharesInPlace(nav.controller) && (message != null || attachment != null)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
ShareTarget.SCAN_QR -> attachment?.let { nav.newStack(Route.ScanQrImage(it.toString())) }
}
} else {
val uri = intent.data?.toString()
@@ -39,6 +39,15 @@ enum class ShareTarget {
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
/**
* "Scan QR code": read a QR out of a shared picture instead of posting it.
*
* The common way a QR code reaches someone is as a screenshot or a photo in a chat, not as
* something in front of the camera. Without this the only way to use one was to display it on
* a second screen and point the phone at it.
*/
SCAN_QR,
}
/**
@@ -71,6 +80,9 @@ object ShareIntentRouting {
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SCAN_QR_ALIAS_SIMPLE_NAME = "ScanQrCodeAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
@@ -78,6 +90,7 @@ object ShareIntentRouting {
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
SCAN_QR_ALIAS_SIMPLE_NAME to ShareTarget.SCAN_QR,
)
/**
@@ -576,6 +576,20 @@ sealed class Route {
@Serializable data class QRDisplay(
val pubkey: String,
/**
* Opens straight into the scanner instead of showing this user's own code. Set by the
* launcher shortcut, whose entire purpose is to skip that step.
*/
val startScanning: Boolean = false,
) : Route()
/**
* Decodes a QR out of an image the user shared into Amethyst, then goes wherever it points.
*
* [uri] is the shared image's content uri, as a string so the route stays serializable.
*/
@Serializable data class ScanQrImage(
val uri: String,
) : Route()
@Serializable data class ContentDiscovery(
@@ -1168,6 +1182,11 @@ fun isSameRoute(
): Boolean {
if (currentRoute == null) return false
// Opening the scanner is an action, not a place. After the user closes the scanner they are
// still on this exact entry, so treating a repeat as a duplicate made the launcher shortcut
// silently do nothing the second time. A fresh entry reopens the camera.
if (newRoute is Route.QRDisplay && newRoute.startScanning) return false
if (currentRoute == newRoute) {
return true
}
@@ -37,14 +37,12 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
@@ -61,10 +59,10 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_nostr
import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_web
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
@@ -221,45 +219,3 @@ private fun ShareNoteAsQrScreenContent(
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}
@@ -21,16 +21,18 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Entry for a NIP-52 calendar RSVP: decodes the [Note] and renders the shared commons
* [CalendarRsvpCard]. Draws only from the event's own tags, so the entry keeps the
* dispatcher signature without touching the account or nav.
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
* in the cache, and renders the shared commons [CalendarRsvpCard].
*/
@Composable
fun RenderCalendarRSVPEvent(
@@ -40,5 +42,40 @@ fun RenderCalendarRSVPEvent(
) {
val event = note.event as? CalendarRSVPEvent ?: return
LoadAppointmentBehind(event, accountViewModel)
CalendarRsvpCard(event)
}
/**
* Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the
* two together.
*
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
* [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables`
* picks up exactly those addressables whose `event == null` and queries the address author's
* outbox relays plus any stored hints.
*
* Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its
* `a` tag into the appointment and reading the participants off it, and every step of that walk
* is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would
* otherwise leave the cache with no entry to walk, so answering it from here would reach the host
* (whose pubkey the coordinate carries) but none of the other invitees.
*
* Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it
* scrolls away or the app backgrounds.
*/
@Composable
private fun LoadAppointmentBehind(
event: CalendarRSVPEvent,
accountViewModel: AccountViewModel,
) {
val address = remember(event) { event.calendarEventAddress() } ?: return
LoadAddressableNote(address, accountViewModel) { appointment ->
if (appointment != null) {
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
}
}
}
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
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
@@ -191,14 +192,31 @@ private fun sendRsvp(
) {
val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
val aTag = ATag(targetAddress, relayHint)
// NIP-52's optional `e` tag: the `a` tag names the appointment's coordinate, which follows
// the host's edits, while this pins the exact revision the user answered. A reader can then
// tell an "accepted" cast against last week's time from one cast against the current one.
val eTag = ETag(eventId, relayHint, targetAddress.pubKeyHex)
val pTag = PTag(targetAddress.pubKeyHex)
val dTag = rsvpDTagFor(targetAddress)
// Only the host is p-tagged, per NIP-52 ("pubkey of the author of the calendar event being
// responded to"). The other invitees still receive this RSVP: EventBroadcaster follows the
// a-tag into the appointment and reads its participants' inbox relays from the appointment's
// own p tags (CalendarTimeSlotEvent/CalendarDateSlotEvent are PubKeyHintProviders).
//
// Copying those participants onto the RSVP as extra p tags would add no routing - the
// broadcaster's recursion and any local participant lookup read the same
// LocalCache.getAddressableNoteIfExists(targetAddress), so they are reachable in exactly the
// same cases - while giving every invitee a notification row for every other invitee's RSVP
// (kind 31925 is in NOTIFICATION_KINDS and tagsAnEventByUser returns true for it), bloating
// the signed event by a host-controlled number of tags, and muddying the spec's meaning of
// this kind's p tag.
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
CalendarRSVPEvent.build(
calendarEventAddress = aTag,
status = status,
calendarEventId = eTag,
calendarEventAuthor = pTag,
dTag = dTag,
),
@@ -0,0 +1,77 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
/**
* Pins the screen to full brightness, and awake, while a QR code is on it.
*
* Half of "this QR code will not scan" is the phone showing it. A dark-mode OLED at the
* auto-brightness the room asked for can put so little contrast between the black and white
* modules that the other camera's binarizer cannot separate them — and the person holding it has
* no idea that is the problem, because to a human eye the code looks perfectly clear.
*/
@Composable
fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}
@@ -48,6 +48,7 @@ import com.google.zxing.qrcode.encoder.ByteMatrix
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlin.math.min
/**
* The quiet zone around the code, in **modules** — the QR spec's minimum of 4.
@@ -59,6 +60,12 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
*/
const val QR_QUIET_ZONE_MODULES = 4f
/**
* Corner rounding on the finder patterns, as a fraction of one module. Small enough to stay well
* inside what a decoder tolerates, large enough to keep the code from looking like a 1998 barcode.
*/
const val FINDER_CORNER_RADIUS_MODULES = 0.22f
@Preview
@Composable
fun QrCodeDrawerPreview() {
@@ -90,7 +97,12 @@ fun QrCodeDrawer(
// (zone included) is exactly as wide as the canvas.
val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f)
val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f)
val radius = CornerRadius(20f)
// Scale the rounding with the module size. A fixed 20px radius is a gentle touch on
// a large code and a serious deformation on a small one: the finder patterns are what
// a decoder locates first, and rounding away a third of a module's worth of their
// corners is exactly the kind of damage that makes a code readable on screen and
// unreadable in a photo of that screen.
val radius = CornerRadius(min(columnWidth, rowHeight) * FINDER_CORNER_RADIUS_MODULES)
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
// of the number of rows/columns to the width and height
@@ -20,64 +20,85 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.point_to_the_qr_code
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScannerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScanOutcome
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.classifyScannedPayload
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
/**
* Scans a QR code and navigates wherever it points.
*
* A payload we decode but cannot route no longer closes the scanner: it stays open and explains
* itself (see `ScanOutcomeSheet`), because "that is a Lightning invoice, not a profile" and "the
* camera never read anything" used to look identical from the outside.
*/
@Composable
fun NIP19QrCodeScanner(
accountViewModel: AccountViewModel,
onScan: (Route?) -> Unit,
) {
SimpleQrCodeScanner {
try {
if (it != null) {
onScan(uriToRoute(it, accountViewModel.account))
QrCodeScannerDialog(
onDismiss = { onScan(null) },
onScan = { contents ->
val route = routeFor(contents, accountViewModel)
if (route != null) {
onScan(route)
ScanOutcome.Handled
} else {
onScan(null)
ScanOutcome.NotSupported
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors.
onScan(null)
}
}
},
)
}
/**
* The route a scanned string leads to, or null when nothing here can open it.
*
* A bare hex pubkey gets re-encoded as an npub first. Plenty of web tools hand out a raw
* 64-character key with no bech32 wrapper, and treating that as unreadable has been a reported
* papercut since 2023 (issue #417).
*/
private fun routeFor(
contents: String,
accountViewModel: AccountViewModel,
): Route? =
try {
val payload = classifyScannedPayload(contents)
val uri = if (payload is ScannedPayload.HexPubKey) payload.npub else contents
uriToRoute(uri, accountViewModel.account)
} catch (e: Throwable) {
if (e is CancellationException) throw e
// The payload itself never reaches the log. A QR code is as likely to hold an nsec, a
// wallet-connect secret or a Cashu token as a profile link, and logcat is readable over
// adb and swept up by device bug reports — the same material ScannedPayload.containsSecret
// exists to keep off the screen two files away. The classification and the length say
// enough to debug a routing failure; classifying again here is wrapped because this is
// the branch for a payload that already made something throw.
val kind = runCatching { classifyScannedPayload(contents)::class.simpleName }.getOrNull() ?: "unclassifiable"
Log.e("NIP19 Scanner", "Could not route a scanned $kind payload of ${contents.length} chars", e)
// A QR code can hold anything at all. Never let one throw.
null
}
/**
* Scans a QR code and hands back whatever it says.
*
* For callers that do their own validation — a wallet-connect URI, a `bunker://` offer, a key on
* the login screen. `null` means the user backed out.
*/
@Composable
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
val qrLauncher =
rememberLauncherForActivityResult(ScanContract()) {
if (it.contents != null) {
onScan(it.contents)
} else {
onScan(null)
}
}
val scanOptions =
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)
}
DisposableEffect(Unit) {
qrLauncher.launch(scanOptions)
onDispose {}
}
QrCodeScannerDialog(
onDismiss = { onScan(null) },
onScan = { contents ->
onScan(contents)
ScanOutcome.Handled
},
)
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_no_code_in_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_try_again
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unavailable
import com.vitorpamplona.amethyst.commons.resources.scan_qr
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrImageImport
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScanOutcomeSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ZxingCppBarcodeDecoder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.classifyScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.copyToClipboard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** What happened to the image the user shared in. */
private sealed interface ImageScanState {
data object Working : ImageScanState
/** Decoded, but nothing here can open it — the outcome sheet explains which. */
data class Unsupported(
val payload: ScannedPayload,
) : ImageScanState
/** No QR code in the picture at all, or the decoder could not start. */
data class Failed(
val message: String,
) : ImageScanState
}
/**
* Reads a QR code out of a picture the user shared into Amethyst, and goes where it points.
*
* The share sheet is how most QR codes actually arrive — a screenshot, or a photo someone sent in
* a chat. Before this the only way to use one was to put it on a second screen and photograph it.
*
* Failure is explicit here for the same reason it is in the live scanner: "that picture has no QR
* code in it" and "that code is something Amethyst can't open" are different problems, and
* collapsing them into a silent bounce is what made the old reader feel broken.
*/
@Composable
fun ScanQrImageScreen(
uri: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val decoder = remember { runCatching { ZxingCppBarcodeDecoder() }.getOrNull() }
var state by remember { mutableStateOf<ImageScanState>(ImageScanState.Working) }
val noCodeFound = stringRes(Res.string.qr_scanner_no_code_in_image)
val decoderUnavailable = stringRes(Res.string.qr_scanner_unavailable)
val context = LocalContext.current
LaunchedEffect(uri, decoder) {
if (decoder == null) {
state = ImageScanState.Failed(decoderUnavailable)
return@LaunchedEffect
}
val text =
withContext(Dispatchers.IO) {
QrImageImport.decode(context, uri.toUri(), decoder).firstOrNull()?.text
}
if (text == null) {
state = ImageScanState.Failed(noCodeFound)
return@LaunchedEffect
}
val payload = classifyScannedPayload(text)
// A bare hex pubkey is re-encoded first, the same as in the live scanner.
val routable = if (payload is ScannedPayload.HexPubKey) payload.npub else text
val route = runCatching { uriToRoute(routable, accountViewModel.account) }.getOrNull()
if (route != null) {
nav.newStack(route)
} else {
state = ImageScanState.Unsupported(payload)
}
}
Scaffold(topBar = { TopBarWithBackButton(stringRes(Res.string.scan_qr), nav) }) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val current = state) {
is ImageScanState.Working -> CircularProgressIndicator()
is ImageScanState.Failed -> {
Text(
text = current.message,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
// The camera is the obvious next move when the picture turned out to hold
// nothing, so offer it rather than leaving a dead end.
Button(onClick = { nav.newStack(Route.QRDisplay(accountViewModel.userProfile().pubkeyHex, startScanning = true)) }) {
Text(stringRes(Res.string.scan_qr))
}
TextButton(onClick = { nav.popBack() }) {
Text(stringRes(Res.string.qr_scanner_try_again))
}
}
is ImageScanState.Unsupported -> Unit
}
}
}
(state as? ImageScanState.Unsupported)?.let { unsupported ->
ScanOutcomeSheet(
payload = unsupported.payload,
onDismiss = { nav.popBack() },
onOpenLink = { url ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
nav.popBack()
},
onCopy = { text ->
copyToClipboard(context, text)
nav.popBack()
},
)
}
}
@@ -122,6 +122,7 @@ fun ShowQRScreen(
pubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
LoadUser(pubkey, accountViewModel) { user ->
if (user != null) {
@@ -129,6 +130,7 @@ fun ShowQRScreen(
user = user,
accountViewModel = accountViewModel,
nav = nav,
startScanning = startScanning,
)
}
}
@@ -140,6 +142,7 @@ fun ShowQRScreen(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
Scaffold(
topBar = {
@@ -162,7 +165,7 @@ fun ShowQRScreen(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShowQRBody(user, accountViewModel, nav)
ShowQRBody(user, accountViewModel, nav, startScanning)
}
}
}
@@ -172,8 +175,11 @@ fun ShowQRBody(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
var presenting by remember { mutableStateOf(true) }
// The launcher shortcut lands here already scanning; everything else starts on the user's own
// code with a Scan button.
var presenting by remember { mutableStateOf(!startScanning) }
if (presenting) {
PresentQR(user, accountViewModel) {
presenting = false
@@ -195,6 +201,10 @@ fun PresentQR(
accountViewModel: AccountViewModel,
switchToScan: () -> Unit,
) {
// The other phone's camera needs contrast, and this screen was the one place we showed a QR
// code without asking for it (ShareNoteAsQrScreen has done so since it shipped).
KeepScreenBrightAndAwake()
RenderName(user, accountViewModel)
Row(
@@ -0,0 +1,155 @@
/*
* 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.Rect
import androidx.camera.core.ImageProxy
import com.vitorpamplona.quartz.utils.Log
import zxingcpp.BarcodeReader
/**
* How hard to work on one image.
*
* The live camera runs [Fast] on most frames and [Thorough] on every Nth, so a difficult code
* still gets the expensive treatment a few times a second without dropping the frame rate for
* the easy ones. Imported stills always get [Thorough] — there is only one image and the user is
* waiting on it.
*/
enum class DecodeEffort {
Fast,
Thorough,
}
/**
* Decodes QR codes out of camera frames and still images.
*
* An interface, not a class, because the engine is a choice we want to be able to re-make: the
* camera plumbing, the overlay and every test above this line are engine-agnostic.
*/
interface BarcodeDecoder {
/** Decodes straight from a CameraX analysis frame. Called on the analysis executor. */
fun decode(
image: ImageProxy,
effort: DecodeEffort,
): List<ScanResult>
/** Decodes an imported still (gallery, clipboard, share). Called off the main thread. */
fun decode(
bitmap: Bitmap,
effort: DecodeEffort = DecodeEffort.Thorough,
): List<ScanResult>
}
/**
* [BarcodeDecoder] on zxing-cpp (Apache-2.0).
*
* Every option here maps to a failure the previous zxing-android-embedded scanner had:
*
* - `tryInvert` replaces the old `MIXED_SCAN`, which inverted *alternate frames* and so threw
* away half of all decode attempts on ordinary dark-on-light codes. zxing-cpp does the
* inverted pass as a fallback inside one call, so nothing is wasted.
* - `tryRotate` picks up codes held sideways.
* - `tryDownscale` finds codes that fill most of the frame, which a fixed-scale detector misses.
* - `tryHarder`/`tryDenoise` (Thorough only) are what actually rescue blurred, creased,
* low-contrast and photographed-off-a-screen codes.
* - `maxNumberOfSymbols = MAX_SYMBOLS` so several codes in frame become a choice for the user
* rather than a coin flip.
*/
class ZxingCppBarcodeDecoder : BarcodeDecoder {
private val fast = BarcodeReader(options(tryHarder = false))
private val thorough = BarcodeReader(options(tryHarder = true))
private fun reader(effort: DecodeEffort) = if (effort == DecodeEffort.Fast) fast else thorough
override fun decode(
image: ImageProxy,
effort: DecodeEffort,
): List<ScanResult> =
try {
reader(effort).read(image).toScanResults()
} catch (e: IllegalStateException) {
// read() rejects any format that is not YUV. We always request YUV_420_888, so this
// means the device handed us something else; log once per frame rather than crash.
Log.w("QrScanner") { "Unsupported analysis image format ${image.format}: ${e.message}" }
emptyList()
}
override fun decode(
bitmap: Bitmap,
effort: DecodeEffort,
): List<ScanResult> = reader(effort).read(bitmap, Rect(0, 0, bitmap.width, bitmap.height)).toScanResults()
private fun List<BarcodeReader.Result>.toScanResults(): List<ScanResult> =
mapNotNull { result ->
val text = result.text
if (result.error != null || text.isNullOrEmpty()) return@mapNotNull null
ScanResult(
text = text,
bounds =
result.position.let {
ScanBounds(
topLeft = ScanPoint(it.topLeft.x.toFloat(), it.topLeft.y.toFloat()),
topRight = ScanPoint(it.topRight.x.toFloat(), it.topRight.y.toFloat()),
bottomRight = ScanPoint(it.bottomRight.x.toFloat(), it.bottomRight.y.toFloat()),
bottomLeft = ScanPoint(it.bottomLeft.x.toFloat(), it.bottomLeft.y.toFloat()),
)
},
sequenceId = result.sequenceId,
sequenceIndex = result.sequenceIndex,
sequenceSize = result.sequenceSize,
)
}
companion object {
/**
* Enough to disambiguate a poster with a few codes on it without letting a page of
* barcodes turn every frame into a long detection run.
*/
const val MAX_SYMBOLS = 5
/**
* Only the square formats. Nostr uses plain QR, but Micro and rMQR cost nothing extra to
* accept and some hardware wallets and printed tags use them. Linear barcodes stay off:
* they have no meaning here and each extra family slows every frame down.
*/
private val FORMATS =
setOf(
BarcodeReader.Format.QR_CODE,
BarcodeReader.Format.MICRO_QR_CODE,
BarcodeReader.Format.RMQR_CODE,
)
private fun options(tryHarder: Boolean) =
BarcodeReader.Options(
formats = FORMATS,
tryHarder = tryHarder,
tryRotate = true,
tryInvert = true,
tryDownscale = true,
tryDenoise = tryHarder,
binarizer = BarcodeReader.Binarizer.LOCAL_AVERAGE,
maxNumberOfSymbols = MAX_SYMBOLS,
textMode = BarcodeReader.TextMode.PLAIN,
)
}
}
@@ -0,0 +1,136 @@
/*
* 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 androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.vitorpamplona.quartz.utils.Log
import kotlin.math.max
/** One analysed frame: what was decoded, how big the analysed image was, and how dark it is. */
data class FrameScan(
val results: List<ScanResult>,
val frame: ScanFrame,
/** Mean luminance over a sparse sample of the Y plane, 0f (black) to 1f (white). */
val brightness: Float,
)
/**
* Decodes every camera frame and reports what it found.
*
* Two things happen per frame beyond the decode itself:
*
* 1. **Effort alternates.** Most frames get a fast pass; every [THOROUGH_EVERY]th gets
* `tryHarder` + `tryDenoise`. A stubborn code therefore still gets several expensive attempts
* a second, without the frame rate collapsing for codes that were never difficult.
* 2. **Brightness is measured.** A sparse sample of the Y plane costs almost nothing and is what
* lets the UI offer the torch exactly when the scene is too dark, rather than parking a
* permanent button in the corner or firing the light at people unprompted.
*/
class QrFrameAnalyzer(
private val decoder: BarcodeDecoder,
private val onFrame: (FrameScan) -> Unit,
) : ImageAnalysis.Analyzer {
private var frameCount = 0L
override fun analyze(image: ImageProxy) {
image.use {
val effort =
if (frameCount++ % THOROUGH_EVERY == 0L) DecodeEffort.Thorough else DecodeEffort.Fast
val brightness = meanLuminance(image)
val results =
try {
decoder.decode(image, effort)
} catch (e: Exception) {
// A frame we cannot read is not worth killing the camera over.
Log.w("QrScanner") { "Decode failed on one frame: ${e.message}" }
emptyList()
}
onFrame(FrameScan(results, rotatedFrameSize(image), brightness))
}
}
/**
* The size of the image the decoder actually saw.
*
* zxing-cpp is handed the crop rect and the rotation, and reports positions inside that
* cropped, rotated space — so a quarter-turn swaps width and height. The overlay maps
* [ScanBounds] onto the preview with this, so getting it wrong draws the highlight in the
* wrong place.
*/
private fun rotatedFrameSize(image: ImageProxy): ScanFrame {
val crop = image.cropRect
val quarterTurned = image.imageInfo.rotationDegrees % 180 != 0
return if (quarterTurned) {
ScanFrame(crop.height(), crop.width())
} else {
ScanFrame(crop.width(), crop.height())
}
}
/**
* Mean luminance over a grid of at most [LUMA_SAMPLES_PER_AXIS]² pixels.
*
* Uses absolute [java.nio.ByteBuffer.get] so it never disturbs the buffer position the
* decoder is about to read from.
*/
private fun meanLuminance(image: ImageProxy): Float {
val plane = image.planes.firstOrNull() ?: return 1f
val buffer = plane.buffer
val rowStride = plane.rowStride
val crop = image.cropRect
val stepX = max(1, crop.width() / LUMA_SAMPLES_PER_AXIS)
val stepY = max(1, crop.height() / LUMA_SAMPLES_PER_AXIS)
var sum = 0L
var count = 0
var y = crop.top
while (y < crop.bottom) {
val row = y * rowStride
var x = crop.left
while (x < crop.right) {
val index = row + x
if (index in 0 until buffer.limit()) {
sum += buffer.get(index).toInt() and 0xFF
count++
}
x += stepX
}
y += stepY
}
return if (count == 0) 1f else sum.toFloat() / count / 255f
}
companion object {
/**
* At ~30fps this is roughly six thorough passes a second — enough that a hard code
* resolves in well under a second, few enough that the analysis thread keeps up.
*/
const val THOROUGH_EVERY = 5L
private const val LUMA_SAMPLES_PER_AXIS = 24
}
}
@@ -0,0 +1,202 @@
/*
* 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.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.graphics.scale
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.math.max
/**
* Decoding a QR code out of an image the user already has.
*
* This is the single biggest gap the camera-only scanner left: most QR codes people need to scan
* in a Nostr client arrive as a screenshot, a photo in a chat, or an image saved from a website.
* Before this, the only way to use one was to display it on a second screen and photograph it.
*/
object QrImageImport {
/**
* Longest edge we downscale a picked image to for the first attempt.
*
* Detection does not improve above this, and full-resolution phone photos are 50+ megapixels
* of mostly-wall that make a thorough pass take seconds.
*/
private const val FIRST_PASS_MAX_EDGE = 2_000
/** Below this, a code is likely too few pixels per module; the upscale pass is worth trying. */
private const val SMALL_IMAGE_EDGE = 600
/** Decodes every QR code in the image at [uri], hardest-effort, with a retry ladder. */
suspend fun decode(
context: Context,
uri: Uri,
decoder: BarcodeDecoder,
): List<ScanResult> =
withContext(Dispatchers.IO) {
val bounds = readBounds(context, uri) ?: return@withContext emptyList()
val longestEdge = max(bounds.outWidth, bounds.outHeight)
if (longestEdge <= 0) return@withContext emptyList()
// Three passes, cheapest first. A code that a downscaled pass misses because its
// modules blurred together often survives at full resolution, and a code in a small
// thumbnail often needs *more* pixels per module than it shipped with.
val sampleSizes =
buildList {
add(sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE))
if (sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE) != 1) add(1)
}
// Checked between passes because a pass itself is one long blocking call into JNI
// and cannot be interrupted. The expensive pass is the full-resolution retry: a
// modern phone photo is 50-108 MP, so closing the scanner while one is running
// otherwise leaves several hundred megabytes and a thorough decode grinding away on
// an IO thread for a result nobody is waiting for any more.
for (sampleSize in sampleSizes) {
ensureActive()
val found = decodeAt(context, uri, sampleSize, upscale = false, decoder)
if (found.isNotEmpty()) return@withContext found
}
if (longestEdge <= SMALL_IMAGE_EDGE) {
ensureActive()
val found = decodeAt(context, uri, sampleSize = 1, upscale = true, decoder)
if (found.isNotEmpty()) return@withContext found
}
emptyList()
}
/** Text sitting on the clipboard, or null when there is none. */
fun clipboardText(context: Context): String? {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return null
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount == 0) return null
return clip
.getItemAt(0)
?.coerceToText(context)
?.toString()
?.trim()
?.takeIf { it.isNotEmpty() }
}
/**
* An image sitting on the clipboard, or null when there is none.
*
* The MIME check matters: a copied link is a clip with a uri too, and without it every such
* clip went to the bitmap decoder and the clipboard *text* was never reachable — Paste could
* not paste an npub, which is most of what it is for.
*/
fun clipboardImage(context: Context): Uri? {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return null
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount == 0) return null
val uri = clip.getItemAt(0)?.uri ?: return null
val declared = (0 until clip.description.mimeTypeCount).map { clip.description.getMimeType(it) }
val looksLikeImage =
declared.any { it.startsWith("image/") } ||
context.contentResolver.getType(uri)?.startsWith("image/") == true
return uri.takeIf { looksLikeImage }
}
private fun decodeAt(
context: Context,
uri: Uri,
sampleSize: Int,
upscale: Boolean,
decoder: BarcodeDecoder,
): List<ScanResult> {
val original = loadBitmap(context, uri, sampleSize) ?: return emptyList()
var upscaled: Bitmap? = null
return try {
if (upscale) upscaled = original.scale(original.width * 2, original.height * 2)
decoder.decode(upscaled ?: original, DecodeEffort.Thorough)
} catch (e: OutOfMemoryError) {
// Not an Exception, so a plain `catch (e: Exception)` misses it. The retry ladder
// deliberately re-decodes at inSampleSize = 1, and a modern phone camera hands us
// 50-108 MP: ~400 MB as ARGB_8888, which is well past the heap on most devices.
// Failing to read a picture must never take the app down with it.
Log.w("QrScanner", "Ran out of memory decoding a picked image", e)
emptyList()
} catch (e: Exception) {
Log.w("QrScanner", "Could not decode picked image", e)
emptyList()
} finally {
upscaled?.recycle()
original.recycle()
}
}
private fun readBounds(
context: Context,
uri: Uri,
): BitmapFactory.Options? =
try {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
options
} catch (e: Exception) {
Log.w("QrScanner", "Could not read image bounds", e)
null
}
/**
* Always an ARGB_8888 software bitmap: zxing-cpp reads the pixels over JNI, and a
* hardware-backed bitmap has no pixels to read.
*/
private fun loadBitmap(
context: Context,
uri: Uri,
sampleSize: Int,
): Bitmap? =
try {
val options =
BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
} catch (e: OutOfMemoryError) {
Log.w("QrScanner", "Ran out of memory loading a picked image", e)
null
} catch (e: Exception) {
Log.w("QrScanner", "Could not load picked image", e)
null
}
/** The power-of-two `inSampleSize` that brings [longestEdge] to at most [target]. */
private fun sampleSizeFor(
longestEdge: Int,
target: Int,
): Int {
var sample = 1
while (longestEdge / sample > target) sample *= 2
return sample
}
}
@@ -0,0 +1,613 @@
/*
* 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.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.close
import com.vitorpamplona.amethyst.commons.resources.point_to_the_qr_code
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_camera_blocked
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_camera_rationale
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_clipboard_empty
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_grant_camera
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_no_code_in_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_open_settings
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unavailable
import com.vitorpamplona.amethyst.ui.call.openAppSettings
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.min
/** What the caller did with a decoded payload — and therefore what the scanner does next. */
enum class ScanOutcome {
/** The caller acted on it. Close the scanner. */
Handled,
/** Decoded fine, but this screen has nothing to do with it. Explain, and keep scanning. */
NotSupported,
}
/**
* Full-screen QR scanner.
*
* A dialog rather than a navigation route on purpose: one of its callers is the logged-out login
* field, which lives outside the navigation graph entirely.
*/
@Composable
fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
SetDialogToEdgeToEdge()
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
QrScannerScreen(onDismiss = onDismiss, onScan = onScan)
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun QrScannerScreen(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
if (cameraPermission.status.isGranted) {
QrCameraScanner(onDismiss = onDismiss, onScan = onScan)
} else {
CameraPermissionGate(permission = cameraPermission, onDismiss = onDismiss)
}
}
/**
* Asks for the camera, and explains itself when refused.
*
* The old scanner simply closed its activity when the permission was denied, which from the
* user's side is a button that does nothing.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun CameraPermissionGate(
permission: PermissionState,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var asked by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
asked = true
permission.launchPermissionRequest()
}
// `shouldShowRationale` is false both before the first ask and after a permanent denial, so
// the two are only distinguishable once we know we have asked.
val blocked = asked && !permission.status.shouldShowRationale
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text =
if (blocked) {
stringRes(Res.string.qr_scanner_camera_blocked)
} else {
stringRes(Res.string.qr_scanner_camera_rationale)
},
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
if (blocked) {
Button(onClick = { openAppSettings(context) }) {
Text(stringRes(Res.string.qr_scanner_open_settings))
}
} else {
Button(onClick = { permission.launchPermissionRequest() }) {
Text(stringRes(Res.string.qr_scanner_grant_camera))
}
}
// Closes the scanner, so it says so: with the camera refused there is nothing to scan again.
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.close), color = Color.White)
}
}
}
@Composable
private fun QrCameraScanner(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val state = remember { QrScannerState() }
val decoder = remember { runCatching { ZxingCppBarcodeDecoder() }.getOrNull() }
val currentOnScan by rememberUpdatedState(onScan)
val currentOnDismiss by rememberUpdatedState(onDismiss)
val noCodeInImage = stringRes(Res.string.qr_scanner_no_code_in_image)
val clipboardEmpty = stringRes(Res.string.qr_scanner_clipboard_empty)
val decoderUnavailable = stringRes(Res.string.qr_scanner_unavailable)
// One shared accept path: camera frames, a tapped candidate and an imported image all land
// here, so the haptic, the dedupe and the "we can't open this" branch behave identically
// however the payload arrived.
val submit: (String) -> Unit = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
when (currentOnScan(text)) {
ScanOutcome.Handled -> currentOnDismiss()
ScanOutcome.NotSupported -> state.onRejected(classifyScannedPayload(text))
}
}
val analysisExecutor = remember { Executors.newSingleThreadExecutor() }
// CONFLATED: the analysis thread outruns the UI, and an old frame is worthless — the only
// one worth acting on is the newest.
val frames = remember { Channel<FrameScan>(Channel.CONFLATED) }
val previewView =
remember {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
var camera by remember { mutableStateOf<Camera?>(null) }
var provider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var viewSize by remember { mutableStateOf(IntSize.Zero) }
var focusRing by remember { mutableStateOf<Offset?>(null) }
DisposableEffect(Unit) {
onDispose {
analysisExecutor.shutdown()
frames.close()
runCatching { provider?.unbindAll() }
}
}
LaunchedEffect(decoder) {
if (decoder == null) state.notice = decoderUnavailable
}
// Bind once the preview has a size: ViewPort needs a laid-out view, and binding both use
// cases under one viewport is what makes the overlay's coordinate mapping exact.
LaunchedEffect(decoder, viewSize) {
if (decoder == null || viewSize == IntSize.Zero) return@LaunchedEffect
val cameraProvider =
try {
ProcessCameraProvider.awaitInstance(context)
} catch (e: Exception) {
Log.w("QrScanner", "Camera provider unavailable", e)
state.notice = decoderUnavailable
return@LaunchedEffect
}
provider = cameraProvider
val preview = Preview.Builder().build().apply { setSurfaceProvider(previewView.surfaceProvider) }
val analysis =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.setResolutionSelector(ANALYSIS_RESOLUTION)
.build()
.apply {
setAnalyzer(analysisExecutor, QrFrameAnalyzer(decoder) { frames.trySend(it) })
}
val group =
UseCaseGroup
.Builder()
.addUseCase(preview)
.addUseCase(analysis)
.apply { previewView.viewPort?.let { setViewPort(it) } }
.build()
try {
cameraProvider.unbindAll()
camera =
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, group).also {
state.torchAvailable = it.cameraInfo.hasFlashUnit()
state.maxZoomRatio = it.cameraInfo.zoomState.value
?.maxZoomRatio ?: 1f
}
} catch (e: Exception) {
Log.w("QrScanner", "Could not bind the camera", e)
state.notice = decoderUnavailable
}
}
LaunchedEffect(Unit) {
for (scan in frames) {
state.onFrame(scan, SystemClock.elapsedRealtime())?.let(submit)
}
}
// One writer each for torch and zoom, driven off state rather than from the gesture handlers,
// so the auto-zoom sweep and a pinch cannot fight over the camera control.
LaunchedEffect(camera) {
val control = camera?.cameraControl ?: return@LaunchedEffect
snapshotFlow { state.torchOn }.collect { runCatching { control.enableTorch(it) } }
}
LaunchedEffect(camera) {
val control = camera?.cameraControl ?: return@LaunchedEffect
snapshotFlow { state.zoomRatio }.collect { runCatching { control.setZoomRatio(it) } }
}
AutoZoomSweep(state = state, enabled = camera != null)
LaunchedEffect(state.notice) {
if (state.notice != null) {
delay(NOTICE_DURATION_MS)
state.notice = null
}
}
LaunchedEffect(focusRing) {
if (focusRing != null) {
delay(FOCUS_RING_DURATION_MS)
focusRing = null
}
}
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null || decoder == null) return@rememberLauncherForActivityResult
scope.launch {
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
if (found == null) state.notice = noCodeInImage else submit(found)
}
}
Box(
modifier =
Modifier
.fillMaxSize()
.onSizeChanged { viewSize = it }
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
if (zoom != 1f) {
state.onPinch()
state.zoomRatio = (state.zoomRatio * zoom).coerceIn(1f, maxOf(1f, state.maxZoomRatio))
}
}
}.pointerInput(Unit) {
detectTapGestures { tap ->
val picked = pickCandidateAt(tap, state, viewSize)
if (picked != null) {
state.onCandidateTapped(picked, SystemClock.elapsedRealtime())?.let(submit)
} else {
focusRing = tap
focusAt(previewView, camera, tap)
}
}
},
) {
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
ScanOverlayCanvas(state = state, viewSize = viewSize, focusRing = focusRing)
Text(
text = stringRes(Res.string.point_to_the_qr_code),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp).fillMaxWidth(0.8f),
)
QrScannerControls(
state = state,
onClose = onDismiss,
onToggleTorch = { state.torchOn = !state.torchOn },
onPickImage = { pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onPaste = {
pasteFromClipboard(
context = context,
decoder = decoder,
onText = submit,
onImage = { uri ->
if (decoder != null) {
scope.launch {
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
if (found == null) state.notice = noCodeInImage else submit(found)
}
}
},
onEmpty = { state.notice = clipboardEmpty },
)
},
)
}
state.rejected?.let { payload ->
ScanOutcomeSheet(
payload = payload,
onDismiss = { state.dismissRejection(SystemClock.elapsedRealtime()) },
onOpenLink = { url ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
state.dismissRejection(SystemClock.elapsedRealtime())
currentOnDismiss()
},
onCopy = { text ->
copyToClipboard(context, text)
state.dismissRejection(SystemClock.elapsedRealtime())
},
)
}
}
/** Draws the aiming brackets, any codes we can see, and the tap-to-focus ring. */
@Composable
private fun ScanOverlayCanvas(
state: QrScannerState,
viewSize: IntSize,
focusRing: Offset?,
) {
val highlight = MaterialTheme.colorScheme.primary
Canvas(modifier = Modifier.fillMaxSize()) {
val mapping = ScanViewMapping.of(state.frame, viewSize)
if (state.candidates.isEmpty()) {
drawViewfinderBrackets(Color.White.copy(alpha = 0.65f))
} else if (mapping != null) {
state.candidates.forEach { candidate ->
candidate.bounds?.let {
drawPath(
path = it.toViewPath(mapping),
color = highlight,
style = Stroke(width = 4.dp.toPx()),
)
}
}
}
focusRing?.let {
drawCircle(
color = Color.White.copy(alpha = 0.8f),
radius = 28.dp.toPx(),
center = it,
style = Stroke(width = 2.dp.toPx()),
)
}
}
}
/**
* Sweeps the zoom while nothing is decoding.
*
* A code too small in frame to resolve is the single most common reason a scan fails, and no
* amount of decoder tuning fixes it — there are not enough pixels per module to read. Rather than
* leave the user to work that out and walk closer, the camera pushes in and back out on its own.
* It stops for good once the user pinches: they have taken over.
*/
@Composable
private fun AutoZoomSweep(
state: QrScannerState,
enabled: Boolean,
) {
LaunchedEffect(enabled, state.autoZoomEnabled) {
if (!enabled || !state.autoZoomEnabled) return@LaunchedEffect
var sweep = 0f
while (isActive) {
delay(AUTO_ZOOM_TICK_MS)
if (state.msSinceLastDetection < QrScannerState.AUTO_ZOOM_AFTER_MS) {
if (sweep != 0f) {
sweep = 0f
state.zoomRatio = 1f
}
continue
}
val ceiling = min(state.maxZoomRatio, QrScannerState.AUTO_ZOOM_MAX)
if (ceiling <= 1.01f) continue
sweep = (sweep + AUTO_ZOOM_TICK_MS.toFloat() / AUTO_ZOOM_PERIOD_MS) % 1f
val triangle = if (sweep < 0.5f) sweep * 2f else (1f - sweep) * 2f
state.zoomRatio = 1f + triangle * (ceiling - 1f)
}
}
}
/** The visible code nearest the tap, or null when the tap was not on one. */
private fun pickCandidateAt(
tap: Offset,
state: QrScannerState,
viewSize: IntSize,
): ScanResult? {
if (state.candidates.size <= 1) return null
val mapping = ScanViewMapping.of(state.frame, viewSize) ?: return null
return state.candidates
.mapNotNull { candidate ->
val bounds = candidate.bounds ?: return@mapNotNull null
val center = bounds.centerInView(mapping)
// In view pixels, to match `distance`. bounds.longestSide is image-space.
val radius = maxOf(bounds.longestSideInView(mapping), MIN_TAP_RADIUS_PX)
val distance = (center - tap).getDistance()
if (distance <= radius) candidate to distance else null
}.minByOrNull { it.second }
?.first
}
private fun focusAt(
previewView: PreviewView,
camera: Camera?,
tap: Offset,
) {
val control = camera?.cameraControl ?: return
runCatching {
val point = previewView.meteringPointFactory.createPoint(tap.x, tap.y)
control.startFocusAndMetering(
FocusMeteringAction
.Builder(point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE)
.setAutoCancelDuration(FOCUS_AUTO_CANCEL_SECONDS, TimeUnit.SECONDS)
.build(),
)
}
}
private fun pasteFromClipboard(
context: Context,
decoder: BarcodeDecoder?,
onText: (String) -> Unit,
onImage: (Uri) -> Unit,
onEmpty: () -> Unit,
) {
val image = QrImageImport.clipboardImage(context)
if (image != null && decoder != null) {
onImage(image)
return
}
val text = QrImageImport.clipboardText(context)
if (!text.isNullOrBlank()) onText(text) else onEmpty()
}
/** Shared with the shared-image scan screen, which offers the same action on the same sheet. */
internal fun copyToClipboard(
context: Context,
text: String,
) {
runCatching {
val clipboard = context.getSystemService(ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
/**
* 1280x720 is the sweet spot: plenty of pixels per module for a code at arm's length, while
* staying inside what every device can sustain at full frame rate through an analysis pipeline.
* Falling back higher before lower keeps detail on devices that cannot produce exactly this.
*/
private val ANALYSIS_RESOLUTION =
ResolutionSelector
.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.setResolutionStrategy(
ResolutionStrategy(Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER),
).build()
private const val AUTO_ZOOM_TICK_MS = 100L
private const val AUTO_ZOOM_PERIOD_MS = 3_000f
private const val NOTICE_DURATION_MS = 3_000L
private const val FOCUS_RING_DURATION_MS = 800L
private const val FOCUS_AUTO_CANCEL_SECONDS = 4L
private const val MIN_TAP_RADIUS_PX = 120f
@@ -0,0 +1,324 @@
/*
* 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.close
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_dark_hint
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_paste
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_pick_one
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_scan_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_sequence_progress
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_torch_off
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_torch_on
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_zoom_reset
import com.vitorpamplona.amethyst.ui.stringRes
import kotlin.math.max
/**
* Maps the decoder's image space onto the preview.
*
* The preview is FILL_CENTER and the analysis frame shares its field of view — both use cases are
* bound under one [androidx.camera.core.ViewPort] — so one uniform scale plus a centring offset
* is exact. [max] rather than `min` because FILL_CENTER overfills and crops.
*/
class ScanViewMapping private constructor(
private val scale: Float,
private val dx: Float,
private val dy: Float,
) {
fun map(point: ScanPoint) = Offset(point.x * scale + dx, point.y * scale + dy)
companion object {
fun of(
frame: ScanFrame,
viewSize: IntSize,
): ScanViewMapping? {
if (frame.width <= 0 || frame.height <= 0 || viewSize.width <= 0 || viewSize.height <= 0) return null
val scale =
max(
viewSize.width.toFloat() / frame.width,
viewSize.height.toFloat() / frame.height,
)
return ScanViewMapping(
scale = scale,
dx = (viewSize.width - frame.width * scale) / 2f,
dy = (viewSize.height - frame.height * scale) / 2f,
)
}
}
}
fun ScanBounds.toViewPath(mapping: ScanViewMapping): Path =
Path().apply {
val start = mapping.map(topLeft)
moveTo(start.x, start.y)
mapping.map(topRight).let { lineTo(it.x, it.y) }
mapping.map(bottomRight).let { lineTo(it.x, it.y) }
mapping.map(bottomLeft).let { lineTo(it.x, it.y) }
close()
}
fun ScanBounds.centerInView(mapping: ScanViewMapping): Offset = mapping.map(ScanPoint(centerX, centerY))
/**
* [longestSide] expressed in view pixels.
*
* Needed because [longestSide] is measured in the decoder's image space: comparing it directly
* against a distance in view space silently shrinks or grows the hit area by the preview's scale
* factor, which on a 1280x720 analysis frame shown on a 1080p-wide screen is off by nearly 2x.
*/
fun ScanBounds.longestSideInView(mapping: ScanViewMapping): Float {
val a = mapping.map(topLeft)
val b = mapping.map(topRight)
val c = mapping.map(bottomRight)
val d = mapping.map(bottomLeft)
return maxOf((a - b).getDistance(), (b - c).getDistance(), (c - d).getDistance(), (d - a).getDistance())
}
/** Four corner brackets marking where to aim. Purely decorative — we decode the whole frame. */
fun DrawScope.drawViewfinderBrackets(color: Color) {
val side = minOf(size.width, size.height) * 0.68f
val left = (size.width - side) / 2f
val top = (size.height - side) / 2f
val arm = side * 0.12f
val stroke = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
fun bracket(
x: Float,
y: Float,
dx: Float,
dy: Float,
) {
drawPath(
Path().apply {
moveTo(x, y + dy * arm)
lineTo(x, y)
lineTo(x + dx * arm, y)
},
color = color,
style = stroke,
)
}
bracket(left, top, 1f, 1f)
bracket(left + side, top, -1f, 1f)
bracket(left, top + side, 1f, -1f)
bracket(left + side, top + side, -1f, -1f)
}
/**
* The chrome over the camera feed: close, torch, import, hints and progress.
*
* Deliberately a slot-free, single-purpose component — there is exactly one scanner screen, and
* pulling these five controls out into parameters would be ceremony without a second caller.
*/
@Composable
fun QrScannerControls(
state: QrScannerState,
onClose: () -> Unit,
onToggleTorch: () -> Unit,
onPickImage: () -> Unit,
onPaste: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(8.dp),
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.close),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
if (!state.autoZoomEnabled && state.zoomRatio > 1.05f) {
ZoomChip(
zoomRatio = state.zoomRatio,
onReset = { state.resetZoom() },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(12.dp),
)
}
HintStack(
state = state,
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 96.dp, start = 24.dp, end = 24.dp),
)
Row(
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally),
) {
if (state.torchAvailable) {
OverlayButton(
symbol = if (state.torchOn) MaterialSymbols.FlashlightOff else MaterialSymbols.FlashlightOn,
description =
if (state.torchOn) {
stringRes(Res.string.qr_scanner_torch_off)
} else {
stringRes(Res.string.qr_scanner_torch_on)
},
highlighted = state.torchOn,
onClick = onToggleTorch,
)
}
OverlayButton(
symbol = MaterialSymbols.PhotoLibrary,
description = stringRes(Res.string.qr_scanner_scan_image),
onClick = onPickImage,
)
OverlayButton(
symbol = MaterialSymbols.ContentPaste,
description = stringRes(Res.string.qr_scanner_paste),
onClick = onPaste,
)
}
}
}
/**
* The one line of text under the viewfinder.
*
* Only ever shows one message, most urgent first: a notice the user asked for ("no code in that
* picture") beats sequence progress, which beats "pick one of these", which beats the dark hint.
*/
@Composable
private fun HintStack(
state: QrScannerState,
modifier: Modifier = Modifier,
) {
val progress = state.sequenceProgress
val message =
when {
state.notice != null -> state.notice
progress != null -> stringRes(Res.string.qr_scanner_sequence_progress, progress.first, progress.second)
state.candidates.size > 1 -> stringRes(Res.string.qr_scanner_pick_one)
state.isDark && !state.torchOn -> stringRes(Res.string.qr_scanner_dark_hint)
else -> null
} ?: return
Surface(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
color = Color.Black.copy(alpha = 0.65f),
) {
Text(
text = message,
color = Color.White,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
@Composable
private fun ZoomChip(
zoomRatio: Float,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.clickable(onClick = onReset),
shape = RoundedCornerShape(16.dp),
color = Color.Black.copy(alpha = 0.55f),
) {
Text(
text = "%.1f× %s".format(zoomRatio, stringRes(Res.string.qr_scanner_zoom_reset)),
color = Color.White,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun OverlayButton(
symbol: MaterialSymbol,
description: String,
onClick: () -> Unit,
highlighted: Boolean = false,
) {
FilledIconButton(
onClick = onClick,
colors =
if (highlighted) {
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
)
},
modifier = Modifier.size(54.dp),
) {
Icon(symbol = symbol, contentDescription = description, modifier = Modifier.size(26.dp))
}
}
@@ -0,0 +1,233 @@
/*
* 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 androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Everything the scanner UI draws, and the decision of what a frame means.
*
* Kept out of the composable so the accept/dedupe/multi-code rules are one readable block rather
* than conditions scattered through a camera callback.
*/
@Stable
class QrScannerState {
private val sequence = StructuredAppendAccumulator()
/** The size of the image the decoder saw, for mapping [ScanResult.bounds] onto the preview. */
var frame by mutableStateOf(ScanFrame(0, 0))
private set
/**
* Codes currently visible. One entry means we take it; several mean we draw them all and wait
* for a tap, because silently picking one of several codes is how you scan the poster next to
* the one you meant.
*/
var candidates by mutableStateOf<List<ScanResult>>(emptyList())
private set
/** Set while a Structured Append payload is half-captured: captured count to total. */
var sequenceProgress by mutableStateOf<Pair<Int, Int>?>(null)
private set
/** True when the scene has been too dark for [DARK_DWELL_MS]; the UI offers the torch. */
var isDark by mutableStateOf(false)
private set
var torchOn by mutableStateOf(false)
var torchAvailable by mutableStateOf(false)
var zoomRatio by mutableFloatStateOf(1f)
var maxZoomRatio by mutableFloatStateOf(1f)
/**
* Auto-zoom stops for good the first time the user pinches. Someone who has framed the shot
* themselves does not want the camera arguing about it.
*/
var autoZoomEnabled by mutableStateOf(true)
private set
/** A payload we decoded but the caller could not use; drives the explain-what-happened sheet. */
var rejected by mutableStateOf<ScannedPayload?>(null)
/** A transient message (no code in that picture, empty clipboard, decoder unavailable). */
var notice by mutableStateOf<String?>(null)
private var lastSubmittedText: String? = null
private var lastSubmittedAt = 0L
private var darkSinceMs = 0L
/** Milliseconds since anything at all was decoded — what auto-zoom watches. */
var msSinceLastDetection by mutableLongStateOf(0L)
private set
private var lastDetectionMs = 0L
fun onPinch() {
autoZoomEnabled = false
}
fun resetZoom() {
zoomRatio = 1f
}
/**
* Folds one analysed frame into the UI state and decides whether we have an answer.
*
* Returns the payload to hand back to the caller, or null to keep scanning. Returning null is
* the normal case — nothing in frame, several codes in frame, a half-finished multi-part
* code, or the same code we just submitted.
*/
fun onFrame(
scan: FrameScan,
nowMs: Long,
): String? {
frame = scan.frame
updateDarkness(scan.brightness, nowMs)
// Checked on every frame, not just on empty ones: an abandoned half-capture is abandoned
// whether or not the camera is busy reading something else.
if (sequence.dropIfStale(nowMs)) sequenceProgress = null
// Seed the clock on the first frame. Left at zero, the very first empty frame would read
// as "nothing decoded since the epoch" and send auto-zoom hunting before the user has had
// a chance to aim.
if (lastDetectionMs == 0L) lastDetectionMs = nowMs
// Nothing is decided while the "we can't open this" sheet is up: the user is reading it,
// and the offending code is very probably still sitting in front of the lens.
if (rejected != null) {
candidates = emptyList()
return null
}
val found = scan.results.distinctBy { it.text }
if (found.isEmpty()) {
candidates = emptyList()
msSinceLastDetection = nowMs - lastDetectionMs
return null
}
lastDetectionMs = nowMs
msSinceLastDetection = 0
candidates = found
// A multi-part code is never complete on its first part, so it can't be a single answer.
found.firstOrNull { it.isPartOfSequence }?.let { part ->
val joined = sequence.add(part, nowMs)
sequenceProgress = if (joined == null) sequence.captured to sequence.total else null
return joined?.let { accept(it, nowMs) }
}
if (found.size > 1) return null
return accept(found.first().text, nowMs)
}
/** Taking one of several visible codes, because the user tapped it. */
fun onCandidateTapped(
result: ScanResult,
nowMs: Long,
): String? {
candidates = emptyList()
// Deliberately bypasses the dedupe window. That window exists to stop ONE code decoding
// thirty times a second from firing the caller thirty times; a tap is one decision by a
// person, and swallowing it makes the highlight a target that can be tapped with nothing
// happening. The latch is still armed, so the camera frames that follow -- the tapped
// code is very probably still in view -- do not fire it again.
lastSubmittedText = result.text
lastSubmittedAt = nowMs
return result.text
}
/**
* Debounced hand-off.
*
* A code held in front of the lens decodes ~30 times a second. Without this the caller's
* handler fires 30 times, which for a navigation target means 30 stacked screens.
*/
private fun accept(
text: String,
nowMs: Long,
): String? {
if (text == lastSubmittedText && nowMs - lastSubmittedAt < DEDUPE_MS) return null
lastSubmittedText = text
lastSubmittedAt = nowMs
return text
}
/** Called when the caller rejects a payload, so the same code does not re-fire immediately. */
fun onRejected(payload: ScannedPayload) {
rejected = payload
candidates = emptyList()
}
/**
* Dismissing the sheet resumes scanning.
*
* The dedupe latch is deliberately LEFT in place. Clearing it - so the user could retry the
* very same code - meant that the offending code, still sitting in front of the lens, decoded
* again on the next frame and re-opened the sheet immediately: "Scan again" became a button
* that could not be escaped. Keeping the latch lets the camera run; pointing at the same code
* again after [DEDUPE_MS] still re-triggers it, which is the retry that was actually wanted.
*/
fun dismissRejection(nowMs: Long) {
rejected = null
// Restart the latch from now, so the grace period is measured from when the user dismissed
// the sheet rather than from when the code was first read.
lastSubmittedAt = nowMs
}
private fun updateDarkness(
brightness: Float,
nowMs: Long,
) {
if (brightness > DARK_THRESHOLD) {
darkSinceMs = 0
isDark = false
return
}
if (darkSinceMs == 0L) darkSinceMs = nowMs
isDark = nowMs - darkSinceMs >= DARK_DWELL_MS
}
companion object {
/** Long enough that one steady code fires once; short enough to rescan on purpose. */
const val DEDUPE_MS = 1_500L
/** Mean luminance below this reads as "the torch would help". */
const val DARK_THRESHOLD = 0.18f
/** Don't offer the torch for a thumb over the lens or a moment of shadow. */
const val DARK_DWELL_MS = 1_000L
/** How long with nothing decoded before auto-zoom starts hunting. */
const val AUTO_ZOOM_AFTER_MS = 1_200L
/** Ceiling for the auto-zoom sweep — past this, focus and shake beat the extra reach. */
const val AUTO_ZOOM_MAX = 2.5f
}
}
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_copy
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_bunker
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_cashu
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_lightning
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_nostr_unsupported
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_nsec
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_signer
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_text
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_wallet
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_web
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_open_link
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_try_again
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unsupported_secret
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unsupported_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
/**
* Explains a code we read but could not act on.
*
* This sheet is the whole point of classifying payloads. The old scanner collapsed "you
* cancelled", "the camera never read anything" and "that scanned perfectly but Amethyst has no
* screen for it" into the same silent return to the previous screen (issue #417), which trains
* people to believe the reader is broken when it is working exactly as designed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanOutcomeSheet(
payload: ScannedPayload,
onDismiss: () -> Unit,
onOpenLink: (String) -> Unit,
onCopy: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(Res.string.qr_scanner_unsupported_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = explain(payload),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// A QR code is scanned in public. Key material and pairing secrets never get echoed
// back onto the screen, no matter how useful it would be for debugging.
if (!payload.containsSecret) {
Text(
text = payload.raw,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (payload is ScannedPayload.Web) {
Button(onClick = { onOpenLink(payload.url) }) {
Text(stringRes(Res.string.qr_scanner_open_link))
}
}
if (!payload.containsSecret) {
TextButton(onClick = { onCopy(payload.raw) }) {
Text(stringRes(Res.string.qr_scanner_copy))
}
}
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.qr_scanner_try_again))
}
}
}
}
}
@Composable
private fun explain(payload: ScannedPayload): String =
when (payload) {
is ScannedPayload.Nostr ->
if (payload.entity is NSec) {
stringRes(Res.string.qr_scanner_kind_nsec)
} else {
stringRes(Res.string.qr_scanner_kind_nostr_unsupported)
}
is ScannedPayload.WalletConnect -> stringRes(Res.string.qr_scanner_kind_wallet)
// Split deliberately: Amethyst PUBLISHES bunker:// addresses (it is the signer) and has no
// screen that consumes one, so pointing the user at the signer screen -- as this used to --
// sends them somewhere that cannot accept it.
is ScannedPayload.Bunker -> stringRes(Res.string.qr_scanner_kind_bunker)
is ScannedPayload.NostrConnect -> stringRes(Res.string.qr_scanner_kind_signer)
is ScannedPayload.PrivateKey -> stringRes(Res.string.qr_scanner_kind_nsec)
is ScannedPayload.Lightning -> stringRes(Res.string.qr_scanner_kind_lightning)
is ScannedPayload.Cashu -> stringRes(Res.string.qr_scanner_kind_cashu)
is ScannedPayload.Web -> stringRes(Res.string.qr_scanner_kind_web)
is ScannedPayload.HexPubKey, is ScannedPayload.Unknown -> stringRes(Res.string.qr_scanner_kind_text)
} + if (payload.containsSecret) "\n\n" + stringRes(Res.string.qr_scanner_unsupported_secret) else ""
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
/** A point in the decoder's image space (origin top-left, after crop and rotation). */
data class ScanPoint(
val x: Float,
val y: Float,
)
/**
* The four corners of a decoded symbol, in the decoder's image space.
*
* Kept because they drive three things the old scanner could not do: highlighting the code we
* actually read, letting the user tap the right one when several are in frame, and measuring how
* small the symbol is so auto-zoom knows whether to push in.
*/
data class ScanBounds(
val topLeft: ScanPoint,
val topRight: ScanPoint,
val bottomRight: ScanPoint,
val bottomLeft: ScanPoint,
) {
val centerX: Float get() = (topLeft.x + topRight.x + bottomRight.x + bottomLeft.x) / 4f
val centerY: Float get() = (topLeft.y + topRight.y + bottomRight.y + bottomLeft.y) / 4f
/** The longest edge of the quad — a rotation-independent stand-in for "how big is it". */
val longestSide: Float
get() =
maxOf(
dist(topLeft, topRight),
dist(topRight, bottomRight),
dist(bottomRight, bottomLeft),
dist(bottomLeft, topLeft),
)
private fun dist(
a: ScanPoint,
b: ScanPoint,
): Float {
val dx = a.x - b.x
val dy = a.y - b.y
return kotlin.math.sqrt(dx * dx + dy * dy)
}
}
/**
* One decoded symbol.
*
* [sequenceSize] is greater than zero only for Structured Append codes — a payload split across
* several physical QR codes. [StructuredAppendAccumulator] reassembles those.
*/
data class ScanResult(
val text: String,
val bounds: ScanBounds?,
val sequenceId: String? = null,
val sequenceIndex: Int = -1,
val sequenceSize: Int = -1,
) {
val isPartOfSequence: Boolean get() = sequenceSize > 0 && sequenceIndex >= 0
}
/** The size of the image the decoder was handed, so callers can map [ScanBounds] onto a view. */
data class ScanFrame(
val width: Int,
val height: Int,
)
@@ -0,0 +1,188 @@
/*
* 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.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
/**
* What a decoded QR string *is*, independent of whether any particular screen can act on it.
*
* The scanner needs this separately from routing: `uriToRoute` answers "where does this
* navigate", and collapses everything it doesn't recognise into `null` — which is how a
* perfectly good scan of an unsupported payload became indistinguishable from the camera never
* reading anything (issue #417). Classifying first lets the UI say *which* of those happened.
*
* Pure Kotlin on purpose: no Android, no `LocalCache`, no navigation. It is the one piece of the
* scanner that can be exhaustively unit-tested on the JVM.
*/
sealed interface ScannedPayload {
/** Exactly what the decoder read, trimmed. */
val raw: String
/**
* True when [raw] carries key material or a pairing secret — an `nsec`, an `ncryptsec`, a
* wallet-connect URI (its `secret=` is spendable), or a NIP-46 URI (its `secret=` authorises
* a signer). Any UI that echoes a payload back to the screen, copies it, or logs it MUST
* check this first. A QR code is scanned in public, over someone's shoulder, by definition.
*/
val containsSecret: Boolean
get() = false
/** A NIP-19 entity, with or without the `nostr:` prefix. */
data class Nostr(
override val raw: String,
val entity: Entity,
) : ScannedPayload {
override val containsSecret get() = entity is NSec
}
/** `nostrconnect://` — an app asking our signer to connect. Carries a `secret`. */
data class NostrConnect(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** `bunker://` — a remote signer offering itself. Carries a `secret`. */
data class Bunker(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** `nostr+walletconnect://` and friends. Carries a spendable `secret`. */
data class WalletConnect(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** A BOLT-11 invoice, an LNURL, or a `lightning:` URI. */
data class Lightning(
override val raw: String,
) : ScannedPayload
/** A Cashu token or payment request. Bearer money — never echo it. */
data class Cashu(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** An `http(s)://` link. [url] is [raw] with any `web+nostr:` style wrapper removed. */
data class Web(
override val raw: String,
val url: String,
) : ScannedPayload
/**
* A bare 64-character hex pubkey with no bech32 wrapper — what issue #417 hit in 2023 and
* what every "copy the pubkey" web tool still produces. [npub] is the same key encoded, so
* callers can hand it to the normal NIP-19 routing path.
*/
data class HexPubKey(
override val raw: String,
val npub: String,
) : ScannedPayload
/**
* Key material we can recognise but not decode. Two things land here:
*
* - an `ncryptsec`, which `Nip19Parser` lists in its regex but has no branch to parse;
* - an `nsec` the parser rejected — truncated by a half-finished copy, or transcribed with a
* typo into whatever generated the code. It is still a private key, and a damaged one
* still shows all but a few of its characters.
*
* Both are classified by *prefix*, not by parse success: whether a payload is dangerous to
* display cannot depend on whether we happen to be able to read it.
*/
data class PrivateKey(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** Decoded fine, but it is not anything Amethyst knows how to act on. */
data class Unknown(
override val raw: String,
) : ScannedPayload
}
private val HEX_64 = Regex("^[0-9a-fA-F]{64}$")
private val LIGHTNING_PREFIXES = listOf("lightning:", "lnbc", "lntb", "lnbcrt", "lnurl")
private val WALLET_CONNECT_PREFIXES =
listOf(
"nostr+walletconnect:",
"nostrwalletconnect:",
"nostr+walletconnect://",
"amethyst+walletconnect:",
)
/**
* Classify a decoded QR string.
*
* Scheme checks run before the NIP-19 scan on purpose: a `bunker://` or `nostrconnect://` URI
* embeds a hex pubkey and relay URLs, and letting the (deliberately permissive) NIP-19 regex
* look at those first risks matching a bech32-shaped fragment out of a relay path and routing
* somewhere absurd.
*/
fun classifyScannedPayload(text: String): ScannedPayload {
val raw = text.trim()
if (raw.isEmpty()) return ScannedPayload.Unknown(raw)
val lower = raw.lowercase()
if (lower.startsWith("bunker:")) return ScannedPayload.Bunker(raw)
if (lower.startsWith("nostrconnect:")) return ScannedPayload.NostrConnect(raw)
if (WALLET_CONNECT_PREFIXES.any { lower.startsWith(it) }) return ScannedPayload.WalletConnect(raw)
if (lower.startsWith("cashu") || lower.startsWith("creq")) return ScannedPayload.Cashu(raw)
// Before the NIP-19 scan, and by prefix rather than by parse: an ncryptsec cannot be decoded
// here, so waiting to find out what it is would mean deciding it is harmless.
if (lower.startsWith("ncryptsec1") || lower.startsWith("nostr:ncryptsec1")) {
return ScannedPayload.PrivateKey(raw)
}
if (LIGHTNING_PREFIXES.any { lower.startsWith(it) }) return ScannedPayload.Lightning(raw)
Nip19Parser.uriToRoute(raw)?.let { return ScannedPayload.Nostr(raw, it.entity) }
// An nsec the parser would not take. The parse is tried first so a well-formed one still
// becomes a [ScannedPayload.Nostr] and keeps the routing that logging in by scanning one
// depends on — but a damaged one must not fall through to [ScannedPayload.Unknown], where
// the sheet prints the payload on screen with a Copy button next to it.
if (lower.startsWith("nsec1") || lower.startsWith("nostr:nsec1")) return ScannedPayload.PrivateKey(raw)
if (HEX_64.matches(raw)) {
val npub = runCatching { NPub.create(raw.lowercase()) }.getOrNull()
if (npub != null) return ScannedPayload.HexPubKey(raw, npub)
}
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return ScannedPayload.Web(raw, raw)
}
return ScannedPayload.Unknown(raw)
}
@@ -0,0 +1,119 @@
/*
* 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
/**
* Reassembles a Structured Append payload — one logical string split across several physical QR
* codes, each tagged with a sequence id, an index and a total.
*
* Why bother: a QR code's capacity falls off a cliff as the payload grows, because more data
* means more modules in the same physical space, and small modules are exactly what defeats a
* camera at arm's length. Splitting is how a long payload (a key backup, an `naddr` with several
* relay hints) stays scannable, and Structured Append is the standard way to do it. We could not
* read one at all before.
*
* Not thread-safe; it is driven from the analysis executor only.
*/
class StructuredAppendAccumulator(
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
) {
private var sequenceId: String? = null
private var expected: Int = 0
private var lastUpdateMs: Long = 0
private val parts = mutableMapOf<Int, String>()
/** Parts captured so far for the sequence in progress. */
val captured: Int get() = parts.size
/** How many parts the sequence in progress needs in total, or 0 when idle. */
val total: Int get() = expected
/**
* Feeds one decoded part in.
*
* Returns the joined payload once every part has been seen, or null while the sequence is
* still incomplete. A part from a different sequence, or one arriving after [timeoutMs] of
* silence, restarts the accumulation rather than corrupting it — someone who gives up
* halfway and points the camera at a different code should not get a splice of the two.
*/
fun add(
result: ScanResult,
nowMs: Long,
): String? {
if (!result.isPartOfSequence) return null
val id = result.sequenceId
val stale = nowMs - lastUpdateMs > timeoutMs
if (id != sequenceId || expected != result.sequenceSize || stale) {
reset()
sequenceId = id
expected = result.sequenceSize
}
lastUpdateMs = nowMs
parts[result.sequenceIndex] = result.text
if (parts.size < expected) return null
// Built with an explicit loop rather than joinToString: bailing out on a missing index
// needs a real `return`, and joinToString is not inline, so a non-local one is illegal
// there. The count can be right and an index still missing, if a malformed code
// reported an index outside 0 until sequenceSize.
val joined = StringBuilder()
for (index in 0 until expected) {
joined.append(parts[index] ?: return null)
}
reset()
return joined.toString()
}
/**
* Drops a half-captured sequence whose parts stopped arriving, and says whether it did.
*
* Kept here, against this accumulator's own last-update clock, because that is the only clock
* that measures the right thing. The caller cannot substitute "nothing has been decoded at
* all": walking away from a half-scanned poster and pointing the camera at an ordinary code
* keeps decoding something on every frame, so that clock never advances and the abandoned
* sequence is never dropped.
*/
fun dropIfStale(nowMs: Long): Boolean {
if (expected == 0) return false
if (nowMs - lastUpdateMs <= timeoutMs) return false
reset()
return true
}
fun reset() {
sequenceId = null
expected = 0
lastUpdateMs = 0
parts.clear()
}
companion object {
/**
* Long enough to walk around a poster and catch the parts, short enough that an abandoned
* half-sequence does not linger into the next scan.
*/
const val DEFAULT_TIMEOUT_MS = 30_000L
}
}
@@ -0,0 +1,216 @@
/*
* Copyright 2021 Axel Waggershauser
*/
// SPDX-License-Identifier: Apache-2.0
// -----------------------------------------------------------------------------
// VENDORED, DO NOT EDIT OR REFORMAT.
//
// Copied verbatim from zxing-cpp v3.1.1:
// wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt
//
// We build libzxingcpp_android.so from source ourselves (tools/zxing-cpp-build)
// instead of consuming the prebuilt io.github.zxing-cpp:android AAR, so the
// Kotlin half of that AAR has to come with it.
//
// The package and class name are load-bearing: the native library exports
// Java_zxingcpp_BarcodeReader_readYBuffer / _readBitmap, so moving or renaming
// this class breaks the JNI lookup at runtime with no build error. R8 would do
// the same, which is why amethyst/proguard-rules.pro keeps zxingcpp.** -- that
// rule used to arrive as the AAR's consumer rule and is ours to carry now.
//
// It keeps its upstream Apache-2.0 header and is excluded from spotless in the
// root build.gradle.kts: stamping our MIT header on someone else's file would
// misstate its provenance. To update, re-run build-zxingcpp.sh at a new tag and
// re-copy this file from the pinned clone so the two halves cannot drift.
// -----------------------------------------------------------------------------
package zxingcpp
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.graphics.Point
import android.graphics.Rect
import android.os.Build
import androidx.camera.core.ImageProxy
import java.nio.ByteBuffer
public class BarcodeReader(public var options: Options = Options()) {
private val supportedYUVFormats: List<Int> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
listOf(ImageFormat.YUV_420_888, ImageFormat.YUV_422_888, ImageFormat.YUV_444_888)
} else {
listOf(ImageFormat.YUV_420_888)
}
init {
System.loadLibrary("zxingcpp_android")
}
// Enumerates barcode formats known to this package.
// Note that this has to be kept synchronized with native (C++/JNI) side.
public enum class Format(public val value: Int) {
NONE (0x0000),
ALL (0x2A2A),
ALL_READABLE (0x722A),
ALL_CREATABLE (0x772A),
ALL_LINEAR (0x6C2A),
ALL_MATRIX (0x6D2A),
ALL_GS1 (0x472A),
ALL_RETAIL (0x522A),
ALL_INDUSTRIAL (0x492A),
CODABAR (0x2046),
CODE_39 (0x2041),
CODE_39_STD (0x7341),
CODE_39_EXT (0x6541),
CODE_32 (0x3241),
PZN (0x7041),
CODE_93 (0x2047),
CODE_128 (0x2043),
ITF (0x2049),
ITF_14 (0x3449),
DATA_BAR (0x2065),
DATA_BAR_OMNI (0x6F65),
DATA_BAR_STK (0x7365),
DATA_BAR_STK_OMNI (0x4F65),
DATA_BAR_LTD (0x6C65),
DATA_BAR_EXP (0x6565),
DATA_BAR_EXP_STK (0x4565),
EAN_UPC (0x2045),
EAN_13 (0x3145),
EAN_8 (0x3845),
EAN_5 (0x3545),
EAN_2 (0x3245),
ISBN (0x6945),
UPC_A (0x6145),
UPC_E (0x6545),
TELEPEN (0x2042),
TELEPEN_ALPHA (0x3042),
TELEPEN_NUMERIC (0x3142),
OTHER_BARCODE (0x2058),
DX_FILM_EDGE (0x7858),
PDF_417 (0x204C),
COMPACT_PDF_417 (0x634C),
MICRO_PDF_417 (0x6D4C),
AZTEC (0x207A),
AZTEC_CODE (0x637A),
AZTEC_RUNE (0x727A),
QR_CODE (0x2051),
QR_CODE_MODEL_1 (0x3151),
QR_CODE_MODEL_2 (0x3251),
MICRO_QR_CODE (0x6D51),
RMQR_CODE (0x7251),
DATA_MATRIX (0x2064),
MAXI_CODE (0x2055),
}
public enum class ContentType {
TEXT, BINARY, MIXED, GS1, ISO15434, UNKNOWN_ECI
}
public enum class Binarizer {
LOCAL_AVERAGE, GLOBAL_HISTOGRAM, FIXED_THRESHOLD, BOOL_CAST
}
public enum class EanAddOnSymbol {
IGNORE, READ, REQUIRE
}
public enum class TextMode {
PLAIN, ECI, HRI, ESCAPED, HEX, HEX_ECI
}
public enum class ErrorType {
NONE, FORMAT, CHECKSUM, UNSUPPORTED
}
public data class Options(
var formats: Set<Format> = setOf(),
var tryHarder: Boolean = false,
var tryRotate: Boolean = false,
var tryInvert: Boolean = false,
var tryDownscale: Boolean = false,
var tryDenoise: Boolean = false,
var isPure: Boolean = false,
var binarizer: Binarizer = Binarizer.LOCAL_AVERAGE,
var downscaleFactor: Int = 3,
var downscaleThreshold: Int = 500,
var minLineCount: Int = 2,
var maxNumberOfSymbols: Int = 0xff,
var validateOptionalChecksum: Boolean = false,
@Deprecated("See https://github.com/zxing-cpp/zxing-cpp/discussions/704")
var tryCode39ExtendedMode: Boolean = true,
@Deprecated("Use validateOptionalChecksum")
var validateCode39CheckSum: Boolean = false,
@Deprecated("Use validateOptionalChecksum")
var validateITFCheckSum: Boolean = false,
var returnErrors: Boolean = false,
var eanAddOnSymbol: EanAddOnSymbol = EanAddOnSymbol.IGNORE,
var textMode: TextMode = TextMode.HRI,
)
public data class Error(
val type: ErrorType,
val message: String
)
public data class Position(
val topLeft: Point,
val topRight: Point,
val bottomRight: Point,
val bottomLeft: Point,
val orientation: Double
)
public data class Result(
val format: Format,
val bytes: ByteArray?,
val text: String?,
val contentType: ContentType,
val position: Position,
val orientation: Int,
val ecLevel: String?,
val symbologyIdentifier: String?,
val sequenceSize: Int,
val sequenceIndex: Int,
val sequenceId: String?,
val readerInit: Boolean,
val lineCount: Int,
val error: Error?,
)
public val lastReadTime : Int = 0 // runtime of last read call in ms (for debugging purposes only)
public fun read(image: ImageProxy): List<Result> {
check(image.format in supportedYUVFormats) {
"Invalid image format: ${image.format}. Must be one of: $supportedYUVFormats"
}
return readYBuffer(
image.planes[0].buffer,
image.planes[0].rowStride,
image.cropRect.left,
image.cropRect.top,
image.cropRect.width(),
image.cropRect.height(),
image.imageInfo.rotationDegrees,
options
)
}
public fun read(
bitmap: Bitmap, cropRect: Rect = Rect(), rotation: Int = 0
): List<Result> {
return readBitmap(
bitmap, cropRect.left, cropRect.top, cropRect.width(), cropRect.height(), rotation, options
)
}
private external fun readYBuffer(
yBuffer: ByteBuffer, rowStride: Int, left: Int, top: Int, width: Int, height: Int, rotation: Int, options: Options
): List<Result>
private external fun readBitmap(
bitmap: Bitmap, left: Int, top: Int, width: Int, height: Int, rotation: Int, options: Options
): List<Result>
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -1670,6 +1670,9 @@
<string name="share_target_as_picture">New Picture</string>
<string name="share_target_as_short_video">New Short</string>
<string name="share_target_as_video">New Video</string>
<string name="share_target_scan_qr">Scan QR code</string>
<string name="shortcut_scan_qr_short">Scan QR</string>
<string name="shortcut_scan_qr_long">Scan a QR code</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_note_placeholder">Add your thoughts…</string>
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Launcher shortcuts (long-press the app icon).
"Scan QR code" exists because scanning was three taps deep - profile, then the QR screen, then
a Scan button - which is a long way to reach the thing you do while standing in front of
someone. The intent carries no targetPackage on purpose: debug and benchmark builds append an
applicationId suffix, so a hardcoded one would resolve to nothing, and manifest placeholders
are not substituted in resource XML, so ${applicationId} does not work here either.
The scheme is `amethyst:`, not `nostr:`, precisely because this intent is implicit: any app
that registers a nostr: VIEW handler could win the chooser and swallow the shortcut. Both
schemes are declared on MainActivity's existing filter, and `amethyst:` is ours. That narrows
the hijack surface to apps deliberately claiming our scheme rather than to every Nostr client
on the device; it does not eliminate it, which is the price of a static shortcut.
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="scan_qr"
android:enabled="true"
android:icon="@drawable/ic_qrcode"
android:shortcutShortLabel="@string/shortcut_scan_qr_short"
android:shortcutLongLabel="@string/shortcut_scan_qr_long">
<intent
android:action="android.intent.action.VIEW"
android:data="amethyst:scanqr" />
</shortcut>
</shortcuts>
@@ -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<Array<String>> {
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)
}
}
@@ -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<String>) =
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<String>) =
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())
}
}
@@ -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
@@ -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 {
@@ -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<Account>()
@@ -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<Account>()
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"
@@ -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)))
}
}
@@ -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<Fixture> {
val fixtures = mutableListOf<Fixture>()
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<BufferedImage, Int> {
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)
}
}
@@ -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<Row>): 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<Row>) {
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"
}
}
@@ -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")
}

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