diff --git a/BUILDING.md b/BUILDING.md index 7d323ba5ab..60b83b2646 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -109,6 +109,12 @@ 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. +> **One Arti library per ABI split.** The APK is split four ways (`arm64-v8a`, +> `x86_64`, `armeabi-v7a`, `x86`) and every split needs its own +> `libarti_android.so`; a split without one installs and runs with Tor silently +> unavailable. The `verifyArtiAbis` Gradle task fails the build if the two lists +> drift, and names the `build-arti.sh --target=…` to run. + --- ## Per-format build commands diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index d1b79c98ff..1c1a37732f 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -67,6 +67,14 @@ afterEvaluate { } } +// Every ABI we split the APK for, and therefore every ABI that needs its own +// libarti_android.so under src/main/jniLibs/ (see tools/arti-build/). The two +// lists drifted once: the splits shipped four ABIs while Arti was built for two, +// so the armeabi-v7a and x86 APKs installed and ran with the dependencies' native +// libraries all present (secp256k1's JNI ships every ABI) and Tor alone dead for +// the life of the install. `verifyArtiAbis` below keeps them in step. +val shippedAbis = listOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + android { namespace = "com.vitorpamplona.amethyst" compileSdk = @@ -258,7 +266,7 @@ android { abi { isEnable = !disableAbiSplits reset() - include("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + include(*shippedAbis.toTypedArray()) isUniversalApk = !disableUniversalApk } } @@ -305,8 +313,9 @@ android { unitTests.isReturnDefaultValues = true // Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android") // find the desktop-host build of our Arti JNI shim. The Android .so - // variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded - // on-device — this Linux x86_64 .so is just for JVM unit-test runs. + // variants live in src/main/jniLibs// — one per ABI in [shippedAbis] + // — and are loaded on-device; this Linux x86_64 .so is just for JVM + // unit-test runs. // -Pamethyst.arti.integration=true opts the (slow, network-dependent) // tests in; see TorArtiNativeIntegrationTest.kdoc. unitTests.all { test -> @@ -321,6 +330,49 @@ android { } } +// Every ABI split must carry Arti, or it ships an APK that is whole except for +// Tor. Nothing else catches that: AGP happily assembles a split out of whatever +// .so files the dependencies provide, the APK installs and runs, and the gap +// only surfaces at System.loadLibrary time on a user's device — where +// TorManager's flow swallows the UnsatisfiedLinkError and leaves the status Off +// forever. Checked at build time instead, against the same list the splits use. +val verifyArtiAbis = + tasks.register("verifyArtiAbis") { + group = "verification" + description = "Checks that every ABI in the APK splits has a libarti_android.so." + + val jniLibs = file("src/main/jniLibs") + val abis = shippedAbis + // Rust target triple per ABI, so the failure says exactly what to build. + val triples = + mapOf( + "arm64-v8a" to "aarch64-linux-android", + "x86_64" to "x86_64-linux-android", + "armeabi-v7a" to "armv7-linux-androideabi", + "x86" to "i686-linux-android", + ) + + doLast { + val missing = abis.filter { !File(jniLibs, "$it/libarti_android.so").exists() } + if (missing.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("No libarti_android.so for ABI(s): ${missing.joinToString()}.") + appendLine("Those APK splits would install with Tor permanently unavailable.") + appendLine("Build them (tools/arti-build/README.md):") + missing.forEach { abi -> + val triple = triples[abi] ?: "" + appendLine(" ./tools/arti-build/build-arti.sh --target=$triple") + } + append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.") + }, + ) + } + } + } + +tasks.named("preBuild") { dependsOn(verifyArtiAbis) } + // androidx.appfunctions-compiler runs in a per-module mode by default, // emitting only the dispatcher Kotlin code. The aggregator that builds // the `app_functions.xml` asset (which the system reads to discover our diff --git a/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so b/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so new file mode 100755 index 0000000000..805b0cc712 Binary files /dev/null and b/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so differ diff --git a/amethyst/src/main/jniLibs/x86/libarti_android.so b/amethyst/src/main/jniLibs/x86/libarti_android.so new file mode 100755 index 0000000000..8ee47b981b Binary files /dev/null and b/amethyst/src/main/jniLibs/x86/libarti_android.so differ diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 492724db5e..543bf630b5 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -8,8 +8,8 @@ JNI wrapper built directly from Arti source. | | Guardian Project AAR | Custom build | |---|---|---| -| **Size** | ~140MB | ~11MB | -| **16KB pages** | No | Yes (rustc aligns Android targets to 16 KiB) | +| **Size** | ~140MB | ~22MB for all four ABIs — 4-6MB in the APK a device installs | +| **16KB pages** | No | Yes on the 64-bit ABIs (rustc aligns them to 16 KiB) | | **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) | | **Version** | Behind | Pinned to latest (see [`ARTI_VERSION`](ARTI_VERSION)) | @@ -71,8 +71,9 @@ release profile in `Cargo.toml` (`lto`, `codegen-units = 1`, `strip`, From `tools/arti-build/`, the helper builds twice from clean and diffs the output: ```bash -./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +./verify-reproducible.sh # all four shipped ABIs ./verify-reproducible.sh --release # arm64-v8a only (faster) +./verify-reproducible.sh --target=armv7-linux-androideabi # one ABI ``` It prints `✅ REPRODUCIBLE` when two clean builds produce identical bytes, then @@ -90,8 +91,12 @@ repo is checked out. 2. **Android targets** ```bash - rustup target add aarch64-linux-android x86_64-linux-android + rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` + One per ABI the APK is split for. They are also listed in + `rust-toolchain.toml`, so a first invocation of the build scripts installs + whatever is missing. 3. **cargo-ndk** — the release the pinned output was verified with: ```bash @@ -120,12 +125,16 @@ repo is checked out. ```bash cd tools/arti-build -# Build for all targets (arm64 + x86_64) +# Build every shipped ABI (arm64-v8a, x86_64, armeabi-v7a, x86) ./build-arti.sh -# Build arm64 only (for release APKs) +# Build arm64-v8a only — a fast local loop, NOT enough to cut a release: +# the APK splits ship four ABIs and each one needs its own libarti_android.so ./build-arti.sh --release +# Build a single ABI without touching the other committed .so files +./build-arti.sh --target=armv7-linux-androideabi + # Clean rebuild from scratch ./build-arti.sh --clean ``` @@ -135,7 +144,7 @@ The script will: 2. Check out the version pinned in `ARTI_VERSION` 3. Copy the JNI wrapper into the source tree 4. Compile with `cargo-ndk` for each target architecture -5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64}/` +5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64,armeabi-v7a,x86}/` 6. Verify JNI symbols are exported correctly ## Output @@ -144,10 +153,30 @@ The script will: amethyst/src/main/jniLibs/ ├── arm64-v8a/ │ └── libarti_android.so (~5-6 MB) -└── x86_64/ - └── libarti_android.so (~6-7 MB, emulator support) +├── x86_64/ +│ └── libarti_android.so (~6-7 MB, emulator support) +├── armeabi-v7a/ +│ └── libarti_android.so (~3-4 MB, 32-bit ARM devices) +└── x86/ + └── libarti_android.so (~6 MB, 32-bit x86 images) ``` +**One per ABI split, always.** `amethyst/build.gradle.kts` splits the APK four +ways and `create-release.yml` publishes all four, so an ABI missing from this +tree ships an APK that is complete except for Arti: the dependencies' native +libraries are all there (secp256k1's JNI, for one, ships every ABI), the app +installs and runs, and Tor alone is dead for that install. `System.loadLibrary` +throws, `TorManager`'s status flow swallows the error, and Tor reports Off +forever. With the defaults (`TorType.INTERNAL`, DM relays and unknown relays +routed over Tor) those relays then dial a SOCKS port nothing listens on and +never connect — so the ABI loses its DMs too, not just Tor. + +The ABI list therefore lives in three places that must agree: `splits.abi` in +`amethyst/build.gradle.kts`, `targets` in `rust-toolchain.toml`, and `TARGETS` +in `build-arti.sh`. The `verifyArtiAbis` Gradle task (wired into `preBuild`) +fails the build when an ABI split has no `libarti_android.so`, so the drift is +caught here rather than on a user's phone. + ## Verifying 16KB page alignment Google Play requires 16KB page-aligned native libraries. Verify with: @@ -160,6 +189,11 @@ The first LOAD segment alignment should be `0x4000` (16384 bytes). This comes from rustc's Android target spec (`max-page-size=16384`), not from the NDK, so it holds for every NDK revision we could build with. +The requirement is a 64-bit one — 16 KB pages exist only on 64-bit Android +devices — so it applies to `arm64-v8a` and `x86_64`. The 32-bit libraries +(`armeabi-v7a`, `x86`) link at the 4 KB alignment their targets specify +(`0x1000`), which is correct for them and not a regression to fix. + ## Checking which toolchain built a `.so` The shipped binaries say so themselves — useful when a rebuild does not match, or @@ -308,7 +342,8 @@ fatal. ### Rust targets not installed ```bash -rustup target add aarch64-linux-android x86_64-linux-android +rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` ### Build fails with dependency errors diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index babc4e48cc..6c01937872 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -4,14 +4,20 @@ # # Prerequisites: # - Rust toolchain: rustup, cargo -# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android +# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android \ +# armv7-linux-androideabi i686-linux-android # - cargo-ndk: cargo install cargo-ndk # - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION # (sdkmanager "ndk;") — see README.md -> "Reproducible builds" # # Usage: -# ./build-arti.sh # Build for all targets (arm64 + x86_64) -# ./build-arti.sh --release # Build arm64 only (for release) +# ./build-arti.sh # Build every shipped ABI (all four) +# ./build-arti.sh --release # arm64-v8a only (faster; NOT enough to cut a +# # release — the APK splits ship four ABIs) +# ./build-arti.sh --target= +# # build just this target, repeatable. Use it to +# # add one ABI without rewriting the other .so +# # files already committed under jniLibs/. # ./build-arti.sh --clean # Clean and rebuild # set -euo pipefail @@ -56,26 +62,40 @@ OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs" LIB_NAME="libarti_android.so" MIN_SDK_VERSION=26 -# Default targets -TARGETS=("aarch64-linux-android" "x86_64-linux-android") -RELEASE_ONLY=false +# Default targets: one per ABI the APK is split for (amethyst/build.gradle.kts -> +# splits.abi). They must stay in sync — an ABI that gets an APK split but no +# libarti_android.so produces an install where every other native library is +# present, so the app looks healthy right up to the moment it tries to reach Tor, +# and then fails silently for the lifetime of that install. +TARGETS=("aarch64-linux-android" "x86_64-linux-android" "armv7-linux-androideabi" "i686-linux-android") CLEAN=false REGEN_LOCK=false +# Collects --target= selections; replaces TARGETS wholesale once any is given. +# A space-separated string, not an array: macOS still ships bash 3.2, where +# `${#empty_array[@]}` under `set -u` is an unbound-variable error. Target +# triples never contain spaces, so word splitting is exact here. +SELECTED_TARGETS="" # Parse arguments for arg in "$@"; do case $arg in - --release) RELEASE_ONLY=true; TARGETS=("aarch64-linux-android") ;; + --release) TARGETS=("aarch64-linux-android") ;; + --target=*) SELECTED_TARGETS="$SELECTED_TARGETS ${arg#--target=}" ;; --clean) CLEAN=true ;; # Refresh the committed Cargo.lock from the pinned Arti tag, then exit # (no compile — needs only git + cargo, not the NDK). Use after bumping # ARTI_VERSION / Cargo.toml; the normal build is --locked and will fail # until the lock is regenerated and committed. --regen-lock) REGEN_LOCK=true; CLEAN=true ;; - --help) echo "Usage: $0 [--release] [--clean] [--regen-lock] [--help]"; exit 0 ;; + --help) echo "Usage: $0 [--release] [--target=]... [--clean] [--regen-lock] [--help]"; exit 0 ;; esac done +if [ -n "$SELECTED_TARGETS" ]; then + # shellcheck disable=SC2206 # deliberate word splitting; see SELECTED_TARGETS + TARGETS=($SELECTED_TARGETS) +fi + print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; } print_success() { echo -e "${GREEN}✓ $1${NC}"; } print_error() { echo -e "${RED}✗ $1${NC}"; } @@ -254,12 +274,16 @@ PATCH # ============================================================================ # Android ABI directory (as laid out under jniLibs/) for a Rust target triple. +# Unknown triples are fatal rather than empty: an empty answer would make the +# caller write "$OUTPUT_DIR/" — i.e. drop the .so loose in jniLibs/, where no ABI +# picks it up — and both verification loops would skip it without a word. abi_dir_for() { case "$1" in aarch64-linux-android) echo "arm64-v8a" ;; x86_64-linux-android) echo "x86_64" ;; armv7-linux-androideabi) echo "armeabi-v7a" ;; i686-linux-android) echo "x86" ;; + *) print_error "Unknown Rust target '$1' — no jniLibs ABI maps to it" >&2; exit 1 ;; esac } diff --git a/tools/arti-build/rust-toolchain.toml b/tools/arti-build/rust-toolchain.toml index cfb3a8367d..356afb295c 100644 --- a/tools/arti-build/rust-toolchain.toml +++ b/tools/arti-build/rust-toolchain.toml @@ -10,10 +10,14 @@ channel = "1.98.1" profile = "minimal" components = ["rustc", "cargo", "rust-std"] -# Only the two ABIs we actually ship libarti_android.so for (see jniLibs/). If -# build-arti.sh is extended to armv7/i686, add them here too — check_prerequisites -# also adds any missing target on the fly. +# One per ABI the APK is split for (see amethyst/build.gradle.kts -> splits.abi): +# every split that ships native code ships libarti_android.so too, or Tor is dead +# on that ABI for the life of the install. The `verifyArtiAbis` Gradle task holds +# the two lists together. check_prerequisites also adds any missing target on the +# fly. targets = [ "aarch64-linux-android", "x86_64-linux-android", + "armv7-linux-androideabi", + "i686-linux-android", ] diff --git a/tools/arti-build/verify-reproducible.sh b/tools/arti-build/verify-reproducible.sh index d9bc45d9e7..4160dc2c38 100755 --- a/tools/arti-build/verify-reproducible.sh +++ b/tools/arti-build/verify-reproducible.sh @@ -9,8 +9,10 @@ # "Reproducible builds". # # Usage: -# ./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +# ./verify-reproducible.sh # all four shipped ABIs # ./verify-reproducible.sh --release # arm64-v8a only (faster) +# ./verify-reproducible.sh --target=armv7-linux-androideabi +# # one ABI, by Rust target triple # # Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, and the exact # Android NDK revision pinned in ANDROID_NDK_VERSION — a different revision is @@ -28,15 +30,33 @@ sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi } +# Mirrors abi_dir_for() in build-arti.sh — kept in step with it, since a triple +# that maps to the wrong directory here would hash a library this run never +# rebuilt and call it reproducible. +abi_dir_for() { + case "$1" in + aarch64-linux-android) echo "arm64-v8a" ;; + x86_64-linux-android) echo "x86_64" ;; + armv7-linux-androideabi) echo "armeabi-v7a" ;; + i686-linux-android) echo "x86" ;; + *) echo "Unknown Rust target '$1'" >&2; exit 1 ;; + esac +} + # Only the ABIs this run actually rebuilds. Hashing everything under jniLibs/ # (what `find` used to do) made `--release` look like it had verified the # x86_64 library: that build never touches it, so the untouched file hashed # identically in both runs and the script reported the whole tree reproducible # and matching the commit. -ABIS="arm64-v8a x86_64" +ABIS="arm64-v8a x86_64 armeabi-v7a x86" +SELECTED="" for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do - [ "$arg" = "--release" ] && ABIS="arm64-v8a" + case "$arg" in + --release) ABIS="arm64-v8a" ;; + --target=*) SELECTED="$SELECTED $(abi_dir_for "${arg#--target=}")" ;; + esac done +[ -n "$SELECTED" ] && ABIS="${SELECTED# }" # sha256 of each built .so, keyed by ABI dir (relative paths → stable keys). hashes() {