Android — Kotlin and Jetpack Compose

These are the steps to set up Android Studio to build and run a simple Android app that calls into a shared core.

Note

This walk-through assumes you have already added the shared and shared_types libraries to your repo, as described in Shared core and types.

Sharp edge

We want to make setting up Android Studio to work with Crux really easy. As time progresses we will try to simplify and automate as much as possible, but at the moment there is some manual configuration to do. This only needs doing once, so we hope it's not too much trouble. If you know of any better ways than those we describe below, please either raise an issue (or a PR) at https://github.com/redbadger/crux.

Create an Android App

The first thing we need to do is create a new Android app in Android Studio.

Open Android Studio and create a new project, for "Phone and Tablet", of type "Empty Activity". In this walk-through, we'll call it "Counter"

  • "Name": Counter
  • "Package name": com.example.simple_counter
  • "Save Location": a directory called Android at the root of our monorepo
  • "Minimum SDK" API 34
  • "Build configuration language": Groovy DSL (build.gradle)

Your repo's directory structure might now look something like this (some files elided):

.
├── Android
│  ├── app
│  │  ├── build.gradle
│  │  ├── libs
│  │  └── src
│  │     └── main
│  │        ├── AndroidManifest.xml
│  │        └── java
│  │           └── com
│  │              └── example
│  │                 └── simple_counter
│  │                    └── MainActivity.kt
│  ├── build.gradle
│  ├── gradle.properties
│  ├── local.properties
│  └── settings.gradle
├── Cargo.lock
├── Cargo.toml
├── shared
│  ├── build.rs
│  ├── Cargo.toml
│  ├── src
│  │  ├── app.rs
│  │  ├── lib.rs
│  │  └── shared.udl
│  └── uniffi.toml
├── shared_types
│  ├── build.rs
│  ├── Cargo.toml
│  └── src
│     └── lib.rs
└── target

Add a Kotlin Android Library

This shared Android library (aar) is going to wrap our shared Rust library.

Under File -> New -> New Module, choose "Android Library" and give it the "Module name" shared. Set the "Package name" to match the one from your /shared/uniffi.toml, which in this example is com.example.simple_counter.shared.

Again set the "Build configuration language" to Groovy DSL (build.gradle).

For more information on how to add an Android library see https://developer.android.com/studio/projects/android-library.

We can now add this library as a dependency of our app.

