ReviewOS

also looking at this

craft-native/craft

feat(android): getDeviceInfo in Zig, and the JNI calls it needed

#137
Merged glennmichael123 wants to merge feat/android-jni-args into main
3 files +741 -0
packages/zig/build.zigmodified+14-0
Changes to packages/zig/build.zig
@@ -413,6 +413,18 @@ pub fn build(b: *std.Build) void {
413413 });
414414 const run_jni_tests = b.addRunArtifact(jni_tests);
415415
416 // The Android bridge modules. Rooted at the device module because it is
417 // the only one so far; a second joins by being imported from here, the way
418 // `ios.zig` gathers the iOS ones.
419 const android_bridge_tests = b.addTest(.{
420 .root_module = b.createModule(.{
421 .root_source_file = b.path("src/bridge_android_device.zig"),
422 .target = target,
423 .optimize = optimize,
424 }),
425 });
426 const run_android_bridge_tests = b.addRunArtifact(android_bridge_tests);
427
416428 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
417429 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
418430 });
@@ -1526,6 +1538,7 @@ pub fn build(b: *std.Build) void {
15261538 }
15271539 test_step.dependOn(&run_ios_conformance_tests.step);
15281540 test_step.dependOn(&run_jni_tests.step);
1541 test_step.dependOn(&run_android_bridge_tests.step);
15291542 test_step.dependOn(&run_menubar_tests.step);
15301543 test_step.dependOn(&run_components_tests.step);
15311544 test_step.dependOn(&run_gpu_tests.step);
@@ -2202,6 +2215,7 @@ pub fn build(b: *std.Build) void {
22022215 // second one: `jni_runtime.zig` is the only way anything here reaches Java,
22032216 // so a run that skipped it would not be an Android test run.
22042217 test_android_step.dependOn(&run_jni_tests.step);
2218 test_android_step.dependOn(&run_android_bridge_tests.step);
22052219
22062220 // Add Android tests to the main test step
22072221 test_step.dependOn(&run_android_tests.step);
packages/zig/src/bridge_android_device.zigadded+468-0
Changes to packages/zig/src/bridge_android_device.zig
@@ -0,0 +1,468 @@
1//! `getDeviceInfo` on Android — the first action this bridge serves from Zig.
2//!
3//! The Kotlin it replaces is `CraftBridge.getDeviceInfo()`, a
4//! `@JavascriptInterface` method that builds a `JSONObject` of thirteen keys
5//! and returns it as a string. Thirteen is the number that matters: an action
6//! that answers twelve of them reports success and hands the page `undefined`
7//! for the thirteenth, which is the exact regression `getDeviceInfo answers
8//! every field the spec answers` was written for on the iOS side. So this
9//! answers all thirteen or it is not this action.
10//!
11//! ## The shape, and why it is split in two
12//!
13//! `read` does the JNI work and returns a plain `DeviceInfo`. `render` turns a
14//! `DeviceInfo` into the reply bytes. Nothing about `render` touches Java.
15//!
16//! That split is what makes the reply testable. Escaping, key completeness,
17//! number formatting and the float's precision are all decided in `render`,
18//! and a host test drives it directly with no JVM, no emulator and no fake.
19//! The half that genuinely needs a device is then only the field *wiring* —
20//! which class, which name, which signature.
21//!
22//! ## Where this diverges from the Kotlin, deliberately
23//!
24//! **`appVersion` and `appBuild` have Kotlin `try`/`catch` fallbacks** —
25//! `"unknown"` and `0` — for a `getPackageInfo` that throws
26//! `NameNotFoundException`. Those are reproduced exactly: a Java exception on
27//! that path is caught, cleared, and turned into the same fallback, not
28//! propagated. An app whose own package is unfindable is a broken install, and
29//! the spec's answer to it is a string that says so rather than a failed call.
30//!
31//! **Everything else propagates.** A missing `android/os/Build` is not a
32//! condition to paper over with a default — it means the class loader handed
33//! back something no Android process would, and a fabricated `"unknown"` there
34//! would be an invented device rather than a reported failure.
35
36const std = ;
37const jni = ;
38const bridge_error = ;
39
40const Jni = jni.Jni;
41const jobject = jni.jobject;
42
43/// The action name, spelled exactly as the Kotlin method is named. The
44/// conformance scan matches the two by string.
45pub const A = struct {
46 pub const get_device_info = "getDeviceInfo";
47};
48
49/// Everything the reply carries, read out of Java and owned by the caller.
50///
51/// Strings are allocated; `deinit` frees them. `platform` is not a field: it
52/// is the literal `"android"` in the Kotlin and there is nothing to read.
53pub const DeviceInfo = struct {
54 model: []const u8,
55 manufacturer: []const u8,
56 brand: []const u8,
57 device: []const u8,
58 system_version: []const u8,
59 app_version: []const u8,
60 sdk_version: i32,
61 screen_width: i32,
62 screen_height: i32,
63 density: f32,
64 app_build: i64,
65 is_emulator: bool,
66
67 pub fn deinit(self: DeviceInfo, allocator: std.mem.Allocator) void {
68 allocator.free(self.model);
69 allocator.free(self.manufacturer);
70 allocator.free(self.brand);
71 allocator.free(self.device);
72 allocator.free(self.system_version);
73 allocator.free(self.app_version);
74 }
75};
76
77/// The reply bytes, byte-for-byte what the page's `JSON.parse` receives.
78///
79/// Keys are emitted in the Kotlin's `put` order. That is not required by JSON
80/// and no consumer depends on it, but a diff against the Kotlin is the way
81/// this gets reviewed, and matching the order keeps that diff readable.
82pub fn render(allocator: std.mem.Allocator, info: DeviceInfo) ![]u8 {
83 var out: std.ArrayListUnmanaged(u8) = .empty;
84 errdefer out.deinit(allocator);
85
86 try out.append(allocator, '{');
87 try appendString(allocator, &out, "model", info.model);
88 try appendString(allocator, &out, "manufacturer", info.manufacturer);
89 try appendString(allocator, &out, "brand", info.brand);
90 try appendString(allocator, &out, "device", info.device);
91 try appendString(allocator, &out, "systemVersion", info.system_version);
92
93 try out.appendSlice(allocator, ",\"sdkVersion\":");
94 try out.print(allocator, "{d}", .{info.sdk_version});
95 try out.appendSlice(allocator, ",\"screenWidth\":");
96 try out.print(allocator, "{d}", .{info.screen_width});
97 try out.appendSlice(allocator, ",\"screenHeight\":");
98 try out.print(allocator, "{d}", .{info.screen_height});
99
100 // `density` is a Java float and JSONObject renders it with Java's
101 // shortest-round-trip rule, so 2.75 is "2.75" and never "2.7500000".
102 // `{d}` is Zig's equivalent; a fixed-precision format would put trailing
103 // zeroes on a value a page may well be comparing as a string.
104 try out.appendSlice(allocator, ",\"density\":");
105 try out.print(allocator, "{d}", .{info.density});
106
107 try out.appendSlice(allocator, ",\"platform\":\"android\"");
108
109 try appendString(allocator, &out, "appVersion", info.app_version);
110
111 try out.appendSlice(allocator, ",\"appBuild\":");
112 try out.print(allocator, "{d}", .{info.app_build});
113
114 try out.appendSlice(allocator, ",\"isEmulator\":");
115 try out.appendSlice(allocator, if (info.is_emulator) "true" else "false");
116
117 try out.append(allocator, '}');
118 return out.toOwnedSlice(allocator);
119}
120
121/// `,"key":"escaped value"`.
122///
123/// The quotes are written here, not by `appendJsonEscaped`, which escapes the
124/// contents and emits no delimiters. Leaving them to it produces a document
125/// that tears at the first value containing a quote — and a device name is
126/// user-settable on most Android builds, so that value is reachable.
127fn appendString(
128 allocator: std.mem.Allocator,
129 out: *std.ArrayListUnmanaged(u8),
130 key: []const u8,
131 value: []const u8,
132) !void {
133 // A separator only when something is already in the object. `out` holds
134 // just `{` on the first call, so the check is exact rather than a guess —
135 // and an unconditional comma here produced `{,"model":...`, which is a
136 // document that stops parsing at character two.
137 if (out.items.len > 1) try out.append(allocator, ',');
138 try out.append(allocator, '"');
139 try out.appendSlice(allocator, key);
140 try out.appendSlice(allocator, "\":\"");
141 try bridge_error.appendJsonEscaped(allocator, out, value);
142 try out.append(allocator, '"');
143}
144
145// =============================================================================
146// The JNI half
147// =============================================================================
148
149/// `Build.FINGERPRINT.contains("generic") || contains("emulator")`.
150///
151/// Kotlin's own test, kept as a function so it can be asserted without a
152/// device. Deliberately not widened: the Kotlin does not check `"sdk"`,
153/// `"vbox"` or the other markers a more thorough emulator test would, and this
154/// answers the same question the shim answers.
155pub fn fingerprintLooksEmulated(fingerprint: []const u8) bool {
156 return std.mem.indexOf(u8, fingerprint, "generic") != null or
157 std.mem.indexOf(u8, fingerprint, "emulator") != null;
158}
159
160/// Read a `static final String` off a class, as UTF-8.
161fn staticString(
162 allocator: std.mem.Allocator,
163 j: Jni,
164 cls: jni.jclass,
165 name: [*:0]const u8,
166) ![]u8 {
167 const id = try j.staticFieldId(cls, name, "Ljava/lang/String;");
168 const value = try j.staticObjectField(cls, id);
169 defer j.deleteLocalRef(value);
170 return j.stringToUtf8(allocator, value);
171}
172
173/// Everything the reply needs, read out of the running process.
174///
175/// `activity` is the `android.app.Activity` the Kotlin bridge holds — the same
176/// object its `activity.windowManager` and `activity.packageManager` reach.
177///
178/// The whole body runs inside one local frame. Reading this much walks eleven
179/// Java objects (two classes, four strings, a WindowManager, a Display, a
180/// DisplayMetrics, a PackageManager, a PackageInfo) and the JVM only
181/// guarantees sixteen local slots — so the alternative to a frame is eleven
182/// `defer`s, one of which eventually gets forgotten.
183pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) !DeviceInfo {
184 try j.pushLocalFrame(24);
185 // `null`: nothing read here survives the frame. Every string is already
186 // copied into allocator memory by `stringToUtf8`, and every number is a
187 // value — so there is no reference worth promoting.
188 defer _ = j.popLocalFrame(null);
189
190 const build = try j.findClass("android/os/Build");
191
192 const model = try staticString(allocator, j, build, "MODEL");
193 errdefer allocator.free(model);
194 const manufacturer = try staticString(allocator, j, build, "MANUFACTURER");
195 errdefer allocator.free(manufacturer);
196 const brand = try staticString(allocator, j, build, "BRAND");
197 errdefer allocator.free(brand);
198 const device = try staticString(allocator, j, build, "DEVICE");
199 errdefer allocator.free(device);
200
201 const fingerprint = try staticString(allocator, j, build, "FINGERPRINT");
202 defer allocator.free(fingerprint);
203
204 // `Build$VERSION` — a nested class, and the `$` is not decoration: the JVM
205 // knows this type by that binary name and `android/os/Build/VERSION` finds
206 // nothing.
207 const version_cls = try j.findClass("android/os/Build$VERSION");
208 const system_version = try staticString(allocator, j, version_cls, "RELEASE");
209 errdefer allocator.free(system_version);
210 const sdk_version = try j.staticIntField(
211 version_cls,
212 try j.staticFieldId(version_cls, "SDK_INT", "I"),
213 );
214
215 const metrics = try readDisplayMetrics(j, activity);
216 const package = try readPackageInfo(allocator, j, activity, sdk_version);
217
218 return .{
219 .model = model,
220 .manufacturer = manufacturer,
221 .brand = brand,
222 .device = device,
223 .system_version = system_version,
224 .app_version = package.version_name,
225 .sdk_version = sdk_version,
226 .screen_width = metrics.width,
227 .screen_height = metrics.height,
228 .density = metrics.density,
229 .app_build = package.build,
230 .is_emulator = fingerprintLooksEmulated(fingerprint),
231 };
232}
233
234const Metrics = struct { width: i32, height: i32, density: f32 };
235
236/// `activity.windowManager.defaultDisplay.getMetrics(DisplayMetrics())`.
237///
238/// `getDefaultDisplay` is deprecated from API 30 and the Kotlin suppresses the
239/// warning rather than branching. Reproduced as-is: switching to
240/// `Context.getDisplay()` here would answer a different question on a
241/// multi-display device than the shim answers, and this action's contract is
242/// the shim's until the shim changes.
243fn readDisplayMetrics(j: Jni, activity: jobject) !Metrics {
244 const activity_cls = try j.objectClass(activity);
245 const window_manager = try j.callObjectMethod(
246 activity,
247 try j.methodId(activity_cls, "getWindowManager", "()Landroid/view/WindowManager;"),
248 );
249
250 const wm_cls = try j.objectClass(window_manager);
251 const display = try j.callObjectMethod(
252 window_manager,
253 try j.methodId(wm_cls, "getDefaultDisplay", "()Landroid/view/Display;"),
254 );
255
256 // `DisplayMetrics` is filled in by the callee, so it has to be constructed
257 // here and handed over — the one place this action allocates a Java object.
258 const metrics_cls = try j.findClass("android/util/DisplayMetrics");
259 const metrics = try j.newObjectA(metrics_cls, try j.methodId(metrics_cls, "<init>", "()V"), &.{});
260
261 const display_cls = try j.objectClass(display);
262 try j.callVoidMethodA(
263 display,
264 try j.methodId(display_cls, "getMetrics", "(Landroid/util/DisplayMetrics;)V"),
265 &.{.{ .l = metrics }},
266 );
267
268 // Public fields, not getters — DisplayMetrics has never had accessors.
269 return .{
270 .width = try j.intField(metrics, try j.fieldId(metrics_cls, "widthPixels", "I")),
271 .height = try j.intField(metrics, try j.fieldId(metrics_cls, "heightPixels", "I")),
272 .density = try j.floatField(metrics, try j.fieldId(metrics_cls, "density", "F")),
273 };
274}
275
276const Package = struct { version_name: []const u8, build: i64 };
277
278/// `packageManager.getPackageInfo(packageName, 0)`, with the Kotlin's own
279/// fallbacks for the throw.
280///
281/// The two `try`/`catch` blocks in the shim collapse every failure to
282/// `"unknown"` and `0`, so that is what a Java exception produces here. The
283/// exception is cleared on the way — `Jni.check` does that — because leaving it
284/// pending would poison the next call rather than the current one, and the
285/// next call is in the caller.
286fn readPackageInfo(allocator: std.mem.Allocator, j: Jni, activity: jobject, sdk: i32) !Package {
287 const fallback = Package{ .version_name = try allocator.dupe(u8, "unknown"), .build = 0 };
288 errdefer allocator.free(fallback.version_name);
289
290 const activity_cls = try j.objectClass(activity);
291 const manager = j.callObjectMethod(
292 activity,
293 try j.methodId(activity_cls, "getPackageManager", "()Landroid/content/pm/PackageManager;"),
294 ) catch return fallback;
295
296 const name = j.callObjectMethod(
297 activity,
298 try j.methodId(activity_cls, "getPackageName", "()Ljava/lang/String;"),
299 ) catch return fallback;
300
301 const manager_cls = try j.objectClass(manager);
302 const get_info = try j.methodId(
303 manager_cls,
304 "getPackageInfo",
305 "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;",
306 );
307 const info = j.callObjectMethodA(manager, get_info, &.{
308 .{ .l = name },
309 .{ .i = 0 },
310 }) catch return fallback;
311
312 const info_cls = try j.objectClass(info);
313
314 const name_field = try j.fieldId(info_cls, "versionName", "Ljava/lang/String;");
315 const version_object = try j.objectField(info, name_field);
316 // `versionName` is genuinely nullable — an app with no `versionName` in its
317 // manifest has it null, and Kotlin's `put` would store JSON null. The shim's
318 // catch does not cover that, so the fallback string is used rather than
319 // emitting a null a page would have to test for.
320 const version_name = if (version_object == null)
321 try allocator.dupe(u8, "unknown")
322 else
323 try j.stringToUtf8(allocator, version_object);
324 errdefer allocator.free(version_name);
325 allocator.free(fallback.version_name);
326
327 // API 28 split the build number in two: `longVersionCode` is the whole
328 // value, `versionCode` its low 32 bits. Reading the wrong one on a modern
329 // app truncates rather than failing.
330 const build: i64 = if (sdk >= 28)
331 try j.longField(info, try j.fieldId(info_cls, "longVersionCode", "J"))
332 else
333 try j.intField(info, try j.fieldId(info_cls, "versionCode", "I"));
334
335 return .{ .version_name = version_name, .build = build };
336}
337
338// =============================================================================
339// Tests
340// =============================================================================
341
342const testing = std.testing;
343
344fn sampleInfo() DeviceInfo {
345 return .{
346 .model = "Pixel 8 Pro",
347 .manufacturer = "Google",
348 .brand = "google",
349 .device = "husky",
350 .system_version = "14",
351 .app_version = "1.2.3",
352 .sdk_version = 34,
353 .screen_width = 1008,
354 .screen_height = 2244,
355 .density = 2.625,
356 .app_build = 4_000_000_123,
357 .is_emulator = false,
358 };
359}
360
361test "the reply carries every key the Kotlin puts, and parses" {
362 // The regression this mirrors is iOS's: `getDeviceInfo` was `.live` and
363 // answered four of fourteen fields, so a page read `undefined` from an
364 // action that reported success. Thirteen keys or this is not the action.
365 const json = try render(testing.allocator, sampleInfo());
366 defer testing.allocator.free(json);
367
368 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
369 defer parsed.deinit();
370 const obj = parsed.value.object;
371
372 try testing.expectEqual(@as(usize, 13), obj.count());
373
374 try testing.expectEqualStrings("Pixel 8 Pro", obj.get("model").?.string);
375 try testing.expectEqualStrings("Google", obj.get("manufacturer").?.string);
376 try testing.expectEqualStrings("google", obj.get("brand").?.string);
377 try testing.expectEqualStrings("husky", obj.get("device").?.string);
378 try testing.expectEqualStrings("14", obj.get("systemVersion").?.string);
379 try testing.expectEqualStrings("android", obj.get("platform").?.string);
380 try testing.expectEqualStrings("1.2.3", obj.get("appVersion").?.string);
381 try testing.expectEqual(@as(i64, 34), obj.get("sdkVersion").?.integer);
382 try testing.expectEqual(@as(i64, 1008), obj.get("screenWidth").?.integer);
383 try testing.expectEqual(@as(i64, 2244), obj.get("screenHeight").?.integer);
384 try testing.expectEqual(@as(i64, 4_000_000_123), obj.get("appBuild").?.integer);
385 try testing.expectEqual(false, obj.get("isEmulator").?.bool);
386 try testing.expectApproxEqAbs(@as(f64, 2.625), obj.get("density").?.float, 0.0001);
387}
388
389test "appBuild survives a value that does not fit in 32 bits" {
390 // The reason `longVersionCode` is read at all. An app past 2^31 builds is
391 // unusual; an app whose `versionCode` encodes a date or an ABI split is
392 // not, and those cross the boundary routinely. Truncating here would
393 // report a different build than the one installed.
394 var info = sampleInfo();
395 info.app_build = 9_007_199_254_740_991;
396 const json = try render(testing.allocator, info);
397 defer testing.allocator.free(json);
398
399 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
400 defer parsed.deinit();
401 try testing.expectEqual(
402 @as(i64, 9_007_199_254_740_991),
403 parsed.value.object.get("appBuild").?.integer,
404 );
405}
406
407test "a device name containing a quote does not tear the document" {
408 // `ro.product.model` is settable on a rooted or custom build, and
409 // `appendJsonEscaped` writes no delimiters — so a caller that forgot the
410 // quotes would produce a reply that stops parsing at the first one. The
411 // same trap that broke the iOS location-recording state file.
412 var info = sampleInfo();
413 info.model = "My \"Best\" Phone";
414 info.app_version = "1.0\\beta";
415 const json = try render(testing.allocator, info);
416 defer testing.allocator.free(json);
417
418 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
419 defer parsed.deinit();
420 try testing.expectEqualStrings("My \"Best\" Phone", parsed.value.object.get("model").?.string);
421 try testing.expectEqualStrings("1.0\\beta", parsed.value.object.get("appVersion").?.string);
422 try testing.expectEqual(@as(usize, 13), parsed.value.object.count());
423}
424
425test "a control character in a device name is escaped, not embedded" {
426 var info = sampleInfo();
427 info.model = "Tab\tNewline\n";
428 const json = try render(testing.allocator, info);
429 defer testing.allocator.free(json);
430
431 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
432 defer parsed.deinit();
433 try testing.expectEqualStrings("Tab\tNewline\n", parsed.value.object.get("model").?.string);
434}
435
436test "density renders the way JSONObject renders a float" {
437 // Java prints a float with the shortest representation that round-trips,
438 // so 2.75 is "2.75". A fixed-precision format here would emit "2.7500000"
439 // and change a value some pages compare as a string.
440 var info = sampleInfo();
441 info.density = 2.75;
442 const json = try render(testing.allocator, info);
443 defer testing.allocator.free(json);
444 try testing.expect(std.mem.indexOf(u8, json, "\"density\":2.75,") != null);
445
446 info.density = 3;
447 const whole = try render(testing.allocator, info);
448 defer testing.allocator.free(whole);
449 try testing.expect(std.mem.indexOf(u8, whole, "\"density\":3,") != null);
450}
451
452test "the emulator test asks exactly what the Kotlin asks" {
453 // Real fingerprints, and the two markers the shim looks for.
454 try testing.expect(fingerprintLooksEmulated("generic/sdk_gphone64_arm64/emu64a:14/UE1A.230829.036/11228894:user/release-keys"));
455 try testing.expect(fingerprintLooksEmulated("Android/aosp_cf_x86_64_phone/emulator:13/x/y:userdebug/test-keys"));
456 try testing.expect(!fingerprintLooksEmulated("google/husky/husky:14/AP1A.240405.002/11480754:user/release-keys"));
457 try testing.expect(!fingerprintLooksEmulated("samsung/dm3qxxx/dm3q:14/UP1A.231005.007/S918BXXU4BWL5:user/release-keys"));
458
459 // Not widened past the shim: these are emulator markers this deliberately
460 // does not know about, because the Kotlin does not either.
461 try testing.expect(!fingerprintLooksEmulated("unknown/vbox86p/vbox86p:9/x/y:userdebug/test-keys"));
462}
463
464test "the action name matches the Kotlin method exactly" {
465 // The scan that pairs the two lists matches by string, so a rename on
466 // either side has to be a rename on both.
467 try testing.expectEqualStrings("getDeviceInfo", A.get_device_info);
468}
packages/zig/src/jni_runtime.zigmodified+259-0
Changes to packages/zig/src/jni_runtime.zig
@@ -80,6 +80,23 @@ pub const jthrowable = jobject;
8080pub const jmethodID = ?*anyopaque;
8181pub const jfieldID = ?*anyopaque;
8282
83/// One Java argument, as the `A`-form calls take them.
84///
85/// A C union of every primitive plus a reference, eight bytes wide. Arguments
86/// are passed as an array of these rather than variadically — see
87/// `Jni.callObjectMethodA` for why that is the only form offered.
88pub const jvalue = extern union {
89 z: jboolean,
90 b: jbyte,
91 c: jchar,
92 s: jshort,
93 i: jint,
94 j: jlong,
95 f: jfloat,
96 d: jdouble,
97 l: jobject,
98};
99
83100/// `JNIEnv*` as the JVM passes it to a native method.
84101pub const JNIEnv = *const *const JNINativeInterface;
85102
@@ -445,6 +462,104 @@ pub const Jni = struct {
445462 return result;
446463 }
447464
465 // --- The argument-taking forms -------------------------------------
466 //
467 // Every one of these is the `A` variant, taking a `[]const jvalue`. The
468 // bare `CallObjectMethod` is variadic in C, and calling a variadic
469 // function through a `callconv(.c)` pointer means reproducing the
470 // platform's argument-passing rules exactly — which arm64 and x86-64
471 // Android do not share. The `A` form takes an array instead, so there is
472 // no ABI to reproduce and one implementation is right on both.
473 //
474 // `args.ptr` is passed even when the slice is empty; a zero-argument call
475 // never reads it, and a null there would be a second thing to reason about.
476
477 pub fn callObjectMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!jobject {
478 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jobject =
479 @ptrCast(self.table().CallObjectMethodA orelse return JniError.NotFound);
480 const result = call(self.env, obj, id, args.ptr);
481 try self.check();
482 return result;
483 }
484
485 pub fn callVoidMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!void {
486 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) void =
487 @ptrCast(self.table().CallVoidMethodA orelse return JniError.NotFound);
488 call(self.env, obj, id, args.ptr);
489 try self.check();
490 }
491
492 /// `new <cls>(args)`. `id` must be the `<init>` method id.
493 pub fn newObjectA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jobject {
494 const new: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jobject =
495 @ptrCast(self.table().NewObjectA orelse return JniError.NotFound);
496 const obj = new(self.env, cls, id, args.ptr);
497 try self.check();
498 return obj orelse JniError.NotFound;
499 }
500
501 // --- Instance fields -------------------------------------------------
502 //
503 // Needed because Android exposes plain public fields on several of the
504 // classes this bridge reads — `DisplayMetrics.widthPixels`,
505 // `PackageInfo.versionName` — with no getter to call.
506
507 pub fn fieldId(self: Self, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) JniError!jfieldID {
508 const get: *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jfieldID =
509 @ptrCast(self.table().GetFieldID orelse return JniError.NotFound);
510 const id = get(self.env, cls, name, sig);
511 try self.check();
512 return id orelse JniError.NotFound;
513 }
514
515 pub fn objectField(self: Self, obj: jobject, id: jfieldID) JniError!jobject {
516 const get: *const fn (JNIEnv, jobject, jfieldID) callconv(.c) jobject =
517 @ptrCast(self.table().GetObjectField orelse return JniError.NotFound);
518 const value = get(self.env, obj, id);
519 try self.check();
520 return value;
521 }
522
523 pub fn intField(self: Self, obj: jobject, id: jfieldID) JniError!jint {
524 const get: *const fn (JNIEnv, jobject, jfieldID) callconv(.c) jint =
525 @ptrCast(self.table().GetIntField orelse return JniError.NotFound);
526 const value = get(self.env, obj, id);
527 try self.check();
528 return value;
529 }
530
531 pub fn longField(self: Self, obj: jobject, id: jfieldID) JniError!jlong {
532 const get: *const fn (JNIEnv, jobject, jfieldID) callconv(.c) jlong =
533 @ptrCast(self.table().GetLongField orelse return JniError.NotFound);
534 const value = get(self.env, obj, id);
535 try self.check();
536 return value;
537 }
538
539 pub fn floatField(self: Self, obj: jobject, id: jfieldID) JniError!jfloat {
540 const get: *const fn (JNIEnv, jobject, jfieldID) callconv(.c) jfloat =
541 @ptrCast(self.table().GetFloatField orelse return JniError.NotFound);
542 const value = get(self.env, obj, id);
543 try self.check();
544 return value;
545 }
546
547 /// Make a Java `String` from UTF-8.
548 ///
549 /// The JVM wants *modified* UTF-8 here, the same encoding `stringToUtf8`
550 /// decodes on the way back — so a string carrying a NUL or an astral
551 /// character has to be re-encoded rather than handed over. Callers pass
552 /// NUL-terminated literals and ASCII package names today, which are
553 /// identical in both encodings; `encodeModifiedUtf8` is the function to
554 /// add when that stops being true, and this is where it goes.
555 pub fn newStringUtf(self: Self, text: [*:0]const u8) JniError!jstring {
556 const new: *const fn (JNIEnv, [*:0]const u8) callconv(.c) jstring =
557 @ptrCast(self.table().NewStringUTF orelse return JniError.NotFound);
558 const str = new(self.env, text);
559 try self.check();
560 return str orelse JniError.OutOfMemory;
561 }
562
448563 pub fn callIntMethod(self: Self, obj: jobject, id: jmethodID) JniError!jint {
449564 const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jint =
450565 @ptrCast(self.table().CallIntMethod orelse return JniError.NotFound);
@@ -459,6 +574,31 @@ pub const Jni = struct {
459574 return get(self.env, obj) orelse JniError.NotFound;
460575 }
461576
577 /// Open a scope that owns every local reference created inside it.
578 ///
579 /// The JVM only guarantees sixteen local reference slots. A function that
580 /// walks a few objects — an Activity to a WindowManager to a Display to a
581 /// DisplayMetrics — spends them without ever looking like it is
582 /// allocating, and overflowing aborts the process rather than failing a
583 /// call. `popLocalFrame` frees the whole scope in one go, which is both
584 /// less code than a `defer` per reference and impossible to get half-right.
585 pub fn pushLocalFrame(self: Self, capacity: jint) JniError!void {
586 const push: *const fn (JNIEnv, jint) callconv(.c) jint =
587 @ptrCast(self.table().PushLocalFrame orelse return JniError.NotFound);
588 if (push(self.env, capacity) != 0) {
589 try self.check();
590 return JniError.OutOfMemory;
591 }
592 }
593
594 /// Close the scope. `keep` is the one reference to survive it, promoted
595 /// into the enclosing frame — null when nothing needs to.
596 pub fn popLocalFrame(self: Self, keep: jobject) jobject {
597 const pop: *const fn (JNIEnv, jobject) callconv(.c) jobject =
598 @ptrCast(self.table().PopLocalFrame orelse return null);
599 return pop(self.env, keep);
600 }
601
462602 pub fn deleteLocalRef(self: Self, obj: jobject) void {
463603 if (obj == null) return;
464604 const del: *const fn (JNIEnv, jobject) callconv(.c) void =
@@ -843,3 +983,122 @@ test "modified UTF-8 decodes to the standard encoding" {
843983 try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xE6\x97"));
844984 try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xFF"));
845985}
986
987// --- Argument passing ------------------------------------------------------
988
989var fake_seen_args: [4]jvalue = undefined;
990var fake_arg_count: usize = 0;
991var fake_called_obj: jobject = null;
992var fake_called_id: jmethodID = null;
993
994var fake_returned: u8 = 0;
995fn fakeCallObjectMethodA(_: JNIEnv, obj: jobject, id: jmethodID, args: [*]const jvalue) callconv(.c) jobject {
996 fake_called_obj = obj;
997 fake_called_id = id;
998 // The fake knows its own arity; a real one is told by the signature.
999 fake_seen_args[0] = args[0];
1000 fake_seen_args[1] = args[1];
1001 fake_arg_count = 2;
1002 return @ptrCast(&fake_returned);
1003}
1004
1005fn fakeIntField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jint {
1006 return 1080;
1007}
1008fn fakeFloatField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jfloat {
1009 return 2.75;
1010}
1011fn fakeLongField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jlong {
1012 return 4_000_000_123;
1013}
1014fn fakeFieldId(_: JNIEnv, _: jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jfieldID {
1015 return @ptrCast(&fake_field);
1016}
1017
1018test "jvalue is the eight-byte union the A-form calls expect" {
1019 // Wrong size here would misalign every argument after the first, and the
1020 // call would still run — the classic JNI failure that looks like garbage
1021 // data rather than a crash.
1022 try testing.expectEqual(@as(usize, 8), @sizeOf(jvalue));
1023 try testing.expectEqual(@as(usize, 8), @alignOf(jvalue));
1024
1025 // Each member reads back what was written, which is what says the union is
1026 // laid out rather than merely sized.
1027 var v = jvalue{ .i = -7 };
1028 try testing.expectEqual(@as(jint, -7), v.i);
1029 v = jvalue{ .j = 4_000_000_123 };
1030 try testing.expectEqual(@as(jlong, 4_000_000_123), v.j);
1031 v = jvalue{ .d = 0.5 };
1032 try testing.expectEqual(@as(jdouble, 0.5), v.d);
1033}
1034
1035test "arguments arrive in the array, in order, undamaged" {
1036 resetFakes();
1037 var table = emptyTable();
1038 table.CallObjectMethodA = @ptrCast(&fakeCallObjectMethodA);
1039 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
1040
1041 const ptr: *const JNINativeInterface = &table;
1042 const jni = Jni.init(&ptr);
1043
1044 var receiver: u8 = 0;
1045 var name_string: u8 = 0;
1046 var method: u8 = 0;
1047
1048 // `getPackageInfo(String, int)` — the exact shape getDeviceInfo needs, and
1049 // the one a variadic call would be most likely to get wrong: a reference
1050 // and an int, which travel in different register files.
1051 const args = [_]jvalue{
1052 .{ .l = @ptrCast(&name_string) },
1053 .{ .i = 0 },
1054 };
1055 const result = try jni.callObjectMethodA(@ptrCast(&receiver), @ptrCast(&method), &args);
1056
1057 try testing.expect(result != null);
1058 try testing.expectEqual(@as(jobject, @ptrCast(&receiver)), fake_called_obj);
1059 try testing.expectEqual(@as(usize, 2), fake_arg_count);
1060 try testing.expectEqual(@as(jobject, @ptrCast(&name_string)), fake_seen_args[0].l);
1061 try testing.expectEqual(@as(jint, 0), fake_seen_args[1].i);
1062}
1063
1064test "instance fields read through the slots they name" {
1065 resetFakes();
1066 var table = emptyTable();
1067 table.FindClass = @ptrCast(&fakeFindClass);
1068 table.GetFieldID = @ptrCast(&fakeFieldId);
1069 table.GetIntField = @ptrCast(&fakeIntField);
1070 table.GetFloatField = @ptrCast(&fakeFloatField);
1071 table.GetLongField = @ptrCast(&fakeLongField);
1072 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
1073
1074 const ptr: *const JNINativeInterface = &table;
1075 const jni = Jni.init(&ptr);
1076
1077 const cls = try jni.findClass("android/util/DisplayMetrics");
1078 var obj: u8 = 0;
1079 const metrics: jobject = @ptrCast(&obj);
1080
1081 // Three different widths off three adjacent table slots — an off-by-one in
1082 // the transcription would read the wrong one and still return a number.
1083 try testing.expectEqual(@as(jint, 1080), try jni.intField(metrics, try jni.fieldId(cls, "widthPixels", "I")));
1084 try testing.expectEqual(@as(jfloat, 2.75), try jni.floatField(metrics, try jni.fieldId(cls, "density", "F")));
1085 try testing.expectEqual(@as(jlong, 4_000_000_123), try jni.longField(metrics, try jni.fieldId(cls, "longVersionCode", "J")));
1086}
1087
1088test "a field read that throws reports the exception, not the value" {
1089 resetFakes();
1090 var table = emptyTable();
1091 table.GetIntField = @ptrCast(&fakeIntField);
1092 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
1093 table.ExceptionDescribe = @ptrCast(&fakeExceptionDescribe);
1094 table.ExceptionClear = @ptrCast(&fakeExceptionClear);
1095 table.DeleteLocalRef = @ptrCast(&fakeDeleteLocalRef);
1096
1097 const ptr: *const JNINativeInterface = &table;
1098 const jni = Jni.init(&ptr);
1099
1100 var throwable: u8 = 0;
1101 fake_pending_exception = @ptrCast(&throwable);
1102 var obj: u8 = 0;
1103 try testing.expectError(JniError.JavaException, jni.intField(@ptrCast(&obj), null));
1104}