We were shipping a QR decoder we could not verify. io.github.zxing-cpp:android ships four .so files built by a third party on toolchains we cannot see, and nothing in this tree could check them -- while tools/arti-build holds libarti_android.so to a pinned-NDK, canonical-path, byte-for-byte reproducible standard. A QR scanner is a thing you point at a stranger's phone; there was no principled reason for the binary that parses the result to be the exempt one. tools/zxing-cpp-build mirrors tools/arti-build: the NDK revision is pinned (and is deliberately the same revision arti pins, so one install serves both), the upstream tag is pinned and cloned at that tag only, absolute paths are remapped, SOURCE_DATE_EPOCH comes from the tag's commit rather than from build time, and everyone builds at the same canonical path. Verified, not asserted: two clean builds of arm64-v8a produced identical bytes (dec4397c3e2f1e482b1905119dd4ea9e285f3420ffe4e66f38b2d22f54639dbf) and verify-reproducible.sh confirms they match what is committed. NDK discovery reads each candidate's source.properties and refuses anything but the pinned revision -- no wildcards, borrowing arti's r25b-vs-r27 lesson rather than re-learning it. After each build the script decodes the library's own .note.android.ident and fails unless the min SDK and NDK build number are what was asked for: the gate checks the input toolchain, the stamp checks the output, and only the second catches a stale CMake cache slipping a different compiler past the first. All four ABIs, unlike arti's two. Tor is optional and can be absent; a scanner that fails to load is a broken core feature, and what this replaced was pure Java that worked everywhere. Dropping the AAR means carrying the two things it supplied besides the binary: - Its Kotlin half, vendored verbatim at src/main/java/zxingcpp. The package and class name are load-bearing -- the library exports Java_zxingcpp_BarcodeReader_readYBuffer -- so it keeps both, its upstream Apache-2.0 header, and an exclusion from spotless so our MIT header is never stamped onto someone else's file. - Its consumer ProGuard rule. Without -keep class zxingcpp.**, R8 renames the class and the scanner fails to start in release builds only, with no build error and no warning. Not a size change: the published AAR's libraries are already stripped, and ours come out only marginally smaller. This buys verifiability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX
zxing-cpp Android Build Tools
Custom-built zxing-cpp native library for Amethyst's QR
scanner. This replaces the prebuilt io.github.zxing-cpp:android AAR with the same library built
from source, the way tools/arti-build replaces the Guardian Project AAR.
Why custom build?
Not for size — the published AAR's libraries are already stripped, and ours come out only slightly smaller. For verifiability.
The AAR ships four .so files built by a third party on toolchains we cannot see, and nothing in
this repository could check them. A QR scanner is a thing you point at a stranger's phone, and the
binary that parses whatever comes back was one we took on trust. Amethyst already holds
libarti_android.so to a reproducible standard; there was no principled reason for the barcode
decoder to be exempt.
Building it ourselves also means F-Droid, Zapstore or any auditor can rebuild from this tag and confirm the committed bytes, instead of being asked to trust a Maven artifact.
Quick start
Pre-built .so files are committed to amethyst/src/main/jniLibs/. You only need to rebuild to
verify the binaries, bump the zxing-cpp version, or change the build flags.
./build-zxingcpp.sh # every ABI
./build-zxingcpp.sh --abi arm64-v8a # one ABI, much faster
./verify-reproducible.sh # build twice, compare bytes, compare against the commit
Prerequisites: git, cmake, ninja, and the exact NDK revision in
ANDROID_NDK_VERSION — which is deliberately the same revision
tools/arti-build pins, so one NDK install serves both native builds.
Reproducible builds
Five things have to be fixed, and each is:
| Source of non-determinism | Pinned by |
|---|---|
| compiler + linker version | ANDROID_NDK_VERSION; build-zxingcpp.sh refuses any other revision |
| upstream source | ZXING_CPP_VERSION, cloned at that tag and nothing else |
absolute paths baked into __FILE__, assertions, debug records |
-ffile-prefix-map / -fdebug-prefix-map in repro-env.sh |
| timestamps | SOURCE_DATE_EPOCH, derived from the pinned tag's commit rather than from build time; __DATE__/__TIME__ redacted |
| codegen/link ordering keyed on the real build path | canonical build path (/tmp/amethyst-zxingcpp-build) |
Why the NDK is pinned. clang compiles the C++ and lld links the
.so, and both stamp their versions into the binary's.commentsection. Swapping the NDK changes the bytes exactly as swapping compilers would.build-zxingcpp.shreads each candidate directory'ssource.propertiesand keeps looking until it finds the pinned revision — no wildcards. Picking "some NDK" is precisely how arti's committed binaries ended up built by r25b while its README told everyone to install r27; that lesson is borrowed here rather than re-learned.
Why the output is re-checked. After each build the script decodes the library's own
.note.android.ident, which records the min SDK and the NDK release and build number, and fails if they are not what was asked for. The revision gate checks the input; this checks the output, which is what catches a stale CMake cache or an overriding environment variable that slipped a different toolchain past the gate.
Verified on this machine: two clean builds of arm64-v8a produced identical bytes
(dec4397c…39dbf), and verify-reproducible.sh confirmed they match the committed library.
What is built
wrappers/android/zxingcpp/src/main/cpp/CMakeLists.txt from the pinned tag, which pulls in
zxing-cpp's core/ and compiles one JNI file. Readers only (ZXING_WRITERS=OFF) — Amethyst
encodes its QR codes with ZXing core on the JVM, and never writes one natively.
All four ABIs, unlike arti's two. Tor is an optional feature that can be absent; a QR scanner that
fails to load is a broken core feature, and the decoder this replaced was pure Java and worked
everywhere. Dropping an ABI would mean a silent regression on those devices unless the ZXing-Java
fallback behind BarcodeDecoder were wired up too.
The Kotlin half
The AAR shipped the zxingcpp.BarcodeReader class alongside the .so, so dropping the AAR means
carrying that too: it is vendored verbatim at amethyst/src/main/java/zxingcpp/BarcodeReader.kt.
Two things there are load-bearing and easy to break:
- The package and class name. The native library exports
Java_zxingcpp_BarcodeReader_readYBuffer, so moving or renaming the class breaks the JNI lookup at runtime with no build error. -keep class zxingcpp.** { *; }inamethyst/proguard-rules.pro. This used to arrive as the AAR's consumer rule. Without it R8 renames the class in release builds and the scanner fails to start — in release only, which is the worst place to find out.
The vendored file keeps its upstream Apache-2.0 header and is excluded from spotless in the root
build.gradle.kts, so our MIT header is never stamped onto it.
Updating zxing-cpp
- Change
ZXING_CPP_VERSIONto the new tag. - Run
./build-zxingcpp.sh. - Re-copy
BarcodeReader.ktfrom the pinned clone (/tmp/amethyst-zxingcpp-build/.zxing-cpp-source/wrappers/android/zxingcpp/src/main/java/zxingcpp/) so the two halves cannot drift — a Kotlin wrapper from one version against a.sofrom another fails at the JNI boundary, not at compile time. - Run
./verify-reproducible.shand commit the.sofiles with the hash it reports.
Licensing
zxing-cpp is Apache-2.0 — permissive, so linking it into Amethyst's MIT-licensed artifacts is
fine, and no linking-exception question arises. The vendored Kotlin file carries its upstream
SPDX-License-Identifier: Apache-2.0 and copyright line unchanged.