Several Android calls must run on the main looper — Window.addFlags touches the view hierarchy, setRequestedOrientation goes through the Activity — and the shim wraps each in activity.runOnUiThread { ... }. That takes a Runnable: a Java object implementing a Java interface, which JNI cannot make. RegisterNatives binds methods onto a class that already exists in the APK, so a native can be called from Java but cannot be handed to Java as an object.
Same wall the reply channel gets around, same answer: Kotlin owns the object, Zig owns the work. CraftNative.runOnMain posts a Runnable that calls straight back into nativeRunTask, carrying a token — and the Activity, so nothing on the Zig side holds a reference across the hop and nothing needs a global ref or a matching release.
A token, not a pointer
The obvious shape is to hand Zig a pointer and cast it back. It is also the shape where a stale Runnable — one the looper still holds after whatever queued it is gone — reads freed memory, and the crash lands somewhere else entirely.
A token is checkable:
- each of the 32 slots carries a generation that increments on reuse, so a token from a previous occupant does not match and its task is dropped;
- claiming also releases, so a
Runnablethat somehow fires twice finds nothing the second time; - an out-of-range token is dropped rather than indexing the array.
All three have tests, and the first two fail when the generation check is removed — that mutation was run before this was committed.
The table is fixed and has no allocator. These tasks live between a page's call and the next turn of the looper, so a full table means something is wrong rather than busy, and the caller falls through to the shim rather than growing. A spin lock rather than a mutex, because std.Io.Mutex on this toolchain wants an Io and a JNI native has nowhere to get one.
The orientation pair, which proves it
Both do their whole job on the looper and neither touches state the shim keeps — which is what makes them the right pair to prove this with, rather than setKeepAwake (which writes a field, albeit one nothing reads).
They answer by returning, not through the reply channel: return true after queueing. That is what the shim's return true after runOnUiThread means too — the JavaBridge thread is never the main thread, so the block always runs later than the answer. The page's promise resolves before the device has turned, on both sides.
Three things ported that read like bugs and are not:
landscapeandlandscapeLeftmap to the same constant, so a page asking for the left-hand landscape gets the general one;landscapeRightis the only name that reachesREVERSE_LANDSCAPE;- the
elsebranch isSCREEN_ORIENTATION_UNSPECIFIED, which is exactly whatunlockOrientationsets — socraft.lockOrientation("portrat")returnstrueand silently unlocks. Worth knowing before debugging it.
The ActivityInfo constants are read through JNI rather than written down, for the reason addContact reads its MIME types: a wrong column name throws, and a wrong orientation number silently locks the device the wrong way round.
What this unblocks next
setKeepAwake (whose isKeepingAwake field turns out to be written once and read nowhere), and the delayed half of scheduleNotification — runOnMain already takes a delayMs and posts through a Handler for it.
Testing
zig build test:android passes with the ratchet at 70. Two mutations fired: dropping the generation check, and pointing landscapeRight at LANDSCAPE. Both libcraft.so targets still build with no NDK.