Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FilePurpose
generated/android/settings.gradleGradle settings for the library module
generated/android/build.gradleandroid-library plugin, NDK config
generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.ktKotlin wrapper (enums, struct classes, namespaced functions)
generated/android/src/main/cpp/weaveffi_jni.cJNI shims that call the C ABI and throw Java exceptions
generated/android/src/main/cpp/CMakeLists.txtNDK CMake build for the JNI shared library

Type mapping

IDL typeKotlin type (external)Kotlin type (wrapper)JNI C type
i32IntIntjint
u32LongLongjlong
i64LongLongjlong
f64DoubleDoublejdouble
i8ByteBytejbyte
i16ShortShortjshort
u8ByteBytejbyte
u16ShortShortjshort
u64LongLongjlong
f32FloatFloatjfloat
boolBooleanBooleanjboolean
stringStringStringjstring
bytesByteArrayByteArrayjbyteArray
handleLongLongjlong
StructNameLongStructNamejlong
EnumName (plain)IntEnumNamejint
EnumName (rich)LongEnumNamejlong
T?T?T?jobject
[i32]IntArrayIntArrayjintArray
[i64]LongArrayLongArrayjlongArray
[string]Array<String>Array<String>jobjectArray
iter<T>Long (iterator handle)Iterator<T> (lazy wrapper class)jlong

Example IDL → generated code

version: "0.5.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 lowerCamelCase with the module prefix stripped by default (strip_module_prefix = false in [android] restores prefixed names). 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 getContactJni(id: Int): Long
        @JvmStatic fun getContact(id: Int): Contact = Contact(getContactJni(id))
        @JvmStatic private external fun findByTypeJni(contactType: Int): LongArray
        @JvmStatic fun findByType(contactType: ContactType): LongArray = findByTypeJni(contactType.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 that throws the generic WeaveFFIException:

static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {
    const char* msg = err->message ? err->message : "WeaveFFI error";
    jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException");
    if (exClass != NULL) {
        jmethodID ctor = (*env)->GetMethodID(env, exClass, "<init>", "(ILjava/lang/String;)V");
        jstring jmsg = (*env)->NewStringUTF(env, msg);
        jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg);
        if (ex != NULL) { (*env)->Throw(env, ex); }
    }
    weaveffi_error_clear(err);
}

JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_getContactJni(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;
}

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)

Typed errors

Every generated file carries the generic open class WeaveFFIException(val code: Int, message: String). A module’s error domain adds a sealed exception hierarchy named after the domain with the trailing Error stem replaced by Exception (KvError becomes KvException), one nested class per code, and a fromCode mapper. From the kvstore sample:

/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */
open class WeaveFFIException(val code: Int, message: String) : Exception(message)

/** Typed error domain `KvError` declared by module `kv`. */
sealed class KvException(code: Int, message: String) : WeaveFFIException(code, message) {
    class KeyNotFound(message: String = "key not found") : KvException(1001, message)
    class Expired(message: String = "entry expired") : KvException(1002, message)
    class StoreFull(message: String = "store has reached capacity") : KvException(1003, message)
    class IoError(message: String = "I/O failure") : KvException(1004, message)

    companion object {
        fun fromCode(code: Int, message: String): WeaveFFIException = when (code) {
            1001 -> KeyNotFound(message)
            1002 -> Expired(message)
            1003 -> StoreFull(message)
            1004 -> IoError(message)
            else -> WeaveFFIException(code, message)
        }
    }
}

A callable with throws: true throws the matching subclass from its JNI shim (a per-domain throw_weaveffi_kv_KvError helper resolves com/weaveffi/KvException$KeyNotFound and friends by code); catch the specific class, the sealed domain, or the generic base:

try {
    store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null)
} catch (e: KvException.StoreFull) {
    // typed case
} catch (e: KvException) {
    // any kv domain error
}

A callable without throws keeps a plain signature; its only possible failures are producer bugs (a panic or a marshalling failure), which arrive as the generic WeaveFFIException. Unknown codes on the typed path fall back to WeaveFFIException too.

Interfaces

An interfaces: entry becomes a Kotlin class holding a Long handle and implementing java.io.Closeable, exactly like a struct wrapper. Its members live on the class: constructors become companion factories (a constructor named new becomes operator fun invoke, so ContactBook() reads like a real constructor), methods are instance functions, statics are companion functions, and close() calls the implicit destroy symbol. From the kvstore sample’s Store (trimmed):

/** An embedded key-value store owning its entries */
class Store internal constructor(internal var handle: Long) : java.io.Closeable {
    companion object {
        init { System.loadLibrary("weaveffi") }

        @JvmStatic private external fun nativeOpen(path: String): Long
        @JvmStatic private external fun nativeDefaultCapacity(): Long
        @JvmStatic private external fun nativeDelete(selfHandle: Long, key: String): Boolean
        @JvmStatic private external fun nativeDestroy(handle: Long)

        /** Open (or create) a store backed by the given filesystem path */
        fun open(path: String): Store = Store(nativeOpen(path))

        /** The largest number of live entries one store will hold */
        fun defaultCapacity(): Long = nativeDefaultCapacity()
    }