Edit the app's build.gradle (/Android/app/build.gradle) to look like this:

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    namespace 'com.example.simple_counter'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.simple_counter"
        minSdk 33
        targetSdk 34
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles {
                getDefaultProguardFile('proguard-android-optimize.txt')
                'proguard-rules.pro'
            }
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.5.3'
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/*'
        }
    }
}

dependencies {
    // our shared library
    implementation project(path: ':shared')

    def composeBom = platform('androidx.compose:compose-bom:2024.04.00')
    implementation composeBom
    androidTestImplementation composeBom

    implementation 'androidx.compose.material3:material3:1.2.1'

    // Optional - Integration with ViewModels
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    // Optional - Integration with LiveData
    implementation("androidx.compose.runtime:runtime-livedata")

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'

    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
    implementation 'androidx.activity:activity-compose:1.8.2'
    implementation "androidx.compose.ui:ui:1.6.5"
    implementation "androidx.compose.ui:ui-tooling-preview:1.6.5"
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.5"
    debugImplementation "androidx.compose.ui:ui-tooling:1.6.5"
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.5"
}

The Rust shared library

We'll use the following tools to incorporate our Rust shared library into the Android library added above. This includes compiling and linking the Rust dynamic library and generating the runtime bindings and the shared types.

The NDK can be installed from "Tools, SDK Manager, SDK Tools" in Android Studio.

Let's get started.

Add the four rust android toolchains to your system:

$ rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android

Edit the project's build.gradle (/Android/build.gradle) to look like this:

plugins {
    id 'com.android.application' version '8.3.2' apply false
    id 'com.android.library' version '8.3.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
    id "org.mozilla.rust-android-gradle.rust-android" version "0.9.3" apply false
}

Edit the library's build.gradle (/Android/shared/build.gradle) to look like this:

plugins {
    id 'com.android.library'
    id 'org.jetbrains.kotlin.android'
    id 'org.mozilla.rust-android-gradle.rust-android'
}

android {
    namespace 'com.example.simple_counter.shared'
    compileSdk 34

    ndkVersion "26.3.11579264"

    defaultConfig {
        minSdk 33
        targetSdk 34

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles "consumer-rules.pro"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles {
                getDefaultProguardFile('proguard-android-optimize.txt')
                'proguard-rules.pro'
            }
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }

    sourceSets {
        main.java.srcDirs += "${projectDir}/../../shared_types/generated/java"
    }
}

dependencies {
    implementation "net.java.dev.jna:jna:5.13.0@aar"

    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.11.0'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

apply plugin: 'org.mozilla.rust-android-gradle.rust-android'

cargo {
    module = "../.."
    libname = "shared"
    // these are the four recommended targets for Android that will ensure your library works on all mainline android devices
    // make sure you have included the rust toolchain for each of these targets: \
    // `rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android`
    targets = ["arm", "arm64", "x86", "x86_64"]
    extraCargoBuildArguments = ['--package', 'shared']
}

afterEvaluate {
    // The `cargoBuild` task isn't available until after evaluation.
    android.libraryVariants.configureEach { variant ->
        def productFlavor = ""
        variant.productFlavors.each {
            productFlavor += "${it.name.capitalize()}"
        }
        def buildType = "${variant.buildType.name.capitalize()}"

        tasks.named("compileDebugKotlin") {
            it.dependsOn(tasks.named("typesGen"), tasks.named("bindGen"))
        }

        tasks.named("generate${productFlavor}${buildType}Assets") {
            it.dependsOn(tasks.named("cargoBuild"))
        }
    }
}

tasks.register('bindGen', Exec) {
    def outDir = "${projectDir}/../../shared_types/generated/java"
    workingDir "../../"
    if (System.getProperty('os.name').toLowerCase().contains('windows')) {
        commandLine("cmd", "/c",
                "cargo build -p shared && " + "target\\debug\\uniffi-bindgen generate shared\\src\\shared.udl " + "--language kotlin " + "--out-dir " + outDir.replace('/', '\\'))
    } else {
        commandLine("sh", "-c",
                """\
                cargo build -p shared && \
                target/debug/uniffi-bindgen generate shared/src/shared.udl \
                --language kotlin \
                --out-dir $outDir
                """)
    }
}

tasks.register('typesGen', Exec) {
    workingDir "../../"
    if (System.getProperty('os.name').toLowerCase().contains('windows')) {
        commandLine("cmd", "/c", "cargo build -p shared_types")
    } else {
        commandLine("sh", "-c", "cargo build -p shared_types")
    }
}

Sharp edge

You will need to set the ndkVersion to one you have installed, go to "Tools, SDK Manager, SDK Tooks" and check "Show Package Details" to get your installed version, or to install the version matching build.gradle above.

Tip

When you have edited the gradle files, don't forget to click "sync now".

If you now build your project you should see the newly built shared library object file.

$ ls --tree Android/shared/build/rustJniLibs
Android/shared/build/rustJniLibs
└── android
   └── arm64-v8a
      └── libshared.so
   └── armeabi-v7a
      └── libshared.so
   └── x86
      └── libshared.so
   └── x86_64
      └── libshared.so

You should also see the generated types — note that the sourceSets directive in the shared library gradle file (above) allows us to build our shared library against the generated types in the shared_types/generated folder.

$ ls --tree shared_types/generated/java
shared_types/generated/java
└── com
   ├── example
   │  └── counter
   │     ├── shared
   │     │  └── shared.kt
   │     └── shared_types
   │        ├── Effect.java
   │        ├── Event.java
   │        ├── RenderOperation.java
   │        ├── Request.java
   │        ├── Requests.java
   │        ├── TraitHelpers.java
   │        └── ViewModel.java
   └── novi
      ├── bincode
      │  ├── BincodeDeserializer.java
      │  └── BincodeSerializer.java
      └── serde
         ├── ArrayLen.java
         ├── BinaryDeserializer.java
         ├── BinarySerializer.java
         ├── Bytes.java
         ├── DeserializationError.java
         ├── Deserializer.java
         ├── Int128.java
         ├── SerializationError.java
         ├── Serializer.java
         ├── Slice.java
         ├── Tuple2.java
         ├── Tuple3.java
         ├── Tuple4.java
         ├── Tuple5.java
         ├── Tuple6.java
         ├── Unit.java
         └── Unsigned.java

Create some UI and run in the Simulator

Example

There is a slightly more advanced example of an Android app in the Crux repository.

However, we will use the simple counter example, which has shared and shared_types libraries that will work with the following example code.

Simple counter example

A simple app that increments, decrements and resets a counter.

Wrap the core to support capabilities

First, let's add some boilerplate code to wrap our core and handle the capabilities that we are using. For this example, we only need to support the Render capability, which triggers a render of the UI.

Let's create a file "File, New, Kotlin Class/File, File" called Core.

Note

This code that wraps the core only needs to be written once — it only grows when we need to support additional capabilities.

Edit Android/app/src/main/java/com/example/simple_counter/Core.kt to look like the following. This code sends our (UI-generated) events to the core, and handles any effects that the core asks for. In this simple example, we aren't calling any HTTP APIs or handling any side effects other than rendering the UI, so we just handle this render effect by updating the published view model from the core.

package com.example.simple_counter

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import com.example.simple_counter.shared.processEvent
import com.example.simple_counter.shared.view
import com.example.simple_counter.shared_types.Effect
import com.example.simple_counter.shared_types.Event
import com.example.simple_counter.shared_types.Request
import com.example.simple_counter.shared_types.Requests
import com.example.simple_counter.shared_types.ViewModel

class Core : androidx.lifecycle.ViewModel() {
    var view: ViewModel? by mutableStateOf(null)
        private set

    fun update(event: Event) {
        val effects = processEvent(event.bincodeSerialize())

        val requests = Requests.bincodeDeserialize(effects)
        for (request in requests) {
            processEffect(request)
        }
    }

    private fun processEffect(request: Request) {
        when (request.effect) {
            is Effect.Render -> {
                this.view = ViewModel.bincodeDeserialize(view())
            }
        }
    }
}

Tip

That when statement, above, is where you would handle any other effects that your core might ask for. For example, if your core needs to make an HTTP request, you would handle that here. To see an example of this, take a look at the counter example in the Crux repository.

Edit /Android/app/src/main/java/com/example/simple_counter/MainActivity.kt to look like the following:

@file:OptIn(ExperimentalUnsignedTypes::class)

package com.example.simple_counter

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.simple_counter.shared_types.Event
import com.example.simple_counter.ui.theme.CounterTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CounterTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
                ) {
                    View()
                }
            }
        }
    }
}

@Composable
fun View(core: Core = viewModel()) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier
            .fillMaxSize()
            .padding(10.dp),
    ) {
        Text(text = (core.view?.count ?: "0").toString(), modifier = Modifier.padding(10.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
            Button(
                onClick = { core.update(Event.Reset()) }, colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.error
                )
            ) { Text(text = "Reset", color = Color.White) }
            Button(
                onClick = { core.update(Event.Increment()) }, colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.primary
                )
            ) { Text(text = "Increment", color = Color.White) }
            Button(
                onClick = { core.update(Event.Decrement()) }, colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.secondary
                )
            ) { Text(text = "Decrement", color = Color.White) }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    CounterTheme {
        View()
    }
}

Success

You should then be able to run the app in the simulator, and it should look like this:

simple counter app