Merge pull request #4144 from vitorpamplona/claude/elegant-lovelace-3qfhpj

Add 32-bit ABI support (armeabi-v7a, x86) to Arti build
This commit is contained in:
Vitor Pamplona
2026-09-18 15:02:36 -04:00
committed by GitHub
8 changed files with 219 additions and 29 deletions
+6
View File
@@ -121,6 +121,12 @@ 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)"`.
> **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
+94 -3
View File
@@ -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 =
@@ -281,7 +289,7 @@ android {
abi {
isEnable = !disableAbiSplits
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
include(*shippedAbis.toTypedArray())
isUniversalApk = !disableUniversalApk
}
}
@@ -344,8 +352,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/<abi>/ — 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 ->
@@ -360,6 +369,88 @@ 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 for that architecture."
val jniLibs = file("src/main/jniLibs")
val abis = shippedAbis
// Per ABI: the Rust target triple (so a failure names the exact build
// command) and the ELF identity the library must have — 32/64-bit class
// (header byte 4) and e_machine (bytes 18-19, little-endian on every
// Android ABI we ship). Existence alone is not enough: a truncated file,
// an empty placeholder, or arm64's .so copied into x86/ all load as
// nothing on device, which is the same silent dead Tor this task exists
// to prevent — and unlike a missing file, those look fine in git.
val expected =
mapOf(
"arm64-v8a" to Triple("aarch64-linux-android", 2, 0xB7),
"x86_64" to Triple("x86_64-linux-android", 2, 0x3E),
"armeabi-v7a" to Triple("armv7-linux-androideabi", 1, 0x28),
"x86" to Triple("i686-linux-android", 1, 0x03),
)
doLast {
val bitness = mapOf(1 to "32-bit", 2 to "64-bit")
val problems = mutableListOf<Pair<String, String>>()
abis.forEach { abi ->
val lib = File(jniLibs, "$abi/libarti_android.so")
val want = expected[abi]
val header = ByteArray(20)
val read = if (lib.isFile) lib.inputStream().use { it.read(header) } else -1
val problem =
when {
!lib.isFile -> "no libarti_android.so"
want == null -> "no expected ELF identity recorded for this ABI"
read < header.size ||
header[0] != 0x7F.toByte() ||
header[1] != 'E'.code.toByte() ||
header[2] != 'L'.code.toByte() ||
header[3] != 'F'.code.toByte() -> "not an ELF file (truncated or corrupt)"
header[4].toInt() != want.second ->
"${bitness[header[4].toInt()] ?: "unknown-class"} ELF, expected ${bitness[want.second]}"
else -> {
val machine = (header[18].toInt() and 0xFF) or ((header[19].toInt() and 0xFF) shl 8)
if (machine != want.third) {
"built for ELF machine 0x%02x, expected 0x%02x".format(machine, want.third)
} else {
null
}
}
}
if (problem != null) problems += abi to problem
}
if (problems.isNotEmpty()) {
throw GradleException(
buildString {
appendLine("libarti_android.so is missing or wrong for ${problems.size} ABI split(s):")
problems.forEach { (abi, problem) -> appendLine(" $abi: $problem") }
appendLine("Those APK splits would install with Tor permanently unavailable.")
appendLine("Rebuild them (tools/arti-build/README.md):")
problems.forEach { (abi, _) ->
val triple = expected[abi]?.first ?: "<add the Rust target for $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
Binary file not shown.
Binary file not shown.
+52 -10
View File
@@ -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)) |
@@ -89,8 +89,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
@@ -108,8 +109,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
@@ -138,12 +143,19 @@ 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
# Print the jniLibs ABI dirs a given invocation would write, then exit
./build-arti.sh --print-abis --release # -> arm64-v8a
# Clean rebuild from scratch
./build-arti.sh --clean
```
@@ -153,7 +165,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
@@ -162,10 +174,34 @@ 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`. (`verify-reproducible.sh` has no copy of its own — it asks
`build-arti.sh --print-abis`, so it can never hash a different set than the one
it just rebuilt.) The `verifyArtiAbis` Gradle task, wired into `preBuild`, fails
the build when an ABI split has no `libarti_android.so` **or** has one that is
not an ELF of that architecture — a truncated file or arm64's library copied
into `x86/` loads as nothing on device, exactly like a missing one, and unlike a
missing one it looks fine in `git status`.
## Verifying 16KB page alignment
Google Play requires 16KB page-aligned native libraries. Verify with:
@@ -178,6 +214,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
@@ -326,7 +367,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
+49 -8
View File
@@ -4,15 +4,26 @@
#
# 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;<revision>") — 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=<triple>
# # 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
# ./build-arti.sh --print-abis # print the jniLibs ABI dirs this invocation
# # would write (honours --release/--target=),
# # then exit. This is how verify-reproducible.sh
# # learns the ABI list instead of keeping its
# # own copy of it.
#
set -euo pipefail
@@ -56,26 +67,42 @@ 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=""
PRINT_ABIS=false
# 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=}" ;;
--print-abis) PRINT_ABIS=true ;;
--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=<triple>]... [--clean] [--regen-lock] [--print-abis] [--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 +281,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
}
@@ -395,6 +426,16 @@ verify_ndk_stamp() {
# ============================================================================
main() {
# Answer --print-abis before any other output, so the caller gets exactly the
# ABI directory names on stdout and nothing else. Runs here rather than in the
# argument loop because abi_dir_for is not defined yet at that point.
if [ "$PRINT_ABIS" = true ]; then
for target in "${TARGETS[@]}"; do
abi_dir_for "$target"
done
exit 0
fi
echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}"
# --regen-lock only needs git + cargo, not the NDK/cargo-ndk toolchain.
+7 -3
View File
@@ -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",
]
+11 -5
View File
@@ -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
@@ -33,10 +35,14 @@ sha256() {
# 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"
for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do
[ "$arg" = "--release" ] && ABIS="arm64-v8a"
done
#
# Asked of build-arti.sh with the very flags it is about to receive, rather than
# kept as a second copy of the ABI list here. A local copy would drift the moment
# an ABI is added: this script would rebuild it twice, hash neither, and still
# print ✅ REPRODUCIBLE — the same false pass the paragraph above describes.
# Under `set -e` a failing --print-abis (e.g. an unknown triple) aborts here.
ABIS="$("$SCRIPT_DIR/build-arti.sh" --print-abis ${PASSTHRU[@]+"${PASSTHRU[@]}"} | tr '\n' ' ')"
ABIS="${ABIS% }"
# sha256 of each built .so, keyed by ABI dir (relative paths → stable keys).
hashes() {