From c2071ee82c12e3b3f2cd1ce00da013ba233735db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 14:58:21 +0000 Subject: [PATCH 1/2] build: pin the app's packaging NDK to the Arti revision AGP runs the NDK's llvm-strip over everything merged into jniLibs, so the NDK is an APK input and not just a concern for whoever compiles Arti. No module set ndkVersion, so the app silently used AGP's own default -- 28.2.13676358 on AGP 9.4.0 ("Because no explicit NDK was requested, the default version [...] for this Android Gradle Plugin will be used") -- while tools/arti-build/ANDROID_NDK_VERSION pins 30.0.16248370 and the committed libarti_android.so are stamped r30/16248370 in .note.android.ident. The library was built with r30 and stripped with r28, and on a machine with no NDK installed it shipped unstripped instead: three different APKs from one source tree, which is what F-Droid's rebuild verification cannot have. The default also moves with every AGP bump. :amethyst now reads ndkVersion straight from ANDROID_NDK_VERSION rather than duplicating it into the version catalog, so the packaging toolchain and the reproducibility pin cannot drift: bumping the pin (which already means rebuilding the .so) moves both. Verified by reflecting the resolved android.ndkVersion out of a configured build: :amethyst = 30.0.16248370. The other Android modules (:benchmark, :baselineprofile, :nappletHost) keep AGP's default -- none of them package native libraries, so no strip step there ever resolves an NDK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ck919Y21reMU6LVrev1QJo --- BUILDING.md | 9 +++++++++ amethyst/build.gradle.kts | 23 +++++++++++++++++++++++ tools/arti-build/README.md | 8 ++++++++ 3 files changed, 40 insertions(+) diff --git a/BUILDING.md b/BUILDING.md index 7d323ba5ab..75e494c156 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -109,6 +109,15 @@ Rust toolchain + the exact Android NDK revision pinned in `tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs — they are **not** required to build Amethyst from the committed sources. +The NDK half of that Arti pin does reach the ordinary Android build, though: +`:amethyst` sets `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP strips +the committed `.so` with the NDK's `llvm-strip` while packaging the APK. The +Rust toolchain stays irrelevant, but Studio/AGP will fetch that NDK revision +(`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"` to pre-install +it). Without any NDK the build still succeeds — AGP warns and packages the +library unstripped, which is fine to run but no longer byte-comparable to a +release APK. + --- ## Per-format build commands diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index d1b79c98ff..0a5e8778a2 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -74,6 +74,29 @@ android { .get() .toInt() + // Packaging toolchain: AGP runs the NDK's llvm-strip over everything that + // lands in jniLibs — our committed libarti_android.so included — so the + // NDK revision is a build input for the APK, not just for whoever compiles + // Arti. Left unset it silently follows AGP's own default (28.2.13676358 on + // AGP 9.4.0), which moves with every AGP bump and is a different toolchain + // from the one that produced the .so, while a machine with no NDK at all + // packages the library unstripped ("Unable to strip the following + // libraries") — three different APKs from the same source, which is + // exactly what F-Droid's rebuild verification cannot have. + // + // Read straight from the Arti pin rather than copied into the version + // catalog: the two can then never drift, and bumping ANDROID_NDK_VERSION + // (which also means rebuilding the .so) moves the packaging toolchain with + // it. See tools/arti-build/README.md → "Reproducible builds". + ndkVersion = + providers + .fileContents(layout.settingsDirectory.file("tools/arti-build/ANDROID_NDK_VERSION")) + .asText + .orNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: error("tools/arti-build/ANDROID_NDK_VERSION is missing or empty — it pins the NDK that strips src/main/jniLibs") + defaultConfig { applicationId = "com.vitorpamplona.amethyst" minSdk = diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 492724db5e..d6322e7a6b 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -49,6 +49,14 @@ committed binary wasn't tampered with. **Five** things have to be fixed: > pinned output was verified with. `cargo-ndk` only wraps the NDK, so a mismatch > is a warning rather than an error — but it is the next thing to check if your > rebuild does not match. +> +> **The app build reads this pin too.** `amethyst/build.gradle.kts` sets +> `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP runs the NDK's +> `llvm-strip` over `src/main/jniLibs/` while packaging — the toolchain that +> strips the library is as much an APK input as the one that compiled it. Unset, +> `ndkVersion` follows AGP's own default (r28 on AGP 9.4.0) and moves with every +> AGP bump. So bumping this file changes what packagers need installed, not only +> what rebuilders need: bump it, rebuild the `.so`, and commit both. `repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0` and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized From 0fc57ef6bec4936cd8fbc0cd8c27d13056f61b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:58:39 +0000 Subject: [PATCH 2/2] build: ship libarti_android.so past AGP's strip step The Cargo release profile already strips this library -- readelf shows no .symtab and no .debug_* -- so AGP's `llvm-strip --strip-unneeded` pass has nothing to remove. It still rewrites the file: llvm-strip rebuilds .comment, the section holding the rustc / clang / lld version stamps, which changes 273 bytes on arm64-v8a (verified by running the same command over the committed .so). That made the packaged bytes a function of whichever llvm-strip ran, so the library inside an APK could never be compared against the committed one that tools/arti-build reproduces byte-for-byte. packaging.jniLibs.keepDebugSymbols now excludes it from that step. No size cost, since there are no symbols left to drop, and unzip -p app.apk lib/arm64-v8a/libarti_android.so | sha256sum can now be checked straight against src/main/jniLibs. Dependency .so files are still stripped, by the NDK pinned in the previous commit -- which is why that pin stays. Verified by reflecting the configured DSL out of the build: :amethyst ndkVersion = 30.0.16248370, keepDebugSymbols = [**/libarti_android.so]. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ck919Y21reMU6LVrev1QJo --- BUILDING.md | 19 +++++++++++-------- amethyst/build.gradle.kts | 16 ++++++++++++++++ tools/arti-build/README.md | 14 ++++++++++++-- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/BUILDING.md b/BUILDING.md index 75e494c156..0a121497a0 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -109,14 +109,17 @@ Rust toolchain + the exact Android NDK revision pinned in `tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs — they are **not** required to build Amethyst from the committed sources. -The NDK half of that Arti pin does reach the ordinary Android build, though: -`:amethyst` sets `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP strips -the committed `.so` with the NDK's `llvm-strip` while packaging the APK. The -Rust toolchain stays irrelevant, but Studio/AGP will fetch that NDK revision -(`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"` to pre-install -it). Without any NDK the build still succeeds — AGP warns and packages the -library unstripped, which is fine to run but no longer byte-comparable to a -release APK. +The NDK half of that Arti pin does reach the ordinary Android build, though. +AGP strips every native library it packages with the NDK's `llvm-strip`, so +`:amethyst` sets `ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the +libraries we build and the ones we merge from dependencies. `libarti_android.so` +is then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`): +the Cargo release profile already stripped it, and llvm-strip would only rewrite +its `.comment` stamps, so skipping the pass costs no size and lets the `.so` +inside an APK be compared byte-for-byte against the committed, independently +reproducible one. The Rust toolchain stays irrelevant either way; Studio/AGP +fetches the pinned NDK on demand, or pre-install it with +`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`. --- diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 0a5e8778a2..4910dd397c 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -318,6 +318,22 @@ android { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib") } + + jniLibs { + // Reproducible builds, part two: ship the Arti library exactly as + // tools/arti-build produced it. Its Cargo release profile already + // strips it (no .symtab, no .debug_*), so AGP's + // `llvm-strip --strip-unneeded` pass has nothing left to remove — but it + // still rewrites the file: llvm-strip rebuilds .comment, the section that + // records the rustc / clang / lld version stamps, which measurably changes + // 273 bytes on arm64-v8a. That made the packaged bytes a function of + // whichever NDK did the stripping, so the .so in an APK could never be + // compared against the committed, independently reproducible one. + // Excluding it from the strip step costs nothing in size (there are no + // symbols to drop) and makes that comparison exact. Dependency .so files + // are still stripped, with the NDK pinned by ndkVersion above. + keepDebugSymbols += "**/libarti_android.so" + } } lint { diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index d6322e7a6b..8305992d0d 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -52,11 +52,21 @@ committed binary wasn't tampered with. **Five** things have to be fixed: > > **The app build reads this pin too.** `amethyst/build.gradle.kts` sets > `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP runs the NDK's -> `llvm-strip` over `src/main/jniLibs/` while packaging — the toolchain that -> strips the library is as much an APK input as the one that compiled it. Unset, +> `llvm-strip` over every native library it packages — the toolchain that strips +> a library is as much an APK input as the one that compiled it. Unset, > `ndkVersion` follows AGP's own default (r28 on AGP 9.4.0) and moves with every > AGP bump. So bumping this file changes what packagers need installed, not only > what rebuilders need: bump it, rebuild the `.so`, and commit both. +> +> **`libarti_android.so` skips that strip step.** The release profile here +> already strips it — no `.symtab`, no `.debug_*` — so `llvm-strip +> --strip-unneeded` has nothing to remove and only rebuilds `.comment`, the +> section carrying the rustc/clang/lld stamps (273 bytes change on arm64-v8a). +> `packaging.jniLibs.keepDebugSymbols` in `amethyst/build.gradle.kts` therefore +> excludes it, which costs no APK size and means the library inside a built APK +> is byte-identical to the one committed in `src/main/jniLibs/`: `unzip -p +> app.apk lib/arm64-v8a/libarti_android.so | sha256sum` can be checked straight +> against the file this script reproduces. `repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0` and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized