How to Use Rust in Android Applications (via JNI/NDK)

Build Rust code for Android using cargo-ndk to generate native libraries for JNI integration.

The bridge between Kotlin and native speed

You built a fast Rust algorithm for image filtering or cryptographic hashing. Now you want to run it inside an Android app written in Kotlin. The Android runtime expects Java bytecode. Your Rust code compiles to machine instructions. You need a bridge. That bridge is JNI, the Java Native Interface. It lets your Kotlin code call into compiled C-compatible libraries. Rust fits right in, but you have to speak the JVM's language.

How JNI actually works

Think of JNI like a customs checkpoint. The JVM manages its own memory and garbage collection. Your Rust code manages its own memory and lifetimes. JNI provides a strict protocol for passing data across the border. Every Java object gets wrapped in a special handle. The JVM manages the handles. Your Rust code must treat them as opaque tokens and use JNI functions to read or modify the underlying data. You never touch the raw Java memory directly.

The JVM passes a pointer to the native environment on every call. That pointer gives you access to functions for creating strings, allocating arrays, and calling back into Java. The pointer is only valid on the thread that made the call. If you pass it to a background thread, the JVM will crash. You have to respect the boundary.

Setting up the cross-compiler

Android runs on ARM chips. Your laptop probably runs x86. Rust defaults to your desktop CPU. You need a cross-compiler that targets Android's ABI. The cargo-ndk crate wraps the Android NDK and handles the heavy lifting. Install it with cargo install cargo-ndk. Add the jni and ndk-context crates to your Cargo.toml. The jni crate gives you safe Rust wrappers around the raw JNI C API. ndk-context helps you grab the Android logger and thread pool.

Configure your Cargo.toml to build a dynamic library. Android expects shared objects with the .so extension.

[lib]
crate-type = ["cdylib"]
name = "my_rust_lib"

Run the build command with your target architecture.

cargo ndk build --release --target aarch64-linux-android

The command compiles your crate into native binaries for Android. It places the resulting .so files in target/aarch64-linux-android/release. Copy them into your Android project's src/main/jniLibs/arm64-v8a directory. Android Studio will pick them up automatically. Convention aside: run cargo fmt and cargo clippy before every build. JNI projects accumulate formatting drift quickly when developers switch between desktop and mobile toolchains.

Writing the Rust side

Every function exposed to Java needs a specific signature. The JVM looks for symbols by name and parameter types. You mark the function with #[no_mangle] so the compiler keeps the exact name. You use extern "system" to match the calling convention expected by the JVM. The first two parameters are always JNIEnv and JClass. They give you access to the Java environment and the class that called the function.

use jni::JNIEnv;
use jni::objects::{JClass, JString};

/// Adds two integers and returns the result to Kotlin.
#[no_mangle]
pub extern "system" fn Java_com_example_myapp_Utils_add(
    mut env: JNIEnv,
    _class: JClass,
    a: i32,
    b: i32,
) -> i32 {
    // The JVM passes primitive types directly by value.
    // No conversion is needed for integers or floats.
    a + b
}

The function name follows a strict pattern. Java_ followed by the package name, then the class name, then the method name. Replace dots with underscores. Kotlin and Java see the same symbol. The compiler will reject you with E0308 if you return a type that does not match the JNI signature. Keep the return type aligned with what the Java side expects.

Walking through the call

When your Kotlin code calls add(3, 4), the runtime loads the .so file if it has not been loaded yet. It scans the binary for the exact symbol name. It finds the function and passes the JNIEnv pointer, a reference to the class, and the two integers. Your Rust code runs on the same thread as the UI or background worker that triggered the call. The addition happens in native memory. The result gets returned as a jint. The JVM receives it and hands it back to Kotlin as a standard Int.

The whole process takes microseconds. The overhead comes from the boundary crossing, not the computation. If you call the function once per frame, the cost is negligible. If you call it in a tight loop, batch the work. Send a slice of data across the boundary once, process it in Rust, and return a single result. The JVM's garbage collector does not pause during native execution, but it will run as soon as control returns to Java. Keep your native functions short and predictable.

Realistic example: processing text safely

Strings require extra care. Java strings are UTF-16. Rust strings are UTF-8. JNI handles are not Rust strings. You must convert them before doing any work. The jni crate provides safe conversion methods that handle the encoding shift and memory allocation.

use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;

/// Reverses a Kotlin string and returns it as a new Java string.
#[no_mangle]
pub extern "system" fn Java_com_example_myapp_Utils_reverseString(
    mut env: JNIEnv,
    _class: JClass,
    input: JString,
) -> jstring {
    // Convert the JNI handle to a Rust String.
    // This copies the data and decodes UTF-16 to UTF-8.
    let rust_str = env
        .get_string(input)
        .expect("Failed to get string from JNI")
        .into();

    // Perform the actual work in native memory.
    let reversed: String = rust_str.chars().rev().collect();

    // Allocate a new Java string from the Rust result.
    // The JVM will manage the lifetime of this new object.
    env.new_string(reversed)
        .expect("Failed to create new JNI string")
        .into()
}

The env.get_string call copies the text into Rust memory. The env.new_string call allocates a new Java object and copies the result back. You never hold onto the original JString handle after the function returns. The JVM expects you to release or convert handles before the native function exits. Convention aside: always call env.release_string_chars if you use the raw C API, but the jni crate handles this automatically. Stick to the safe wrappers unless you have a measured reason to drop down to raw pointers.

Pitfalls and compiler traps

JNI signatures are verbose. A function that takes a string and returns a boolean uses the signature "(Ljava/lang/String;)Z". If you mistype it, the runtime throws an UnsatisfiedLinkError. The compiler will not catch it. You have to match the Java side exactly. Use a tool like javap to generate the correct signature string.

Thread affinity is the most common crash cause. The JNIEnv pointer is tied to the calling thread. If you spawn a Rust thread and try to use env inside it, the JVM will abort with a segfault. You must call back into Java from the original thread, or use AttachCurrentThread to get a new environment for the worker thread. The ndk-context crate provides get_thread_pool to manage this safely.

Memory leaks happen when you create Java objects but never return them or store them in a field. The JVM will eventually garbage collect them, but filling the heap with temporary native allocations slows down the garbage collector. Keep allocations proportional to the work you are doing.

If you forget #[no_mangle], the Rust compiler will rename the symbol to something like _ZN12my_rust_lib4add17h...E. The JVM will search for Java_com_example_myapp_Utils_add, find nothing, and crash at startup. Treat #[no_mangle] as mandatory for every JNI entry point. If you accidentally return a &str instead of a jstring, the compiler rejects you with E0308 (mismatched types). JNI boundaries demand exact type matching.

When to bring Rust into Android

Use Rust via JNI when you need deterministic performance for cryptography, image processing, or physics simulations. Use Kotlin or Java when you are building UI layouts, handling user input, or managing lifecycle events. Use the Android NDK directly in C when you are maintaining a legacy codebase that already depends on CMake and raw pointers. Use a WebView with JavaScript when you need rapid prototyping and can tolerate garbage collection pauses. Reach for safe Rust abstractions first. Isolate the JNI boundary in a single module. Keep the rest of your codebase pure Rust.

Where to go next

Treat the JNI boundary as a contract. Verify the signature. Respect the thread. Keep the conversion layer thin.