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.
This walk-through assumes you have already added the shared
and shared_types
libraries to your repo, as described in Shared core and types.
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 Compose Activity (Material3)". In this walk-through, we'll call it
"Counter", use a minimum SDK of API 34, and save it in a directory called
Android
.
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
│ │ └── counter
│ │ └── MainActivity.kt
│ ├── build.gradle
│ ├── gradle.properties
│ ├── local.properties
│ └── settings.gradle
├── Cargo.lock
├── Cargo.toml
├── shared
│ ├── build.rs
│ ├── Cargo.toml
│ ├── src
│ │ ├── counter.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 call it
something like shared
. Set the "Package name" to match the one from your
/shared/uniffi.toml
, e.g. com.example.counter.shared
.
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:2022.10.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.0-alpha10'
// Optional - Integration with ViewModels
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
// 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.6.2'
implementation 'androidx.activity:activity-compose:1.8.1'
implementation "androidx.compose.ui:ui:1.5.4"
implementation "androidx.compose.ui:ui-tooling-preview:1.5.4"
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.5.4"
debugImplementation "androidx.compose.ui:ui-tooling:1.5.4"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.4"
}
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 Android NDK
- Mozilla's Rust gradle plugin for Android
- Java Native Access
- Uniffi to generate Java bindings
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.1.2' apply false
id 'com.android.library' version '8.1.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 "25.2.9519653"
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.10.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")
}
}
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
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.
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(ViewModel.bincodeDeserialize(view()))
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())
}
}
}
}
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.*
import androidx.compose.material3.*
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.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()
}
}