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
packages/android/src/index.test.tsmodified+28-0
Changes to packages/android/src/index.test.ts
@@ -32,6 +32,34 @@ describe('Craft Android builder', () => {
3232 })).toBe('')
3333 })
3434
35 it('emits the native holder to a fixed package, whatever the app is called', async () => {
36 // The prebuilt libcraft.so binds its natives by class name in JNI_OnLoad
37 // and cannot know a package chosen here. So CraftNative lives at a path
38 // that does not depend on the app, and CraftBridge — which does — reaches
39 // it by import. If this file ever moves under packagePath, registration
40 // stops finding the class and every action silently stays on the shim.
41 const output = mkdtempSync(join(tmpdir(), 'craft-android-native-'))
42 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output })
43
44 const holder = join(output, 'app/src/main/java/com/craft/runtime/CraftNative.kt')
45 expect(existsSync(holder)).toBe(true)
46
47 const source = readFileSync(holder, 'utf-8')
48 expect(source).toContain('package com.craft.runtime')
49 // Nothing here is substituted, and an unreplaced marker would mean it was
50 // routed through the templating that rewrites CraftBridge.
51 expect(source).not.toContain('{{')
52 expect(source).not.toContain('org.wildloop.app')
53
54 // And the generated bridge actually reaches it.
55 const bridge = readFileSync(
56 join(output, 'app/src/main/java/org/wildloop/app/CraftBridge.kt'),
57 'utf-8',
58 )
59 expect(bridge).toContain('import com.craft.runtime.CraftNative')
60 expect(bridge).toContain('CraftNative.getDeviceInfo(activity)?.let { return it }')
61 })
62
3563 it('copies a complete web distribution while preserving native configuration', async () => {
3664 const root = mkdtempSync(join(tmpdir(), 'craft-android-assets-'))
3765 const web = join(root, 'web')
packages/android/src/index.tsmodified+9-0
Changes to packages/android/src/index.ts
@@ -241,6 +241,15 @@ export async function init(options: InitOptions): Promise<void> {
241241 )
242242 }`)
243243 writeFileSync(join(output, 'app/src/main/java', packagePath, 'CraftBridge.kt'), craftBridge)
244
245 // CraftNative goes to a fixed package, not the app's. The prebuilt
246 // libcraft.so binds its natives by class name in JNI_OnLoad, and it cannot
247 // know a name chosen here — so this one file is deliberately not templated
248 // and deliberately not under packagePath.
249 const craftNative = readFileSync(join(TEMPLATES_DIR, 'CraftNative.kt.template'), 'utf-8')
250 const nativeDir = join(output, 'app/src/main/java/com/craft/runtime')
251 mkdirSync(nativeDir, { recursive: true })
252 writeFileSync(join(nativeDir, 'CraftNative.kt'), craftNative)
244253 const healthTemplate = readFileSync(join(
245254 TEMPLATES_DIR,
246255 config.enableHealthConnect ? 'CraftHealthConnect.kt.template' : 'CraftHealthConnectStub.kt.template',
packages/android/templates/CraftBridge.kt.templatemodified+6-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -18,6 +18,7 @@ import android.net.NetworkCapabilities
1818import android.net.NetworkRequest
1919import android.net.Uri
2020import android.os.Build
21import com.craft.runtime.CraftNative
2122import android.os.Bundle
2223import android.os.Looper
2324import android.os.VibrationEffect
@@ -1807,6 +1808,11 @@ class CraftBridge(
18071808
18081809 @JavascriptInterface
18091810 fun getDeviceInfo(): String {
1811 // The Zig runtime first, the body below when it declines. Null means
1812 // this build does not serve the action natively — a shim-only app, or
1813 // one whose libcraft.so failed to bind — and is not an error.
1814 CraftNative.getDeviceInfo(activity)?.let { return it }
1815
18101816 val metrics = DisplayMetrics()
18111817 @Suppress("DEPRECATION")
18121818 activity.windowManager.defaultDisplay.getMetrics(metrics)
packages/android/templates/CraftNative.kt.templateadded+79-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -0,0 +1,79 @@
1package com.craft.runtime
2
3import android.app.Activity
4
5/**
6 * The Zig runtime's native methods.
7 *
8 * This class exists for one reason: its package is fixed. `CraftBridge` is
9 * generated with a templated package declaration, so its name differs per app,
10 * and the prebuilt `libcraft.so` cannot know it — neither JNI's
11 * `Java_<package>_...` symbol mangling nor a `FindClass` inside `JNI_OnLoad`
12 * can name a class chosen at generation time. Binding to
13 * `com.craft.runtime.CraftNative` instead lets one library serve every app
14 * unchanged.
15 *
16 * Nothing in this file is substituted. A template marker appearing here would
17 * make the library app-specific and the registration in `android_dispatch.zig`
18 * would stop finding this class.
19 *
20 * ## Every method may answer "not mine"
21 *
22 * A null return means Zig does not serve that action in this build, and the
23 * caller falls through to `CraftBridge`'s own Kotlin. That is the same
24 * hand-back the iOS seam performs, and it is what makes the migration
25 * incremental: an app on a build where Zig serves one action behaves exactly
26 * like one where it serves none.
27 *
28 * ## Why the natives are not `@JvmStatic`
29 *
30 * In a Kotlin `object`, `@JvmStatic` generates a static bridge alongside the
31 * instance method, and which of the two carries the `native` flag is a detail
32 * of the compiler rather than something this can rely on. `RegisterNatives`
33 * binds one specific method, and a static native takes `(JNIEnv*, jclass)`
34 * where an instance native takes `(JNIEnv*, jobject)` — so guessing wrong is
35 * a signature mismatch at load. Declaring them as ordinary instance methods on
36 * the singleton removes the question.
37 */
38object CraftNative {
39
40 /**
41 * True when `libcraft.so` loaded.
42 *
43 * The library is genuinely optional: an app can be built with no Zig
44 * runtime in `jniLibs`, and that is the shim-only configuration rather
45 * than an error. So the failure is caught and remembered — an
46 * `UnsatisfiedLinkError` escaping a static initialiser would take the
47 * whole class down and turn "no native runtime" into a crash on the first
48 * bridge call.
49 */
50 val isAvailable: Boolean = try {
51 System.loadLibrary("craft")
52 true
53 } catch (e: UnsatisfiedLinkError) {
54 false
55 } catch (e: SecurityException) {
56 false
57 }
58
59 private external fun nativeGetDeviceInfo(activity: Activity): String?
60
61 /**
62 * `getDeviceInfo`, or null to use the Kotlin implementation.
63 *
64 * The catch is not belt-and-braces. The library can load while
65 * `JNI_OnLoad` still fails to bind — a missing class, a signature that
66 * does not match — and an unbound `external fun` throws
67 * `UnsatisfiedLinkError` at the call rather than at load. Turning that
68 * into null is what keeps a half-registered runtime behaving like no
69 * runtime at all.
70 */
71 fun getDeviceInfo(activity: Activity): String? {
72 if (!isAvailable) return null
73 return try {
74 nativeGetDeviceInfo(activity)
75 } catch (e: UnsatisfiedLinkError) {
76 null
77 }
78 }
79}
packages/zig/build.zigmodified+54-4
Changes to packages/zig/build.zig
@@ -420,12 +420,14 @@ pub fn build(b: *std.Build) void {
420420 });
421421 const run_jni_tests = b.addRunArtifact(jni_tests);
422422
423 // The Android bridge modules. Rooted at the device module because it is
424 // the only one so far; a second joins by being imported from here, the way
425 // `ios.zig` gathers the iOS ones.
423 // The Android bridge modules. Rooted at the dispatch seam, which imports
424 // every module it can route to — so a module joins these tests by being
425 // reachable from the thing that serves it, the way `ios.zig` gathers the
426 // iOS ones. A module nothing dispatches to is untested here, which is the
427 // right signal.
426428 const android_bridge_tests = b.addTest(.{
427429 .root_module = b.createModule(.{
428 .root_source_file = b.path("src/bridge_android_device.zig"),
430 .root_source_file = b.path("src/android_dispatch.zig"),
429431 .target = target,
430432 .optimize = optimize,
431433 }),
@@ -2197,6 +2199,36 @@ pub fn build(b: *std.Build) void {
21972199 build_android.dependOn(&android_arm64_install.step);
21982200 build_android_all.dependOn(&android_arm64_install.step);
21992201
2202 // The JNI library, and it has to be shared where the one above is static.
2203 // `System.loadLibrary("craft")` resolves `libcraft.so` through the dynamic
2204 // loader; a `.a` is not loadable at runtime at all, so a static build of
2205 // this would produce an artifact no Kotlin could ever call into.
2206 //
2207 // Rooted at `android_dispatch.zig` rather than `android.zig`: the seam is
2208 // the library's entry point — it owns `JNI_OnLoad` and reaches every
2209 // module it can route to — while `android.zig` is the CraftActivity API,
2210 // a different thing that happens to target the same OS.
2211 //
2212 // The name is what Kotlin passes to loadLibrary, so it is `craft` and not
2213 // the arch-suffixed name beside it: the loader picks the ABI directory,
2214 // and a per-arch name would make the Kotlin need to know its own
2215 // architecture.
2216 const android_arm64_jni = b.addLibrary(.{
2217 .linkage = .dynamic,
2218 .name = "craft",
2219 .root_module = b.createModule(.{
2220 .root_source_file = b.path("src/android_dispatch.zig"),
2221 .target = android_arm64_target,
2222 .optimize = optimize,
2223 }),
2224 });
2225
2226 const android_arm64_jni_install = b.addInstallArtifact(android_arm64_jni, .{
2227 .dest_dir = .{ .override = .{ .custom = "android/arm64-v8a" } },
2228 });
2229 build_android.dependOn(&android_arm64_jni_install.step);
2230 build_android_all.dependOn(&android_arm64_jni_install.step);
2231
22002232 // Android Emulator (x86_64)
22012233 const android_x86_target = b.resolveTargetQuery(.{
22022234 .cpu_arch = .x86_64,
@@ -2204,6 +2236,24 @@ pub fn build(b: *std.Build) void {
22042236 .abi = .android,
22052237 });
22062238
2239 const android_x86_jni = b.addLibrary(.{
2240 .linkage = .dynamic,
2241 .name = "craft",
2242 .root_module = b.createModule(.{
2243 .root_source_file = b.path("src/android_dispatch.zig"),
2244 .target = android_x86_target,
2245 .optimize = optimize,
2246 }),
2247 });
2248
2249 // `x86_64`, matching the jniLibs directory the loader looks in — not
2250 // `x86`, which is the 32-bit ABI and would be silently ignored.
2251 const android_x86_jni_install = b.addInstallArtifact(android_x86_jni, .{
2252 .dest_dir = .{ .override = .{ .custom = "android/x86_64" } },
2253 });
2254 build_android_x86.dependOn(&android_x86_jni_install.step);
2255 build_android_all.dependOn(&android_x86_jni_install.step);
2256
22072257 const android_x86_lib = b.addLibrary(.{
22082258 .linkage = .static,
22092259 .name = "craft-android-x86_64",
packages/zig/src/android_dispatch.zigadded+323-0
Changes to packages/zig/src/android_dispatch.zig
@@ -0,0 +1,323 @@
1//! Where Kotlin hands an action to Zig.
2//!
3//! The counterpart to `ios_dispatch.zig`, and the two seams differ because the
4//! two bridges do. iOS posts a message and settles a promise later, so its seam
5//! is `craft_ios_handle_action` returning "claimed / not mine" with the reply
6//! arriving separately. Android's `@JavascriptInterface` methods are ordinary
7//! synchronous calls that return a `String`, so the seam is a synchronous
8//! function returning a `jstring`.
9//!
10//! ## `null` means "not mine", exactly as `.not_ours` does on iOS
11//!
12//! Every native method here returns null for an action Zig does not serve, and
13//! the Kotlin falls through to its own implementation. That is the same
14//! hand-back `ios_dispatch.hostServesItself` performs, and it is what makes
15//! this migratable one action at a time: an app running a build where Zig
16//! serves three actions behaves identically to one where it serves none.
17//!
18//! ## The templated package, and why there is a second Kotlin class
19//!
20//! The usual way to bind a `native` method is to export a symbol the JVM finds
21//! by mangling — `Java_com_example_CraftBridge_nativeGetDeviceInfo`. That name
22//! embeds the package, and `CraftBridge.kt.template` opens with
23//! `package `, defaulting to `com.craft.<appname>`. A mangled
24//! export would resolve for exactly one app and raise `UnsatisfiedLinkError`
25//! on first call for every other — at runtime, long after a build that looked
26//! fine. `RegisterNatives` binds by name at load time instead and never sees
27//! the package.
28//!
29//! That solves the method name and not the class name: `FindClass` needs one,
30//! and a prebuilt library cannot know a name chosen when the app is generated.
31//! So the natives are not bound to `CraftBridge` at all. They are bound to
32//! `com.craft.runtime.CraftNative`, a small holder whose package is **fixed**
33//! and not templated, and the generated `CraftBridge` delegates to it. One
34//! extra class buys a library that is identical for every app.
35
36const std = ;
37const builtin = ;
38const jni = ;
39const device = ;
40
41const Jni = jni.Jni;
42
43/// The allocator every reply is built with.
44///
45/// `page_allocator`, and the choice is a build constraint rather than a
46/// preference. `c_allocator` would pull in bionic, and Zig cannot provide
47/// bionic — a static Android library links nothing and builds anyway, but this
48/// one is shared and has to resolve its symbols, so libc here means the whole
49/// library needs the NDK to build at all. Standing free of libc keeps it
50/// buildable with nothing but Zig.
51///
52/// Every native call wraps this in an arena, so the page granularity costs one
53/// page per call rather than one per allocation, and nothing has to be freed
54/// individually on the way out.
55const backing = std.heap.page_allocator;
56
57/// The VM, captured at load so a later callback on a framework thread can ask
58/// it for that thread's `JNIEnv`.
59///
60/// A `JNIEnv` is per-thread and must never be cached across threads; a
61/// `JavaVM` is process-wide and is the one handle that may be. Storing the
62/// wrong one of these is the crash that only shows up once something calls
63/// back from a non-Java thread.
64var java_vm: ?jni.JavaVM = null;
65
66pub fn vm() ?jni.JavaVM {
67 return java_vm;
68}
69
70/// The Java class the natives bind to. Fixed, unlike the bridge's own package.
71pub const holder_class = "com/craft/runtime/CraftNative";
72
73/// The JVM calls this when `System.loadLibrary` resolves the library.
74///
75/// Returning a version the runtime does not offer fails the load, so this
76/// returns 1.6 — what ART provides and what Android documents as the floor.
77///
78/// `FindClass` is legal here and only here for a class of the app's own: this
79/// runs on the thread that called `loadLibrary`, which carries the app's class
80/// loader. The same call from a thread the JVM did not create resolves against
81/// the *system* loader and would not find `CraftNative` at all — one of JNI's
82/// sharper edges, and the reason registration happens at load rather than
83/// lazily on first use.
84///
85/// A failed registration does not fail the load. Returning an error version
86/// here would stop the library loading at all, and the Kotlin holder already
87/// treats a missing native as "ask the shim" — so the honest outcome is a
88/// logged warning and an app that behaves exactly as it did before Zig was
89/// linked.
90pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint {
91 java_vm = vm_handle;
92
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 ),
108 }
109
110 return jni.JNI_VERSION_1_6;
111}
112
113/// The native methods this library binds, by Java name and signature.
114///
115/// The signatures are checked by the JVM at registration rather than at call
116/// time, which is the one piece of type safety this seam gets for free: a
117/// wrong descriptor here fails `RegisterNatives` at load with a message naming
118/// the method, rather than corrupting a stack on first use.
119const natives = [_]jni.JNINativeMethod{
120 .{
121 .name = "nativeGetDeviceInfo",
122 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
123 .fnPtr = @ptrCast(&nativeGetDeviceInfo),
124 },
125};
126
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`.
143///
144/// Idempotent by JNI's own rule: registering a method that is already
145/// registered replaces it, so a second call after a hot reload is not an error.
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;
152}
153
154/// `nativeGetDeviceInfo(activity)` — the first action served from Zig.
155///
156/// Returns null on any failure rather than throwing. The Kotlin's own
157/// `getDeviceInfo` is the fallback, so a null here is "ask the shim", which is
158/// strictly better than propagating a Java exception the page would see as an
159/// unexplained rejection from a method whose Kotlin version cannot fail.
160fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) callconv(.c) jni.jstring {
161 const j = Jni.init(env);
162
163 // One arena for the whole call. The reply is built out of a dozen small
164 // strings that all die together the moment `NewStringUTF` has copied them
165 // into the JVM's heap, which is exactly the lifetime an arena models.
166 var arena = std.heap.ArenaAllocator.init(backing);
167 defer arena.deinit();
168 const allocator = arena.allocator();
169
170 const info = device.read(allocator, j, activity) catch |err| {
171 std.log.warn("craft: getDeviceInfo fell through to the shim ({s})", .{@errorName(err)});
172 return null;
173 };
174
175 const json = device.render(allocator, info) catch return null;
176
177 // `NewStringUTF` reads to a NUL, and `render` produces a plain slice.
178 // Copied rather than terminated in place because the reply is JSON built
179 // for the page, and giving `render` a sentinel to satisfy would put a
180 // JNI detail into the half of this action that has nothing to do with Java.
181 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
182 @memcpy(owned, json);
183
184 return j.newStringUtf(owned.ptr) catch null;
185}
186
187// =============================================================================
188// Tests
189// =============================================================================
190
191const testing = std.testing;
192
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" {
282 // Returning a version ART does not offer fails the whole library load, and
283 // surfaces as an UnsatisfiedLinkError with no useful cause.
284 var invoke = fakeVm();
285 const ptr: *const jni.JNIInvokeInterface = &invoke;
286 try testing.expectEqual(jni.JNI_VERSION_1_6, JNI_OnLoad(&ptr, null));
287
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.
290 try testing.expect(vm() != null);
291 java_vm = null;
292}
293
294test "the registered natives name methods the Kotlin actually declares" {
295 // A descriptor is checked by the JVM at registration, so a wrong one fails
296 // at load rather than at call — but only if the *name* matches something.
297 // These two strings are the contract with CraftBridge.kt.
298 try testing.expectEqual(@as(usize, 1), natives.len);
299 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
300
301 // And the class they bind to is the fixed one, not the templated bridge.
302 // A `{{` here would mean the library had been made app-specific.
303 try testing.expectEqualStrings("com/craft/runtime/CraftNative", holder_class);
304 try testing.expect(std.mem.indexOf(u8, holder_class, "{") == null);
305 try testing.expectEqualStrings(
306 "(Landroid/app/Activity;)Ljava/lang/String;",
307 std.mem.span(natives[0].signature),
308 );
309}
310
311test "the JavaVM table puts GetEnv where the header does" {
312 try testing.expectEqual(@as(usize, 3), @offsetOf(jni.JNIInvokeInterface, "DestroyJavaVM") / @sizeOf(jni.JniFn));
313 try testing.expectEqual(@as(usize, 4), @offsetOf(jni.JNIInvokeInterface, "AttachCurrentThread") / @sizeOf(jni.JniFn));
314 try testing.expectEqual(@as(usize, 6), @offsetOf(jni.JNIInvokeInterface, "GetEnv") / @sizeOf(jni.JniFn));
315 try testing.expectEqual(@as(usize, 8 * @sizeOf(jni.JniFn)), @sizeOf(jni.JNIInvokeInterface));
316}
317
318test "a JNINativeMethod is three pointers, in the header's order" {
319 // RegisterNatives reads this array by offset like everything else in JNI.
320 try testing.expectEqual(@as(usize, 0), @offsetOf(jni.JNINativeMethod, "name"));
321 try testing.expectEqual(@as(usize, @sizeOf(usize)), @offsetOf(jni.JNINativeMethod, "signature"));
322 try testing.expectEqual(@as(usize, 2 * @sizeOf(usize)), @offsetOf(jni.JNINativeMethod, "fnPtr"));
323}
packages/zig/src/jni_runtime.zigmodified+155-10
Changes to packages/zig/src/jni_runtime.zig
@@ -24,13 +24,18 @@
2424//! the transcription by counting rather than by trusting it, and
2525//! `jniIndexOf` + the offset test at the bottom assert it.
2626//!
27//! ## Why the struct stops at 170
27//! ## Why the struct stops at 215
2828//!
29//! It is deliberately truncated after `ReleaseStringUTFChars`. Zig never
30//! *allocates* one of these — the table always belongs to the JVM — so
31//! reading a field at offset ≤ 170 out of the real, longer table is correct,
32//! and every entry not declared is one that cannot be transcribed wrongly.
33//! Adding a call means extending the list in order, from the header.
29//! It is deliberately truncated after `RegisterNatives`. Zig never *allocates*
30//! one of these — the table always belongs to the JVM — so reading a field at
31//! a lower offset out of the real, longer table is correct, and every entry
32//! not declared is one that cannot be transcribed wrongly. Adding a call means
33//! extending the list in order, from the header.
34//!
35//! Forty-four of the declared entries are the array functions at 171-214, and
36//! none of them is called. They are there because `RegisterNatives` sits
37//! behind them: a member's position is its offset, so there is no way to reach
38//! 215 without transcribing every step to it.
3439//!
3540//! ## Every field is an opaque pointer, on purpose
3641//!
@@ -307,7 +312,61 @@ pub const JNINativeInterface = extern struct {
307312 GetStringUTFLength: JniFn, // 168
308313 GetStringUTFChars: JniFn, // 169
309314
310 ReleaseStringUTFChars: JniFn, // 170 — the table is cut here
315 ReleaseStringUTFChars: JniFn, // 170
316
317 // 171-214 are the array functions. None is called: nothing in this bridge
318 // passes a Java array yet. They are declared anyway because `RegisterNatives`
319 // sits behind them and a member's position is its offset — there is no way
320 // to reach 215 without transcribing every step to it.
321 GetArrayLength: JniFn, // 171
322 NewObjectArray: JniFn,
323 GetObjectArrayElement: JniFn,
324 SetObjectArrayElement: JniFn,
325 NewBooleanArray: JniFn,
326 NewByteArray: JniFn,
327 NewCharArray: JniFn,
328 NewShortArray: JniFn,
329 NewIntArray: JniFn,
330 NewLongArray: JniFn,
331
332 NewFloatArray: JniFn, // 181
333 NewDoubleArray: JniFn,
334 GetBooleanArrayElements: JniFn,
335 GetByteArrayElements: JniFn,
336 GetCharArrayElements: JniFn,
337 GetShortArrayElements: JniFn,
338 GetIntArrayElements: JniFn,
339 GetLongArrayElements: JniFn,
340 GetFloatArrayElements: JniFn,
341 GetDoubleArrayElements: JniFn,
342
343 ReleaseBooleanArrayElements: JniFn, // 191
344 ReleaseByteArrayElements: JniFn,
345 ReleaseCharArrayElements: JniFn,
346 ReleaseShortArrayElements: JniFn,
347 ReleaseIntArrayElements: JniFn,
348 ReleaseLongArrayElements: JniFn,
349 ReleaseFloatArrayElements: JniFn,
350 ReleaseDoubleArrayElements: JniFn,
351 GetBooleanArrayRegion: JniFn,
352 GetByteArrayRegion: JniFn,
353
354 GetCharArrayRegion: JniFn, // 201
355 GetShortArrayRegion: JniFn,
356 GetIntArrayRegion: JniFn,
357 GetLongArrayRegion: JniFn,
358 GetFloatArrayRegion: JniFn,
359 GetDoubleArrayRegion: JniFn,
360 SetBooleanArrayRegion: JniFn,
361 SetByteArrayRegion: JniFn,
362 SetCharArrayRegion: JniFn,
363 SetShortArrayRegion: JniFn,
364
365 SetIntArrayRegion: JniFn, // 211
366 SetLongArrayRegion: JniFn,
367 SetFloatArrayRegion: JniFn,
368 SetDoubleArrayRegion: JniFn,
369 RegisterNatives: JniFn, // 215 — the table is cut here
311370};
312371
313372/// The declared index of a member, for the offset audit below.
@@ -318,6 +377,89 @@ pub fn jniIndexOf(comptime name: []const u8) usize {
318377 return @offsetOf(JNINativeInterface, name) / @sizeOf(JniFn);
319378}
320379
380// =============================================================================
381// The JavaVM, and binding native methods
382//
383// A second, much smaller table. `JNI_OnLoad` is handed a `JavaVM*` rather than
384// a `JNIEnv*` — the VM is process-wide and thread-independent, the env is
385// per-thread — so reaching Java from `JNI_OnLoad` means asking the VM for this
386// thread's env first.
387// =============================================================================
388
389/// `JavaVM`'s function table. Eight entries, and the layout is from the same
390/// header as the big one.
391pub const JNIInvokeInterface = extern struct {
392 reserved0: JniFn, // 0
393 reserved1: JniFn,
394 reserved2: JniFn,
395 DestroyJavaVM: JniFn, // 3
396 AttachCurrentThread: JniFn, // 4
397 DetachCurrentThread: JniFn, // 5
398 GetEnv: JniFn, // 6
399 AttachCurrentThreadAsDaemon: JniFn, // 7
400};
401
402pub const JavaVM = *const *const JNIInvokeInterface;
403
404/// The JNI version this library asks for and `JNI_OnLoad` returns.
405///
406/// 1.6 is what every Android runtime provides and the floor Android documents;
407/// asking for 1.8 gets `JNI_EVERSION` from ART and the library fails to load.
408pub const JNI_VERSION_1_6: jint = 0x00010006;
409pub const JNI_OK: jint = 0;
410pub const JNI_EDETACHED: jint = -2;
411
412/// One `name`/`signature`/`function` triple for `RegisterNatives`.
413///
414/// `name` and `signature` are `char*` in the header rather than `const char*`;
415/// the JVM does not write through them, and matching the declared type avoids
416/// a cast at every call site.
417pub const JNINativeMethod = extern struct {
418 name: [*:0]const u8,
419 signature: [*:0]const u8,
420 fnPtr: *const anyopaque,
421};
422
423/// This thread's `JNIEnv`, or null when the thread is not attached.
424///
425/// `GetEnv` is the only correct way to get one. A `JNIEnv` belongs to a single
426/// thread and caching one across threads is the classic JNI crash — so a
427/// callback arriving on a framework thread asks here rather than reusing the
428/// env it was registered with. This is the same hazard `ios_events.zig` solves
429/// by hopping to the main queue, in a form where the runtime will not tell you
430/// you got it wrong.
431pub fn envForThisThread(vm: JavaVM) ?JNIEnv {
432 const get: *const fn (JavaVM, *?*anyopaque, jint) callconv(.c) jint =
433 @ptrCast(vm.*.GetEnv orelse return null);
434
435 var env: ?*anyopaque = null;
436 if (get(vm, &env, JNI_VERSION_1_6) != JNI_OK) return null;
437 return @ptrCast(@alignCast(env orelse return null));
438}
439
440/// Bind native implementations to a Java class's `external`/`native` methods.
441///
442/// The alternative is exporting a symbol named
443/// `Java_<package>_<class>_<method>`, which the JVM finds by mangling. That
444/// cannot work here: craft templates the package name per app, so a mangled
445/// export would bind for exactly one package and silently fail to resolve for
446/// every other — an `UnsatisfiedLinkError` at first call, long after build.
447/// Registering by name at load time is independent of the package.
448pub fn registerNatives(
449 env: JNIEnv,
450 cls: jclass,
451 methods: []const JNINativeMethod,
452) JniError!void {
453 const j = Jni.init(env);
454 const register: *const fn (JNIEnv, jclass, [*]const JNINativeMethod, jint) callconv(.c) jint =
455 @ptrCast(env.*.RegisterNatives orelse return JniError.NotFound);
456
457 if (register(env, cls, methods.ptr, @intCast(methods.len)) != JNI_OK) {
458 try j.check();
459 return JniError.NotFound;
460 }
461}
462
321463// =============================================================================
322464// Errors
323465// =============================================================================
@@ -764,12 +906,15 @@ test "the transcribed table puts every member at the index the header gives it"
764906 try testing.expectEqual(@as(usize, 167), jniIndexOf("NewStringUTF"));
765907 try testing.expectEqual(@as(usize, 169), jniIndexOf("GetStringUTFChars"));
766908 try testing.expectEqual(@as(usize, 170), jniIndexOf("ReleaseStringUTFChars"));
909 try testing.expectEqual(@as(usize, 171), jniIndexOf("GetArrayLength"));
910 try testing.expectEqual(@as(usize, 199), jniIndexOf("GetBooleanArrayRegion"));
911 try testing.expectEqual(@as(usize, 215), jniIndexOf("RegisterNatives"));
767912
768 // And the table is exactly as long as it claims — 171 entries, cut after
769 // ReleaseStringUTFChars. A member appended without a reason would make the
913 // And the table is exactly as long as it claims — 216 entries, cut after
914 // RegisterNatives. A member appended without a reason would make the
770915 // truncation comment a lie.
771916 try testing.expectEqual(
772 @as(usize, 171 * @sizeOf(JniFn)),
917 @as(usize, 216 * @sizeOf(JniFn)),
773918 @sizeOf(JNINativeInterface),
774919 );
775920}