also looking at this
feat(android): getDeviceInfo in Zig, and the JNI calls it needed
#137
3 files
+741
-0
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -413,6 +413,18 @@ pub fn build(b: *std.Build) void { | ||
| 413 | 413 | }); |
| 414 | 414 | const run_jni_tests = b.addRunArtifact(jni_tests); |
| 415 | 415 | |
| 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 | ||
| 416 | 428 | ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{ |
| 417 | 429 | .root_source_file = b.path("../ios/templates/CraftApp.swift"), |
| 418 | 430 | }); |
| @@ -1526,6 +1538,7 @@ pub fn build(b: *std.Build) void { | ||
| 1526 | 1538 | } |
| 1527 | 1539 | test_step.dependOn(&run_ios_conformance_tests.step); |
| 1528 | 1540 | test_step.dependOn(&run_jni_tests.step); |
| 1541 | test_step.dependOn(&run_android_bridge_tests.step); | |
| 1529 | 1542 | test_step.dependOn(&run_menubar_tests.step); |
| 1530 | 1543 | test_step.dependOn(&run_components_tests.step); |
| 1531 | 1544 | test_step.dependOn(&run_gpu_tests.step); |
| @@ -2202,6 +2215,7 @@ pub fn build(b: *std.Build) void { | ||
| 2202 | 2215 | // second one: `jni_runtime.zig` is the only way anything here reaches Java, |
| 2203 | 2216 | // so a run that skipped it would not be an Android test run. |
| 2204 | 2217 | test_android_step.dependOn(&run_jni_tests.step); |
| 2218 | test_android_step.dependOn(&run_android_bridge_tests.step); | |
| 2205 | 2219 | |
| 2206 | 2220 | // Add Android tests to the main test step |
| 2207 | 2221 | test_step.dependOn(&run_android_tests.step); |
| @@ -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 | ||
| 36 | const std = ; | |
| 37 | const jni = ; | |
| 38 | const bridge_error = ; | |
| 39 | ||
| 40 | const Jni = jni.Jni; | |
| 41 | const 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. | |
| 45 | pub 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. | |
| 53 | pub 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. | |
| 82 | pub 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. | |
| 127 | fn 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. | |
| 155 | pub 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. | |
| 161 | fn 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. | |
| 183 | pub 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 | ||
| 234 | const 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. | |
| 243 | fn 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 | ||
| 276 | const 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. | |
| 286 | fn 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 | ||
| 342 | const testing = std.testing; | |
| 343 | ||
| 344 | fn 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 | ||
| 361 | test "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 | ||
| 389 | test "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 | ||
| 407 | test "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 | ||
| 425 | test "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 | ||
| 436 | test "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 | ||
| 452 | test "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 | ||
| 464 | test "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 | } | |
| @@ -80,6 +80,23 @@ pub const jthrowable = jobject; | ||
| 80 | 80 | pub const jmethodID = ?*anyopaque; |
| 81 | 81 | pub const jfieldID = ?*anyopaque; |
| 82 | 82 | |
| 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. | |
| 88 | pub 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 | ||
| 83 | 100 | /// `JNIEnv*` as the JVM passes it to a native method. |
| 84 | 101 | pub const JNIEnv = *const *const JNINativeInterface; |
| 85 | 102 | |
| @@ -445,6 +462,104 @@ pub const Jni = struct { | ||
| 445 | 462 | return result; |
| 446 | 463 | } |
| 447 | 464 | |
| 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 | ||
| 448 | 563 | pub fn callIntMethod(self: Self, obj: jobject, id: jmethodID) JniError!jint { |
| 449 | 564 | const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jint = |
| 450 | 565 | @ptrCast(self.table().CallIntMethod orelse return JniError.NotFound); |
| @@ -459,6 +574,31 @@ pub const Jni = struct { | ||
| 459 | 574 | return get(self.env, obj) orelse JniError.NotFound; |
| 460 | 575 | } |
| 461 | 576 | |
| 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 | ||
| 462 | 602 | pub fn deleteLocalRef(self: Self, obj: jobject) void { |
| 463 | 603 | if (obj == null) return; |
| 464 | 604 | const del: *const fn (JNIEnv, jobject) callconv(.c) void = |
| @@ -843,3 +983,122 @@ test "modified UTF-8 decodes to the standard encoding" { | ||
| 843 | 983 | try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xE6\x97")); |
| 844 | 984 | try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xFF")); |
| 845 | 985 | } |
| 986 | ||
| 987 | // --- Argument passing ------------------------------------------------------ | |
| 988 | ||
| 989 | var fake_seen_args: [4]jvalue = undefined; | |
| 990 | var fake_arg_count: usize = 0; | |
| 991 | var fake_called_obj: jobject = null; | |
| 992 | var fake_called_id: jmethodID = null; | |
| 993 | ||
| 994 | var fake_returned: u8 = 0; | |
| 995 | fn 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 | ||
| 1005 | fn fakeIntField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jint { | |
| 1006 | return 1080; | |
| 1007 | } | |
| 1008 | fn fakeFloatField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jfloat { | |
| 1009 | return 2.75; | |
| 1010 | } | |
| 1011 | fn fakeLongField(_: JNIEnv, _: jobject, _: jfieldID) callconv(.c) jlong { | |
| 1012 | return 4_000_000_123; | |
| 1013 | } | |
| 1014 | fn fakeFieldId(_: JNIEnv, _: jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jfieldID { | |
| 1015 | return @ptrCast(&fake_field); | |
| 1016 | } | |
| 1017 | ||
| 1018 | test "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 | ||
| 1035 | test "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 | ||
| 1064 | test "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 | ||
| 1088 | test "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 | } | |