Files
archy/docs/companion-qr-decoder-zxing-cpp.md
Dorian 12c853da45 docs(companion): verify the zxing-cpp integration sketch online
The QR-decoder option doc was written on an offline machine with the
Maven coordinates and wrapper API flagged as from-memory. Verified
against Maven Central + the wrapper source: artifact is
io.github.zxing-cpp:android:3.1.1 (current release), Format.QR_CODE is
nested inside BarcodeReader (not a top-level BarcodeFormat), options are
a constructor-argument data class, and read(ImageProxy) handles the
Y-plane/cropRect/rotation itself. Sketch updated accordingly; the option
itself stays NOT-actioned pending the move-to-the-code decision trigger.
2026-08-31 13:06:45 +01:00

144 lines
6.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (150–300 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 5–10× 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 ~1–2 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
> Verified 2026-08-31 against Maven Central and the wrapper source
> (`wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt` at
> `io.github.zxing-cpp:android:3.1.1`, the current release). Coordinates and
> API below are what the published artifact actually ships.
`Android/app/build.gradle.kts`:
```kotlin
// Replaces com.google.zxing:core for the live-camera path.
implementation("io.github.zxing-cpp:android:3.1.1")
```
`QrCodeAnalyzer` collapses to roughly:
```kotlin
private val reader = BarcodeReader(
options = BarcodeReader.Options(
formats = setOf(BarcodeReader.Format.QR_CODE),
tryHarder = true,
tryRotate = true,
tryInvert = true,
)
)
override fun analyze(image: ImageProxy) {
try {
reader.read(image).firstOrNull()?.text?.let(onDecoded)
} finally {
image.close()
}
}
```
API notes from the published wrapper: `BarcodeReader.read(ImageProxy)` takes
the CameraX `YUV_420_888` frame directly (it reads the Y plane + cropRect +
rotation itself — the manual crop/copy machinery really can go); options are
one constructor-argument data class; `Format.QR_CODE` is nested inside
`BarcodeReader` (not a top-level `BarcodeFormat`); results carry `text`,
`contentType`, `position` — and `lastReadTime` gives the per-call decode time
in ms, useful to measure the claimed 5–10× while evaluating. Keep
`com.google.zxing:core` for the still-image path regardless (below).
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.