also looking at this
feat(android): the seam — Kotlin can now call into Zig
#138
7 files
+654
-14
| @@ -32,6 +32,34 @@ describe('Craft Android builder', () => { | ||
| 32 | 32 | })).toBe('') |
| 33 | 33 | }) |
| 34 | 34 | |
| 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 | ||
| 35 | 63 | it('copies a complete web distribution while preserving native configuration', async () => { |
| 36 | 64 | const root = mkdtempSync(join(tmpdir(), 'craft-android-assets-')) |
| 37 | 65 | const web = join(root, 'web') |
| @@ -241,6 +241,15 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 241 | 241 | ) |
| 242 | 242 | }`) |
| 243 | 243 | 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) | |
| 244 | 253 | const healthTemplate = readFileSync(join( |
| 245 | 254 | TEMPLATES_DIR, |
| 246 | 255 | config.enableHealthConnect ? 'CraftHealthConnect.kt.template' : 'CraftHealthConnectStub.kt.template', |
| @@ -18,6 +18,7 @@ import android.net.NetworkCapabilities | ||
| 18 | 18 | import android.net.NetworkRequest |
| 19 | 19 | import android.net.Uri |
| 20 | 20 | import android.os.Build |
| 21 | import com.craft.runtime.CraftNative | |
| 21 | 22 | import android.os.Bundle |
| 22 | 23 | import android.os.Looper |
| 23 | 24 | import android.os.VibrationEffect |
| @@ -1807,6 +1808,11 @@ class CraftBridge( | ||
| 1807 | 1808 | |
| 1808 | 1809 | @JavascriptInterface |
| 1809 | 1810 | 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 | ||
| 1810 | 1816 | val metrics = DisplayMetrics() |
| 1811 | 1817 | @Suppress("DEPRECATION") |
| 1812 | 1818 | activity.windowManager.defaultDisplay.getMetrics(metrics) |
| @@ -0,0 +1,79 @@ | ||
| 1 | package com.craft.runtime | |
| 2 | ||
| 3 | import 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 | */ | |
| 38 | object 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 | } | |
| @@ -420,12 +420,14 @@ pub fn build(b: *std.Build) void { | ||
| 420 | 420 | }); |
| 421 | 421 | const run_jni_tests = b.addRunArtifact(jni_tests); |
| 422 | 422 | |
| 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. | |
| 426 | 428 | const android_bridge_tests = b.addTest(.{ |
| 427 | 429 | .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"), | |
| 429 | 431 | .target = target, |
| 430 | 432 | .optimize = optimize, |
| 431 | 433 | }), |
| @@ -2197,6 +2199,36 @@ pub fn build(b: *std.Build) void { | ||
| 2197 | 2199 | build_android.dependOn(&android_arm64_install.step); |
| 2198 | 2200 | build_android_all.dependOn(&android_arm64_install.step); |
| 2199 | 2201 | |
| 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 | ||
| 2200 | 2232 | // Android Emulator (x86_64) |
| 2201 | 2233 | const android_x86_target = b.resolveTargetQuery(.{ |
| 2202 | 2234 | .cpu_arch = .x86_64, |
| @@ -2204,6 +2236,24 @@ pub fn build(b: *std.Build) void { | ||
| 2204 | 2236 | .abi = .android, |
| 2205 | 2237 | }); |
| 2206 | 2238 | |
| 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 | ||
| 2207 | 2257 | const android_x86_lib = b.addLibrary(.{ |
| 2208 | 2258 | .linkage = .static, |
| 2209 | 2259 | .name = "craft-android-x86_64", |
| @@ -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 | ||
| 36 | const std = ; | |
| 37 | const builtin = ; | |
| 38 | const jni = ; | |
| 39 | const device = ; | |
| 40 | ||
| 41 | const 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. | |
| 55 | const 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. | |
| 64 | var java_vm: ?jni.JavaVM = null; | |
| 65 | ||
| 66 | pub 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. | |
| 71 | pub 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. | |
| 90 | pub 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. | |
| 119 | const 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. | |
| 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`. | |
| 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. | |
| 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; | |
| 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. | |
| 160 | fn 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 | ||
| 191 | const 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 | ||
| 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" { | |
| 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 | ||
| 294 | test "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 | ||
| 311 | test "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 | ||
| 318 | test "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 | } | |
| @@ -24,13 +24,18 @@ | ||
| 24 | 24 | //! the transcription by counting rather than by trusting it, and |
| 25 | 25 | //! `jniIndexOf` + the offset test at the bottom assert it. |
| 26 | 26 | //! |
| 27 | //! ## Why the struct stops at 170 | |
| 27 | //! ## Why the struct stops at 215 | |
| 28 | 28 | //! |
| 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. | |
| 34 | 39 | //! |
| 35 | 40 | //! ## Every field is an opaque pointer, on purpose |
| 36 | 41 | //! |
| @@ -307,7 +312,61 @@ pub const JNINativeInterface = extern struct { | ||
| 307 | 312 | GetStringUTFLength: JniFn, // 168 |
| 308 | 313 | GetStringUTFChars: JniFn, // 169 |
| 309 | 314 | |
| 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 | |
| 311 | 370 | }; |
| 312 | 371 | |
| 313 | 372 | /// The declared index of a member, for the offset audit below. |
| @@ -318,6 +377,89 @@ pub fn jniIndexOf(comptime name: []const u8) usize { | ||
| 318 | 377 | return @offsetOf(JNINativeInterface, name) / @sizeOf(JniFn); |
| 319 | 378 | } |
| 320 | 379 | |
| 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. | |
| 391 | pub 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 | ||
| 402 | pub 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. | |
| 408 | pub const JNI_VERSION_1_6: jint = 0x00010006; | |
| 409 | pub const JNI_OK: jint = 0; | |
| 410 | pub 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. | |
| 417 | pub 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. | |
| 431 | pub 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. | |
| 448 | pub 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 | ||
| 321 | 463 | // ============================================================================= |
| 322 | 464 | // Errors |
| 323 | 465 | // ============================================================================= |
| @@ -764,12 +906,15 @@ test "the transcribed table puts every member at the index the header gives it" | ||
| 764 | 906 | try testing.expectEqual(@as(usize, 167), jniIndexOf("NewStringUTF")); |
| 765 | 907 | try testing.expectEqual(@as(usize, 169), jniIndexOf("GetStringUTFChars")); |
| 766 | 908 | 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")); | |
| 767 | 912 | |
| 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 | |
| 770 | 915 | // truncation comment a lie. |
| 771 | 916 | try testing.expectEqual( |
| 772 | @as(usize, 171 * @sizeOf(JniFn)), | |
| 917 | @as(usize, 216 * @sizeOf(JniFn)), | |
| 773 | 918 | @sizeOf(JNINativeInterface), |
| 774 | 919 | ); |
| 775 | 920 | } |