    /** Remove the entry for the given key, returning true if it existed */
    fun delete(key: String): Boolean = nativeDelete(handle, key)

    /** Stream every key, optionally filtered by a prefix */
    fun listKeys(prefix: String?): Iterator<String> = KvStoreListKeysIterator(nativeListKeys(handle, prefix))

    /** Reclaim space asynchronously; returns the number of bytes reclaimed */
    suspend fun compact(): Long = suspendCancellableCoroutine { cont ->
        nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) })
    }

    /** Legacy single-shot put kept for compatibility */
    @Deprecated("use put() with explicit kind")
    fun legacyPut(key: String, value: ByteArray): Boolean = nativeLegacyPut(handle, key, value)

    override fun close() {
        if (handle != 0L) {
            nativeDestroy(handle)
            handle = 0L
        }
    }
}
Store.open("/tmp/cache.kv").use { store ->
    store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null)
    println(store.count())
}

The JNI externals pass the wrapper’s handle as the leading selfHandle argument. An interface parameter elsewhere in the API takes the wrapper class (WeaveFFI.getStats(store: Store) in the nested stats module); an interface return wraps the new owned handle.

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 describe(shape: Shape): String and 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.scale(c, 3.0)   // returns a new Shape
    try {
        println(WeaveFFI.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 scale. The finalize() safety net runs during GC but is not a substitute for deterministic cleanup.

Build instructions

  1. Install Android Studio (Giraffe or newer) plus the NDK.

  2. 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
    
  3. Open generated/android in Android Studio, sync Gradle, and build the AAR (./gradlew :weaveffi:assemble).

  4. 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 and interface wrappers implement Closeable; either call .close() explicitly or use use { ... }. The finalize() 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_string before returning.
  • Byte arrays returned from JNI are copied with SetByteArrayRegion, then the Rust buffer is freed with weaveffi_free_bytes.
  • Returned string arrays and maps free each element with weaveffi_free_string after copying, then release the array buffer (or both parallel key/value buffers) with weaveffi_free_bytes.
  • Optional values are passed as boxed wrappers (Integer, Long, Double, Boolean); the JNI shim unboxes and forwards them to the C ABI. A returned boxed optional scalar is read and its box freed with weaveffi_free_bytes.

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 runTaskAsync(name: String, callback: Any)
@JvmStatic suspend fun runTask(name: String): TaskResult {
    val raw: Long = suspendCancellableCoroutine { cont ->
        runTaskAsync(name, WeaveContinuation(cont) { code, message -> TaskException.fromCode(code, message) })
    }
    return TaskResult(raw)
}

internal class WeaveContinuation<T>(
    private val cont: kotlinx.coroutines.CancellableContinuation<T>,
    private val mapError: (Int, String) -> Throwable
) {
    @Suppress("UNCHECKED_CAST")
    fun onSuccess(result: Any?) { cont.resume(result as T) }
    fun onError(code: Int, message: String) { cont.resumeWithException(mapError(code, message)) }
}

run_task declares throws: true, so a failed suspend call resumes with the typed TaskException; an async callable without throws maps its (producer-bug-only) failures to the generic WeaveFFIException.

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_runTaskAsync(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(int, String) or callback.onSuccess(Object) ... */
    weaveffi_jni_handle_uncaught(env);
    (*env)->DeleteGlobalRef(env, ctx->callback);
    JavaVM* jvm = ctx->jvm;
    free(ctx);
    if (attached) (*jvm)->DetachCurrentThread(jvm);
}

The completion callback fires exactly once, on a producer thread. Result buffers passed to it (strings, byte arrays, arrays) are borrowed from the producer for the callback’s duration, so the shim copies them into Java objects (NewStringUTF, SetByteArrayRegion) inside the callback and never frees them. Owned-object results are the exception: the callback receives ownership, resumes the continuation with the raw handle, and the suspend wrapper adopts it into the wrapper class (TaskResult(raw) above). An exception thrown by the resumed coroutine goes through the same weaveffi_jni_handle_uncaught path as listener exceptions (see Callbacks and listeners).

The generated build.gradle does not declare a coroutines dependency; add org.jetbrains.kotlinx:kotlinx-coroutines-android (or -core) to the consuming project.

For callables 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. From the kvstore sample’s async method Store.compact:

@JvmStatic private external fun nativeCompactAsync(selfHandle: Long, cancelToken: Long, callback: Any)

suspend fun compact(): Long = suspendCancellableCoroutine { cont ->
    nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) })
}

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 registerMessageListener(callback: (String) -> Unit): Long
@JvmStatic external fun unregisterMessageListener(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);
    weaveffi_jni_handle_uncaught(env);
    (*env)->PopLocalFrame(env, NULL);
    if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
}

JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerMessageListener(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).

An exception thrown by the Kotlin callback has no caller to propagate to, since the frame below it is native producer code. The glue routes it through weaveffi_jni_handle_uncaught, which delivers it to the handler installed on the module companion:

/**
 * Installs a handler for exceptions thrown by listener callbacks and
 * async continuations on native producer threads. These exceptions have
 * no Kotlin caller to propagate to; when no handler is installed, they
 * are logged with their stack trace and dropped. Pass `null` to
 * restore the default logging behavior.
 */
@JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) {
    callbackExceptionHandler = handler
}

When a module declares listeners or async functions, the JNI glue also defines JNI_OnLoad, which caches a global reference to the wrapper class and the dispatchCallbackException method id so the producer thread can deliver exceptions without an extra class lookup.

Iterators

iter<T> returns surface as Iterator<T> in Kotlin, backed by a generated per-function wrapper class that is fully lazy: the external launcher returns the raw iterator handle as a Long, and each hasNext() lookahead issues exactly one nativeNext call, which maps to one producer _next call. Nothing is drained into a hidden list. From the events sample (get_messages returns iter<string>):

@JvmStatic private external fun getMessagesJni(): Long
@JvmStatic fun getMessages(): Iterator<String> = EventsGetMessagesIterator(getMessagesJni())

/**
 * A lazy iterator over the `String` elements streamed by [getMessages]. Each step pulls
 * exactly one element from the native producer. The native handle is
 * released when the producer is exhausted, when [close] is called, or by
 * the finalizer if the iterator is abandoned, whichever comes first.
 */
class EventsGetMessagesIterator internal constructor(private var handle: Long) : Iterator<String>, java.io.Closeable {
    private var nextSlot: Array<Any?>? = null

    override fun hasNext(): Boolean {
        if (nextSlot != null) return true
        if (handle == 0L) return false
        val slot = nativeNext(handle)
        if (slot == null) {
            close()
            return false
        }
        nextSlot = slot
        return true
    }

    override fun next(): String {
        if (!hasNext()) throw NoSuchElementException()
        val raw = nextSlot!![0]
        nextSlot = null
        return raw as String
    }

    override fun close() {
        if (handle != 0L) {
            nativeDestroy(handle)
            handle = 0L
        }
    }

    protected fun finalize() {
        close()
    }

    companion object {
        init { System.loadLibrary("weaveffi") }

        @JvmStatic private external fun nativeNext(handle: Long): Array<Any?>?
        @JvmStatic private external fun nativeDestroy(handle: Long)
    }
}

The JNI nativeNext shim pulls one element and returns it in a one-slot Object[]; null means the stream is exhausted. Each string element is freed with weaveffi_free_string right after NewStringUTF copies it; when the element type is a struct, the raw handle is returned instead and the Kotlin next() adopts it into the owning wrapper class (Contact(raw as Long)), whose close() eventually destroys it:

JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EventsGetMessagesIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) {
    weaveffi_events_GetMessagesIterator* _iter = (weaveffi_events_GetMessagesIterator*)(intptr_t)handle;
    const char* _item = (const char*)0;
    weaveffi_error err = {0, NULL};
    int32_t _has = weaveffi_events_GetMessagesIterator_next(_iter, &_item, &err);
    if (err.code != 0) {
        throw_weaveffi_error(env, &err);
        return NULL;
    }
    if (_has == 0) { return NULL; }
    jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, "");
    weaveffi_free_string(_item);
    jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object");
    jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL);
    (*env)->SetObjectArrayElement(env, _slot, 0, _jitem);
    return _slot;
}

The native handle is destroyed exactly once: close() is called eagerly when hasNext() sees exhaustion, callers can call close() themselves (the class implements Closeable) when abandoning iteration early, and the finalize() safety net covers abandoned iterators during GC. Nulling the handle makes a double destroy impossible.

Errors from the launcher and from each next follow the function’s error strategy: the throwing kvstore sample’s Store.listKeys shim throws the typed domain exception (throw_weaveffi_kv_KvError, so KvException.KeyNotFound and friends) from the step that failed, while the non-throwing getMessages throws the generic WeaveFFIException only for producer bugs.

Troubleshooting

  • UnsatisfiedLinkError: Couldn't find libweaveffi.so: the Rust-built cdylib was not packaged inside the AAR. Place it under src/main/jniLibs/<abi>/ and rebuild.
  • UnsatisfiedLinkError for the JNI symbol itself: Kotlin external function names must match the JNI signature, including the _1 escape for underscores. Re-run weaveffi generate if you hand-edited either side.
  • Crashes when releasing strings: the JNI shim is responsible for calling ReleaseStringUTFChars on every GetStringUTFChars. If you edit the shim, keep the pairing intact.
  • R8/ProGuard removes WeaveFFI symbols: keep the wrapper class with -keep class com.weaveffi.** { *; } in your ProGuard rules.