also looking at this
feat(android): the seam — Kotlin can now call into Zig
#138
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.
| @@ -90,18 +90,21 @@ pub const holder_class = "com/craft/runtime/CraftNative"; | ||
| 90 | 90 | pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint { |
| 91 | 91 | java_vm = vm_handle; |
| 92 | 92 | |
| 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 | ), | |
| 105 | 108 | } |
| 106 | 109 | |
| 107 | 110 | return jni.JNI_VERSION_1_6; |
| @@ -121,17 +124,31 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 121 | 124 | }, |
| 122 | 125 | }; |
| 123 | 126 | |
| 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. | |
| 131 | pub 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`. | |
| 126 | 143 | /// |
| 127 | 144 | /// Idempotent by JNI's own rule: registering a method that is already |
| 128 | 145 | /// registered replaces it, so a second call after a hot reload is not an error. |
| 129 | pub 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; | |
| 146 | pub 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; | |
| 135 | 152 | } |
| 136 | 153 | |
| 137 | 154 | /// `nativeGetDeviceInfo(activity)` — the first action served from Zig. |
| @@ -173,15 +190,103 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c | ||
| 173 | 190 | |
| 174 | 191 | const testing = std.testing; |
| 175 | 192 | |
| 176 | test "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 | ||
| 199 | var fake_env_table: jni.JNINativeInterface = undefined; | |
| 200 | var fake_env_ptr: *const jni.JNINativeInterface = undefined; | |
| 201 | var fake_class_storage: u8 = 0; | |
| 202 | var fake_registered_count: jni.jint = -1; | |
| 203 | var fake_register_result: jni.jint = jni.JNI_OK; | |
| 204 | var fake_find_class_succeeds = true; | |
| 205 | var fake_get_env_succeeds = true; | |
| 206 | ||
| 207 | fn 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 | ||
| 213 | fn 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 | ||
| 218 | fn fakeExceptionOccurred(_: jni.JNIEnv) callconv(.c) jni.jobject { | |
| 219 | return null; | |
| 220 | } | |
| 221 | ||
| 222 | fn 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 | ||
| 232 | fn 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 | ||
| 249 | test "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 | ||
| 261 | test "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 | ||
| 281 | test "JNI_OnLoad asks for the version Android actually provides, and captures the VM" { | |
| 177 | 282 | // 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; | |
| 181 | 286 | try testing.expectEqual(jni.JNI_VERSION_1_6, JNI_OnLoad(&ptr, null)); |
| 182 | 287 | |
| 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. | |
| 185 | 290 | try testing.expect(vm() != null); |
| 186 | 291 | java_vm = null; |
| 187 | 292 | } |