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/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}