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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig/src/android_dispatch.zigmodified+131-26
Changes to packages/zig/src/android_dispatch.zig
@@ -90,18 +90,21 @@ pub const holder_class = "com/craft/runtime/CraftNative";
9090pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint {
9191 java_vm = vm_handle;
9292
93 if (jni.envForThisThread(vm_handle)) |env| {
94 const j = Jni.init(env);
95 if (j.findClass(holder_class)) |cls| {
96 _ = registerFrom(env, cls);
97 } else |err| {
98 std.log.err(
99 "craft: {s} not found ({s}); every action stays on the Kotlin shim",
100 .{ holder_class, @errorName(err) },
101 );
102 }
103 } else {
104 std.log.err("craft: no JNIEnv at load; every action stays on the Kotlin shim", .{});
93 switch (bindNatives(vm_handle)) {
94 .registered => {},
95 .no_env => std.log.err(
96 "craft: no JNIEnv at load; every action stays on the Kotlin shim",
97 .{},
98 ),
99 .class_not_found => std.log.err(
100 "craft: {s} not on the class path; every action stays on the Kotlin shim",
101 .{holder_class},
102 ),
103 .register_failed => std.log.err(
104 "craft: RegisterNatives refused a method on {s}; check the descriptors " ++
105 "against CraftNative.kt. Every action stays on the Kotlin shim",
106 .{holder_class},
107 ),
105108 }
106109
107110 return jni.JNI_VERSION_1_6;
@@ -121,17 +124,31 @@ const natives = [_]jni.JNINativeMethod{
121124 },
122125};
123126
124/// Bind the natives onto `cls`. Called from Kotlin with its own class, because
125/// the class name is templated and `JNI_OnLoad` cannot know it.
127/// How binding went. A value rather than a log line, so every path is
128/// assertable: the test runner treats an error-level log as a failed test, so
129/// a function that reported by logging could only ever be tested on the path
130/// that succeeds — which is the one path that does not need testing.
131pub const LoadOutcome = enum {
132 registered,
133 /// The VM would not give this thread a `JNIEnv`.
134 no_env,
135 /// `CraftNative` was not on the class path.
136 class_not_found,
137 /// The class was found and `RegisterNatives` refused it — almost always a
138 /// method name or descriptor that does not match what Kotlin declares.
139 register_failed,
140};
141
142/// Find the holder and bind the natives to it. No logging; see `LoadOutcome`.
126143///
127144/// Idempotent by JNI's own rule: registering a method that is already
128145/// registered replaces it, so a second call after a hot reload is not an error.
129pub fn registerFrom(env: jni.JNIEnv, cls: jni.jclass) bool {
130 jni.registerNatives(env, cls, &natives) catch |err| {
131 std.log.err("craft: RegisterNatives failed ({s}); Zig serves no actions this run", .{@errorName(err)});
132 return false;
133 };
134 return true;
146pub fn bindNatives(vm_handle: jni.JavaVM) LoadOutcome {
147 const env = jni.envForThisThread(vm_handle) orelse return .no_env;
148 const j = Jni.init(env);
149 const cls = j.findClass(holder_class) catch return .class_not_found;
150 jni.registerNatives(env, cls, &natives) catch return .register_failed;
151 return .registered;
135152}
136153
137154/// `nativeGetDeviceInfo(activity)` — the first action served from Zig.
@@ -173,15 +190,103 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
173190
174191const testing = std.testing;
175192
176test "JNI_OnLoad asks for the version Android actually provides" {
193// --- A fake JVM, enough to drive the whole load path ----------------------
194//
195// `JNI_OnLoad` is handed a JavaVM and has to reach a JNIEnv, a class and
196// RegisterNatives before it has bound anything. All three are table entries,
197// so all three can be Zig functions.
198
199var fake_env_table: jni.JNINativeInterface = undefined;
200var fake_env_ptr: *const jni.JNINativeInterface = undefined;
201var fake_class_storage: u8 = 0;
202var fake_registered_count: jni.jint = -1;
203var fake_register_result: jni.jint = jni.JNI_OK;
204var fake_find_class_succeeds = true;
205var fake_get_env_succeeds = true;
206
207fn fakeGetEnv(_: jni.JavaVM, out: *?*anyopaque, _: jni.jint) callconv(.c) jni.jint {
208 if (!fake_get_env_succeeds) return jni.JNI_EDETACHED;
209 out.* = @ptrCast(@constCast(&fake_env_ptr));
210 return jni.JNI_OK;
211}
212
213fn fakeFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
214 if (!fake_find_class_succeeds) return null;
215 return @ptrCast(&fake_class_storage);
216}
217
218fn fakeExceptionOccurred(_: jni.JNIEnv) callconv(.c) jni.jobject {
219 return null;
220}
221
222fn fakeRegisterNatives(
223 _: jni.JNIEnv,
224 _: jni.jclass,
225 _: [*]const jni.JNINativeMethod,
226 count: jni.jint,
227) callconv(.c) jni.jint {
228 fake_registered_count = count;
229 return fake_register_result;
230}
231
232fn fakeVm() jni.JNIInvokeInterface {
233 var invoke = std.mem.zeroes(jni.JNIInvokeInterface);
234 invoke.GetEnv = @ptrCast(&fakeGetEnv);
235
236 fake_env_table = std.mem.zeroes(jni.JNINativeInterface);
237 fake_env_table.FindClass = @ptrCast(&fakeFindClass);
238 fake_env_table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
239 fake_env_table.RegisterNatives = @ptrCast(&fakeRegisterNatives);
240 fake_env_ptr = &fake_env_table;
241
242 fake_registered_count = -1;
243 fake_register_result = jni.JNI_OK;
244 fake_find_class_succeeds = true;
245 fake_get_env_succeeds = true;
246 return invoke;
247}
248
249test "the load path finds the holder and binds every native to it" {
250 var invoke = fakeVm();
251 const ptr: *const jni.JNIInvokeInterface = &invoke;
252
253 try testing.expectEqual(LoadOutcome.registered, bindNatives(&ptr));
254
255 // Every declared method was handed to RegisterNatives in one call — a
256 // partial registration would leave some actions bound and others throwing
257 // UnsatisfiedLinkError at their first use.
258 try testing.expectEqual(@as(jni.jint, @intCast(natives.len)), fake_registered_count);
259}
260
261test "each way the load can fail is reported as itself" {
262 // These are the three states an app can actually be in, and telling them
263 // apart is the difference between a one-line log and an afternoon: no
264 // runtime linked, a stale Kotlin holder, a descriptor that stopped
265 // matching.
266 var invoke = fakeVm();
267 const ptr: *const jni.JNIInvokeInterface = &invoke;
268
269 fake_get_env_succeeds = false;
270 try testing.expectEqual(LoadOutcome.no_env, bindNatives(&ptr));
271
272 fake_get_env_succeeds = true;
273 fake_find_class_succeeds = false;
274 try testing.expectEqual(LoadOutcome.class_not_found, bindNatives(&ptr));
275
276 fake_find_class_succeeds = true;
277 fake_register_result = -1;
278 try testing.expectEqual(LoadOutcome.register_failed, bindNatives(&ptr));
279}
280
281test "JNI_OnLoad asks for the version Android actually provides, and captures the VM" {
177282 // Returning a version ART does not offer fails the whole library load, and
178 // the failure surfaces as an UnsatisfiedLinkError with no useful cause.
179 var table = std.mem.zeroes(jni.JNIInvokeInterface);
180 const ptr: *const jni.JNIInvokeInterface = &table;
283 // surfaces as an UnsatisfiedLinkError with no useful cause.
284 var invoke = fakeVm();
285 const ptr: *const jni.JNIInvokeInterface = &invoke;
181286 try testing.expectEqual(jni.JNI_VERSION_1_6, JNI_OnLoad(&ptr, null));
182287
183 // And it captured the VM, which is the handle a later off-thread callback
184 // has to go through.
288 // The VM is the one handle that may be cached across threads, and a later
289 // callback on a framework thread has nothing else to ask for its env.
185290 try testing.expect(vm() != null);
186291 java_vm = null;
187292}