Skip to content

feat(android): replace ML Kit with zxing-cpp - #62

Merged
ismai117 merged 4 commits into
ismai117:mainfrom
wodoro:feat/zxing-cpp-android
Sep 14, 2026
Merged

ismai117 merged 4 commits into
ismai117:mainfrom
wodoro:feat/zxing-cpp-android

Conversation

@wodoro

@wodoro wodoro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #60.

Android now decodes with io.github.zxing-cpp:android (Apache 2.0) instead of
com.google.mlkit:barcode-scanning, so every dependency is open source and KScan
can be used in apps published on F-Droid.

Two commits, no new modules. The measurement that justifies it, the corpus generator and the full per-frame data live in #61, which is kept only as the record of how the decision was made; this one is
just the change.

What it costs and what it buys

Measured over 468 generated frames, all thirteen formats KScan exposes across
distance, in- and out-of-plane rotation, lighting, contrast, defocus and noise,
on an API 37 x86_64 emulator:

ML Kit zxing-cpp
Read correctly 89.1% 79.9%
Misread 0.6% 0.9%
Median decode 60.7 ms 8.1 ms

9.2 points worse overall, ~7x faster, and 14.6 MB smaller (sample debug APK
44.3 MB -> 29.7 MB; ML Kit's bundled libbarhopper_v3.so is 20.2 MB across four
ABIs against zxing-cpp's 6.6 MB).

The average hides the shape. zxing-cpp is ahead on QR, Data Matrix and Aztec
(94.4% against 89.6%), on inverted and coloured symbols, and dramatically on small
ones: where the symbol spans 12% of the frame ML Kit reads 23.1% and zxing-cpp
100%. It is behind on one thing, linear barcodes rotated off axis, which
accounts for 47 of the 72 frames it lost. Right angles are fine; 15 degrees costs
it, 45 costs it badly. Excluding those conditions it is 1.0 point ahead overall.

Replaying every scene as a second of hand-held video rather than a single frame
does not rescue it: sensor noise turns out to be entirely transient (both reach
100%), but the rotation loss is persistent. A user holding a linear barcode at 30
degrees will watch it fail to scan. That is the honest cost of the switch.

Breaking

  • ScannerView's autoZoom is removed. It was ML Kit's
    ZoomSuggestionOptions, which zxing-cpp has no equivalent for, and it never did
    anything on iOS, desktop or web. ScannerController still drives zoom. Callers
    passing arguments positionally should note that result moves up a place. ABI
    dumps are regenerated and the sample's Auto zoom toggle goes with it.

0.10.0 is already an unreleased breaking version, so this rides along with the
existing ScannerView changes rather than adding a second migration.

Behaviour worth knowing

Decoding moved off the main thread. zxing-cpp decodes on the thread that calls
it, so analysis runs on a thread of the library's own. filter and result are
still called on the main thread, unchanged.

FrameInverter had to stay. zxing-cpp's tryInvert is only offered to readers
declaring support for reversed reflectance, which is QR, Data Matrix and Aztec but
never a linear one or PDF417 (MultiFormatReader skips readers without
supportsInversion on an inverted image). Dropping the explicit negative pass
would have silently lost inverted linear barcodes, which work today. It now
produces an eight bit alpha bitmap that zxing-cpp reads as a luminance plane, so
it costs one byte per pixel rather than four.

Misread profile differs. zxing-cpp's four misreads are all truncated ITF, which
has no mandatory check digit; it returned 1234 for 1234567890 on rotated or
tilted frames. RepeatedDetection requires the same value on two frames before
reporting, so real exposure is lower than the single-frame 0.9%, but
validateOptionalChecksum could be exposed if you want it stricter.

Dependency audit

io.github.zxing-cpp:android:3.1.1, checked before adopting:

  • SHA-1/256/512 of the AAR match Maven Central's published checksums; .asc
    signature present; release cut by the upstream maintainer.
  • Upstream is Apache-2.0, active since 2016, ~2k stars.
  • The AAR manifest declares no permissions and no services, receivers or
    providers.
  • The native library links only libc, libm, libdl, liblog and
    libjnigraphics. No socket, connect, getaddrinfo, exec, fork, popen,
    system or ptrace in its import table, and no URLs in its strings. It exports
    exactly two JNI entry points, readBitmap and readYBuffer.
  • Transitive dependencies are androidx.camera:camera-core and kotlin-stdlib.
    No Play Services.

Also in here

The pre-commit hook called ./tools/ktlint, which is no longer in the tree, so
committing a Kotlin file failed. It now calls spotlessApply, which is where the
ktlint version, the Compose rule set and the editorconfig overrides already live
and what CI checks.

The hook shelled out to ./tools/ktlint, which is no longer in the tree, so
committing a Kotlin file failed with "no such file or directory".

Spotless already owns the project's ktlint version, its Compose rule set and
its editorconfig overrides, and CI checks the same configuration through
spotlessCheck. Calling spotlessApply keeps formatting defined in exactly one
place rather than in a checked-in binary that has to be kept in step with it.
com.google.mlkit:barcode-scanning is proprietary, which kept KScan out of apps
published on F-Droid. Every dependency is now open source.

Measured over 468 generated frames covering all thirteen formats at a range of
angles, distances and lighting, on an API 37 x86_64 emulator: ML Kit reads
89.1% of them and zxing-cpp 79.9%, misreading 0.6% and 0.9%. zxing-cpp is ahead
on QR, Data Matrix and Aztec, and on symbols small enough that ML Kit stops
seeing them at all; it is behind on linear symbols rotated off axis, on
strongly uneven lighting and on heavy defocus. It decodes roughly seven times
faster and drops 14.6MB from the sample APK.

The retry options and the local-average binarizer were chosen by measurement
rather than by default: the four retries are worth 12.2 points of read rate, a
global histogram binarizer costs 2.8, and accepting a single scan line leaves
the read rate where it is while raising the misread rate to 1.5%. tryDenoise
reaches the native side but changed no outcome, so it is left off.

FrameInverter stays, and now produces an eight bit alpha bitmap that zxing-cpp
reads as a luminance plane. zxing-cpp's own tryInvert is offered only to readers
that declare support for reversed reflectance, which is QR, Data Matrix and
Aztec but never a linear one, so a light-on-dark barcode still needs the
negative handing to it explicitly.

zxing-cpp decodes on the thread that calls it, so analysis moves off the main
thread onto one of the library's own. The analyzer hands its results back
through an executor, which keeps a caller's filter and result on the main thread
where they have always been called, and confines the repeat counting and the
accept decision to that thread.

BREAKING CHANGE: ScannerView's autoZoom is removed. It was backed by ML Kit's
zoom suggestions, which zxing-cpp has no equivalent for, and it never did
anything on iOS, desktop or web. ScannerController still drives zoom. Callers
passing it positionally should note that result has moved up a place.

Closes ismai117#60
Naming TYPE_UNKNOWN in codeTypes matched everything. It maps to no platform
format, and both decoders read an empty format set as no restriction at all, so
zxing-cpp would decode its full repertoire, DataBar and MaxiCode and the rest,
map each to TYPE_UNKNOWN for want of a name, and find that sitting in the
requested list. Desktop reaches the same state by leaving POSSIBLE_FORMATS
unset.

ML Kit never showed this because its repertoire is exactly the thirteen formats
KScan maps, so nothing it decoded was ever unnamed.

isRequestedFormat now rejects TYPE_UNKNOWN however it was asked for, which is
what the wildcard path already did, so the two agree. Every platform's scan
path funnels through it or through firstMatching, so the fix is not Android's
alone.
@ismai117

Copy link
Copy Markdown
Owner

Great! @wodoro i will test this out on my side

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Replaces Android ML Kit barcode decoding with open-source zxing-cpp while preserving KScan’s multiplatform API aside from removing autoZoom.

Changes:

  • Added zxing-cpp decoding, format mapping, inversion, and background camera analysis.
  • Removed ScannerView.autoZoom and updated samples, APIs, documentation, and changelog.
  • Updated dependencies, ProGuard rules, tests, and pre-commit formatting.
File summaries
File Description
sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/settings/SettingsScreen.kt Updated as part of this pull request.
sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/scanner/ScannerState.kt Updated as part of this pull request.
sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/scanner/ScannerScreen.kt Updated as part of this pull request.
README.md Updated as part of this pull request.
kscan/src/webMain/kotlin/org/ncgroup/kscan/view/ScannerViewImpl.web.kt Updated as part of this pull request.
kscan/src/jvmMain/kotlin/org/ncgroup/kscan/view/ScannerViewImpl.jvm.kt Updated as part of this pull request.
kscan/src/iosMain/kotlin/org/ncgroup/kscan/view/ScannerViewImpl.ios.kt Updated as part of this pull request.
kscan/src/commonTest/kotlin/org/ncgroup/kscan/format/BarcodeSelectionTest.kt Updated as part of this pull request.
kscan/src/commonMain/kotlin/org/ncgroup/kscan/view/ScannerViewImpl.kt Updated as part of this pull request.
kscan/src/commonMain/kotlin/org/ncgroup/kscan/ScannerView.kt Updated as part of this pull request.
kscan/src/commonMain/kotlin/org/ncgroup/kscan/format/BarcodeSelection.kt Updated as part of this pull request.
kscan/src/commonMain/kotlin/org/ncgroup/kscan/BarcodeFormat.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/view/ScannerViewImpl.android.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/scanner/ZxingCppBarcode.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/scanner/ScannerOptions.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/scanner/FrameInverter.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/scanner/BarcodeAnalyzer.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt Updated as part of this pull request.
kscan/src/androidMain/kotlin/org/ncgroup/kscan/format/BarcodeFormatMapper.kt Updated as part of this pull request.
kscan/src/androidHostTest/kotlin/org/ncgroup/kscan/scanner/InvertLuminanceTest.kt Updated as part of this pull request.
kscan/src/androidHostTest/kotlin/org/ncgroup/kscan/format/BarcodeFormatMapperTest.kt Updated as part of this pull request.
kscan/build.gradle.kts Updated as part of this pull request.
kscan/api/kscan.klib.api Updated as part of this pull request.
kscan/api/jvm/kscan.api Updated as part of this pull request.
gradle/libs.versions.toml Updated as part of this pull request.
consumer-rules.pro Updated as part of this pull request.
CHANGELOG.md Updated as part of this pull request.
.hooks/pre-commit Updated as part of this pull request.
Review details

Suppressed comments (3)

.hooks/pre-commit:22

  • git add "$file" stages the entire file after spotlessApply. If a Kotlin file has both staged and unstaged edits, the unstaged edits are silently included in the commit; formatting the whole tree makes this easier to encounter. Preserve the staged patch or avoid re-adding complete files so the hook cannot expand the commit.
echo "$CHANGED_FILES" | while read -r file; do
  if [ -f "$file" ]; then
    git add "$file"

kscan/src/androidMain/kotlin/org/ncgroup/kscan/format/BarcodeFormatMapper.kt:29

  • When codeTypes contains only TYPE_UNKNOWN, this returns an empty set. zxing-cpp treats an empty Options.formats as unrestricted, so the analyzer still performs a full decode (and periodic inversion) only to discard every result, contrary to the new no-match behavior. Short-circuit this request or pass an explicit no-format configuration to avoid the unnecessary work.
    // Named rather than left empty for every format: an empty set asks zxing-cpp
    // for every symbology it knows, including the ones KScan has no name for,
    // which would only be decoded to be reported as TYPE_UNKNOWN and dropped.
    fun toZxingCppFormats(appFormats: List<BarcodeFormat>): Set<BarcodeReader.Format> = formats.platformFormats(appFormats).toSet()

kscan/src/androidMain/kotlin/org/ncgroup/kscan/scanner/BarcodeAnalyzer.kt:53

  • This callback is queued after the proxy has already been closed, so the analysis executor can enqueue a decode failure after an earlier barcode's report callback has already succeeded. Because this guard only checks closed (which is not set until disposal), callers can receive OnFailed after OnSuccess; also suppress failures once hasSuccessfullyProcessedBarcode is true.
                callbackExecutor.execute { if (!closed) onFailed(e) }
  • Files reviewed: 28/28 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt Outdated
Comment thread kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt Outdated
Two things the review caught in scanImage.

It decoded on the thread that called it. ML Kit's task API had kept that off the
main thread and answered on it, so a caller scanning from a picker callback,
which is what the sample does, was newly made to wait for a photograph to be
decoded. It runs on a pool again and posts the outcome to the main thread, as
before.

It also never tried the negative. The camera path does, because zxing-cpp offers
tryInvert only to the readers that allow reversed reflectance and never to a
linear one, so a still photograph of a light-on-dark Code 128 or EAN was missed
where the same barcode through the camera was found. ML Kit did not invert
either, so this is new behaviour rather than a restored regression.

The negative is built as an eight bit luminance bitmap, read a row at a time, so
a large photograph costs one byte a pixel rather than the four a colour copy
would.
@ismai117

Copy link
Copy Markdown
Owner

🚀

@ismai117
ismai117 merged commit a3075ab into ismai117:main Sep 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use open-source lib for barcode scanning

3 participants