Files
archy/docs/companion-qr-decoder-zxing-cpp.md
T

135 lines
5.6 KiB
Markdown
Raw Normal View History

2026-08-12 10:55:50 +00:00
# Companion QR decoder — the zxing-cpp option (deferred)
*2026-08-11. Status: **NOT actioned.** Held as the next lever if the tuned
ZXing-Java pipeline proves insufficient in field testing. Companion-only —
touches `Android/` and nothing else.*
Related: [`qr-scanner-snappiness-handover.md`](qr-scanner-snappiness-handover.md)
(web + native survey, 2026-07-29), [`companion-pairing-qr.md`](companion-pairing-qr.md)
(the payload being scanned).
## Where we actually landed first
Before reaching for a new decoder, the native scanner
(`Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt`)
was rebuilt around one rule:
> **Every frame costs the same, and every frame sees the whole scene.**
Per frame: centre ROI at full resolution (dense invoices keep their
pixels-per-module) + the whole frame at half resolution (coverage) + one
alternating `GlobalHistogramBinarizer` pass. Bounded extras only: an inverted
ROI every 8th frame, one `TRY_HARDER` pass over the *half*-frame at most once
a second.
Two bugs were fixed on the way, both worth remembering because they are easy
to reintroduce:
1. **Escalation-on-failure is backwards.** An earlier version unlocked
progressively more expensive searches on each frame that missed, ending in
a `TRY_HARDER` pass over the full 2 MP frame (150300 ms). The result was a
scanner that locked on instantly when the code was already in view at open,
and crawled when the user opened the camera and then moved to the code —
because hunting collapsed the rate from ~30 attempts/sec to ~4, each on a
motion-blurred frame. Failure means the user is still aiming, which is when
the scanner must be *fastest*, not most thorough.
2. **A one-shot `startFocusAndMetering` locks the lens.** It puts AF in AUTO
until auto-cancel; the 5 s default spans exactly the window where the user
is swinging the phone toward the code, and a locked lens cannot follow.
Auto-cancel is now 1 s so `CONTROL_AF_MODE_CONTINUOUS_PICTURE` does the
tracking.
Plus `CONTROL_AE_TARGET_FPS_RANGE` pinned to the highest floor the back camera
offers at ≤30 fps, which caps exposure (~33 ms) and kills the motion blur that
indoor auto-exposure otherwise bakes into every hand-held frame.
That combination tested better on device (2026-08-11). This document covers
what to do **if it is still not good enough.**
## The remaining structural limit
The decoder engine itself. ZXing's Java implementation is both the slow part
and the picky part — most relevantly, it rejects perspective-skewed codes
outright, which is much of what the sensor sees while the user is moving. No
amount of frame budgeting fixes a decoder that will not accept the frame.
## The candidate: zxing-cpp
`io.github.zxing-cpp:android` — the maintained C++ rewrite of ZXing with an
official Android/Kotlin wrapper.
**Why it clears the project's dependency bar** (`~/.claude/CLAUDE.md`):
Apache-2.0, established OSS, fully on-device, no telemetry, no Play Services,
no account or network dependency. This is the distinguishing point against
**ML Kit**, which is the other fast option and is disqualified: it is
proprietary and Play-Services-backed.
**What it buys:**
- Roughly 510× faster than ZXing-Java on the same frames.
- Materially better on the cases that actually fail here: perspective/rotation
(`tryRotate`, and its detector handles warp rather than rejecting it),
blur, low contrast, damaged codes.
- Built-in inversion handling (`tryInvert`), removing our alternating
inverted-ROI pass.
- Accepts an `ImageProxy` directly, so the manual Y-plane crop/copy machinery
in `QrCodeAnalyzer` can largely be deleted — including the reused
`roiBuffer`/`halfBuffer` and the `pixelStride` handling.
**Costs / risks:**
- New native dependency. APK grows ~12 MB — limited because the app is
already arm64-only (`abiFilters += "arm64-v8a"`), so only one ABI ships.
- Adds a native attack/maintenance surface next to the existing Rust FIPS
core. Pin the version exactly, per project rules.
- The tuned camera work above (AE FPS floor, AF auto-cancel, flat per-frame
budget) stays relevant regardless — a faster decoder does not fix a blurred
or out-of-focus frame. Do **not** rip that out as part of this change.
## Integration sketch
> ⚠️ Coordinates and API surface below are from memory and were **not**
> verified against Maven Central — the machine this was written on had no
> network. Confirm the current artifact version and wrapper API on the first
> online Gradle sync before trusting the snippet.
`Android/app/build.gradle.kts`:
```kotlin
// Replaces com.google.zxing:core for the live-camera path.
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
```
`QrCodeAnalyzer` collapses to roughly:
```kotlin
private val reader = BarcodeReader().apply {
options = BarcodeReader.Options(
formats = setOf(BarcodeFormat.QR_CODE),
tryHarder = true,
tryRotate = true,
tryInvert = true,
)
}
override fun analyze(image: ImageProxy) {
try {
reader.read(image).firstOrNull()?.text?.let(onDecoded)
} finally {
image.close()
}
}
```
Keep `com.google.zxing:core` for now regardless: the still-image path
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
`prewarmQrScanner` both use it, and neither is on the hot path.
## Decision trigger
Action this only if field testing shows the current pipeline still failing the
**move-to-the-code** case — open the scanner pointing at nothing, then bring it
to a QR at a normal hand-held distance. If that reads within about a second in
ordinary room light, the Java decoder is doing its job and this stays on the
shelf.