| @@ -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 | +} |