also looking at this
feat(android): getDeviceInfo in Zig, and the JNI calls it needed
#137
3 files
+741
-0
| @@ -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 | } | |