Rust + NDK on Android, Part 2 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK. Next: UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand (coming soon).
Calling Rust from Kotlin takes three pieces: a Kotlin function marked external, a Rust function exported under a name the Android runtime can find, and one System.loadLibrary call to bring the compiled library into your process. There is no header file and no code generator in between. The entire contract is a naming convention plus a calling convention, and both fit in your head.
This assumes the toolchain from Part 1: rustup with the Android targets, the NDK, and cargo-ndk. On top of that we’ll build a small crate, jni-basics, that exports three functions: integer math, a string reverse, and a greeting builder that mixes a string with a boolean. Every snippet compiled and ran on my machine; the terminal outputs are pasted from those runs.
I’m showing raw JNI on purpose. Part 3 introduces UniFFI, which generates all of this, but when a binding generator misbehaves, this is the layer you end up debugging.
Key Takeaways
- JNI resolves native methods by a name pattern:
Java_plus package, class, and method with dots turned into underscores. Get one token wrong and you getUnsatisfiedLinkErrorat first call, not at load time.#[no_mangle]keeps rustc from renaming your symbol, andextern "system"selects the calling convention JNI expects. Neither is magic; each has a one-paragraph explanation.- Primitives cross the boundary as plain machine types: Kotlin
Intisjintis Rusti32. Strings are object references that must be converted, and JNI uses modified UTF-8, not real UTF-8.- Keep the logic in plain Rust functions with host-run unit tests and make the JNI wrappers thin. The bridge should be boring.
What external fun does at runtime
Start on the side you already know. The whole Kotlin surface of this module lives in app/src/main/java/dev/davthecoder/rustseries/JniBasics.kt:
package dev.davthecoder.rustseries
object JniBasics {
init {
System.loadLibrary("jnibasics")
}
external fun add(a: Int, b: Int): Long
external fun reverse(input: String): String
external fun greet(name: String, premium: Boolean): String
}
external is Kotlin’s spelling of Java’s native keyword. The compiler emits a method with no body, flagged as native in the bytecode, and promises the JVM that an implementation will show up at runtime. System.loadLibrary("jnibasics") turns the name into libjnibasics.so, finds it inside your APK’s native library directory for the device’s ABI, and loads it with the platform’s dynamic linker.
The part that surprises people is when resolution happens. ART does not check that add, reverse, and greet exist when the library loads. It waits until the first time each method is called, then builds the expected symbol name and searches the loaded libraries for it (Android Developers, JNI tips). A miss throws UnsatisfiedLinkError at the call site. Your app can run happily for minutes before one tap hits an unresolved native method and crashes. In debug builds I like to call the cheapest native function once at startup, purely to fail fast if the wiring is broken.
The JNI naming rule, token by token
The runtime finds your Rust function by name, and the name is derived mechanically from the Kotlin declaration. For JniBasics.reverse the expected symbol is Java_dev_davthecoder_rustseries_JniBasics_reverse, which decomposes like this:
| Token | Meaning |
|---|---|
Java_ |
Fixed prefix for every statically resolved JNI function |
dev_davthecoder_rustseries |
The package, with each dot replaced by an underscore |
JniBasics |
The class, or in our case the Kotlin object |
reverse |
The method name |
The mapping is defined in the JNI specification itself, under Resolving Native Method Names (Oracle, JNI specification). Three escape rules matter in practice. An underscore inside a package or class name becomes _1, because a plain underscore already means a dot. An overloaded native method gets a double underscore followed by a mangled parameter signature, so two external fun reverse overloads would need two different, uglier symbol names. And non-ASCII characters become _0 followed by four hex digits.
You can dodge all three rules by convention: keep JNI-facing package names free of underscores, give each crate one bridge class, and never overload an external fun. That’s why this series uses dev.davthecoder.rustseries and one Kotlin object per module.
#[no_mangle] and extern "system", explained
Every JNI tutorial shows this pair, and almost none explains it. Both attributes have real jobs.
By default, rustc renames every symbol so that different crates, versions, and generic instantiations can coexist in one binary. On my host build of this crate, nm lists the plain add function as __RNvCs8f3uGV3I3E_9jnibasics3add. ART would never find that. #[no_mangle] opts one function out of mangling so the symbol survives exactly as written, and together with pub and the cdylib crate type it lands in the dynamic symbol table where the runtime’s lookup can see it.
One toolchain note before you copy anything: cargo new creates crates on the 2024 edition now, and there the attribute must be written #[unsafe(no_mangle)] (Rust 2024 edition guide). This series pins edition = "2021" in every Cargo.toml, which matches most JNI code in the wild. Same behavior, different spelling, and a confusing compiler error if you mix them up.
extern "system" sets the function’s calling convention: the register and stack protocol used to pass arguments. JNI’s headers declare every native entry point with the JNICALL convention, and Rust’s "system" ABI string means “whatever the platform’s system interface uses” (The Rust Reference, external blocks). On every Android ABI this is identical to extern "C", and the distinction only ever mattered on 32-bit Windows x86, where system calls used stdcall. Write "system" anyway; it’s correct on every platform and costs nothing.
A complete Rust JNI module for Kotlin
The crate manifest lives at rust/jni-basics/Cargo.toml:
[package]
name = "jni-basics"
version = "0.1.0"
edition = "2021"
[lib]
name = "jnibasics"
crate-type = ["cdylib", "lib"]
[dependencies]
jni = "0.21"
Two details worth noticing. Cargo would name the library libjni_basics.so by default, replacing the dash with an underscore, so I set an explicit [lib] name to keep the loadLibrary string clean. And crate-type lists both cdylib for the Android artifact and lib so cargo test can link the crate on your laptop.
Every JNI function receives two arguments before your own. The first is JNIEnv, your handle into the running VM: it converts strings, throws exceptions, looks up classes, and calls Java methods back (docs.rs, jni crate). The second is the receiver. For an instance method it’s the object the method was called on, typed JObject; for a static method it’s the class, typed JClass. Members of a Kotlin object are instance methods on the singleton, so the honest type here is JObject. Plenty of samples write JClass and get away with it because both are pointers underneath and the argument goes unused, but this post is about not cargo-culting, so we write the true one.
Here is the complete rust/jni-basics/src/lib.rs:
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring, JNI_TRUE};
use jni::JNIEnv;
pub fn add(a: i32, b: i32) -> i64 {
a as i64 + b as i64
}
pub fn reverse(input: &str) -> String {
input.chars().rev().collect()
}
pub fn greet(name: &str, premium: bool) -> String {
if premium {
format!("Welcome back, {name}. Premium is active.")
} else {
format!("Hello, {name}.")
}
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_add(
_env: JNIEnv,
_this: JObject,
a: jint,
b: jint,
) -> jlong {
add(a, b)
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_reverse(
mut env: JNIEnv,
_this: JObject,
input: JString,
) -> jstring {
let input: String = env
.get_string(&input)
.map(|s| s.into())
.unwrap_or_default();
let output = reverse(&input);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_greet(
mut env: JNIEnv,
_this: JObject,
name: JString,
premium: jboolean,
) -> jstring {
let name: String = env
.get_string(&name)
.map(|s| s.into())
.unwrap_or_else(|_| "unknown".to_string());
let output = greet(&name, premium == JNI_TRUE);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[cfg(test)]
mod tests {
use super::{add, greet, reverse};
#[test]
fn add_survives_int_overflow() {
assert_eq!(add(i32::MAX, 1), 2_147_483_648);
}
#[test]
fn reverse_reverses() {
assert_eq!(reverse("Rust"), "tsuR");
}
#[test]
fn greet_includes_name() {
assert!(greet("Android", true).contains("Android"));
}
}
Notice the shape. The logic lives in three plain Rust functions with no JNI types anywhere near them, and each exported wrapper does nothing but convert arguments, call the real function, and convert the result. That split is what lets the tests at the bottom run on your laptop without a device or an emulator:
running 3 tests
test tests::add_survives_int_overflow ... ok
test tests::greet_includes_name ... ok
test tests::reverse_reverses ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Build the Android libraries from the rust/ directory with the same cargo-ndk invocation as Part 1, plus -p to pick the crate:
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \
-o ../app/src/main/jniLibs build --release -p jni-basics
Then verify the export happened. llvm-nm ships in the NDK’s toolchain directory, and -gD shows the dynamic symbol table, exactly the set ART can resolve against:
$ llvm-nm -gD app/src/main/jniLibs/arm64-v8a/libjnibasics.so | grep Java_
00000000000192d8 T Java_dev_davthecoder_rustseries_JniBasics_add
00000000000192e4 T Java_dev_davthecoder_rustseries_JniBasics_greet
00000000000194f8 T Java_dev_davthecoder_rustseries_JniBasics_reverse
Three symbols, three external fun declarations, character for character. When those match, the Kotlin side just works:
val sum = JniBasics.add(Int.MAX_VALUE, 1) // 2147483648L
val flipped = JniBasics.reverse("Rust") // "tsuR"
val message = JniBasics.greet("Ada", premium = true)
Primitives, strings, and booleans across the boundary
The three functions cover the kinds of traffic you’ll actually send across JNI. Primitives map directly between the type systems:
| Kotlin | JNI type | Rust type |
|---|---|---|
Int |
jint |
i32 |
Long |
jlong |
i64 |
Boolean |
jboolean |
u8 |
Float |
jfloat |
f32 |
Double |
jdouble |
f64 |
Byte |
jbyte |
i8 |
Short |
jshort |
i16 |
Char |
jchar |
u16 |
Primitives are copied by value with the same machine representation on both sides, so there is nothing to convert and nothing to free. The add function shows the one thing worth thinking about: widths. Kotlin’s Int is exactly i32, so Int.MAX_VALUE + 1 overflows in Kotlin, but our add does its arithmetic in i64 and returns jlong, so the same inputs come back as a correct 2147483648L.
Booleans hide a small trap. jboolean is an unsigned 8-bit integer, not a Rust bool, because that’s how the JNI ABI defines it. The greet wrapper compares against JNI_TRUE to get a real bool. Don’t be tempted to transmute: a Rust bool must be exactly 0 or 1, and trusting a foreign byte to respect that is undefined behavior waiting for an audience.
Strings are a different species entirely. A JString is not text; it’s a reference to an object inside the VM heap, and Rust can’t read it directly. env.get_string copies the contents out and decodes them into an owned Rust String, and env.new_string builds a fresh Java string from Rust text on the way back. The subtlety is the encoding: JNI’s string functions speak modified UTF-8, in which the null character and supplementary code points like emoji are encoded differently from standard UTF-8 (Oracle, JNI specification). The jni crate converts correctly in both directions, which is one of the quieter reasons to use it.
Both directions copy the full string, which is fine for names and messages and wrong for megabytes. Moving serious data needs arrays, ByteBuffers, and zero-copy techniques, and that’s a whole post: Part 6, Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy, covers exactly that.
One honesty note about reverse: chars() iterates Unicode scalar values, so combining accents will detach from their base characters. Correct grapheme-level reversal needs the unicode-segmentation crate; this is a teaching function, not a text engine.
Static naming vs RegisterNatives
There is a second way to connect the two worlds: export a single JNI_OnLoad function and call RegisterNatives from it, handing the VM a table that maps method names to arbitrary Rust function pointers. The jni crate exposes this as register_native_methods. You gain real advantages: your Rust functions can have normal names, a typo in the table fails at load time instead of at first call, and the library exports almost nothing. Android’s own JNI tips page recommends this pattern for C++ codebases (Android Developers, JNI tips).
I still recommend static naming at this stage. It needs one attribute instead of a registration table, and every connection is a symbol you can inspect with llvm-nm. When something breaks, the error names the exact missing symbol. Once your API grows past a handful of functions you shouldn’t be scaling hand-written JNI at all; that’s what Part 3 is for.
Frequently Asked Questions
Do I need javah or C header files to call Rust from Kotlin?
No. Header generation was only ever a convenience for C and C++ implementations, and the javah tool was removed back in JDK 10 (OpenJDK, JEP 313). In Rust you produce the correctly named symbol yourself with #[no_mangle], and llvm-nm confirms it’s there.
Why does calling Rust from Kotlin throw UnsatisfiedLinkError?
One of three things: the library never loaded, the symbol name doesn’t match the expected Java_ pattern, or the symbol isn’t exported because #[no_mangle], pub, or crate-type = ["cdylib"] is missing. Run llvm-nm -gD on the .so and compare token by token. Resolution is lazy, so the error fires at first call, not at startup.
What is the difference between extern “C” and extern “system” in Rust?
On Android, nothing: both select the same calling convention on every ABI the NDK supports. They diverged only on 32-bit Windows x86, where "system" meant stdcall. Use "system" for JNI entry points because it matches how the JNI headers declare JNICALL, and it stays correct on any platform your crate might reach.
Is JNI call overhead a problem when calling Rust from Kotlin?
A JNI call costs more than a regular Kotlin call, and string arguments add a copy and an encoding conversion in each direction. The design rule is to make crossings coarse: one call that does real work beats a chatty loop of tiny calls. For bulk data there are cheaper vehicles than strings, which Part 6 covers in depth.
Where this leaves you
The point of writing this layer by hand once is that none of it stays mysterious. Each export is a naming rule plus two attributes. The receiver is a JObject because a Kotlin object’s members are instance methods, not because some sample said so. And llvm-nm settles what the library exports before you ever run the app.
What this approach doesn’t do is scale. Every new method means a hand-matched signature on both sides and a naming rule that punishes typos at runtime. Part 3, UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand, generates the whole bridge from annotated Rust. The machinery you just learned is what makes that generator’s output readable instead of magical. The hub post has the full series map.

Loading comments…