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..155d501 --- /dev/null +++ b/examples/timer/android/jni/my_timer_jni.c @@ -0,0 +1,128 @@ +// Generated by nim-ffi Kotlin/JNI codegen. Do not edit by hand. +// +// JNI bridge exposing the library's native (zero-serialization) C ABI to Kotlin. +// 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 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 fields[2][256]; // string fields of a struct return + 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; + strncpy(r->fields[0], e->echoed, sizeof(r->fields[0]) - 1); + strncpy(r->fields[1], e->timerName, sizeof(r->fields[1]) - 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_MyTimerNode_nativeCreate(JNIEnv *env, jobject thiz, jstring j_name) { + const char *name = (*env)->GetStringUTFChars(env, j_name, NULL); + TimerConfig config = {.name = name}; + Resp r; + resp_init(&r); + void *ctx = my_timer_create(config, ack_cb, &r); + resp_wait(&r); + (*env)->ReleaseStringUTFChars(env, j_name, name); + resp_destroy(&r); + (void)thiz; + return (jlong)(intptr_t)ctx; +} + +JNIEXPORT jobjectArray JNICALL +Java_org_logos_mytimer_MyTimerNode_nativeEcho(JNIEnv *env, jobject thiz, jlong ctx, jstring j_message, jlong delayMs) { + const char *message = (*env)->GetStringUTFChars(env, j_message, NULL); + EchoRequest req = {.message = message, .delayMs = (int64_t)delayMs}; + Resp r; + resp_init(&r); + if (my_timer_echo((void *)(intptr_t)ctx, echo_cb, &r, req) == RET_OK) + resp_wait(&r); + (*env)->ReleaseStringUTFChars(env, j_message, message); + 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.fields[0])); + (*env)->SetObjectArrayElement(env, arr, 1, (*env)->NewStringUTF(env, r.fields[1])); + resp_destroy(&r); + (void)thiz; + return arr; +} + +JNIEXPORT jstring JNICALL +Java_org_logos_mytimer_MyTimerNode_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; +} + +JNIEXPORT void JNICALL +Java_org_logos_mytimer_MyTimerNode_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..3be200c --- /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() { + MyTimerNode("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/MyTimerNode.kt b/examples/timer/android/src/main/kotlin/org/logos/mytimer/MyTimerNode.kt new file mode 100644 index 0000000..d64379b --- /dev/null +++ b/examples/timer/android/src/main/kotlin/org/logos/mytimer/MyTimerNode.kt @@ -0,0 +1,28 @@ +// Generated by nim-ffi Kotlin codegen. Do not edit by hand. +package org.logos.mytimer + +/** Typed result mirroring the library's EchoResponse. */ +data class EchoResponse(val echoed: String, val timerName: String) + +class MyTimerNode(name: String) : AutoCloseable { + private val ctx: Long = nativeCreate(name) + + fun echo(message: String, delayMs: Long = 0): EchoResponse { + val a = nativeEcho(ctx, message, delayMs) + return EchoResponse(a[0], a[1]) + } + fun version(): String = nativeVersion(ctx) + override fun close() = nativeDestroy(ctx) + + private external fun nativeCreate(name: String): Long + private external fun nativeEcho(ctx: Long, message: String, delayMs: Long): Array + private external fun nativeVersion(ctx: Long): String + private external fun nativeDestroy(ctx: Long) + + companion object { + init { + System.loadLibrary("my_timer") + System.loadLibrary("my_timer_jni") + } + } +} \ No newline at end of file diff --git a/ffi.nimble b/ffi.nimble index d6b3215..5bc95f8 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -172,6 +172,17 @@ task genbindings_go, "Generate Go (cgo) bindings for the timer example": if findExe("gofmt").len > 0: exec "gofmt -w examples/timer/go_bindings/my_timer.go" +task genbindings_kotlin, "Generate the Kotlin/JNI wrapper for the Android timer example": + # Emits src/main/kotlin/.../MyTimerNode.kt + jni/my_timer_jni.c over the native + # C ABI. The C headers the shim includes come from `nimble genbindings_c`; run + # that too if the library's types or procs changed. + exec "nim c " & nimFlagsOrc & + " --app:lib --noMain --nimMainPrefix:libmy_timer" & + " -d:ffiGenBindings -d:targetLang=kotlin" & + " -d:ffiOutputDir=examples/timer/android" & + " -d:ffiSrcPath=../timer.nim" & + " -o:/dev/null examples/timer/timer.nim" + task genbindings_cddl, "Generate CDDL schema for the timer example": exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libtimer" & diff --git a/ffi/codegen/kotlin.nim b/ffi/codegen/kotlin.nim new file mode 100644 index 0000000..b74a4ea --- /dev/null +++ b/ffi/codegen/kotlin.nim @@ -0,0 +1,573 @@ +## Kotlin/JNI binding generator for the nim-ffi framework. +## +## Emits the two artifacts an Android consumer needs over the *native* +## (zero-serialization) C ABI from `c.nim`: +## - `Node.kt` — an idiomatic Kotlin `AutoCloseable` wrapper. Each call is +## a blocking `external fun` into the JNI shim; a struct return surfaces as a +## Kotlin `data class`. +## - `_jni.c` — the JNI shim. 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 stays synchronous. A +## struct return is read out of the typed C-POD inside the callback (valid +## only there). +## +## Callback-shape selection is per proc, from its metadata: ctor/void -> `ack_cb` +## (ret + error text), `string` return -> `string_cb`, struct return -> a +## per-proc `_cb` that copies fields out inside the callback. +## +## Scope (first increment): procs whose params are scalars / strings (or a single +## `{.ffi.}` struct of those), and — for struct returns — structs whose fields +## are all strings (surfaced as a `String[]` across JNI). Procs needing +## seq/Option params, multiple struct params, or a non-string struct return are +## skipped with a logged notice so the generated code always compiles. That +## marshaling, events, and async are the next increments. + +import std/[os, strutils] +import ./meta, ./string_helpers + +const OrgPrefix = "org.logos" + +proc pkgName(libName: string): string = + ## "my_timer" -> "org.logos.mytimer". Underscores are dropped from the leaf so + ## the JNI symbol needs no `_1` mangling. + return OrgPrefix & "." & libName.replace("_", "") + +proc className(libName: string): string = + return snakeToPascalCase(libName) & "Node" + +proc jniPrefix(libName: string): string = + return "Java_" & pkgName(libName).replace(".", "_") & "_" & className(libName) + +# --- shared metadata helpers -------------------------------------------------- + +type FieldPlan = object + name: string + typeName: string + +proc isSimpleScalar(typeName: string): bool = + let t = typeName.strip() + case t + of "string", "cstring", "bool", "float", "float32", "float64", "int", "int64", + "int32", "int16", "int8", "clong", "cint", "uint", "uint64", "uint32", + "uint16", "uint8", "byte", "csize_t": + return true + else: + return false + +proc isStringy(typeName: string): bool = + return typeName.strip() in ["string", "cstring"] + +proc structFields(types: seq[FFITypeMeta], typeName: string): seq[FieldPlan] = + var plan: seq[FieldPlan] = @[] + for t in types: + if t.name == typeName: + for f in t.fields: + plan.add(FieldPlan(name: f.name, typeName: f.typeName)) + return plan + +proc isKnownStruct(types: seq[FFITypeMeta], typeName: string): bool = + for t in types: + if t.name == typeName: + return true + return false + +proc allFieldsSimple(types: seq[FFITypeMeta], typeName: string): bool = + let fields = structFields(types, typeName) + if fields.len == 0: + return false + for f in fields: + if not isSimpleScalar(f.typeName): + return false + return true + +proc allFieldsString(types: seq[FFITypeMeta], typeName: string): bool = + let fields = structFields(types, typeName) + if fields.len == 0: + return false + for f in fields: + if not isStringy(f.typeName): + return false + return true + +proc returnsString(p: FFIProcMeta): bool = + return p.returnTypeName.strip() == "string" + +proc returnsStruct(p: FFIProcMeta, types: seq[FFITypeMeta]): bool = + return isKnownStruct(types, p.returnTypeName.strip()) + +proc canEmit(p: FFIProcMeta, types: seq[FFITypeMeta]): bool = + ## Covered by this increment: simple params (with at most one struct param), + ## and — when the return is a struct — an all-string struct. + var structParams = 0 + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + continue + if isKnownStruct(types, ep.typeName): + inc structParams + if not allFieldsSimple(types, ep.typeName): + return false + else: + return false + if structParams > 1: + return false + if returnsStruct(p, types) and not allFieldsString(types, p.returnTypeName.strip()): + return false + return true + +proc methodBase(p: FFIProcMeta): string = + ## "my_timer_echo" -> "echo"; snake_case tail collapsed to camelCase. + var base = p.procName + if base.startsWith(p.libName & "_"): + base = base[p.libName.len + 1 .. ^1] + let parts = base.split('_') + var s = parts[0] + for i in 1 ..< parts.len: + s &= capitalizeFirstLetter(parts[i]) + return s + +proc nativeName(p: FFIProcMeta): string = + return "native" & capitalizeFirstLetter(methodBase(p)) + +proc cbName(p: FFIProcMeta): string = + return methodBase(p) & "_cb" + +# --- flattened parameters (a single struct param's fields flatten in) --------- + +proc flatParams(p: FFIProcMeta, types: seq[FFITypeMeta]): seq[FieldPlan] = + var params: seq[FieldPlan] = @[] + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + params.add(FieldPlan(name: ep.name, typeName: ep.typeName)) + else: + params.add(structFields(types, ep.typeName)) + return params + +# === Kotlin side ============================================================== + +proc ktType(typeName: string): string = + let t = typeName.strip() + if isStringy(t): + return "String" + case t + of "bool": "Boolean" + of "float", "float32": "Float" + of "float64": "Double" + else: "Long" # all integer aliases cross as int64 in the native ABI + +proc ktDefault(typeName: string): string = + let t = typeName.strip() + if t == "bool": + return "false" + return "0" + +proc ktParamList(params: seq[FieldPlan], withDefaults: bool): string = + var parts: seq[string] = @[] + for fp in params: + var decl = fp.name & ": " & ktType(fp.typeName) + if withDefaults and not isStringy(fp.typeName): + decl &= " = " & ktDefault(fp.typeName) + parts.add(decl) + return parts.join(", ") + +proc ktArgs(params: seq[FieldPlan]): string = + var parts: seq[string] = @[] + for fp in params: + parts.add(fp.name) + return parts.join(", ") + +proc generateKotlin( + procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string +): string = + let cls = className(libName) + var lines: seq[string] = @[] + lines.add("// Generated by nim-ffi Kotlin codegen. Do not edit by hand.") + lines.add("package " & pkgName(libName)) + lines.add("") + + # data class per all-string struct return type + var emitted: seq[string] = @[] + for p in procs: + if not canEmit(p, types): + continue + let rt = p.returnTypeName.strip() + if returnsStruct(p, types) and rt notin emitted: + emitted.add(rt) + var fieldDecls: seq[string] = @[] + for f in structFields(types, rt): + fieldDecls.add("val " & f.name & ": " & ktType(f.typeName)) + lines.add("/** Typed result mirroring the library's " & rt & ". */") + lines.add("data class " & rt & "(" & fieldDecls.join(", ") & ")") + lines.add("") + + # find the ctor to drive the primary constructor + var ctor: FFIProcMeta + var haveCtor = false + for p in procs: + if p.kind == FFIKind.CTOR and canEmit(p, types): + ctor = p + haveCtor = true + + let ctorParams = if haveCtor: flatParams(ctor, types) else: @[] + lines.add( + "class " & cls & "(" & ktParamList(ctorParams, withDefaults = false) & + ") : AutoCloseable {" + ) + if haveCtor: + lines.add( + " private val ctx: Long = " & nativeName(ctor) & "(" & ktArgs(ctorParams) & ")" + ) + lines.add("") + + # public methods + for p in procs: + if not canEmit(p, types) or p.kind != FFIKind.FFI: + continue + let params = flatParams(p, types) + let pl = ktParamList(params, withDefaults = true) + let callArgs = + if params.len > 0: "ctx, " & ktArgs(params) else: "ctx" + if returnsStruct(p, types): + let rt = p.returnTypeName.strip() + let fields = structFields(types, rt) + lines.add(" fun " & methodBase(p) & "(" & pl & "): " & rt & " {") + lines.add(" val a = " & nativeName(p) & "(" & callArgs & ")") + var ctorArgs: seq[string] = @[] + for i in 0 ..< fields.len: + ctorArgs.add("a[" & $i & "]") + lines.add(" return " & rt & "(" & ctorArgs.join(", ") & ")") + lines.add(" }") + elif returnsString(p): + lines.add( + " fun " & methodBase(p) & "(" & pl & "): String = " & nativeName(p) & "(" & + callArgs & ")" + ) + else: + lines.add( + " fun " & methodBase(p) & "(" & pl & ") = " & nativeName(p) & "(" & callArgs & + ")" + ) + + # close() + for p in procs: + if p.kind == FFIKind.DTOR and canEmit(p, types): + lines.add(" override fun close() = " & nativeName(p) & "(ctx)") + lines.add("") + + # external declarations + for p in procs: + if not canEmit(p, types): + continue + let params = flatParams(p, types) + case p.kind + of FFIKind.CTOR: + lines.add( + " private external fun " & nativeName(p) & "(" & + ktParamList(params, withDefaults = false) & "): Long" + ) + of FFIKind.DTOR: + lines.add(" private external fun " & nativeName(p) & "(ctx: Long)") + of FFIKind.FFI: + let lead = if params.len > 0: "ctx: Long, " else: "ctx: Long" + let ret = + if returnsStruct(p, types): "): Array" + elif returnsString(p): "): String" + else: ")" + lines.add( + " private external fun " & nativeName(p) & "(" & lead & + ktParamList(params, withDefaults = false) & ret + ) + + lines.add("") + lines.add(" companion object {") + lines.add(" init {") + lines.add(" System.loadLibrary(\"" & libName & "\")") + lines.add(" System.loadLibrary(\"" & libName & "_jni\")") + lines.add(" }") + lines.add(" }") + lines.add("}") + return lines.join("\n") + +# === JNI C shim =============================================================== + +proc cScalarType(typeName: string): string = + let t = typeName.strip() + if t in ["float", "float32"]: + return "float" + if t == "float64": + return "double" + if t == "bool": + return "int" + return "int64_t" + +proc jniScalarType(typeName: string): string = + let t = typeName.strip() + if t in ["float", "float32"]: + return "jfloat" + if t == "float64": + return "jdouble" + if t == "bool": + return "jboolean" + return "jlong" + +proc maxReturnFields(procs: seq[FFIProcMeta], types: seq[FFITypeMeta]): int = + var m = 1 + for p in procs: + if canEmit(p, types) and returnsStruct(p, types): + m = max(m, structFields(types, p.returnTypeName.strip()).len) + return m + +proc emitJniMarshal( + p: FFIProcMeta, types: seq[FFITypeMeta] +): tuple[params, pre, post, args: seq[string]] = + ## Builds the JNI signature params, the prologue (GetStringUTFChars + struct + ## literals), the epilogue (ReleaseStringUTFChars), and the C call arguments. + var params, pre, post, args: seq[string] = @[] + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + params.add(jniScalarType(ep.typeName) & " " & ep.name) + args.add("(" & cScalarType(ep.typeName) & ")" & ep.name) + else: + var inits: seq[string] = @[] + for f in structFields(types, ep.typeName): + if isStringy(f.typeName): + params.add("jstring j_" & f.name) + pre.add( + " const char *" & f.name & " = (*env)->GetStringUTFChars(env, j_" & + f.name & ", NULL);" + ) + post.add( + " (*env)->ReleaseStringUTFChars(env, j_" & f.name & ", " & f.name & ");" + ) + inits.add("." & f.name & " = " & f.name) + else: + params.add(jniScalarType(f.typeName) & " " & f.name) + inits.add("." & f.name & " = (" & cScalarType(f.typeName) & ")" & f.name) + pre.add(" " & ep.typeName & " " & ep.name & " = {" & inits.join(", ") & "};") + args.add(ep.name) + return (params, pre, post, args) + +proc emitStructCb( + lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta] +) = + let rt = p.returnTypeName.strip() + let fields = structFields(types, rt) + lines.add("static void " & cbName(p) & "(int ret, const char *msg, size_t len, void *ud) {") + lines.add(" Resp *r = ud;") + lines.add(" pthread_mutex_lock(&r->mu);") + lines.add(" r->ret = ret;") + lines.add(" if (ret == RET_OK) {") + lines.add(" const " & rt & " *e = (const " & rt & " *)msg;") + for i, f in fields: + lines.add( + " strncpy(r->fields[" & $i & "], e->" & f.name & ", sizeof(r->fields[" & $i & + "]) - 1);" + ) + lines.add(" } else {") + lines.add(" copy_raw(r->text, sizeof(r->text), msg, len);") + lines.add(" }") + lines.add(" r->done = 1;") + lines.add(" pthread_cond_signal(&r->cv);") + lines.add(" pthread_mutex_unlock(&r->mu);") + lines.add("}") + +proc emitJniFunc( + lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta], libName: string +) = + let (params, pre, post, args) = emitJniMarshal(p, types) + let sig = if params.len > 0: ", " & params.join(", ") else: "" + let callArgs = if args.len > 0: ", " & args.join(", ") else: "" + let fn = jniPrefix(libName) & "_" & nativeName(p) + case p.kind + of FFIKind.CTOR: + # Native ctor ABI: void *(, cb, ud). + let lead = if args.len > 0: args.join(", ") & ", " else: "" + lines.add("JNIEXPORT jlong JNICALL") + lines.add(fn & "(JNIEnv *env, jobject thiz" & sig & ") {") + lines.add(pre) + lines.add(" Resp r;") + lines.add(" resp_init(&r);") + lines.add(" void *ctx = " & p.procName & "(" & lead & "ack_cb, &r);") + lines.add(" resp_wait(&r);") + lines.add(post) + lines.add(" resp_destroy(&r);") + lines.add(" (void)thiz;") + lines.add(" return (jlong)(intptr_t)ctx;") + lines.add("}") + of FFIKind.DTOR: + lines.add("JNIEXPORT void JNICALL") + lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx) {") + lines.add(" " & p.procName & "((void *)(intptr_t)ctx);") + lines.add(" (void)env;") + lines.add(" (void)thiz;") + lines.add("}") + of FFIKind.FFI: + if returnsStruct(p, types): + let n = structFields(types, p.returnTypeName.strip()).len + lines.add("JNIEXPORT jobjectArray JNICALL") + lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {") + lines.add(pre) + lines.add(" Resp r;") + lines.add(" resp_init(&r);") + lines.add( + " if (" & p.procName & "((void *)(intptr_t)ctx, " & cbName(p) & ", &r" & + callArgs & ") == RET_OK)" + ) + lines.add(" resp_wait(&r);") + lines.add(post) + lines.add(" jclass strClass = (*env)->FindClass(env, \"java/lang/String\");") + lines.add( + " jobjectArray arr = (*env)->NewObjectArray(env, " & $n & ", strClass, NULL);" + ) + for i in 0 ..< n: + lines.add( + " (*env)->SetObjectArrayElement(env, arr, " & $i & + ", (*env)->NewStringUTF(env, r.fields[" & $i & "]));" + ) + lines.add(" resp_destroy(&r);") + lines.add(" (void)thiz;") + lines.add(" return arr;") + lines.add("}") + elif returnsString(p): + lines.add("JNIEXPORT jstring JNICALL") + lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {") + lines.add(pre) + lines.add(" Resp r;") + lines.add(" resp_init(&r);") + lines.add( + " if (" & p.procName & "((void *)(intptr_t)ctx, string_cb, &r" & callArgs & + ") == RET_OK)" + ) + lines.add(" resp_wait(&r);") + lines.add(post) + lines.add(" jstring out = (*env)->NewStringUTF(env, r.text);") + lines.add(" resp_destroy(&r);") + lines.add(" (void)thiz;") + lines.add(" return out;") + lines.add("}") + else: + lines.add("JNIEXPORT void JNICALL") + lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {") + lines.add(pre) + lines.add(" Resp r;") + lines.add(" resp_init(&r);") + lines.add( + " if (" & p.procName & "((void *)(intptr_t)ctx, ack_cb, &r" & callArgs & + ") == RET_OK)" + ) + lines.add(" resp_wait(&r);") + lines.add(post) + lines.add(" resp_destroy(&r);") + lines.add(" (void)env;") + lines.add(" (void)thiz;") + lines.add("}") + lines.add("") + +proc jniPreamble(libName: string, maxFields: int): string = + return ( + """ +// Generated by nim-ffi Kotlin/JNI codegen. Do not edit by hand. +// +// JNI bridge exposing the library's native (zero-serialization) C ABI to Kotlin. +// 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 is read out of the +// typed C-POD inside the callback (valid only there). +#include """" & libName & + """.h" + +#include +#include +#include +#include + +typedef struct { + int ret, done; + char text[1024]; // string return / error text + char fields[""" & $maxFields & + """][256]; // string fields of a struct return + 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); +} +""" + ) + +proc generateJni( + procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string +): string = + var lines: seq[string] = @[] + lines.add(jniPreamble(libName, maxReturnFields(procs, types))) + for p in procs: + if canEmit(p, types) and returnsStruct(p, types): + emitStructCb(lines, p, types) + lines.add("") + for p in procs: + if canEmit(p, types): + emitJniFunc(lines, p, types, libName) + return lines.join("\n") + +# === entry point ============================================================== + +proc generateKotlinBindings*( + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, + outputDir: string, + nimSrcRelPath: string, + events: seq[FFIEventMeta] = @[], +) = + ## `outputDir` is the Android module root. The Kotlin wrapper lands under its + ## package path; the JNI shim under `jni/`. The C headers it includes come from + ## the C generator (`genbindings_c`). + for p in procs: + if not canEmit(p, types): + echo "kotlin codegen: skipping '" & p.procName & + "' (needs seq/Option, multi-struct params, or a non-string struct return" & + " — not yet supported)" + + let ktDir = outputDir / "src/main/kotlin" / pkgName(libName).replace(".", "/") + createDir(ktDir) + writeFile(ktDir / (className(libName) & ".kt"), generateKotlin(procs, types, libName)) + + let jniDir = outputDir / "jni" + createDir(jniDir) + writeFile(jniDir / (libName & "_jni.c"), generateJni(procs, types, libName)) diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5179bf7..a3b6c95 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -9,6 +9,7 @@ when defined(ffiGenBindings): import ../codegen/cddl import ../codegen/c import ../codegen/go + import ../codegen/kotlin # --------------------------------------------------------------------------- # String helpers used by multiple macros @@ -1988,10 +1989,15 @@ macro genBindings*( ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry, ) + of "kotlin": + generateKotlinBindings( + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, + ffiEventRegistry, + ) else: error( "genBindings: unknown targetLang '" & lang & - "'. Use 'c', 'go', 'rust', 'cpp', or 'cddl'." + "'. Use 'c', 'go', 'kotlin', 'rust', 'cpp', or 'cddl'." ) return newEmptyNode()