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

6.2 KiB
Raw Permalink Blame History

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 (web + native survey, 2026-07-29), 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:

// Replaces com.google.zxing:core for the live-camera path.
implementation("io.github.zxing-cpp:android:3.1.1")

QrCodeAnalyzer collapses to roughly:

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.