Files
amethyst/tools/zxing-cpp-build/build-zxingcpp.sh
Claude f70205a5d4 build(qr): guard libzxingcpp_android.so like libarti_android.so
PR #4146 committed libzxingcpp_android.so for all four ABIs but landed it
outside both native-library guards main had just built for Arti, so two
properties the QR decoder's README claims are not actually true of the
APK:

* verifyArtiAbis only ever looked for libarti_android.so. An ABI split
  missing libzxingcpp_android.so — or holding a truncated one, or arm64's
  copied into x86/ — builds and installs clean, and the scanner then fails
  to load on that architecture with nothing in the build to catch it.
  Renamed verifyNativeAbis and driven from a map of committed libraries,
  so each one is checked on every shipped ABI and the error names the
  build command to re-run.

* keepDebugSymbols excluded only libarti_android.so, so AGP's llvm-strip
  pass rewrites the QR library on its way into the APK. That makes
  `unzip -p app.apk lib/<abi>/libzxingcpp_android.so | sha256sum` a
  function of whoever built the APK rather than of the committed bytes,
  which is exactly the comparison tools/zxing-cpp-build exists to make
  possible.

The same PR also added tools/zxing-cpp-build/ANDROID_NDK_VERSION as a
second copy of the NDK pin. c2071ee82c had just made :amethyst read
ndkVersion straight from tools/arti-build/ANDROID_NDK_VERSION rather than
duplicate it ("the two can then never drift"), and ea8326f759 deleted
verify-reproducible.sh's private copy of the ABI list for the same
reason. build-zxingcpp.sh now reads that one file too and the copy is
gone, so a bump moves both native builds and the strip toolchain
together.

Docs follow: BUILDING.md still described Arti as the only committed .so
and named verifyArtiAbis, as did tools/arti-build/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX
2026-09-18 22:03:47 +00:00

