fix(android): build Arti for all four shipped ABIs

The APK is split four ways (arm64-v8a, x86_64, armeabi-v7a, x86) and
create-release.yml publishes every one of them, but libarti_android.so
existed only for arm64-v8a and x86_64. The armeabi-v7a and x86 APKs
therefore installed with each dependency's native library present
(secp256k1's JNI ships all four ABIs) and Arti's missing:
System.loadLibrary throws, TorManager's status flow swallows the error,
and Tor reports Off for the life of the install. 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
those ABIs lost their DMs as well as Tor.

Build Arti for armv7-linux-androideabi and i686-linux-android, and keep
the lists from drifting again:

- rust-toolchain.toml / build-arti.sh: all four targets by default, plus
  --target=<triple> to add one ABI without rewriting the .so files
  already committed.
- verify-reproducible.sh: verifies all four, or one ABI by triple.
- verifyArtiAbis (wired into preBuild): fails the build when an ABI
  split has no libarti_android.so, and names the build-arti.sh command
  that produces it.

Both new binaries come from the pinned Rust 1.98.1 + NDK 30.0.16248370,
export every JNI symbol, and link only libc/libm/libdl like the existing
two. 16 KB page alignment stays a 64-bit concern; the 32-bit libraries
link at 4 KB as their targets specify.

They have not been exercised on a 32-bit device or emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1p385SiQMd2Lhkbg2YKc
This commit is contained in:
Claude
2026-09-18 15:11:36 +00:00
parent b76401e136
commit 4406019059
8 changed files with 168 additions and 27 deletions
+6
View File
@@ -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
+55 -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 =
@@ -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/<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 ->
@@ -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] ?: "<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.
+45 -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)) |
@@ -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
+32 -8
View File
@@ -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;<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
#
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=<triple>]... [--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
}
+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",
]
+23 -3
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
@@ -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() {