ReviewOS

also looking at this

craft-native/craft

feat(android): a JNI runtime layer, and the discipline it has to carry

#136
Merged glennmichael123 wants to merge feat/android-jni-runtime into main
2 files +864 -0
9 days ago

First brick for Android. Nothing on that side can move until Zig can reach Java, and nothing in the tree could.

Why Android

actionsserved from Zig
iOS — CraftApp.swift~10693
Android — CraftBridge.kt103 @JavascriptInterface methods0

android.zig is 337 lines with one public function (quickStart). build-android / build-android-x86 steps and .abi = .android targets already exist — the toolchain path is there, the bridge isn't.

This is not objc_runtime.zig with different names

Objective-C is a handful of C symbols the linker resolves. JNI exports none. Everything goes through a function table the JVM hands the native method:

JNIEnv       == const struct JNINativeInterface *
the argument == JNIEnv *      (a pointer to that pointer)
a call       == (*env)->FindClass(env, "android/os/Build")

So JNIEnv here is *const *const JNINativeInterface, and every call is two dereferences and an index into a table.

The index is the whole correctness problem

A member's position is its memory offset. Get one wrong and the call still compiles, still runs, and invokes a different function with arguments meant for another signature — a crash if you're lucky, silent corruption if not. There is no name check at runtime.

So the table is transcribed member by member from OpenJDK's jni.h, not from memory. Every tenth entry carries its index so a reader can audit the transcription by counting rather than trusting it, and a test asserts eighteen known offsets plus the total size.

Mutation check: deleting the single IsAssignableFrom slot → expected 15, found 14. One dropped member shifts everything after it, and the test catches it.

Three decisions worth calling out:

  • The struct stops at ReleaseStringUTFChars (170). Zig never allocates one of these — the table always belongs to the JVM — so reading a field at a lower offset out of the real, longer table is correct. Every entry not declared is one that cannot be transcribed wrongly.
  • Every slot is one function-pointer type, cast to its real signature at the call site — the shape objc_runtime.msgSendId1 already uses for objc_msgSend. Layout becomes trivially right (every member one pointer wide), and a mistyped signature stays local to one wrapper instead of shifting every offset after it. (?*const anyopaque was the first attempt; a data pointer is align-1 and a function pointer isn't, which wanted an @alignCast at all 20 call sites.)
  • Only the nullary CallObjectMethod/CallIntMethod forms. Those are variadic in C, and reproducing the platform's argument-passing rules through a callconv(.c) pointer is a real ABI hazard — arm64 and x86-64 Android disagree about where the eighth integer argument goes. CallObjectMethodA with its jvalue array is the form to add when arguments are needed: same table, no variadic ABI to reproduce.

Exception discipline is the layer's actual job

JNI does not signal failure by returning an error. A failing call sets a pending exception on the thread and returns whatever it returns; calling almost any other JNI function while one is pending is undefined behaviour. "Check after every call that can throw" isn't defensive style here — it's the calling convention. Objective-C has no equivalent; a message to nil is just nil.

Every wrapper that can throw ends in check, which describes to logcat, clears, and deletes the throwable's local ref. Clearing is mandatory rather than polite: reporting the error without clearing turns one failure into an unpredictable second one. The local ref matters too — a leak per failed call is exactly how a 16-slot local frame overflows in a loop.

Strings are converted, not passed through

The JVM returns modified UTF-8, which is not UTF-8:

  • U+0000 is C0 80 — an overlong form standard UTF-8 forbids. (It's also why std.mem.span can be trusted on the buffer: a NUL byte never appears inside the payload.)
  • Astral characters are two three-byte sequences, one per UTF-16 surrogate, not one four-byte sequence. An emoji in a device name arrives as a CESU-8 pair.

Both are rejected by standard parsers, so a bridge that put these bytes straight into a JSON reply would emit a document nothing accepts. Tested against U+1F600 as ED A0 BD ED B8 80, an embedded NUL, a bare ED sequence that is not a surrogate, and truncated input.

(The first surrogate implementation was wrong — it folded only the trailing bits of each sequence and dropped the 0xD000 the leading ED contributes, landing several planes away. The test caught it as an @intCast panic.)

The layer is host-testable, by design

A JNIEnv is a pointer to a table of pointers, so the tests build one out of Zig functions and drive every wrapper against it — no JVM, no emulator, no NDK. objc_runtime.zig has no equivalent and never could; it needs a real runtime.

zig build test:android → 8/8, peak RSS 214 MB.

Next

getDeviceInfo end-to-end, which needs android/os/Build statics (model, manufacturer, brand, device, VERSION.RELEASE, VERSION.SDK_INT, FINGERPRINT) plus Activity instance calls for the display metrics and package info. Deliberately not bundled here — and it will answer all 13 of the Kotlin reply's fields or not claim the action, which is the rule the iOS reply-shape check exists to enforce.

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

Sign in to comment on this pull request.