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 raiseUnsatisfiedLinkErroron first call for every other — at runtime, in the app, long after a build that looked fine. FindClasswith a hardcoded name inJNI_OnLoadhas 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 library | The 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 libc | A 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 call | c_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_OnLoadFailure is never fatal, at three levels
An app built without the Zig runtime in jniLibs is the shim-only configuration, not an error:
System.loadLibrarythrowing is caught in the holder's initialiser. AnUnsatisfiedLinkErrorescaping a static initialiser takes the whole class down and turns "no native runtime" into a crash on the first bridge call.- A
JNI_OnLoadthat can't find the holder logs and still returns 1.6 — returning an error version stops the library loading entirely. - An unbound
external funthrows 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.