Android JNI & NDK
Everything that leaves the pure Kotlin/Java world: native shared libraries (.so), the JNI boundary, cross-boundary refs and exceptions, and the modern Android packaging constraints (16 KB pages, split APKs, App Bundles).
Pair with kotlin-performance for measuring native-call overhead, smali-dex-patching for offensive patching of the managed side, and mobile-technique for auditing shipped native code.
When to activate
- Embedding a C/C++/Rust library in a Kotlin/Java app
- Debugging JNI reference leaks,
LocalReferenceTable overflow, or ArrayIndexOutOfBoundsException originating in native code
- Diagnosing a native crash (
SIGSEGV, SIGABRT) from a tombstone or Play Console ANR
- Migrating to 16 KB page-size compliance for Google Play (deadline Nov 2025 for apps targeting API 35+)
- Building AAB / APK with multiple ABIs and understanding split delivery
- Reverse-engineering a shipped
.so to understand what native code an app runs
Core mental model
- The JVM (ART) and native code share a process but different memory managers, exception systems, and reference tracking.
- Every call across the boundary has cost (~25–100 ns even for
@CriticalNative) — batch and design coarse APIs, not chatty ones.
- Native code owns local references valid within one JNI call and global references valid until explicitly deleted. Losing track of either leaks or crashes.
- The GC cannot pause a thread running native code — long native work blocks the whole runtime's GC cycle.
- One process contains one ART instance; multiple
.so files load into the same address space and can call each other freely.
Native call cost tiers (from Android runtime docs, Angler 2016 baseline; still directional)
| Call type |
Overhead |
Restrictions |
| Standard JNI |
~115 ns |
Full JNIEnv* + jobject this/jclass clazz |
@FastNative |
~35 ns |
Skips locking; body cannot access this from arbitrary heap without care |
@CriticalNative |
~25 ns |
Removes JNIEnv* and jclass params; must be static, primitive-only args, cannot call back into JVM |
On Android 12+ the compiled managed→native call for @CriticalNative is nearly free — worth the constraints for hot paths. See references/fast-critical-native.md.
Workflow: adding native code
- Add the NDK Gradle plugin to
app/build.gradle.kts:android {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
defaultConfig {
externalNativeBuild {
cmake {
cppFlags += listOf("-std=c++20", "-Wall", "-Wextra")
arguments += listOf(
"-DANDROID_STL=c++_shared",
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384"
)
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
}
}
ndkVersion = "27.2.12479018"
}
- Declare native methods in Kotlin:
class NativeCrypto {
external fun encrypt(input: ByteArray, key: ByteArray): ByteArray
companion object { init { System.loadLibrary("native_crypto") } }
}
- Implement in C++:
#include <jni.h>
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_NativeCrypto_encrypt(JNIEnv* env, jobject /* this */,
jbyteArray input, jbyteArray key) {
// See references/jni-refs-and-exceptions.md for correct handling
}
- Register natives explicitly (faster startup, better obfuscation):
static const JNINativeMethod kMethods[] = {
{"encrypt", "([B[B)[B", reinterpret_cast<void*>(NativeCrypto_encrypt)},
};
jint JNI_OnLoad(JavaVM* vm, void*) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return -1;
jclass cls = env->FindClass("com/example/NativeCrypto");
env->RegisterNatives(cls, kMethods, sizeof(kMethods) / sizeof(*kMethods));
return JNI_VERSION_1_6;
}
- Build with
./gradlew :app:externalNativeBuildDebug to iterate quickly. assembleRelease handles ABI splits.
- Verify 16 KB alignment (mandatory Nov 2025+ for API 35+ apps on Play):
llvm-readelf -lW libnative_crypto.so | awk '/LOAD/ {print $NF}'
# Every LOAD segment's Align field must be >= 0x4000 (16384) on arm64-v8a and x86_64.
ABI reality check
| ABI |
Use |
arm64-v8a |
Modern Android devices (2019+). Required. |
armeabi-v7a |
Older 32-bit ARM. Still ~10% of Play install base as of 2025. |
x86_64 |
Emulators, Chromebooks, some Windows Subsystem for Android. Include for dev. |
x86 |
Effectively dead; skip unless supporting legacy emulator paths. |
App Bundle (AAB) splits per-ABI automatically. abiFilters in the module controls what's built and shipped.
16 KB page size (Nov 2025 Play deadline)
- Android 15+ devices can boot with 16 KB kernel pages for improved performance.
- All
.so files for arm64-v8a and x86_64 (the 64-bit ABIs) must have LOAD segments aligned to 16 KB.
- NDK r27+ / AGP 8.3+ / CMake 3.22+ handle this automatically. NDK r26 and older need manual linker flags:
# CMakeLists.txt for older toolchains
target_link_options(mylib PRIVATE
"-Wl,-z,max-page-size=16384"
"-Wl,-z,common-page-size=16384"
)
- Verify per-
.so:llvm-readelf -lW libmylib.so | grep -E "LOAD" | awk '{ print $NF }'
# Expect Align = 0x4000 for each LOAD segment on 64-bit ABIs
- Third-party AARs must also comply — check every
.so in build/intermediates/merged_native_libs/.
- Code must not assume
PAGE_SIZE = 4096. Query at runtime via sysconf(_SC_PAGESIZE) or getpagesize().
Full workflow with automation tools: references/16kb-page-size.md.
Loading and packaging
System.loadLibrary("native_crypto") looks for libnative_crypto.so in the APK's lib/<abi>/ folder.
System.load("/absolute/path.so") loads from arbitrary path — used for dynamic feature modules and (offensively) for injected libraries.
Runtime.getRuntime().load*() are aliases.
- Compressed
.so files (android:extractNativeLibs="true") are the legacy default; modern AAPT2/AGP defaults to uncompressed for direct mmap from APK (faster load, no disk copy). Verify with zipinfo -v app.apk | grep "\.so" — flags should show "stored" not "deflated" for optimal load.
- Multiple
.so files can depend on each other; use System.loadLibrary in dependency order or Java_com_example_NativeCrypto_encrypt will fail at first call because the dependency isn't loaded.
JNI reference hygiene (top failure source)
- Local refs — automatically freed at JNI method return. Table default size 512 entries; exceed and JVM aborts with
LocalReferenceTable overflow.
- Explicitly
env->DeleteLocalRef(obj) inside long loops.
- Use
PushLocalFrame(capacity) / PopLocalFrame(nullptr) for scoped batches.
- Global refs — survive across JNI calls. Free with
env->DeleteGlobalRef(g). Every global ref must be paired with a delete or it leaks until process exit.
- Weak global refs — GC can collect the referent; check with
IsSameObject(weakRef, nullptr) before use.
- Never store a
JNIEnv* — it's per-thread. Store JavaVM* and call AttachCurrentThread from other threads.
- Threads created in native code (
pthread_create) that call back into Java must attach: vm->AttachCurrentThread(&env, nullptr); detach in a cleanup handler via DetachCurrentThread.
Full patterns: references/jni-refs-and-exceptions.md.
Exception propagation
- After every JNI call that can throw (
CallXxxMethod, NewObject, FindClass, GetFieldID, ...), check with env->ExceptionCheck().
- Native functions cannot call additional JNI functions with a pending exception (except a small allowlist:
DeleteLocalRef, ExceptionClear, ...). Doing so aborts with JNI DETECTED ERROR IN APPLICATION.
- Handle by either propagating (return early — the Kotlin caller sees the exception) or clearing (
env->ExceptionClear() — silently swallow, discouraged).
- Native code cannot throw C++ exceptions across the JNI boundary — they trigger
abort(). Wrap C++ code in try { } catch (const std::exception& e) { env->ThrowNew(env->FindClass("java/lang/RuntimeException"), e.what()); }.
Native crash triage
Tombstones live at /data/tombstones/ (root only) or Play Console → Vitals → Crashes. Format:
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
x0 0000000000000000 x1 00000073c98a7c00 ...
pc 00000073c94a0234 libnative_crypto.so!encrypt+0x14
Symbolicate:
$NDK/ndk-stack -sym app/build/intermediates/cxx/RelWithDebInfo/*/obj/arm64-v8a < tombstone.txt
Or manually:
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line -Cfe libnative_crypto.so 0x14
Rules:
Full triage flow: references/native-crashes-and-debugging.md.
Rust on Android (increasingly common)
Details: references/rust-jni.md.
Native performance
- Include callers of native methods in the Baseline Profile (see
kotlin-performance/references/baseline-and-startup-profiles.md) — ART pre-compiles the caller side, reducing transition overhead.
- Explicit
RegisterNatives at JNI_OnLoad avoids name-based lookup at first call (faster) and hides method names from strings output.
- For hot paths that would otherwise pay the JNI transition per element, batch: pass a whole array + length once, not one element at a time.
- Prefer
GetPrimitiveArrayCritical / ReleasePrimitiveArrayCritical for zero-copy access to primitive arrays — but cannot call JNI or block during the critical region.
- CPU affinity:
sched_setaffinity is available on Android for pinning to big cores. Rarely worth it, but valid for realtime audio/video threads.
Reverse-engineering a shipped .so
Offensive angle covered in depth by mobile-technique/references/android-ipc-attack-surface.md and the reversing-technique skill:
unzip -j app.apk 'lib/arm64-v8a/*.so' -d libs/
llvm-nm -D libmylib.so | grep Java_ — lists JNI-exported symbols; the class/method mapping is in the name.
objdump -d libmylib.so | less or Ghidra for structural analysis.
- Native anti-analysis: check for
ptrace self-attach, read /proc/self/status | grep TracerPid, JNI reflection to hide method registration. Frida hooks work at the native symbol boundary.
Quick review checklist
- Every
NewGlobalRef paired with DeleteGlobalRef; every GetStringUTFChars paired with ReleaseStringUTFChars
ExceptionCheck() after every JNI call that can throw
- No C++ exception escapes into JNI code
- Native methods declared
external fun (Kotlin) or native (Java), System.loadLibrary inside a companion init { }
JNI_OnLoad uses RegisterNatives; no reliance on name-based discovery in release builds
.so files are 16 KB aligned for arm64-v8a and x86_64
abiFilters explicitly listed; no accidental 32-bit-only or x86 shipping
- Debug symbols uploaded to Play Console (or
.symbols.zip archived in CI) for crash symbolication
- No
System.load from a user-writable path (/sdcard/...) — DoS + code injection vector
Common anti-patterns
- Cached
JNIEnv* used from another thread — undefined behavior; store JavaVM* and AttachCurrentThread.
- Local ref used across JNI calls — only valid until the current native function returns. Convert to global if needed longer.
GetStringChars without matching ReleaseStringChars — pins the string in memory until process exit.
- Building for one ABI only — most Play install groups need
arm64-v8a + armeabi-v7a minimum.
extractNativeLibs="true" in the manifest — legacy; costs disk and startup time. Remove; AGP handles it.
- Assuming
PAGE_SIZE == 4096 — breaks on 16 KB Android 15+ devices.
- Passing a
ByteArray back-and-forth per byte — batch or use ByteBuffer.allocateDirect for zero-copy.
- Loading
.so from an intent-supplied path — RCE. Fixed paths from installed APK only.
Resources
Load on demand:
- references/jni-refs-and-exceptions.md — local vs global refs,
PushLocalFrame, ExceptionCheck after every fallible call, JavaVM* thread attach, common LocalReferenceTable overflow diagnosis; load when writing or debugging JNI implementation code
- references/fast-critical-native.md —
@FastNative / @CriticalNative optimization, restrictions, DIY annotation classes (missing from public SDK), RegisterNatives binding, when the win is real; load when a native call is on a hot path measured at > 1 M ops/sec
- references/16kb-page-size.md — compliance workflow, CMake/ndk-build flags, verification tools (
android-16kb-validator, llvm-readelf), third-party AAR audit, Play deadline; load when the app has any native code and targets API 35+
- references/native-crashes-and-debugging.md — tombstone anatomy,
ndk-stack, addr2line, LLDB attach for on-device debugging, ASan/UBSan setup, Play Console symbol upload; load when triaging a native crash or setting up sanitizers
- references/rust-jni.md —
cargo-ndk + jni-rs workflow, safe wrapper patterns, catch_unwind for panic containment, cross-build for all ABIs, 16 KB alignment; load when embedding a Rust library or evaluating Rust for a new native module
- references/native-memory-and-crashes.md — native heap allocation tracking (
libc.debug.malloc), LeakSanitizer setup, JNI ref leaks (not visible in Java heap dump), dumpsys meminfo native section triage; load when investigating native memory growth or OOM in a native module
1---2name: android-jni-ndk3description: Kotlin/Java ↔ native (C/C++/Rust) bridging on Android: NDK setup, JNI mechanics, `System.loadLibrary`, `RegisterNatives`, `@FastNative`/`@CriticalNative`, reference hygiene, exception propagation, 16 KB page-size compliance, native crash triage. Use when building or debugging a native module, embedding a `.so` library, decoding a native ANR or SIGSEGV, or reverse-engineering an APK's native code paths.4license: MIT5---67# Android JNI & NDK89Everything that leaves the pure Kotlin/Java world: native shared libraries (`.so`), the JNI boundary, cross-boundary refs and exceptions, and the modern Android packaging constraints (16 KB pages, split APKs, App Bundles).1011Pair with `kotlin-performance` for measuring native-call overhead, `smali-dex-patching` for offensive patching of the managed side, and `mobile-technique` for auditing shipped native code.1213## When to activate1415- Embedding a C/C++/Rust library in a Kotlin/Java app16- Debugging JNI reference leaks, `LocalReferenceTable overflow`, or `ArrayIndexOutOfBoundsException` originating in native code17- Diagnosing a native crash (`SIGSEGV`, `SIGABRT`) from a tombstone or Play Console ANR18- Migrating to 16 KB page-size compliance for Google Play (deadline Nov 2025 for apps targeting API 35+)19- Building AAB / APK with multiple ABIs and understanding split delivery20- Reverse-engineering a shipped `.so` to understand what native code an app runs2122---2324## Core mental model2526- The **JVM (ART)** and **native code** share a process but different memory managers, exception systems, and reference tracking.27- Every call across the boundary has cost (~25–100 ns even for `@CriticalNative`) — batch and design coarse APIs, not chatty ones.28- Native code owns **local references** valid within one JNI call and **global references** valid until explicitly deleted. Losing track of either leaks or crashes.29- The GC cannot pause a thread running native code — long native work blocks the whole runtime's GC cycle.30- One process contains one ART instance; multiple `.so` files load into the same address space and can call each other freely.3132---3334## Native call cost tiers (from Android runtime docs, Angler 2016 baseline; still directional)3536| Call type | Overhead | Restrictions |37|-----------|----------|--------------|38| Standard JNI | ~115 ns | Full `JNIEnv*` + `jobject this`/`jclass clazz` |39| `@FastNative` | ~35 ns | Skips locking; body cannot access `this` from arbitrary heap without care |40| `@CriticalNative` | ~25 ns | Removes `JNIEnv*` and `jclass` params; must be `static`, primitive-only args, cannot call back into JVM |4142On Android 12+ the compiled managed→native call for `@CriticalNative` is nearly free — worth the constraints for hot paths. See `references/fast-critical-native.md`.4344---4546## Workflow: adding native code47481. **Add the NDK Gradle plugin** to `app/build.gradle.kts`:49 ```kotlin50 android {51 externalNativeBuild {52 cmake {53 path = file("src/main/cpp/CMakeLists.txt")54 version = "3.22.1"55 }56 }57 defaultConfig {58 externalNativeBuild {59 cmake {60 cppFlags += listOf("-std=c++20", "-Wall", "-Wextra")61 arguments += listOf(62 "-DANDROID_STL=c++_shared",63 "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384"64 )65 abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")66 }67 }68 }69 ndkVersion = "27.2.12479018"70 }71 ```722. **Declare native methods in Kotlin**:73 ```kotlin74 class NativeCrypto {75 external fun encrypt(input: ByteArray, key: ByteArray): ByteArray76 companion object { init { System.loadLibrary("native_crypto") } }77 }78 ```793. **Implement in C++**:80 ```cpp81 #include <jni.h>82 extern "C" JNIEXPORT jbyteArray JNICALL83 Java_com_example_NativeCrypto_encrypt(JNIEnv* env, jobject /* this */,84 jbyteArray input, jbyteArray key) {85 // See references/jni-refs-and-exceptions.md for correct handling86 }87 ```884. **Register natives explicitly** (faster startup, better obfuscation):89 ```cpp90 static const JNINativeMethod kMethods[] = {91 {"encrypt", "([B[B)[B", reinterpret_cast<void*>(NativeCrypto_encrypt)},92 };93 jint JNI_OnLoad(JavaVM* vm, void*) {94 JNIEnv* env;95 if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return -1;96 jclass cls = env->FindClass("com/example/NativeCrypto");97 env->RegisterNatives(cls, kMethods, sizeof(kMethods) / sizeof(*kMethods));98 return JNI_VERSION_1_6;99 }100 ```1015. **Build** with `./gradlew :app:externalNativeBuildDebug` to iterate quickly. `assembleRelease` handles ABI splits.1026. **Verify 16 KB alignment** (mandatory Nov 2025+ for API 35+ apps on Play):103 ```bash104 llvm-readelf -lW libnative_crypto.so | awk '/LOAD/ {print $NF}'105 # Every LOAD segment's Align field must be >= 0x4000 (16384) on arm64-v8a and x86_64.106 ```107108---109110## ABI reality check111112| ABI | Use |113|-----|-----|114| `arm64-v8a` | Modern Android devices (2019+). **Required.** |115| `armeabi-v7a` | Older 32-bit ARM. Still ~10% of Play install base as of 2025. |116| `x86_64` | Emulators, Chromebooks, some Windows Subsystem for Android. Include for dev. |117| `x86` | Effectively dead; skip unless supporting legacy emulator paths. |118119App Bundle (AAB) splits per-ABI automatically. `abiFilters` in the module controls what's built and shipped.120121## 16 KB page size (Nov 2025 Play deadline)122123- Android 15+ devices can boot with 16 KB kernel pages for improved performance.124- All `.so` files for `arm64-v8a` and `x86_64` (the 64-bit ABIs) must have LOAD segments aligned to 16 KB.125- NDK r27+ / AGP 8.3+ / CMake 3.22+ handle this automatically. NDK r26 and older need manual linker flags:126 ```cmake127 # CMakeLists.txt for older toolchains128 target_link_options(mylib PRIVATE129 "-Wl,-z,max-page-size=16384"130 "-Wl,-z,common-page-size=16384"131 )132 ```133- Verify per-`.so`:134 ```bash135 llvm-readelf -lW libmylib.so | grep -E "LOAD" | awk '{ print $NF }'136 # Expect Align = 0x4000 for each LOAD segment on 64-bit ABIs137 ```138- Third-party AARs must also comply — check every `.so` in `build/intermediates/merged_native_libs/`.139- Code must not assume `PAGE_SIZE = 4096`. Query at runtime via `sysconf(_SC_PAGESIZE)` or `getpagesize()`.140141Full workflow with automation tools: `references/16kb-page-size.md`.142143---144145## Loading and packaging146147- `System.loadLibrary("native_crypto")` looks for `libnative_crypto.so` in the APK's `lib/<abi>/` folder.148- `System.load("/absolute/path.so")` loads from arbitrary path — used for dynamic feature modules and (offensively) for injected libraries.149- `Runtime.getRuntime().load*()` are aliases.150- Compressed `.so` files (`android:extractNativeLibs="true"`) are the legacy default; modern AAPT2/AGP defaults to uncompressed for direct `mmap` from APK (faster load, no disk copy). Verify with `zipinfo -v app.apk | grep "\.so"` — flags should show "stored" not "deflated" for optimal load.151- Multiple `.so` files can depend on each other; use `System.loadLibrary` in dependency order or `Java_com_example_NativeCrypto_encrypt` will fail at first call because the dependency isn't loaded.152153---154155## JNI reference hygiene (top failure source)156157- **Local refs** — automatically freed at JNI method return. Table default size 512 entries; exceed and JVM aborts with `LocalReferenceTable overflow`.158 - Explicitly `env->DeleteLocalRef(obj)` inside long loops.159 - Use `PushLocalFrame(capacity)` / `PopLocalFrame(nullptr)` for scoped batches.160- **Global refs** — survive across JNI calls. Free with `env->DeleteGlobalRef(g)`. **Every** global ref must be paired with a delete or it leaks until process exit.161- **Weak global refs** — GC can collect the referent; check with `IsSameObject(weakRef, nullptr)` before use.162- Never store a `JNIEnv*` — it's per-thread. Store `JavaVM*` and call `AttachCurrentThread` from other threads.163- Threads created in native code (`pthread_create`) that call back into Java **must** attach: `vm->AttachCurrentThread(&env, nullptr)`; detach in a cleanup handler via `DetachCurrentThread`.164165Full patterns: `references/jni-refs-and-exceptions.md`.166167---168169## Exception propagation170171- After **every** JNI call that can throw (`CallXxxMethod`, `NewObject`, `FindClass`, `GetFieldID`, ...), check with `env->ExceptionCheck()`.172- Native functions **cannot** call additional JNI functions with a pending exception (except a small allowlist: `DeleteLocalRef`, `ExceptionClear`, ...). Doing so aborts with `JNI DETECTED ERROR IN APPLICATION`.173- Handle by either propagating (return early — the Kotlin caller sees the exception) or clearing (`env->ExceptionClear()` — silently swallow, discouraged).174- Native code cannot throw C++ exceptions across the JNI boundary — they trigger `abort()`. Wrap C++ code in `try { } catch (const std::exception& e) { env->ThrowNew(env->FindClass("java/lang/RuntimeException"), e.what()); }`.175176---177178## Native crash triage179180Tombstones live at `/data/tombstones/` (root only) or Play Console → Vitals → Crashes. Format:181182```183signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0184 x0 0000000000000000 x1 00000073c98a7c00 ...185 pc 00000073c94a0234 libnative_crypto.so!encrypt+0x14186```187188Symbolicate:189190```bash191$NDK/ndk-stack -sym app/build/intermediates/cxx/RelWithDebInfo/*/obj/arm64-v8a < tombstone.txt192```193194Or manually:195196```bash197$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line -Cfe libnative_crypto.so 0x14198```199200Rules:201202- Ship `libmylib.so.debug` (via AGP `packagingOptions { doNotStrip("**/lib*.so") }` for local, or upload debug symbols to Play Console for production).203- Set CMake to `Release` with debug info: `-DCMAKE_BUILD_TYPE=RelWithDebInfo`. Strip separately for shipping.204- Enable Address Sanitizer on debug builds:205 ```cmake206 target_compile_options(mylib PRIVATE -fsanitize=address -fno-omit-frame-pointer)207 target_link_options(mylib PRIVATE -fsanitize=address)208 ```209 Add wrap script per NDK docs. Available for API 21+.210211Full triage flow: `references/native-crashes-and-debugging.md`.212213---214215## Rust on Android (increasingly common)216217- Use `cargo-ndk` + `jni-rs` crate:218 ```bash219 cargo install cargo-ndk220 cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o app/src/main/jniLibs build --release221 ```222- `jni-rs` provides safe wrappers around `JNIEnv`, refs, and exceptions.223- Rust `panic` across JNI = process abort. Wrap in `catch_unwind` + convert to `ThrowNew`.224- 16 KB alignment: `rustflags = ["-Clink-arg=-Wl,-z,max-page-size=16384"]` in `.cargo/config.toml`.225- Chrome, Firefox, and increasing parts of AOSP use Rust for security-critical native code. Prefer Rust over C++ for new modules where practical.226227Details: `references/rust-jni.md`.228229---230231## Native performance232233- Include callers of native methods in the Baseline Profile (see `kotlin-performance/references/baseline-and-startup-profiles.md`) — ART pre-compiles the caller side, reducing transition overhead.234- Explicit `RegisterNatives` at `JNI_OnLoad` avoids name-based lookup at first call (faster) and hides method names from `strings` output.235- For hot paths that would otherwise pay the JNI transition per element, batch: pass a whole array + length once, not one element at a time.236- Prefer `GetPrimitiveArrayCritical` / `ReleasePrimitiveArrayCritical` for zero-copy access to primitive arrays — but **cannot** call JNI or block during the critical region.237- CPU affinity: `sched_setaffinity` is available on Android for pinning to big cores. Rarely worth it, but valid for realtime audio/video threads.238239---240241## Reverse-engineering a shipped `.so`242243Offensive angle covered in depth by `mobile-technique/references/android-ipc-attack-surface.md` and the `reversing-technique` skill:244245- `unzip -j app.apk 'lib/arm64-v8a/*.so' -d libs/`246- `llvm-nm -D libmylib.so | grep Java_` — lists JNI-exported symbols; the class/method mapping is in the name.247- `objdump -d libmylib.so | less` or Ghidra for structural analysis.248- Native anti-analysis: check for `ptrace` self-attach, `read /proc/self/status | grep TracerPid`, JNI reflection to hide method registration. Frida hooks work at the native symbol boundary.249250---251252## Quick review checklist253254- Every `NewGlobalRef` paired with `DeleteGlobalRef`; every `GetStringUTFChars` paired with `ReleaseStringUTFChars`255- `ExceptionCheck()` after every JNI call that can throw256- No C++ exception escapes into JNI code257- Native methods declared `external fun` (Kotlin) or `native` (Java), `System.loadLibrary` inside a companion `init { }`258- `JNI_OnLoad` uses `RegisterNatives`; no reliance on name-based discovery in release builds259- `.so` files are 16 KB aligned for `arm64-v8a` and `x86_64`260- `abiFilters` explicitly listed; no accidental 32-bit-only or `x86` shipping261- Debug symbols uploaded to Play Console (or `.symbols.zip` archived in CI) for crash symbolication262- No `System.load` from a user-writable path (`/sdcard/...`) — DoS + code injection vector263264---265266## Common anti-patterns267268- **Cached `JNIEnv*` used from another thread** — undefined behavior; store `JavaVM*` and `AttachCurrentThread`.269- **Local ref used across JNI calls** — only valid until the current native function returns. Convert to global if needed longer.270- **`GetStringChars` without matching `ReleaseStringChars`** — pins the string in memory until process exit.271- **Building for one ABI only** — most Play install groups need `arm64-v8a` + `armeabi-v7a` minimum.272- **`extractNativeLibs="true"`** in the manifest — legacy; costs disk and startup time. Remove; AGP handles it.273- **Assuming `PAGE_SIZE == 4096`** — breaks on 16 KB Android 15+ devices.274- **Passing a `ByteArray` back-and-forth per byte** — batch or use `ByteBuffer.allocateDirect` for zero-copy.275- **Loading `.so` from an intent-supplied path** — RCE. Fixed paths from installed APK only.276277---278279## Resources280281Load on demand:282283- [references/jni-refs-and-exceptions.md](references/jni-refs-and-exceptions.md) — local vs global refs, `PushLocalFrame`, `ExceptionCheck` after every fallible call, `JavaVM*` thread attach, common `LocalReferenceTable overflow` diagnosis; load when writing or debugging JNI implementation code284- [references/fast-critical-native.md](references/fast-critical-native.md) — `@FastNative` / `@CriticalNative` optimization, restrictions, DIY annotation classes (missing from public SDK), `RegisterNatives` binding, when the win is real; load when a native call is on a hot path measured at > 1 M ops/sec285- [references/16kb-page-size.md](references/16kb-page-size.md) — compliance workflow, CMake/ndk-build flags, verification tools (`android-16kb-validator`, `llvm-readelf`), third-party AAR audit, Play deadline; load when the app has any native code and targets API 35+286- [references/native-crashes-and-debugging.md](references/native-crashes-and-debugging.md) — tombstone anatomy, `ndk-stack`, `addr2line`, LLDB attach for on-device debugging, ASan/UBSan setup, Play Console symbol upload; load when triaging a native crash or setting up sanitizers287- [references/rust-jni.md](references/rust-jni.md) — `cargo-ndk` + `jni-rs` workflow, safe wrapper patterns, `catch_unwind` for panic containment, cross-build for all ABIs, 16 KB alignment; load when embedding a Rust library or evaluating Rust for a new native module288- [references/native-memory-and-crashes.md](references/native-memory-and-crashes.md) — native heap allocation tracking (`libc.debug.malloc`), LeakSanitizer setup, JNI ref leaks (not visible in Java heap dump), `dumpsys meminfo` native section triage; load when investigating native memory growth or OOM in a native module