From c51f313cd2cdfbd2dcd5548e607bacfe0601ae57 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 31 May 2026 13:39:32 +0200 Subject: [PATCH] docs(examples): add Android (Kotlin/JNI) example over the native C ABI An Android library module wrapping the timer library's native ABI behind an idiomatic Kotlin `TimerNode` class via a JNI shim. `build-libs.sh` cross-compiles two native libraries per ABI (arm64-v8a + x86_64) into src/main/jniLibs/: libmy_timer.so (the Nim library) and libmy_timer_jni.so (the JNI bridge, which NEEDs the former). The shim turns each Kotlin `external fun` into a blocking call and reads the typed EchoResponse struct out of the result callback. Gradle packages everything under jniLibs/ automatically; an instrumented test covers create/version/echo on a device/emulator. Native build validated for both ABIs (correct aarch64/x86_64 ELF, JNI symbols exported, libmy_timer.so linked). Co-Authored-By: Claude Opus 4.8 --- examples/timer/android/.gitignore | 5 + examples/timer/android/README.md | 72 ++++++++++ examples/timer/android/build-libs.sh | 50 +++++++ examples/timer/android/build.gradle.kts | 37 +++++ examples/timer/android/jni/my_timer_jni.c | 133 ++++++++++++++++++ examples/timer/android/settings.gradle.kts | 23 +++ .../kotlin/org/logos/mytimer/TimerNodeTest.kt | 21 +++ .../android/src/main/AndroidManifest.xml | 2 + .../kotlin/org/logos/mytimer/TimerNode.kt | 38 +++++ 9 files changed, 381 insertions(+) create mode 100644 examples/timer/android/.gitignore create mode 100644 examples/timer/android/README.md create mode 100755 examples/timer/android/build-libs.sh create mode 100644 examples/timer/android/build.gradle.kts create mode 100644 examples/timer/android/jni/my_timer_jni.c create mode 100644 examples/timer/android/settings.gradle.kts create mode 100644 examples/timer/android/src/androidTest/kotlin/org/logos/mytimer/TimerNodeTest.kt create mode 100644 examples/timer/android/src/main/AndroidManifest.xml create mode 100644 examples/timer/android/src/main/kotlin/org/logos/mytimer/TimerNode.kt diff --git a/examples/timer/android/.gitignore b/examples/timer/android/.gitignore new file mode 100644 index 0000000..aeaf44f --- /dev/null +++ b/examples/timer/android/.gitignore @@ -0,0 +1,5 @@ +/src/main/jniLibs/ +/.nimcache/ +/build/ +/.gradle/ +*.so diff --git a/examples/timer/android/README.md b/examples/timer/android/README.md new file mode 100644 index 0000000..026e1ef --- /dev/null +++ b/examples/timer/android/README.md @@ -0,0 +1,72 @@ +# Android example — Kotlin over the native C ABI (JNI) + +An Android library module that wraps the timer library's **native** +(zero-serialization) C ABI behind an idiomatic Kotlin class, `TimerNode`, via a +small JNI shim. Struct returns come back as typed Kotlin values. + +```kotlin +TimerNode("my-app").use { node -> + println(node.version()) // "nim-timer v0.1.0" + val r = node.echo("hello", delayMs = 5) + println("${r.echoed} / ${r.timerName}") +} +``` + +## How it fits together + +``` + Kotlin TimerNode ──external fun──▶ libmy_timer_jni.so ──C ABI──▶ libmy_timer.so + (src/main/kotlin) (jni/my_timer_jni.c) (Nim, generated) +``` + +- `libmy_timer.so` — the Nim library, exporting the native C ABI. +- `libmy_timer_jni.so` — a JNI shim that turns each `native` Kotlin method into a + blocking call into that ABI, reading the typed `EchoResponse` struct out of the + result callback. It `NEEDS` `libmy_timer.so` at load time. + +| Path | Description | +|------|-------------| +| `build-libs.sh` | Cross-compiles both `.so`s per ABI into `src/main/jniLibs//`. | +| `jni/my_timer_jni.c` | The JNI bridge. | +| `src/main/kotlin/.../TimerNode.kt` | The Kotlin wrapper + `EchoResult`. | +| `build.gradle.kts`, `settings.gradle.kts` | Library module build files. | +| `src/androidTest/...` | Instrumented test (runs on a device/emulator). | + +## Build + +```sh +cd examples/timer/android +export ANDROID_NDK_ROOT=/path/to/android-ndk # or rely on the default +./build-libs.sh # arm64-v8a + x86_64 by default +``` + +This stages `libmy_timer.so` + `libmy_timer_jni.so` under `src/main/jniLibs/`. +The Android Gradle plugin packages everything under `jniLibs/` into the AAR/APK +automatically — no extra Gradle config needed. + +Add ABIs by extending the table at the bottom of `build-libs.sh` and the +`abiFilters` in `build.gradle.kts`. + +## Use it + +- **As a module**: drop `android/` into your project, `include(":mytimer")` in + your `settings.gradle`, and `implementation(project(":mytimer"))`. +- **By hand**: copy `src/main/jniLibs//*.so` into your app's `jniLibs/` and + `TimerNode.kt` into your sources. + +Then `org.logos.mytimer.TimerNode` is ready to use. + +## Run the test + +```sh +./gradlew connectedAndroidTest # needs a connected device or emulator +``` + +## Notes + +- This is the **native, same-process** path — the app loads the library directly. + The CBOR ABI is for inter-process communication only (see [`../ipc`](../ipc)). +- Each call blocks on a condvar in the JNI shim until the library's FFI-thread + callback fires, so the Kotlin API is simple and synchronous. +- Regenerate `../c_bindings/my_timer.h` with `nimble genbindings_c` (from the + repo root) if the library's API changes. diff --git a/examples/timer/android/build-libs.sh b/examples/timer/android/build-libs.sh new file mode 100755 index 0000000..41684e0 --- /dev/null +++ b/examples/timer/android/build-libs.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Cross-compile the native libraries for Android and stage them under +# src/main/jniLibs//, so Gradle packages them into the AAR/APK: +# - libmy_timer.so the Nim timer library (native C ABI) +# - libmy_timer_jni.so the JNI shim that bridges it to Kotlin +# +# Requires the Android NDK (set ANDROID_NDK_ROOT, or it falls back to the +# Homebrew location) and Nim. Builds arm64-v8a + x86_64 by default (real devices +# and the emulator); add rows to the table below for more ABIs. +# +# ./build-libs.sh +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +NIM_SRC="$REPO_ROOT/examples/timer/timer.nim" +HDR_DIR="$REPO_ROOT/examples/timer/c_bindings" +JNILIBS="$HERE/src/main/jniLibs" + +NDK="${ANDROID_NDK_ROOT:-/opt/homebrew/share/android-ndk}" +API="${ANDROID_API:-21}" +HOST_TAG="$(ls "$NDK/toolchains/llvm/prebuilt" | head -1)" +TC="$NDK/toolchains/llvm/prebuilt/$HOST_TAG" + +build_abi() { # + local abi="$1" cpu="$2" triple="$3" + local cc="$TC/bin/${triple}${API}-clang" + local out="$JNILIBS/$abi" + echo ">> $abi" + mkdir -p "$out" + # 1) the Nim library (native C ABI) + ( cd "$REPO_ROOT" && nim c --mm:orc -d:release -d:chronicles_log_level=WARN \ + --threads:on --os:android --cpu:"$cpu" --cc:clang \ + --clang.exe:"$cc" --clang.linkerexe:"$cc" \ + --app:lib --noMain --nimMainPrefix:libmy_timer \ + --nimcache:"$HERE/.nimcache/$abi" \ + -o:"$out/libmy_timer.so" "$NIM_SRC" >/dev/null ) + # 2) the JNI shim, linked against the Nim library + "$cc" -shared -fPIC -O2 -I"$HDR_DIR" "$HERE/jni/my_timer_jni.c" \ + -L"$out" -lmy_timer -o "$out/libmy_timer_jni.so" + echo " $(cd "$out" && echo *.so)" +} + +test -x "$TC/bin/aarch64-linux-android${API}-clang" \ + || { echo "NDK not found at $NDK (set ANDROID_NDK_ROOT)"; exit 1; } + +rm -rf "$JNILIBS" +build_abi arm64-v8a arm64 aarch64-linux-android +build_abi x86_64 amd64 x86_64-linux-android +echo ">> done -> src/main/jniLibs/" diff --git a/examples/timer/android/build.gradle.kts b/examples/timer/android/build.gradle.kts new file mode 100644 index 0000000..e143aa5 --- /dev/null +++ b/examples/timer/android/build.gradle.kts @@ -0,0 +1,37 @@ +// Android library module wrapping the timer library. The native .so files are +// produced by ./build-libs.sh into src/main/jniLibs// and packaged +// automatically by the Android Gradle plugin. +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.logos.mytimer" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Limit to the ABIs build-libs.sh produces. Add more rows there + here + // (armeabi-v7a, x86) if you need them. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + sourceSets["main"].kotlin.srcDir("src/main/kotlin") + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test:runner:1.5.2") +} diff --git a/examples/timer/android/jni/my_timer_jni.c b/examples/timer/android/jni/my_timer_jni.c new file mode 100644 index 0000000..435cdb4 --- /dev/null +++ b/examples/timer/android/jni/my_timer_jni.c @@ -0,0 +1,133 @@ +// JNI bridge: exposes the timer library's native C ABI to Kotlin/Java. +// +// The library calls back on its own FFI thread; each bridge function blocks on a +// condvar until the callback fires, then returns a plain Java value — so the +// Kotlin side sees a simple synchronous API. A struct return (EchoResponse) is +// read out of the typed C-POD inside the callback (valid only there). +#include "my_timer.h" + +#include +#include +#include +#include + +typedef struct { + int ret, done; + char text[1024]; // string return / error text + char echoed[256]; // EchoResponse.echoed + char timerName[256]; + pthread_mutex_t mu; + pthread_cond_t cv; +} Resp; + +static void resp_init(Resp *r) { + memset(r, 0, sizeof(*r)); + pthread_mutex_init(&r->mu, NULL); + pthread_cond_init(&r->cv, NULL); +} +static void resp_destroy(Resp *r) { + pthread_mutex_destroy(&r->mu); + pthread_cond_destroy(&r->cv); +} +static void resp_wait(Resp *r) { + pthread_mutex_lock(&r->mu); + while (!r->done) pthread_cond_wait(&r->cv, &r->mu); + pthread_mutex_unlock(&r->mu); +} + +static void copy_raw(char *dst, size_t cap, const char *msg, size_t len) { + size_t n = len < cap - 1 ? len : cap - 1; + if (msg && n) memcpy(dst, msg, n); + dst[n] = '\0'; +} + +static void ack_cb(int ret, const char *msg, size_t len, void *ud) { + Resp *r = ud; + pthread_mutex_lock(&r->mu); + r->ret = ret; + if (ret == RET_ERR) copy_raw(r->text, sizeof(r->text), msg, len); + r->done = 1; + pthread_cond_signal(&r->cv); + pthread_mutex_unlock(&r->mu); +} +static void string_cb(int ret, const char *msg, size_t len, void *ud) { + Resp *r = ud; + pthread_mutex_lock(&r->mu); + r->ret = ret; + copy_raw(r->text, sizeof(r->text), msg, len); + r->done = 1; + pthread_cond_signal(&r->cv); + pthread_mutex_unlock(&r->mu); +} +static void echo_cb(int ret, const char *msg, size_t len, void *ud) { + Resp *r = ud; + pthread_mutex_lock(&r->mu); + r->ret = ret; + if (ret == RET_OK) { + const EchoResponse *e = (const EchoResponse *)msg; // typed struct return + strncpy(r->echoed, e->echoed, sizeof(r->echoed) - 1); + strncpy(r->timerName, e->timerName, sizeof(r->timerName) - 1); + } else { + copy_raw(r->text, sizeof(r->text), msg, len); + } + r->done = 1; + pthread_cond_signal(&r->cv); + pthread_mutex_unlock(&r->mu); +} + +JNIEXPORT jlong JNICALL +Java_org_logos_mytimer_TimerNode_nativeCreate(JNIEnv *env, jobject thiz, + jstring jname) { + const char *name = (*env)->GetStringUTFChars(env, jname, NULL); + Resp r; + resp_init(&r); + TimerConfig cfg = {.name = name}; + void *ctx = my_timer_create(cfg, ack_cb, &r); + resp_wait(&r); + (*env)->ReleaseStringUTFChars(env, jname, name); + resp_destroy(&r); + (void)thiz; + return (jlong)(intptr_t)ctx; +} + +JNIEXPORT jstring JNICALL +Java_org_logos_mytimer_TimerNode_nativeVersion(JNIEnv *env, jobject thiz, + jlong ctx) { + Resp r; + resp_init(&r); + if (my_timer_version((void *)(intptr_t)ctx, string_cb, &r) == RET_OK) + resp_wait(&r); + jstring out = (*env)->NewStringUTF(env, r.text); + resp_destroy(&r); + (void)thiz; + return out; +} + +// Returns String[2] = { echoed, timerName }. +JNIEXPORT jobjectArray JNICALL +Java_org_logos_mytimer_TimerNode_nativeEcho(JNIEnv *env, jobject thiz, jlong ctx, + jstring jmsg, jlong delayMs) { + const char *msg = (*env)->GetStringUTFChars(env, jmsg, NULL); + Resp r; + resp_init(&r); + EchoRequest req = {.message = msg, .delayMs = (int64_t)delayMs}; + if (my_timer_echo((void *)(intptr_t)ctx, echo_cb, &r, req) == RET_OK) + resp_wait(&r); + (*env)->ReleaseStringUTFChars(env, jmsg, msg); + + jclass strClass = (*env)->FindClass(env, "java/lang/String"); + jobjectArray arr = (*env)->NewObjectArray(env, 2, strClass, NULL); + (*env)->SetObjectArrayElement(env, arr, 0, (*env)->NewStringUTF(env, r.echoed)); + (*env)->SetObjectArrayElement(env, arr, 1, (*env)->NewStringUTF(env, r.timerName)); + resp_destroy(&r); + (void)thiz; + return arr; +} + +JNIEXPORT void JNICALL +Java_org_logos_mytimer_TimerNode_nativeDestroy(JNIEnv *env, jobject thiz, + jlong ctx) { + my_timer_destroy((void *)(intptr_t)ctx); + (void)env; + (void)thiz; +} diff --git a/examples/timer/android/settings.gradle.kts b/examples/timer/android/settings.gradle.kts new file mode 100644 index 0000000..3d1f042 --- /dev/null +++ b/examples/timer/android/settings.gradle.kts @@ -0,0 +1,23 @@ +// Standalone settings so this directory builds as its own library module. +// To use it from an existing app instead, drop the `android/` directory in as a +// module and add `include(":mytimer")` to your project's settings. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + plugins { + id("com.android.library") version "8.5.2" + id("org.jetbrains.kotlin.android") version "1.9.24" + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "mytimer" diff --git a/examples/timer/android/src/androidTest/kotlin/org/logos/mytimer/TimerNodeTest.kt b/examples/timer/android/src/androidTest/kotlin/org/logos/mytimer/TimerNodeTest.kt new file mode 100644 index 0000000..fbd1c75 --- /dev/null +++ b/examples/timer/android/src/androidTest/kotlin/org/logos/mytimer/TimerNodeTest.kt @@ -0,0 +1,21 @@ +package org.logos.mytimer + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +// Instrumented test — runs on a device/emulator (the .so files are device code). +// ./gradlew connectedAndroidTest +@RunWith(AndroidJUnit4::class) +class TimerNodeTest { + @Test + fun createVersionEcho() { + TimerNode("android-demo").use { node -> + assertEquals("nim-timer v0.1.0", node.version()) + val r = node.echo("hello from Kotlin", delayMs = 2) + assertEquals("hello from Kotlin", r.echoed) + assertEquals("android-demo", r.timerName) // lib state round-tripped + } + } +} diff --git a/examples/timer/android/src/main/AndroidManifest.xml b/examples/timer/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/examples/timer/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/examples/timer/android/src/main/kotlin/org/logos/mytimer/TimerNode.kt b/examples/timer/android/src/main/kotlin/org/logos/mytimer/TimerNode.kt new file mode 100644 index 0000000..351fd81 --- /dev/null +++ b/examples/timer/android/src/main/kotlin/org/logos/mytimer/TimerNode.kt @@ -0,0 +1,38 @@ +package org.logos.mytimer + +/** Typed result of [TimerNode.echo] — mirrors the library's EchoResponse. */ +data class EchoResult(val echoed: String, val timerName: String) + +/** + * Idiomatic Kotlin wrapper over the timer library's native C ABI, bridged through + * JNI (see jni/my_timer_jni.c). Each call blocks until the library's FFI-thread + * callback fires, so these methods are simple and synchronous. + * + * Call [close] (or use Kotlin's `use { }`) to tear the context down. + */ +class TimerNode(name: String) : AutoCloseable { + private val ctx: Long = nativeCreate(name) + + fun version(): String = nativeVersion(ctx) + + fun echo(message: String, delayMs: Long = 0): EchoResult { + val a = nativeEcho(ctx, message, delayMs) + return EchoResult(a[0], a[1]) + } + + override fun close() = nativeDestroy(ctx) + + private external fun nativeCreate(name: String): Long + private external fun nativeVersion(ctx: Long): String + private external fun nativeEcho(ctx: Long, message: String, delayMs: Long): Array + private external fun nativeDestroy(ctx: Long) + + companion object { + init { + // libmy_timer.so (the Nim library) is a NEEDED dependency of the JNI + // shim, but load it explicitly first so the linker finds it. + System.loadLibrary("my_timer") + System.loadLibrary("my_timer_jni") + } + } +}