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 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 13:39:32 +02:00
parent c000a8467d
commit c51f313cd2
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
9 changed files with 381 additions and 0 deletions

5
examples/timer/android/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/src/main/jniLibs/
/.nimcache/
/build/
/.gradle/
*.so

View File

@ -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/<abi>/`. |
| `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/<abi>/*.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.

View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Cross-compile the native libraries for Android and stage them under
# src/main/jniLibs/<abi>/, 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() { # <abi> <nim-cpu> <ndk-triple>
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/"

View File

@ -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/<abi>/ 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")
}

View File

@ -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 <jni.h>
#include <pthread.h>
#include <stdint.h>
#include <string.h>
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;
}

View File

@ -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"

View File

@ -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
}
}
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@ -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<String>
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")
}
}
}