Rust Android Build -- RIPDPI
Purpose
RIPDPI ships 4 Android ABIs (arm64-v8a, armeabi-v7a, x86_64, x86) cross-compiled from Rust via cargo-ndk + a Gradle convention plugin. This skill codifies the build-and-verify discipline: which rustflags go where, how to verify 16 KiB alignment per ABI, the ELF symbol allowlist, size budgets per ABI, and the NDK 29 specifics that broke older config snippets.
When to consult
- Editing
native/rust/.cargo/config.tomlfor any*-linux-android*target. - Modifying the
[profile.release]or[profile.android-jni]block in workspaceCargo.toml. - Auditing a built
libripdpi.so/libripdpi-tunnel.sobefore release. - Reviewing a Gradle convention-plugin change in
build-logic/. - Investigating a Play Console rejection citing 16 KiB alignment, native crashes, or symbol issues.
16 KiB page-size alignment
Status quo
Play Store has required 16 KiB-aligned .so files for new and updated apps targeting Android 15+ since 1 November 2025. NDK r28+ (RIPDPI pins NDK r29 = 29.0.14206865) compiles 16 KiB-aligned by default. .cargo/config.toml per-target rustflags reinforce this for cargo build invocations that do not go through cargo-ndk.
Per-ABI rustflags
native/rust/.cargo/config.toml should contain:
[target.aarch64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
"-C", "force-frame-pointers=yes",
]
[target.x86_64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
"-C", "force-frame-pointers=yes",
]
# 32-bit ABIs do NOT need 16 KiB alignment — kernel uses 4 KiB pages.
[target.armv7-linux-androideabi]
rustflags = ["-C", "force-frame-pointers=yes"]
[target.i686-linux-android]
rustflags = ["-C", "force-frame-pointers=yes"]
Putting 16 KiB flags on armv7/i686 is harmless but wastes some space on padding. Pure-Rust crates do not need the explicit -Wl flags (NDK lld defaults are correct on r28+), but transitive C dependencies (ring, aws-lc-sys, boring-sys) compiled by cc-rs honor the rustflags and produce correctly aligned object files.
Verification per ABI
NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$(uname | tr '[:upper:]' '[:lower:]')-x86_64/bin"
# arm64-v8a — Align column must be 0x4000 for all LOAD segments
"$NDK_BIN/llvm-readelf" -lW app/build/intermediates/.../arm64-v8a/libripdpi.so \
| awk '/LOAD/ {print $NF}' \
| sort -u
# Expected: 0x4000
# armv7-linux-androideabi — Align column will be 0x1000 (4 KiB), that's correct
"$NDK_BIN/llvm-readelf" -lW app/build/intermediates/.../armeabi-v7a/libripdpi.so \
| awk '/LOAD/ {print $NF}' | sort -u
# Expected: 0x1000
AAB-level verification:
zipalign -c -P 16 -v 4 app/build/outputs/bundle/release/app-release.aab
# Exit 0 = all .so files inside the bundle are properly aligned at 16 KiB.
Pre-release gate: a CI step should fail if 0x4000 is missing from arm64-v8a or x86_64 LOAD segments.
Common traps
- A transitive C dep (typically
ringorboring-sys) that compiles without the-zflags. Verify viallvm-readelf -d libripdpi.so | grep DT_NEEDEDto enumerate, then re-build the dep with explicitCFLAGS=-Wl,-z,max-page-size=16384. mmap(addr, size, ...)calls in vendor C code withsizenot 16 KiB aligned. The kernel rounds up; the C code then assumes its smaller original size. Audit anymmapin dependencies.- A
#define PAGE_SIZE 4096somewhere in a vendor C dep. NDK r29 explicitly REMOVEDPAGE_SIZEfromunistd.hfor arm64-v8a/x86_64 to force the audit. If your build fails onPAGE_SIZEundefined, that is correct — the C code must callsysconf(_SC_PAGESIZE).
Size-optimized release profile
Workspace Cargo.toml:
[profile.android-jni]
inherits = "release"
opt-level = "z" # size > speed for Android distribution
lto = "fat"
codegen-units = 1
panic = "unwind" # JNI needs unwind for catch_unwind
strip = "symbols"
debug = false
[profile.android-jni-dev]
inherits = "dev"
opt-level = 1
panic = "unwind"
debug = "line-tables-only" # symbols for on-device profiling
Plus RUSTFLAGS at build invocation:
RUSTFLAGS="-C link-arg=-Wl,--gc-sections -C link-arg=-Wl,--icf=all -C link-arg=-Wl,--exclude-libs,ALL" \
cargo ndk -t arm64-v8a build --profile android-jni
What each flag does:
--gc-sections— dead-code elimination at link time. ~5–10% size reduction.--icf=all— identical code folding. Multiple identical functions (common with generics post-monomorphization) collapse to one. ~5% reduction.--exclude-libs,ALL— symbols from static rlibs are NOT exported. Equivalent to-fvisibility=hiddenfor C/C++.
For an additional 20–40% size reduction at the cost of losing panic info:
RUSTFLAGS="..." cargo +nightly ndk -t arm64-v8a build \
--profile android-jni \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort
panic_immediate_abort strips the core::fmt::Arguments machinery and unwind tables. Keep a separate release-with-symbols profile for nightly CI soak (with panic = "unwind" and full debug info) so when a crash happens the team has a reproducible binary.
ELF symbol allowlist
The only symbols that should be exported from libripdpi.so / libripdpi-tunnel.so:
JNI_OnLoad/JNI_OnUnloadJava_*(JNI method exports following the JNI naming convention)- System symbols:
_init,_fini,__cxa_finalize(linker-generated)
Verify:
llvm-objdump -T app/build/intermediates/.../arm64-v8a/libripdpi.so \
| awk '/ DF / && !/^Java_/ && !/JNI_On/ && !/__cxa/ && !/_init/ && !/_fini/ {print}'
# Expected output: empty
Any unexpected symbol is ABI leak — a pub fn somewhere in the workspace marked #[unsafe(no_mangle)] without the Java_* prefix. These leak the Rust ABI to anyone who can dlopen your library.
--exclude-libs,ALL in rustflags prevents static-rlib symbols from leaking. Apply it.
.so size budgets
Per-ABI baselines (RIPDPI as of mid-2026):
| ABI | libripdpi.so |
libripdpi-tunnel.so |
|---|---|---|
| arm64-v8a | ~4-5 MiB | ~1.5-2.5 MiB |
| armeabi-v7a | ~3-4 MiB | ~1.2-2.0 MiB |
| x86_64 | ~4.5-5.5 MiB | ~1.8-2.8 MiB |
| x86 | ~3.5-4.5 MiB | ~1.4-2.2 MiB |
Gate: any PR that grows a .so by >5% from ci/baseline-sizes.json blocks. >1% warns. Update baseline manually in a separate PR with explicit rationale.
Audit a regression:
cd native/rust
cargo bloat --profile android-jni --target aarch64-linux-android --crates -n 30
cargo bloat --profile android-jni --target aarch64-linux-android -n 30 # by function
Common culprits:
- A new monomorphized generic explosion. Use the inner-function pattern (see
rust-performanceskill). - A new transitive dependency. Check
cargo tree -p ripdpi-androiddiff. - Loss of
--icf=all/--gc-sectionsfrom RUSTFLAGS. - LTO regression — verify
lto = "fat"is still active.
NDK 29 specifics
NDK r29 (RIPDPI's pin) changed:
PAGE_SIZEmacro removed for arm64-v8a / x86_64 when 16 KiB mode is active. Usesysconf(_SC_PAGESIZE).- LLVM toolchain bumped. Codegen may shift
.sosize by 1–3% versus r28; rebaseline after any NDK bump. - Some Binder headers removed (not part of NDK ABI). If a C dep includes
binder.hfrom outside NDK, it must vendor the headers or fail. lldb.shfixes — debugging cross-compiled Rust improves but no agent-visible change.
When bumping NDK in a future PR:
- Update
native/rust/rust-toolchain.tomlif Rust MSRV needs adjusting (NDK r29 supports rustc 1.78+). - Rebuild all 4 ABIs, run
llvm-readelf -lWper.so, confirm alignment. - Re-run size baseline measurement, update
ci/baseline-sizes.json, separate PR. - Run
android-test-runneragainst the new NDK on every API level (28, 33, 34, 35).
Cargo + Gradle integration
RIPDPI uses a Gradle convention plugin (ripdpi.android.rust-native) that calls cargo build per ABI in parallel, with per-ABI CARGO_TARGET_DIR to avoid lock contention. The plugin sets CARGO_TARGET_<TRIPLE>_LINKER to the NDK's clang.
Do NOT switch back to cargo-ndk CLI in build scripts without consulting cargo-workflows skill — the Gradle plugin's per-ABI parallelism is faster than cargo-ndk's sequential mode.
Related skills
cargo-workflows— workspace structure, Cargo.lock discipline, edition migration.rust-performance— flamegraphs, cargo-bloat, monomorphization audit.rust-android-jni— JNI export naming,EnvUnowned::with_envpattern.native-verifieragent — automates ELF inspection and size gate.rust-toolchain-pin.mdrule — channel and components governance.
Source: po4yka/RIPDPI — distributed by TomeVault.