ReviewOS

also looking at this

craft-native/craft

feat(android): the seam — Kotlin can now call into Zig

#138
Merged glennmichael123 wants to merge feat/android-seam into main
7 files +654 -14
10 days ago

getDeviceInfo landed in #137 with no caller. This is the wiring — and most of it is about one problem.

The package is templated, so nothing can name the class

CraftBridge.kt.template opens with package , defaulting to com.craft.<appname>. The class a prebuilt library must bind to is therefore named when the app is generated, not when the library is built. That rules out both usual answers:

  • Exporting Java_<package>_<class>_<method> mangles the package into the symbol. It would resolve for exactly one app and raise UnsatisfiedLinkError on first call for every other — at runtime, in the app, long after a build that looked fine.
  • FindClass with a hardcoded name in JNI_OnLoad has the same problem one level down.

So the natives bind to com.craft.runtime.CraftNative, a small holder whose package is fixed and which the generator deliberately writes outside packagePath. The templated CraftBridge reaches it by import. One extra class buys a libcraft.so that is byte-identical for every app.

A test asserts the holder lands at that path, contains no substitution marker, doesn't mention the app's package, and that the generated bridge actually imports and calls it.

Not @JvmStatic, deliberately

In a Kotlin object, @JvmStatic generates a static bridge alongside the instance method, and which one carries the native flag is a compiler detail. RegisterNatives binds one specific method — and a static native takes (JNIEnv*, jclass) where an instance native takes (JNIEnv*, jobject). Guessing wrong is a signature mismatch at load. Ordinary instance methods on the singleton remove the question.

Three things the build needed

A shared libraryThe existing Android artifacts are .linkage = .static, and a .a is not loadable at runtime at all — a static build of this would produce something no Kotlin could ever call.
No libcA static Android library links nothing and builds anyway; a shared one must resolve its symbols, and Zig cannot provide bionic. link_libc = true here means the whole library needs the NDK to build. Standing free of it keeps this buildable with nothing but Zig.
An arena per callc_allocator was the only thing pulling libc in. The reply is a dozen small strings that all die the moment NewStringUTF has copied them into the JVM's heap — exactly the lifetime an arena models.

Verified locally, both ABIs, no NDK:

zig-out/android/arm64-v8a/libcraft.so
zig-out/android/x86_64/libcraft.so
000000000017c5ac T JNI_OnLoad

Failure is never fatal, at three levels

An app built without the Zig runtime in jniLibs is the shim-only configuration, not an error:

  1. System.loadLibrary throwing is caught in the holder's initialiser. An UnsatisfiedLinkError escaping a static initialiser takes the whole class down and turns "no native runtime" into a crash on the first bridge call.
  2. A JNI_OnLoad that can't find the holder logs and still returns 1.6 — returning an error version stops the library loading entirely.
  3. An unbound external fun throws at the call, not at load, so the holder catches there too.

Every path lands on the same answer: null, and CraftBridge runs its own Kotlin. Same hand-back ios_dispatch.hostServesItself performs, which is what makes this migratable one action at a time.

One JNI sharp edge, recorded

FindClass is called in JNI_OnLoad and only there. It runs on the thread that called loadLibrary, which carries the app's class loader — the same call from a thread the JVM did not create resolves against the system loader and would not find the holder at all. That's why registration happens at load rather than lazily on first use.

zig build test:android 56/56 · bun test (android) 10/10 · both .sos build · pickier clean.

7 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.