Rust shared core, part 5: what to check when shipping a .so on Android
When a Rust module shared by several platforms goes into Android, generating the Kotlin bindings does not finish the integration. If an ABI is missing or in the wrong place, or if a platform or store condition is not met, the build, the install, or the run can fail somewhere.
Part 5 covers the build wiring and the compatibility checks for putting a Rust binary into an Android release. The versions and policies in it are the values in the Android documentation as I rechecked it on October 4, 2026. The environment in which I built an example is recorded separately in the section “The alignment of a .so built from an example.”
Each ABI needs its own .so
The ABIs the NDK supports are four: armeabi-v7a, arm64-v8a, x86, and x86_64. Which of them to ship is decided by the app’s policy. For each chosen ABI, a .so has to be built with the matching Rust target.
The output goes under jniLibs in the app module, in a directory per ABI. Below is an example with three ABIs chosen.
apps/android/
app/
src/main/
jniLibs/
arm64-v8a/libdomain_core.so
armeabi-v7a/libdomain_core.so
x86_64/libdomain_core.so
This layout makes the file for each ABI easy to find. Copying the files by hand carries a risk. After the Rust code changes, the app can still contain the old binary.
Wiring the Rust build into Gradle
The ABIs, the page size, and the compatible versions come from the documentation. The Gradle task setup in this section and the checklists further down are my own design choices built on that, not requirements of the documentation.
To avoid copying by hand, connect it to the build. This setup makes preBuild depend on a task that builds Rust.
preBuild
dependsOn buildRustCoreForAndroid
buildRustCoreForAndroid
-> ABI별 cargo build
-> UniFFI Kotlin binding 생성
-> jniLibs / generated source 경로에 output 배치
The three arrows read: build with cargo for each ABI, generate the UniFFI Kotlin binding, and place the output in the jniLibs and generated source paths.
The block above draws the relation between tasks and is not a Gradle configuration to use as written. The order is to compile with cargo, generate Kotlin code with UniFFI, and put the results in the jniLibs and generated source paths.
Without inputs and outputs declared on the Gradle task, unnecessary rebuilds can happen, or a change can go undetected. The boundary of the task has to be set with both local speed and reproducibility in CI in mind.
Hide generated types behind a Kotlin interface
Exposing generated types across ViewModels, Composables, and repositories increases coupling. A Kotlin adapter and the SudokuEngineClient contract separate the internal model. This is the shape of the interface I propose.
interface SudokuEngineClient {
fun startGame(request: StartGameRequest): GameSnapshot
fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}
The implementation calls UniFFI, and the ViewModel uses only this contract. Tests can put in a fake. Using the same Rust module on the web as a WebAssembly package is left to part 6.
Heavy Rust computation is handled off the main thread. I recommend separating the choice of dispatcher in the ViewModel scope from the update of UI state, and not exposing the Rust dependency to Compose UI. Measured cost and the specific dispatcher are not given in this post.
The 16 KB page rule
According to the Android guide to 16 KB page sizes, apps on Google Play that target Android 15 (API level 35) or later have to support 16 KB pages on 64-bit devices. From February 1, 2027, updates that do not support 16 KB cannot be released. The earlier version of this post gave November 1, 2025 as the start date, and that date is not in the document as rechecked on October 4, 2026. Native code brought in by SDKs is in scope, so a Rust .so is included.
Per the documentation, NDK r28 and later use 16 KB alignment by default. Even so, every library in the release has to be checked without relying on the default. An old binary brought in by an ad or analytics SDK can affect the compatibility of the whole app.
Four items need checking.
- The ELF alignment of every
.so - Behavior on a 16 KB emulator or device
- Whether third-party SDKs contain native code
- Whether the inspection used the release APK or AAB, not a debug build
The alignment of an APK is checked with zipalign, and the page size of a connected device is read with adb.
zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE
These two commands look at the alignment of the APK packaging and at the condition of the device. They do not by themselves confirm the ELF alignment of every .so or that the app works. A project that ships mainly as an AAB adds the Play Console pre-launch report, or an APK built from the bundle, to what is checked. I could not run these two commands. No APK could be built, for the reason given in the next section.
The alignment of a .so built from an example
I built a small Rust example for three ABIs and checked the ELF alignment myself. The workspace is not a real app. It is a minimal one made to check the structure. I ran it on October 4, 2026, on macOS 27.0.1 with cargo 1.96.0 and the NDK installed on this Mac, 30.0.15729638-beta2. That is a different version from the NDK 28.2 in the section “Pin the tool versions” below, and it is a beta. The three Android targets of Rust were already installed.
For each target, cargo is told to use the NDK’s clang as the linker. The alignment is read from the LOAD lines of llvm-objdump -p, which is the method the Android guide gives. These lines are taken from the build script. Two things are simplified because it is an example. The first line picks the highest version among the installed NDKs, so installing another NDK can change the result. To reproduce it, give the NDK path or version explicitly. The 24 in the linker names stands for minSdkVersion. If the app’s minSdk is lower than 24, it has to match that value.
NDK=$(ls -d "$HOME"/Library/Android/sdk/ndk/* | sort -V | tail -1)
TC="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
# …
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$TC/armv7a-linux-androideabi24-clang"
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TC/x86_64-linux-android24-clang"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
cargo build -q -p sudoku-uniffi --release --target "$target"
so="target/$target/release/libsudoku_uniffi.so"
echo "$target: $(stat -f %z "$so") bytes"
# …
done
The alignment was read with one more script. sort -u collapses lines with the same value into one.
OBJDUMP="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
echo "$target"
"$OBJDUMP" -p "target/$target/release/libsudoku_uniffi.so" | awk '/LOAD/ {print " LOAD " $(NF-1) " " $NF}' | sort -u
done
The two scripts printed the following. The file size per ABI is above, and the alignment on the LOAD lines is below.
aarch64-linux-android: 716632 bytes
armv7-linux-androideabi: 505140 bytes
x86_64-linux-android: 686000 bytes
aarch64-linux-android
LOAD align 2**14
armv7-linux-androideabi
LOAD align 2**12
x86_64-linux-android
LOAD align 2**14
For the 64-bit ABIs, arm64-v8a and x86_64, every LOAD segment was 214, which is 16 KB. The guide says the load segments must not have values less than 214. For the 32-bit armeabi-v7a, the value was 2**12. The 16 KB rule is about 64-bit devices. No linker option was passed separately.
More was left unchecked than checked. Gradle is not on this Mac, so no APK or AAB could be built. That means the zipalign check, the jniLibs placement, a release build, and loading on a device or an emulator were not tried. The Kotlin bindings were generated and not compiled.
Pin the tool versions
In the compatibility table of the AGP 9.1 release notes as rechecked on October 4, 2026, the values for AGP 9.1.1 are Gradle 9.3.1, JDK 17, and NDK 28.2.13676358. From AGP 9.0 on, the default NDK is in the r28 line, and 9.1.1 uses the same default. Different NDK versions among developers can be a cause of different alignment and linker behavior.
So the following six are pinned in one place and managed consistently.
AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets
A new configuration file is not needed. The version catalog, the Gradle wrapper, the CI image, and the README that are already maintained can serve as the reference. It is also worth confirming that ./gradlew assembleRelease from a fresh clone, or the release job in CI, succeeds including the generation of the Rust output. For using the NDK with a build system other than Gradle, see the NDK guide to other build systems.
A working debug build does not cover release
Because of R8, minify, packagingOptions, ABI splits, and App Bundle processing, the result of a debug build is not enough to judge a release. Five things are checked on the release output.
- Whether every required ABI is included
- Whether the name the binding looks for matches the name of the binary
- Whether
System.loadLibrarysucceeds - Whether the 16 KB rule is met
- Whether a Rust call succeeds right after a fresh install
I recommend checking automatically in CI. When that is hard, a manual checklist for each release candidate is the minimum alternative. The basic concepts of native calls are in the Android JNI tips.
At the release stage, a Rust module is one more native binary dependency. Which ABIs to support, how to design incremental builds for the Gradle task, which thread to run on, whether third-party code meets the rule, and whether the final release really works all have to be confirmed separately in each project. The policy date and the version values are those in the documentation as rechecked on October 4, 2026.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.