135 lines
5.6 KiB
Markdown
135 lines
5.6 KiB
Markdown
# 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
|
|||
|
|
|
|||
|
|
> ⚠️ 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.
|