Android
Overview
The Android target produces a Gradle android-library template that
combines a Kotlin wrapper, JNI C shims, and a CMake build for the JNI
shared library. The wrapper exposes idiomatic Kotlin types while the JNI
layer bridges them to the C ABI.
What gets generated
| File | Purpose |
|---|---|
generated/android/settings.gradle | Gradle settings for the library module |
generated/android/build.gradle | android-library plugin, NDK config |
generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.kt | Kotlin wrapper (enums, struct classes, namespaced functions) |
generated/android/src/main/cpp/weaveffi_jni.c | JNI shims that call the C ABI and throw Java exceptions |
generated/android/src/main/cpp/CMakeLists.txt | NDK CMake build for the JNI shared library |
Type mapping
| IDL type | Kotlin type (external) | Kotlin type (wrapper) | JNI C type |
|---|---|---|---|
i32 | Int | Int | jint |
u32 | Long | Long | jlong |
i64 | Long | Long | jlong |
f64 | Double | Double | jdouble |
i8 | Byte | Byte | jbyte |
i16 | Short | Short | jshort |
u8 | Byte | Byte | jbyte |
u16 | Short | Short | jshort |
u64 | Long | Long | jlong |
f32 | Float | Float | jfloat |
bool | Boolean | Boolean | jboolean |
string | String | String | jstring |
bytes | ByteArray | ByteArray | jbyteArray |
handle | Long | Long | jlong |
StructName | Long | StructName | jlong |
EnumName (plain) | Int | EnumName | jint |
EnumName (rich) | Long | EnumName | jlong |
T? | T? | T? | jobject |
[i32] | IntArray | IntArray | jintArray |
[i64] | LongArray | LongArray | jlongArray |
[string] | Array<String> | Array<String> | jobjectArray |
iter<T> | Iterator<T> | Iterator<T> | jobject |
Example IDL → generated code
version: "0.4.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: age, type: i32 }
functions:
- name: get_contact
params:
- { name: id, type: i32 }
return: Contact
- name: find_by_type
params:
- { name: contact_type, type: ContactType }
return: "[Contact]"
The Kotlin wrapper declares external fun entries inside a companion
object and loads the JNI library on first use. Function names are
prefixed with the module name. Where a parameter or return value needs
wrapping (enums, structs), the external entry is a private ...Jni
function with lowered types and a public wrapper converts at the
boundary. Struct returns come back as handles and are wrapped in the
struct class; [Contact] stays a LongArray of handles:
package com.weaveffi
class WeaveFFI {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic private external fun contacts_get_contactJni(id: Int): Long
@JvmStatic fun contacts_get_contact(id: Int): Contact = Contact(contacts_get_contactJni(id))
@JvmStatic private external fun contacts_find_by_typeJni(contact_type: Int): LongArray
@JvmStatic fun contacts_find_by_type(contact_type: ContactType): LongArray = contacts_find_by_typeJni(contact_type.value)
}
}
Enums become Kotlin enum class with a fromValue factory:
enum class ContactType(val value: Int) {
Personal(0),
Work(1),
Other(2);
companion object {
fun fromValue(value: Int): ContactType = entries.first { it.value == value }
}
}
Structs are wrapped in a Kotlin class implementing Closeable, with a
finalize() safety net:
class Contact internal constructor(internal var handle: Long) : java.io.Closeable {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic external fun nativeCreate(name: String, age: Int): Long
@JvmStatic external fun nativeDestroy(handle: Long)
@JvmStatic external fun nativeGetName(handle: Long): String
@JvmStatic external fun nativeGetAge(handle: Long): Int
fun create(name: String, age: Int): Contact = Contact(nativeCreate(name, age))
}
val name: String get() = nativeGetName(handle)
val age: Int get() = nativeGetAge(handle)
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
protected fun finalize() {
close()
}
}
The JNI shims (weaveffi_jni.c) bridge each Kotlin external fun into
the C ABI and route errors through a shared throw_weaveffi_error
helper:
static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {
const char* msg = err->message ? err->message : "WeaveFFI error";
jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException");
(*env)->ThrowNew(env, exClass, msg);
weaveffi_error_clear(err);
}
JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_contacts_1get_1contactJni(JNIEnv* env, jclass clazz, jint id) {
weaveffi_error err = {0, NULL};
weaveffi_contacts_Contact* rv = weaveffi_contacts_get_contact((int32_t)id, &err);
if (err.code != 0) {
throw_weaveffi_error(env, &err);
return 0;
}
return (jlong)(intptr_t)rv;
}
When the module declares an error domain, the
generator emits a sealed class WeaveFFIException with one PascalCased
subclass per code (KEY_NOT_FOUND → WeaveFFIException.KeyNotFound), and
the shim resolves the matching subclass by code
(FindClass(env, "com/weaveffi/WeaveFFIException$KeyNotFound")). Modules
without a declared domain get an open class WeaveFFIException, and unknown
codes fall back to java.lang.RuntimeException.
The CMake file links the JNI shim against the generated C header:
cmake_minimum_required(VERSION 3.22)
project(weaveffi)
add_library(weaveffi SHARED weaveffi_jni.c)
target_include_directories(weaveffi PRIVATE ../../../../c)
Rich (algebraic) enums
A rich (algebraic) enum, a sum type whose variants carry associated
data, lowers to an opaque object handle at the C ABI, exactly like a
struct, and shares the same ownership model as the struct wrappers above.
The Kotlin wrapper is a Closeable class holding a Long handle, with
one static factory per variant, a nested Tag discriminant enum class,
and per-variant field getters. (A plain C-style enum with no payloads
stays a Kotlin enum class backed by an Int; see above.)
For the shapes module’s Shape enum (Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }), the generator emits (abridged):
/** An algebraic shape (sum type with associated data) */
class Shape internal constructor(internal var handle: Long) : java.io.Closeable {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic external fun nativeTag(handle: Long): Int
@JvmStatic external fun nativeDestroy(handle: Long)
@JvmStatic external fun nativeNewEmpty(): Long
@JvmStatic external fun nativeNewCircle(radius: Double): Long
@JvmStatic external fun nativeNewRectangle(width: Float, height: Float): Long
@JvmStatic external fun nativeNewLabeled(label: String, count: Byte): Long
@JvmStatic external fun nativeGetCircleRadius(handle: Long): Double
@JvmStatic external fun nativeGetLabeledCount(handle: Long): Byte
/** The empty shape */
fun empty(): Shape = Shape(nativeNewEmpty())
/** A circle with a radius */
fun circle(radius: Double): Shape = Shape(nativeNewCircle(radius))
/** An axis-aligned rectangle */
fun rectangle(width: Float, height: Float): Shape = Shape(nativeNewRectangle(width, height))
/** A labeled shape with a small count */
fun labeled(label: String, count: Byte): Shape = Shape(nativeNewLabeled(label, count))
}
enum class Tag(val value: Int) {
Empty(0),
Circle(1),
Rectangle(2),
Labeled(3);
companion object {
fun fromValue(value: Int): Tag = entries.first { it.value == value }
}
}
val tag: Tag get() = Tag.fromValue(nativeTag(handle))
/** Radius in points */
val circleRadius: Double get() = nativeGetCircleRadius(handle)
val labeledCount: Byte get() = nativeGetLabeledCount(handle)
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
protected fun finalize() {
close()
}
}
Each nativeNew* factory maps to a per-variant constructor
(weaveffi_shapes_Shape_<Variant>_new), nativeTag reads the
discriminant (weaveffi_shapes_Shape_tag), the nativeGet* getters read
one variant field (weaveffi_shapes_Shape_<Variant>_get_<field>), and
nativeDestroy frees the handle (weaveffi_shapes_Shape_destroy). The
JNI shims that back these external methods are named
Java_com_weaveffi_Shape_native*:
JNIEXPORT jlong JNICALL Java_com_weaveffi_Shape_nativeNewCircle(JNIEnv* env, jclass clazz, jdouble radius) {
weaveffi_error err = {0, NULL};
weaveffi_shapes_Shape* rv = weaveffi_shapes_Shape_Circle_new((double)radius, &err);
if (err.code != 0) {
throw_weaveffi_error(env, &err);
return 0;
}
return (jlong)(intptr_t)rv;
}
JNIEXPORT jint JNICALL Java_com_weaveffi_Shape_nativeTag(JNIEnv* env, jclass clazz, jlong handle) {
return (jint)weaveffi_shapes_Shape_tag((const weaveffi_shapes_Shape*)(intptr_t)handle);
}
JNIEXPORT void JNICALL Java_com_weaveffi_Shape_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) {
weaveffi_shapes_Shape_destroy((weaveffi_shapes_Shape*)(intptr_t)handle);
}
Free functions that take or return the enum pass the handle across the
boundary; on the WeaveFFI companion they are
shapes_describe(shape: Shape): String and
shapes_scale(shape: Shape, factor: Double): Shape:
Shape.circle(2.0).use { c ->
println(c.tag) // Tag.Circle
println(c.circleRadius) // 2.0
val bigger = WeaveFFI.shapes_scale(c, 3.0) // returns a new Shape
try {
println(WeaveFFI.shapes_describe(bigger))
} finally {
bigger.close()
}
}
Ownership: a Shape owns its native handle, so call close() (or use
use { ... }) on every Shape you construct or receive, including the
new Shape returned by shapes_scale. The finalize() safety net runs
during GC but is not a substitute for deterministic cleanup.
Build instructions
-
Install Android Studio (Giraffe or newer) plus the NDK.
-
Cross-compile the Rust cdylib for every Android ABI you support:
rustup target add aarch64-linux-android armv7-linux-androideabi \ x86_64-linux-android i686-linux-android export ANDROID_NDK_HOME=/path/to/ndk cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ build --release -p your_library -
Open
generated/androidin Android Studio, sync Gradle, and build the AAR (./gradlew :weaveffi:assemble). -
Add the resulting AAR as a dependency in your app module and ensure your
jniLibs/directory contains the Rust-built cdylib for each supported ABI.
Memory and ownership
- Struct wrappers implement
Closeable; either call.close()explicitly or useuse { ... }. Thefinalize()safety net runs during GC but is not a substitute for deterministic cleanup. - Strings returned from JNI are fresh Java strings; the JNI shim frees
the underlying Rust pointer with
weaveffi_free_stringbefore returning. - Byte arrays returned from JNI are copied with
SetByteArrayRegionbefore the Rust buffer is freed. - Optional values are passed as boxed wrappers (
Integer,Long,Double,Boolean); the JNI shim unboxes and forwards them to the C ABI.
Async support
Async IDL functions (async: true) are exposed as Kotlin suspend fun
declarations built on suspendCancellableCoroutine. The public suspend
wrapper passes a WeaveContinuation (a small class with onSuccess /
onError methods) to a private external launcher; struct results
resume as raw handles and are re-wrapped after the await. From the
async-demo sample (WeaveFFI.kt):
@JvmStatic private external fun tasks_run_taskAsync(name: String, callback: Any)
@JvmStatic suspend fun tasks_run_task(name: String): TaskResult {
val raw: Long = suspendCancellableCoroutine { cont ->
tasks_run_taskAsync(name, WeaveContinuation(cont))
}
return TaskResult(raw)
}
internal class WeaveContinuation<T>(private val cont: kotlinx.coroutines.CancellableContinuation<T>) {
@Suppress("UNCHECKED_CAST")
fun onSuccess(result: Any?) { cont.resume(result as T) }
fun onError(message: String) { cont.resumeWithException(RuntimeException(message)) }
}
The JNI launcher allocates a per-call context holding the JavaVM and
a NewGlobalRef to the WeaveContinuation, then hands the C ABI a
completion callback. That callback attaches the producer’s thread to
the JVM if it is not already attached, calls onSuccess/onError,
deletes the global ref, frees the context exactly once, and detaches
the thread if it attached it:
typedef struct {
JavaVM* jvm;
jobject callback;
} weaveffi_jni_async_ctx;
JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_tasks_1run_1taskAsync(JNIEnv* env, jclass clazz, jstring name, jobject callback) {
weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx));
(*env)->GetJavaVM(env, &ctx->jvm);
ctx->callback = (*env)->NewGlobalRef(env, callback);
const char* name_chars = (*env)->GetStringUTFChars(env, name, NULL);
weaveffi_tasks_run_task_async(name_chars, weaveffi_tasks_run_task_jni_cb, ctx);
(*env)->ReleaseStringUTFChars(env, name, name_chars);
}
static void weaveffi_tasks_run_task_jni_cb(void* context, weaveffi_error* err, void* result) {
weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context;
JNIEnv* env = NULL;
int attached = 0;
if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; }
attached = 1;
}
/* ... calls callback.onError(String) or callback.onSuccess(Object) ... */
if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
(*env)->DeleteGlobalRef(env, ctx->callback);
JavaVM* jvm = ctx->jvm;
free(ctx);
if (attached) (*jvm)->DetachCurrentThread(jvm);
}
The generated build.gradle does not declare a coroutines dependency;
add org.jetbrains.kotlinx:kotlinx-coroutines-android (or -core) to
the consuming project.
For functions marked cancellable: true, the C ABI takes an extra
weaveffi_cancel_token* parameter. The private external launcher
carries it as cancelToken: Long and the shim casts it to
weaveffi_cancel_token*, but the public suspend wrapper currently
passes 0L (no token); coroutine cancellation isn’t wired to the
native cancel token:
@JvmStatic private external fun kv_compact_asyncAsync(store: Long, cancelToken: Long, callback: Any)
@JvmStatic suspend fun kv_compact_async(store: Store): Long = suspendCancellableCoroutine { cont ->
kv_compact_asyncAsync(store.handle, 0L, WeaveContinuation(cont))
}
Callbacks and listeners
IDL callbacks paired with listeners produce a register/unregister
pair. From the events sample:
modules:
- name: events
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
The Kotlin surface takes a lambda and returns a Long subscription id;
pass that id back to unregister:
@JvmStatic external fun events_register_message_listener(callback: (String) -> Unit): Long
@JvmStatic external fun events_unregister_message_listener(id: Long)
The JNI shim keeps the lambda alive with a NewGlobalRef stored in a
mutex-guarded registry (a linked list of contexts holding the JavaVM,
the global ref, and the subscription id). When the producer fires, a C
trampoline attaches the producer’s thread to the JVM if needed and
invokes the lambda through its kotlin.jvm.functions.Function1
invoke(Object): Object method; unregistering removes the registry
entry, deletes the global ref, and frees the context:
static void weaveffi_events_OnMessage_fn_jni_tramp(const char* message, void* context) {
weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context;
JNIEnv* env = NULL;
int attached = 0;
if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return;
attached = 1;
}
if ((*env)->PushLocalFrame(env, 32) != 0) {
if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
return;
}
jobject _a0 = message ? (jobject)(*env)->NewStringUTF(env, message) : (jobject)(*env)->NewStringUTF(env, "");
jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback);
jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;");
(*env)->CallObjectMethod(env, ctx->callback, invoke, _a0);
if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
(*env)->PopLocalFrame(env, NULL);
if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
}
JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_events_1register_1message_1listener(JNIEnv* env, jclass clazz, jobject callback) {
weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx));
(*env)->GetJavaVM(env, &ctx->jvm);
ctx->callback = (*env)->NewGlobalRef(env, callback);
uint64_t id = weaveffi_events_register_message_listener(weaveffi_events_OnMessage_fn_jni_tramp, ctx);
/* ... stores ctx in the registry under id ... */
return (jlong)id;
}
The callback runs on the producer’s thread, whichever thread the
native side fires the event from. For UI work, hop to the main thread
yourself (e.g. withContext(Dispatchers.Main) or Handler.post).
Iterators
iter<T> returns surface as Iterator<T> in Kotlin, but the shim
drains the native iterator eagerly: it calls the generated _next C
function until exhaustion, copies each element into a
java.util.ArrayList (freeing the Rust string as it goes), destroys
the iterator handle, and returns the list’s iterator(). From the
events sample (get_messages returns iter<string>):
@JvmStatic external fun events_get_messages(): Iterator<String>
weaveffi_events_GetMessagesIterator* _iter = weaveffi_events_get_messages(&err);
/* ... */
while (weaveffi_events_GetMessagesIterator_next(_iter, &_item, &_iter_err) != 0) {
jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, "");
(*env)->CallBooleanMethod(env, _list, _al_add, _jitem);
(*env)->DeleteLocalRef(env, _jitem);
weaveffi_free_string(_item);
}
weaveffi_events_GetMessagesIterator_destroy(_iter);
Troubleshooting
UnsatisfiedLinkError: Couldn't find libweaveffi.so: the Rust-built cdylib was not packaged inside the AAR. Place it undersrc/main/jniLibs/<abi>/and rebuild.UnsatisfiedLinkErrorfor the JNI symbol itself: Kotlin external function names must match the JNI signature, including the_1escape for underscores. Re-runweaveffi generateif you hand-edited either side.- Crashes when releasing strings: the JNI shim is responsible for
calling
ReleaseStringUTFCharson everyGetStringUTFChars. If you edit the shim, keep the pairing intact. - R8/ProGuard removes
WeaveFFIsymbols: keep the wrapper class with-keep class com.weaveffi.** { *; }in your ProGuard rules.