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
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8"
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
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
|
||||
|
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||