Rust cross-platform... The Android part...

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

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.

DISCLAIMER: The idea is NOT to develop a full android application entirely in Rust, but to delegate specific functionality by integrating this language, which brings high performance and memory safety between its main characteristics.

Our Goal

Our project consists of an Android Application that will call Rust code in order to encrypt/decrypt a given String:


fernando-cejas Our Android App calling Rust code.

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:


fernando-cejas Our global project overview.

Rust and Android interaction involves a bunch of parts (my approach is to have 2 separated projects that we can independently evolve):

  1. Rust compilation takes place in the first place.
  2. JNI artifacts (libraries) are generated for different android cpu architectures and instruction sets.
  3. These artifacts (extension .so) should be placed in jniLibs folder inside the Android Project.
  4. 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 with ANDROID_NDK_VERSION = "25.2.9519653".
  • 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:


fernando-cejas Our Rust ‘crypto’ project overview.

  • 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.

NOTE: We focus on the sub-projects that involve Android, so do not worry about the content of the other folders, since each of them is independent and they do not affect each other.

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.

NOTE: I have used the jni crate for this purpose, which has excellent documentation.

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 and armv7a-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!!!

References