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 (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
|
|
|
|
|
|
|
2026-08-31 13:06:45 +01:00
|
|
|
|
> 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.
|
2026-08-12 10:55:50 +00:00
|
|
|
|
|
|
|
|
|
|
`Android/app/build.gradle.kts`:
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
// Replaces com.google.zxing:core for the live-camera path.
|
2026-08-31 13:06:45 +01:00
|
|
|
|
implementation("io.github.zxing-cpp:android:3.1.1")
|
2026-08-12 10:55:50 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`QrCodeAnalyzer` collapses to roughly:
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
2026-08-31 13:06:45 +01:00
|
|
|
|
private val reader = BarcodeReader(
|
2026-08-12 10:55:50 +00:00
|
|
|
|
options = BarcodeReader.Options(
|
2026-08-31 13:06:45 +01:00
|
|
|
|
formats = setOf(BarcodeReader.Format.QR_CODE),
|
2026-08-12 10:55:50 +00:00
|
|
|
|
tryHarder = true,
|
|
|
|
|
|
tryRotate = true,
|
|
|
|
|
|
tryInvert = true,
|
|
|
|
|
|
)
|
2026-08-31 13:06:45 +01:00
|
|
|
|
)
|
2026-08-12 10:55:50 +00:00
|
|
|
|
|
|
|
|
|
|
override fun analyze(image: ImageProxy) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
reader.read(image).firstOrNull()?.text?.let(onDecoded)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
image.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-31 13:06:45 +01:00
|
|
|
|
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).
|
|
|
|
|
|
|
2026-08-12 10:55:50 +00:00
|
|
|
|
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.
|