First brick for Android. Nothing on that side can move until Zig can reach Java, and nothing in the tree could.
Why Android
| actions | served from Zig | |
|---|---|---|
iOS — CraftApp.swift | ~106 | 93 |
Android — CraftBridge.kt | 103 @JavascriptInterface methods | 0 |
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
IsAssignableFromslot →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.msgSendId1already uses forobjc_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 anyopaquewas the first attempt; a data pointer is align-1 and a function pointer isn't, which wanted an@alignCastat all 20 call sites.) - Only the nullary
CallObjectMethod/CallIntMethodforms. Those are variadic in C, and reproducing the platform's argument-passing rules through acallconv(.c)pointer is a real ABI hazard — arm64 and x86-64 Android disagree about where the eighth integer argument goes.CallObjectMethodAwith itsjvaluearray 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+0000isC0 80— an overlong form standard UTF-8 forbids. (It's also whystd.mem.spancan 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.