244 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Build libzxingcpp_android.so from zxing-cpp source, reproducibly.
#
# Replaces the prebuilt io.github.zxing-cpp:android AAR, whose four .so files
# were built by a third party on unknown toolchains and which nothing in this
# tree could verify. Same standard tools/arti-build holds libarti_android.so
# to: pinned NDK, pinned source, canonical build path, verifiable bytes.
#
# Usage:
# ./build-zxingcpp.sh # every ABI
# ./build-zxingcpp.sh --abi arm64-v8a # one ABI (faster)
# ./build-zxingcpp.sh --out DIR # write .so somewhere else (verify uses this)
#
# Prerequisites: git, cmake, ninja, and the exact NDK revision in
# tools/arti-build/ANDROID_NDK_VERSION. Any other revision is refused — it
# would change the output bytes, which is the whole point.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ZXING_VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ZXING_CPP_VERSION")"
# One pin for the whole repo, deliberately not a copy of our own. :amethyst
# already reads this same file for `ndkVersion` (the NDK that strips whatever
# lands in src/main/jniLibs), so a second copy here could only ever be a way
# to disagree with the toolchain that packages the .so we produce — the exact
# byte-level drift the pin exists to prevent. Bumping it rebuilds both
# libarti_android.so and libzxingcpp_android.so, which is correct: they must
# be built and stripped by one NDK.
NDK_VERSION="$(tr -d '[:space:]' < "$PROJECT_ROOT/tools/arti-build/ANDROID_NDK_VERSION")"
# Canonical build path. Codegen and link ordering can key on the real build
# directory even with path remapping in place, so everyone builds here or
# nobody's bytes match. Overriding it changes the output; only do that if you
# do not care about matching the published .so.
BUILD_ROOT="${ZXING_REPRO_DIR:-/tmp/amethyst-zxingcpp-build}"
SOURCE_DIR="$BUILD_ROOT/.zxing-cpp-source"
OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs"
LIB_NAME="libzxingcpp_android.so"
MIN_SDK_VERSION=26
# Every ABI the app splits on. A QR scanner that does not load is a broken core
# feature — the decoder this replaced was pure Java and worked everywhere — so
# every split gets one, and :amethyst's verifyNativeAbis fails the build if one
# is missing or built for the wrong architecture.
ABIS=(arm64-v8a armeabi-v7a x86 x86_64)
while [ $# -gt 0 ]; do
case "$1" in
--abi) ABIS=("$2"); shift 2 ;;
--out) OUTPUT_DIR="$2"; shift 2 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
info() { printf '\033[0;34m==>\033[0m %s\n' "$1"; }
ok() { printf '\033[0;32m ok\033[0m %s\n' "$1"; }
fail() { printf '\033[0;31merror:\033[0m %s\n' "$1" >&2; exit 1; }
# ---------------------------------------------------------------------------
# NDK: find the pinned revision, refuse anything else
# ---------------------------------------------------------------------------
ndk_revision() {
sed -n 's/^Pkg\.Revision *= *//p' "$1/source.properties" 2>/dev/null | tr -d '[:space:]' || true
}
find_ndk() {
# No wildcards. An exported ANDROID_NDK_HOME is only a hint: CI images and
# IDE installs routinely point it at a bundled NDK that is not ours, so each
# candidate is checked against source.properties and skipped when it does
# not match. Picking "some NDK" is exactly how arti's committed binaries
# ended up built by r25b while its docs asked for r27.
local candidate revision rejected=""
for candidate in \
"${ANDROID_NDK_HOME:-}" \
"${ANDROID_NDK_ROOT:-}" \
"${ANDROID_HOME:-}/ndk/$NDK_VERSION" \
"${ANDROID_SDK_ROOT:-}/ndk/$NDK_VERSION" \
"${HOME:-}/Android/Sdk/ndk/$NDK_VERSION" \
"${HOME:-}/Library/Android/sdk/ndk/$NDK_VERSION" \
"/usr/local/lib/android/sdk/ndk/$NDK_VERSION"; do
[ -n "$candidate" ] || continue
[ -d "$candidate" ] || continue
revision="$(ndk_revision "$candidate")"
if [ "$revision" = "$NDK_VERSION" ]; then
echo "$candidate"
return 0
fi
[ -n "$revision" ] && rejected="$rejected\n $candidate (r$revision)"
done
printf 'error: NDK %s not found.\n' "$NDK_VERSION" >&2
[ -n "$rejected" ] && printf ' rejected:%b\n' "$rejected" >&2
printf ' install it with: sdkmanager "ndk;%s"\n' "$NDK_VERSION" >&2
return 1
}
# An ELF tool, preferring the pinned NDK's own llvm-* copy so the post-build
# checks work on hosts without binutils (notably macOS).
ndk_tool() {
local name="$1" candidate
for candidate in "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/"llvm-$name"; do
[ -x "$candidate" ] && { echo "$candidate"; return 0; }
done
command -v "$name" 2>/dev/null && return 0
return 1
}
# ---------------------------------------------------------------------------
# Source
# ---------------------------------------------------------------------------
fetch_source() {
if [ ! -d "$SOURCE_DIR/.git" ]; then
info "Cloning zxing-cpp $ZXING_VERSION"
rm -rf "$SOURCE_DIR"
git clone --depth 1 --branch "$ZXING_VERSION" \
https://github.com/zxing-cpp/zxing-cpp.git "$SOURCE_DIR" >/dev/null 2>&1 \
|| fail "could not clone zxing-cpp at $ZXING_VERSION"
else
info "Updating clone to $ZXING_VERSION"
git -C "$SOURCE_DIR" fetch --depth 1 origin tag "$ZXING_VERSION" >/dev/null 2>&1 || true
git -C "$SOURCE_DIR" checkout -q "$ZXING_VERSION"
fi
local head
head="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
ok "zxing-cpp $ZXING_VERSION at $head"
}
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
build_abi() {
local abi="$1"
local build_dir="$BUILD_ROOT/build/$abi"
info "Building $abi"
rm -rf "$build_dir"
mkdir -p "$build_dir"
# The wrapper's own CMakeLists pulls in core/ and sets the flags upstream
# cares about (readers only, hidden visibility, --exclude-libs). Everything
# added here is either reproducibility or the release/strip settings the
# published AAR applies at packaging time rather than in CMake.
cmake -S "$SOURCE_DIR/wrappers/android/zxingcpp/src/main/cpp" -B "$build_dir" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-$MIN_SDK_VERSION" \
-DANDROID_ARM_NEON=ON \
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
-DCMAKE_BUILD_TYPE=Release \
-DZXING_WRITERS=OFF \
-DZXING_READERS=ON \
-DZXING_UNIT_TESTS=OFF \
-DCMAKE_C_FLAGS="$ZXING_REPRO_CFLAGS" \
-DCMAKE_CXX_FLAGS="$ZXING_REPRO_CFLAGS" \
-DCMAKE_SHARED_LINKER_FLAGS="$ZXING_REPRO_LDFLAGS" \
>/dev/null || fail "cmake configure failed for $abi"
cmake --build "$build_dir" --target zxingcpp_android >/dev/null \
|| fail "build failed for $abi"
local built="$build_dir/$LIB_NAME"
[ -f "$built" ] || fail "$LIB_NAME not produced for $abi"
# Strip: the published AAR ships stripped libraries (AGP strips at packaging
# time), so an unstripped one would differ from what the app used to carry
# for no benefit — debug info in a shipped .so helps nobody here.
local strip_tool
strip_tool="$(ndk_tool strip)" || fail "no strip tool found"
"$strip_tool" --strip-unneeded "$built"
mkdir -p "$OUTPUT_DIR/$abi"
cp "$built" "$OUTPUT_DIR/$abi/$LIB_NAME"
verify_abi "$abi" "$OUTPUT_DIR/$abi/$LIB_NAME"
}
# Re-check the NDK stamp in the output rather than trusting that the right NDK
# was selected: this is what catches a toolchain that was picked up despite the
# revision gate (a stale CMake cache, an overriding environment variable).
verify_abi() {
local abi="$1" lib="$2"
local readelf
readelf="$(ndk_tool readelf)" || { ok "$abi (no readelf; stamp not checked)"; return 0; }
# .note.android.ident records the min SDK as a little-endian word followed by the NDK's
# release name and build number as NUL-padded strings, e.g. `1a 00 00 00 r30 16248370`.
# readelf prints it as raw hex, so the strings are recovered from that rather than grepped
# for directly -- min SDK 26 is the byte 0x1a and never appears as the text "26".
local hex ascii
hex="$("$readelf" --notes "$lib" 2>/dev/null | sed -n 's/.*description data: *//p' | head -1)"
[ -n "$hex" ] || fail "$abi: no .note.android.ident -- not an Android library?"
ascii="$(printf '%s' "$hex" | tr -d ' ' | sed 's/../\\x&/g' | xargs -0 printf 2>/dev/null | tr -c '[:print:]' ' ')"
# The build number is everything after the last dot of the pinned revision.
local ndk_build_number="${NDK_VERSION##*.}"
printf '%s' "$ascii" | grep -q "$ndk_build_number" \
|| fail "$abi: built by the wrong NDK -- .note.android.ident has no build number $ndk_build_number (got: $ascii)"
# First word, little-endian, is the min SDK the library targets.
local min_sdk_hex min_sdk
min_sdk_hex="$(printf '%s' "$hex" | awk '{printf "%s%s%s%s", $4, $3, $2, $1}')"
min_sdk=$((16#$min_sdk_hex))
[ "$min_sdk" = "$MIN_SDK_VERSION" ] \
|| fail "$abi: targets minSdk $min_sdk, expected $MIN_SDK_VERSION"
local size
size="$(wc -c < "$lib" | tr -d '[:space:]')"
ok "$abi minSdk $min_sdk NDK $ndk_build_number ${size} bytes"
}
# ---------------------------------------------------------------------------
main() {
command -v cmake >/dev/null || fail "cmake not found"
command -v ninja >/dev/null || fail "ninja not found"
command -v git >/dev/null || fail "git not found"
ANDROID_NDK_HOME="$(find_ndk)" || exit 1
export ANDROID_NDK_HOME
ok "NDK $NDK_VERSION at $ANDROID_NDK_HOME"
mkdir -p "$BUILD_ROOT"
fetch_source
# shellcheck source=repro-env.sh
. "$SCRIPT_DIR/repro-env.sh"
ok "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-unset}"
for abi in "${ABIS[@]}"; do
build_abi "$abi"
done
info "Done. Libraries in $OUTPUT_DIR"
}
main "$@"