Rust cross-platform... The Android part...
In this journey, we will go through the process, infrastructure and architecture on how we can integrate Rust in our Android Project in a cross-platform development way. Topics like JNI and NDK are also covered, plus some anti-patterns and real use cases/scenarios.
“Nothing is impossible. The word itself says ‘I’m possible!”
- Introduction and Whys
- Our Goal
- Where is the code?
- The Big Picture
- Running the Project
- Crypto: The Rust Project
- Android: Implementation Details
- Use Case Scenarios
- Conclusion
- References
Introduction and Whys
Even in a world of Kotlin Multiplatform, there are other options, which might cover other specifc use case scenarios (more about it along this post).
This is actually the main reason, why I would like to present Rust as a candidate for code reusability across different platforms… in this case for Android Development.
Our Goal
Our project consists of an Android Application that will call Rust code in order to encrypt/decrypt a given String
:
Where is the code?
Before continuing, it is worth mentioning that the entire codebase sits in a Github Repository containig extra documentation and code comments to facilitate UNDERSTANDING and LEARNING.
The Big Picture
In a nutshell, our project will follow this flow:
Rust and Android interaction involves a bunch of parts (my approach is to have 2 separated projects that we can independently evolve):
- Rust compilation takes place in the first place.
- JNI artifacts (libraries) are generated for different android cpu architectures and instruction sets.
- These artifacts (extension
.so
) should be placed injniLibs
folder inside the Android Project. - Android consumes them via Java Native Interface (JNI).
As a next step, let’s run the project, break things down and dive deeper into each part.
Running the Project
After cloning the repo, follow the steps below.
Requirements
- Android SDK and NDK installed.
- ANDROID_HOME env variable pointing to the Android Sdk location: mine is at
/home/fernando/Android/Sdk
. - Android NDK version should match the one inside the
jni_crypto/build.rs
file.- In my case
$ANDROID_HOME/ndk/25.2.9519653
matches withANDROID_NDK_VERSION = "25.2.9519653"
.
- In my case
- Rust latest edition. If in trouble, check the project
Cargo.toml
file for the correct one. - Your IDE and Editor of preference.
Generating Rust artifacts (crates)
- Go to
rust-library/jni_cryptor
folder. - Run
cargo run --bin release
. - Run
cargo run --bin publish
. - OPTIONAL:
cargo test
.
Running the Android App
- Import in Android Studio the
android-sample
folder (build.gradle.kts
file). - Run
app
via IDE.
Crypto: The Rust Project
The Rust project structure (called crypto
) looks like this:
- cryptor: Our core crate where we perform
string
encryption/decryption. - cryptor_global: As it name established, a global crate for code reusability.
- cryptor_jni: Our JNI exposed API that act as a proxy by calling cryptor functions.
Crypto: Show me the code
Let’s use the example of text encryption (it is simplified by only base64-encoding a string
). So here is our encrypt
function in Rust, part of the crypto crate inside the cryptor/src/libs.rs
file:
use base64::{
Engine as _,
engine::general_purpose::STANDARD as base64Engine
};
///
/// Encrypts a String.
///
pub fn encrypt(to: &str) -> String {
base64Engine.encode(String::from(to))
}
And a tiny test for it:
use cryptor;
#[test]
fn test_encrypt_string() {
let to_encrypt = "hello_world_from_rust";
let str_encoded_b64 = "aGVsbG9fd29ybGRfZnJvbV9ydXN0";
let encrypted_result = cryptor::encrypt(&to_encrypt);
assert_eq!(str_encoded_b64, encrypted_result);
}
Now we need our JNI Api in place, which makes use of our crypto crate (as showcased in the crypto
project structure picture above). This sits inside the cryptor_jni/scr/libs.rs
file:
///
/// [cfg(target_os = "android")]: Compiler flag ("cfg") which exposes
/// the JNI interface for targeting Android in this case
///
/// [allow(non_snake_case)]: Tells the compiler not to warn if
/// we are not using snake_case for a variable or function names.
/// For Android Development we want to be consistent with code style.
///
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;
// This is the interface to the JVM
// that we'll call the majority of our
// methods on.
// @See https://docs.rs/jni/latest/jni/
use self::jni::JNIEnv;
// These objects are what you should use as arguments to your
// native function. They carry extra lifetime information to
// prevent them escaping this context and getting used after
// being GC'd.
use self::jni::objects::{JClass, JString};
// This is just a pointer. We'll be returning it from our function.
// We can't return one of the objects with lifetime information
// because the lifetime checker won't let us.
use self::jni::sys::jstring;
use cryptor::encrypt;
///
/// Encrypts a String.
///
#[no_mangle] // This keeps Rust from "mangling" the name so it is unique (crate).
pub extern "system" fn Java_com_fernandocejas_rust_Cryptor_encrypt<'local>(
mut env: JNIEnv<'local>,
// This is the class that owns our static method. It's not going to be used,
// but still must be present to match the expected signature of a static
// native method.
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
// First, we have to get the string out of Java. Check out the `strings`
// module for more info on how this works.
let to_encrypt: String = env.get_string(&input)
.expect("Couldn't get java string!").into();
// We encrypt our str calling the cryptor library
let encrypted_str = encrypt(&to_encrypt);
// Here we have to create a new Java string to return. Again, more info
// in the `strings` module.
let output = env.new_string(&encrypted_str)
.expect("Couldn't create Java String!");
// Finally, extract the raw pointer to return.
output.into_raw()
}
}
Something to pay a bit of attention to, is the function signature, which we will cover in our android project part. But for now, let’s leave it here and focus on our artifact (crate) generation.
Crypto: Artifact Generation
At this point, our Rust code is in place, and we need to generate our .so
artifacts via cargo
(Rust package manager).
When building the crypto_jni
crate with the cargo build
command (inside our crypto_jni
foler), cargo
first searches for a build script file (build.rs
) in the root folder of the project in order to execute it.
AND HERE IS WHERE THE MAGIC HAPPENS!!!… so let’s have a look at what is inside our build.rs
file:
...
static ANDROID_NDK_VERSION: &str = "25.2.9519653";
...
fn main() {
system::rerun_if_changed("build.rs");
create_android_targets_config_file();
add_android_targets_to_toolchain();
}
Basically we are creating a cargo config file containing android targets information, needed by cargo
to perform cross compilation.
Run cargo build
inside the cryptor_jni
folder and once done open the generated file at rust-library/cryptor_jni/.cargo/config
, which should look similar to this:
[target.armv7-linux-androideabi]
ar = ".../ndk/25.2.9519653/.../linux-x86_64/bin/arm-linux-androideabi-ar"
linker = ".../ndk/25.2.9519653/.../linux-x86_64/bin/armv7a-linux-androideabi21-clang"
[target.i686-linux-android]
ar = ".../ndk/25.2.9519653/.../linux-x86_64/bin/i686-linux-android-ar"
linker = ".../ndk/25.2.9519653/.../linux-x86_64/bin/i686-linux-android21-clang"
[target.aarch64-linux-android]
ar = ".../ndk/25.2.9519653/.../linux-x86_64/bin/aarch64-linux-android-ar"
linker = ".../ndk/25.2.9519653/.../linux-x86_64/bin/aarch64-linux-android21-clang"
[target.x86_64-linux-android]
ar = ".../ndk/25.2.9519653/.../linux-x86_64/bin/x86_64-linux-android-ar"
linker = ".../ndk/25.2.9519653/.../linux-x86_64/bin/x86_64-linux-android21-clang"
Each target in the above config file derives from the Official Android Documentation on “Using NDK with other build systems” which basically states that in order to build for an specific cpu architecture/type and instruction set (ABI), the Android NDK provides pre-compiled toolchains that need to be used (Ex.
arm-linux-androideabi-ar
andarmv7a-linux-androideabi21-clang
).
Now cargo
knows what to build and how, for instance, the next step is to add those targets to the rust toolchain, which is basically what this line of code is doing:
fn main() {
...
/// ## Examples
/// `rustup target add arm-linux-androideabi`
///
/// Reference:
/// - https://rust-lang.github.io/rustup/cross-compilation.html
add_android_targets_to_toolchain();
}
The above code enables us to run the following commands if we wanted to individually build our targets:
cargo build --target armv7-linux-androideabi
cargo build --target i686-linux-android
cargo build --target aarch64-linux-android
cargo build --target x86_64-linux-android
Although this is perfect valid, it is tedious… that is why it is a good practice to AUTOMATE ALL THE THINGS (as much as possible). And this is done by the cryptor_jni/src/bin/release.rs file, relying on cargo
binary targets, which are basically programs that can be executed after comopilation:
cargo run --bin release
Last but not least, there is another binary target call publish (publish.rs
file) that we can execute:
cargo run --bin publish
This will copy all the generated targets to its corresponding android directories in our android-sample project.
Crypto: Android ABIs
We have been mentioning ABIs previously along this article, but what is that exactly and how does an ABI relate to a target?
ABI stands for Application Binary Interface, which is a combination of a CPU type/architecture and instruction set. In Android Development any NDK target must be mapped to a specific directory in the project. This relationship is as following according to the documentation:
-------------------------------------------------------------
ANDROID TARGET ABI (folder inside `jniLibs`)
-------------------------------------------------------------
armv7a-linux-androideabi ---> armeabi-v7a
aarch64-linux-android ---> arm64-v8a
i686-linux-android ---> x86
x86_64-linux-android ---> x86_64
-------------------------------------------------------------
Crypto: Infrastructure Improvements
So far, evertyhing is ready for development on the Rust side with some automation… But of course, there are a couple of IMPROVEMENTS that I did not want to skip… and even though these are OUT OF SCOPE of this article, they are definitely worth highlighting:
- There is NO Semantic Versioning for crates, which is something required as soon as the project grows in complexity.
- Artifacts are copied and overriden directly inside the android project (
jniLibs
directory): ideally these ones should be properly versioned (mentioned above) and uploaded to a crates repository or similar.
Android: Implementation Details
On the Android side of things, there are a couple of moving parts that we have to take into consideration.
Android: Setting Up the Build System
In the build.gradle.kts
we need to add NDK configuration:
android {
...
ndk {
// Specifies the ABI configurations of your native
// libraries Gradle should build and package with your APK.
// Here is a list of supported ABIs:
// https://developer.android.com/ndk/guides/abis
abiFilters.addAll(
setOf(
"armeabi-v7a",
"arm64-v8a",
"x86",
"x86_64"
)
)
}
...
}
Android: Loading Rust Libraries
This is done at Android Application Class level:
class AndroidApplication : Application() {
override fun onCreate() {
super.onCreate()
loadJNILibraries()
}
private fun loadJNILibraries() {
/**
* Loads the Crypto C++/Rust (via JNI) Library.
*
* IMPORTANT:
* The name passed as argument () maps to the
* original library name in our Rust project.
*/
System.loadLibrary("cryptor_jni")
}
}
Android: Calling Rust from Kotlin
In order to call Rust via JNI, we have to respect method/function signature. This is essential and a MUST so that classes, functions and methods can be found by the android runtime.
Remember this piece of code from our cryptor_jni
project that encrypts a String
:
...
pub extern "system" fn Java_com_fernandocejas_rust_Cryptor_encrypt<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
...
}
...
Invoking it from Kotlin, would mean creating a kotlin class RESPECTING package and function naming:
package com.fernandocejas.rust
/**
* Helper that acts as an interface between native
* code (in this case Rust via JNI) and Kotlin.
*
* By convention the function signatures should respect
* the original ones from Rust via JNI Project.
*/
class Cryptor {
/**
* Encrypt a string.
*
* This is an external call to Rust using
* the Java Native Interface (JNI).
*
* @link https://developer.android.com/ndk/samples/sample_hellojni
*/
@Throws(IllegalArgumentException::class)
external fun encrypt(string: String): String
...
}
We are done!!! Now we can inject our Cryptor
class where it is needed and encrypt/decrypt Strings
:
private val cryptor = Cryptor()
val encryptedString = cryptor.encrypt("something")
Use Case Scenarios
You might be wondering what is the real purpose of all this wiring for integrating Rust with Android… At the moment I can think of some real use cases:
- Music Player: this comes from my experience at SoundCloud, where we had our project in C++ cross platform compiled.
- Video Player or Media Library: similar use case as a Music Player but with video encoding/decoding.
- Encryption Library: as showcased in this post but with more advanced functionality and low level details.
- Existent Project: sometimes we cannot control 100% our environment, so how does Mozilla integrates its Gecko Engine on Firefox Android?
Conclusion
As a conlusion, I would say… WE DO NOT WANT to replace Kotlin with Rust. Kotlin is very good as what it is meant to be: Android Development in THIS CASE. But keep in mind that PICKING THE BEST TOOL FOR THE RIGHT JOB is essential to fulfill project requirements, and it is in this context where we can count on Rust as ONE MORE TOOL IN OUR TOOLBOX.
Ufff… that was a loooong post… but if you made it to this point, you should definitely feel proud of yourself… Till the next time and do not forget to provide FEEDBACK!!!