craft-native 0.0.246 → 0.0.248

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/android/src/index.d.ts +11 -0
  2. package/dist/android/src/index.js +33 -21
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +136 -1
  5. package/dist/android/templates/CraftHeartRate.kt.template +938 -0
  6. package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
  7. package/dist/android/templates/CraftNative.kt.template +17 -0
  8. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  11. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  12. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  13. package/dist/android/templates/CraftNativeServices.kt.template +59 -1
  14. package/dist/android/templates/CraftNativeShell.kt.template +54 -14
  15. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  16. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  17. package/dist/api/mobile.d.ts +39 -3
  18. package/dist/browser.js +9 -9
  19. package/dist/cli.js +90 -673
  20. package/dist/components/index.d.ts +50 -9
  21. package/dist/index.cjs +394 -438
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +446 -493
  25. package/dist/ios/src/index.d.ts +2 -0
  26. package/dist/ios/src/index.js +54 -53
  27. package/dist/ios/templates/CraftApp.swift +37 -2
  28. package/dist/ios/templates/CraftHealth.swift +217 -0
  29. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  30. package/dist/ios/templates/CraftHybrid.swift +18 -19
  31. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  32. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  33. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  34. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  35. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  36. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  37. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  38. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  39. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  40. package/dist/ios/templates/testing/HostApp.swift +9 -2
  41. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  42. package/dist/macos/templates/native_layout.zig +226 -2
  43. package/dist/mobile-testing.js +21 -3
  44. package/dist/mobile.js +101 -63
  45. package/dist/testing/media-cache/media-cache.kt +4 -2
  46. package/package.json +1 -1
@@ -313,8 +313,10 @@ final class CraftNativeSounds {
313
313
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
314
314
  stop(id: id, fadeOut: 0)
315
315
  let loop = options["loop"] as? Bool ?? false
316
- let volume = Float(min(max((options["volume"] as? NSNumber)?.doubleValue ?? 1, 0), 1))
317
- let fadeIn = max(0, (options["fadeIn"] as? NSNumber)?.doubleValue ?? 0) / 1000
316
+ let volumeValue = craftNativeNumber(options["volume"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil } ?? 1
317
+ let fadeInValue = craftNativeNumber(options["fadeIn"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil } ?? 0
318
+ let volume = Float(min(max(volumeValue, 0), 1))
319
+ let fadeIn = max(0, fadeInValue) / 1000
318
320
  Self.claimSession(respectSilentSwitch: options["respectSilentSwitch"] as? Bool ?? true)
319
321
  let item = AVPlayerItem(url: url)
320
322
  let player = AVQueuePlayer()
@@ -1,4 +1,5 @@
1
1
  import AuthenticationServices
2
+ import CoreFoundation
2
3
  import SafariServices
3
4
  import UIKit
4
5
  import WebKit
@@ -54,7 +55,12 @@ enum CraftPresenter {
54
55
  /// view's points, as a rect. Nil when it is not one.
55
56
  static func rect(_ value: Any?) -> CGRect? {
56
57
  guard let value = value as? [String: Any] else { return nil }
57
- func number(_ key: String) -> CGFloat? { (value[key] as? NSNumber).map { CGFloat(truncating: $0) } }
58
+ func number(_ key: String) -> CGFloat? {
59
+ guard let number = value[key] as? NSNumber,
60
+ CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
61
+ let value = CGFloat(truncating: number)
62
+ return value.isFinite ? value : nil
63
+ }
58
64
  guard let x = number("x"), let y = number("y") else { return nil }
59
65
  return CGRect(x: x, y: y, width: max(0, number("width") ?? 0), height: max(0, number("height") ?? 0))
60
66
  }
@@ -464,7 +470,9 @@ extension CraftSignInWithApple: ASAuthorizationControllerDelegate, ASAuthorizati
464
470
  /// show the same glyph the system's own controls use.
465
471
  enum CraftSymbols {
466
472
  static func image(named name: String, options: [String: Any]) -> String? {
467
- let pointSize = (options["pointSize"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 17
473
+ let pointSize = craftNativeNumber(options["pointSize"])
474
+ .map { CGFloat(truncating: $0) }
475
+ .flatMap { $0.isFinite ? $0 : nil } ?? 17
468
476
  let weight: UIImage.SymbolWeight
469
477
  switch options["weight"] as? String {
470
478
  case "ultraLight": weight = .ultraLight
@@ -276,6 +276,7 @@ final class DriverTests: XCTestCase {
276
276
  let host = XCUIApplication(bundleIdentifier: "{{BUNDLE_PREFIX}}.host")
277
277
  host.launchArguments = ["--craft-universal-link", url.absoluteString]
278
278
  host.launch()
279
+ host.buttons["craft-driver-open-universal-link"].tap()
279
280
  let opened = app.wait(for: .runningForeground, timeout: 75)
280
281
  if !opened {
281
282
  post("/save?name=universal-link-host.txt", Data(host.debugDescription.utf8))
@@ -7,8 +7,15 @@ struct HostApp: App {
7
7
 
8
8
  var body: some Scene {
9
9
  WindowGroup {
10
- Text(status).accessibilityIdentifier("craft-driver-status")
11
- .task { await openUniversalLinkIfRequested() }
10
+ VStack {
11
+ Text(status).accessibilityIdentifier("craft-driver-status")
12
+ // XCTest must finish acquiring the host's launch assertion
13
+ // before a handoff can move another application foreground.
14
+ Button("Open Universal Link") {
15
+ Task { await openUniversalLinkIfRequested() }
16
+ }
17
+ .accessibilityIdentifier("craft-driver-open-universal-link")
18
+ }
12
19
  }
13
20
  }
14
21
 
@@ -108,6 +108,13 @@ final class CraftNativeMutationDocument {
108
108
  case "moveChild":
109
109
  try moveChild(operation, nodes: &candidateNodes)
110
110
  if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
111
+ case "reparentChild":
112
+ let previousParent = try reparentChild(operation, nodes: &candidateNodes)
113
+ if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
114
+ appendUnique(previousParent, to: &affectedNodeIds)
115
+ // A child crossing parents needs the committed tree to be
116
+ // reconciled so the host can move its existing native view.
117
+ requiresFullRender = true
111
118
  case "removeNode":
112
119
  if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
113
120
  appendUnique(parent, to: &affectedNodeIds)
@@ -251,6 +258,35 @@ final class CraftNativeMutationDocument {
251
258
  nodes[parentId] = parent
252
259
  }
253
260
 
261
+ private func reparentChild(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
262
+ let parentId = try requiredId(operation, field: "parentId", name: "reparentChild")
263
+ let childId = try requiredId(operation, field: "childId", name: "reparentChild")
264
+ guard var parent = nodes[parentId] else {
265
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
266
+ }
267
+ guard var child = nodes[childId], let previousParentId = child.parent,
268
+ var previousParent = nodes[previousParentId] else {
269
+ throw CraftNativeMutationFailure("NOT_ATTACHED", "node \(childId) is not attached to a parent")
270
+ }
271
+ guard acceptsChildren(parent.type) else {
272
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
273
+ }
274
+ guard parentId != childId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
275
+ throw CraftNativeMutationFailure("CYCLE", "reparenting node \(childId) would create a cycle")
276
+ }
277
+ guard parentId != previousParentId else {
278
+ throw CraftNativeMutationFailure("SAME_PARENT", "node \(childId) is already a child of \(parentId)")
279
+ }
280
+ let index = try insertionIndex(operation, count: parent.children.count)
281
+ previousParent.children.removeAll { $0 == childId }
282
+ parent.children.insert(childId, at: index)
283
+ child.parent = parentId
284
+ nodes[previousParentId] = previousParent
285
+ nodes[parentId] = parent
286
+ nodes[childId] = child
287
+ return previousParentId
288
+ }
289
+
254
290
  private func removeNode(
255
291
  _ operation: [String: Any],
256
292
  nodes: inout [String: Node],
@@ -105,6 +105,8 @@ pub const Style = struct {
105
105
  /// ABI and remains host-neutral because hosts resolve percentages before
106
106
  /// serializing the style.
107
107
  grid_auto_rows: ?f32 = null,
108
+ /// Explicit equal row-track count. Null means derive rows from children.
109
+ grid_rows: ?u32 = null,
108
110
  };
109
111
 
110
112
  pub const Node = struct {
@@ -297,6 +299,51 @@ pub const LayoutNodeInputV4 = extern struct {
297
299
  child_count: u32,
298
300
  };
299
301
 
302
+ /// v5 appends the resolved explicit grid-row count to the v4 payload.
303
+ pub const LayoutStyleInputV5 = extern struct {
304
+ direction: u32,
305
+ width: f32,
306
+ height: f32,
307
+ intrinsic_width: f32,
308
+ intrinsic_height: f32,
309
+ baseline: f32,
310
+ aspect_ratio: f32,
311
+ flex_basis: f32,
312
+ flex_grow: f32,
313
+ flex_shrink: f32,
314
+ min_width: f32,
315
+ max_width: f32,
316
+ min_height: f32,
317
+ max_height: f32,
318
+ gap: f32,
319
+ justify_content: u32,
320
+ align_items: u32,
321
+ align_self: u32,
322
+ flex_wrap: u32,
323
+ align_content: u32,
324
+ margin_top: f32,
325
+ margin_right: f32,
326
+ margin_bottom: f32,
327
+ margin_left: f32,
328
+ padding_top: f32,
329
+ padding_right: f32,
330
+ padding_bottom: f32,
331
+ padding_left: f32,
332
+ grid: u32,
333
+ grid_columns: u32,
334
+ row_gap: f32,
335
+ column_gap: f32,
336
+ reverse: u32,
337
+ grid_auto_rows: f32,
338
+ grid_rows: u32,
339
+ };
340
+
341
+ pub const LayoutNodeInputV5 = extern struct {
342
+ style: LayoutStyleInputV5,
343
+ first_child: u32,
344
+ child_count: u32,
345
+ };
346
+
300
347
  const max_layout_nodes: usize = 65_536;
301
348
 
302
349
  /// Resolve an entire native view tree using the same layout rules used by the
@@ -452,6 +499,43 @@ pub export fn craft_native_layout_tree_v4(
452
499
  return if (result == node_count) 0 else -1;
453
500
  }
454
501
 
502
+ /// v5 tree layout entry point. It preserves v4's auto-row and reverse fields,
503
+ /// adding an explicit equal-track row count for gridTemplateRows.
504
+ pub export fn craft_native_layout_tree_v5(
505
+ viewport_width: f32,
506
+ viewport_height: f32,
507
+ nodes: [*]const LayoutNodeInputV5,
508
+ node_count: usize,
509
+ frames: [*]Frame,
510
+ frame_count: usize,
511
+ ) callconv(.c) c_int {
512
+ if (node_count == 0 or node_count > max_layout_nodes or frame_count < node_count) return -1;
513
+
514
+ const input = nodes[0..node_count];
515
+ const visited = std.heap.page_allocator.alloc(bool, node_count) catch return -1;
516
+ defer std.heap.page_allocator.free(visited);
517
+ @memset(visited, false);
518
+ if (!validateLayoutTreeV5(input, 0, visited)) return -1;
519
+
520
+ var tree = std.heap.page_allocator.alloc(Node, node_count) catch return -1;
521
+ defer std.heap.page_allocator.free(tree);
522
+ for (input, 0..) |item, index| {
523
+ const first = @as(usize, @intCast(item.first_child));
524
+ const count = @as(usize, @intCast(item.child_count));
525
+ tree[index] = .{
526
+ .style = layoutStyleV5(item.style),
527
+ .children = tree[first .. first + count],
528
+ };
529
+ }
530
+
531
+ const result = layout(
532
+ tree[0],
533
+ .{ .width = finiteNonNegative(viewport_width), .height = finiteNonNegative(viewport_height) },
534
+ frames[0..frame_count],
535
+ ) catch return -1;
536
+ return if (result == node_count) 0 else -1;
537
+ }
538
+
455
539
  fn validateLayoutTree(input: []const LayoutNodeInput, index: usize, visited: []bool) bool {
456
540
  if (index >= input.len or visited[index]) return false;
457
541
  visited[index] = true;
@@ -491,6 +575,19 @@ fn validateLayoutTreeV4(input: []const LayoutNodeInputV4, index: usize, visited:
491
575
  return true;
492
576
  }
493
577
 
578
+ fn validateLayoutTreeV5(input: []const LayoutNodeInputV5, index: usize, visited: []bool) bool {
579
+ if (index >= input.len or visited[index]) return false;
580
+ visited[index] = true;
581
+ const item = input[index];
582
+ const first = @as(usize, @intCast(item.first_child));
583
+ const count = @as(usize, @intCast(item.child_count));
584
+ if (first > input.len or count > input.len - first or (count > 0 and first <= index)) return false;
585
+ for (first..first + count) |child| {
586
+ if (!validateLayoutTreeV5(input, child, visited)) return false;
587
+ }
588
+ return true;
589
+ }
590
+
494
591
  fn layoutStyle(input: LayoutStyleInput) Style {
495
592
  return .{
496
593
  .direction = if (input.direction == 1) .column else .row,
@@ -614,6 +711,47 @@ fn layoutStyleV4(input: LayoutStyleInputV4) Style {
614
711
  };
615
712
  }
616
713
 
714
+ fn layoutStyleV5(input: LayoutStyleInputV5) Style {
715
+ return .{
716
+ .direction = if (input.direction == 1) .column else .row,
717
+ .reverse = (input.reverse & 1) != 0,
718
+ .wrap_reverse = (input.reverse & 2) != 0,
719
+ .width = optionalFinite(input.width),
720
+ .height = optionalFinite(input.height),
721
+ .intrinsic_width = optionalFinite(input.intrinsic_width),
722
+ .intrinsic_height = optionalFinite(input.intrinsic_height),
723
+ .baseline = optionalFinite(input.baseline),
724
+ .aspect_ratio = optionalFinite(input.aspect_ratio),
725
+ .flex_basis = optionalFinite(input.flex_basis),
726
+ .flex_grow = finiteNonNegative(input.flex_grow),
727
+ .flex_shrink = finiteNonNegative(input.flex_shrink),
728
+ .min_width = optionalFinite(input.min_width),
729
+ .max_width = optionalFinite(input.max_width),
730
+ .min_height = optionalFinite(input.min_height),
731
+ .max_height = optionalFinite(input.max_height),
732
+ .gap = finiteNonNegative(input.gap),
733
+ .justify_content = @fromBackingInt(@intCast(@min(input.justify_content, 5))),
734
+ .align_items = @fromBackingInt(@intCast(@min(input.align_items, 4))),
735
+ .align_self = if (input.align_self == 0) null else @fromBackingInt(@intCast(@min(input.align_self - 1, 4))),
736
+ .flex_wrap = input.flex_wrap != 0,
737
+ .align_content = @fromBackingInt(@intCast(@min(input.align_content, 6))),
738
+ .margin_top = finiteNonNegative(input.margin_top),
739
+ .margin_right = finiteNonNegative(input.margin_right),
740
+ .margin_bottom = finiteNonNegative(input.margin_bottom),
741
+ .margin_left = finiteNonNegative(input.margin_left),
742
+ .padding_top = finiteNonNegative(input.padding_top),
743
+ .padding_right = finiteNonNegative(input.padding_right),
744
+ .padding_bottom = finiteNonNegative(input.padding_bottom),
745
+ .padding_left = finiteNonNegative(input.padding_left),
746
+ .grid = input.grid != 0,
747
+ .grid_columns = @max(@as(u32, 1), input.grid_columns),
748
+ .row_gap = finiteNonNegative(input.row_gap),
749
+ .column_gap = finiteNonNegative(input.column_gap),
750
+ .grid_auto_rows = optionalFinite(input.grid_auto_rows),
751
+ .grid_rows = if (input.grid_rows == 0) null else input.grid_rows,
752
+ };
753
+ }
754
+
617
755
  fn optionalFinite(value: f32) ?f32 {
618
756
  return if (std.math.isFinite(value)) value else null;
619
757
  }
@@ -1359,12 +1497,26 @@ fn layoutGrid(
1359
1497
  const row_gap = axisGap(node.style.row_gap, node.style.gap);
1360
1498
  const available_width = @max(0, width - column_gap * @as(f32, @floatFromInt(column_count - 1)));
1361
1499
  const cell_width = available_width / @as(f32, @floatFromInt(column_count));
1500
+ const natural_rows = if (node.children.len == 0) 0 else (node.children.len + column_count - 1) / column_count;
1501
+ const row_count = @max(natural_rows, @as(usize, @intCast(node.style.grid_rows orelse 0)));
1502
+ const row_heights = std.heap.page_allocator.alloc(f32, row_count) catch return error.FrameBufferTooSmall;
1503
+ defer std.heap.page_allocator.free(row_heights);
1504
+ for (0..row_count) |row| {
1505
+ var track = gridRowHeight(node, row, column_count);
1506
+ if (node.style.grid_rows != null) {
1507
+ const gap_count = if (row_count > 1) row_count - 1 else 0;
1508
+ const gaps = row_gap * @as(f32, @floatFromInt(gap_count));
1509
+ const equal = @max(0, (height - gaps) / @as(f32, @floatFromInt(@max(row_count, 1))));
1510
+ track = @max(track, equal);
1511
+ }
1512
+ row_heights[row] = track;
1513
+ }
1362
1514
  var next_index = frame_index + 1;
1363
1515
  var row_y = y;
1364
1516
  for (node.children, 0..) |child, index| {
1365
1517
  const row = index / column_count;
1366
1518
  const column = index % column_count;
1367
- const row_height = gridRowHeight(node, row, column_count);
1519
+ const row_height = row_heights[row];
1368
1520
  const child_available_width = @max(0, cell_width - horizontalMargins(child));
1369
1521
  const ratio_width = if (validAspectRatio(child.style.aspect_ratio)) |ratio|
1370
1522
  if (child.style.width == null and child.style.height != null) child.style.height.? * ratio else null
@@ -1395,7 +1547,6 @@ fn layoutGrid(
1395
1547
  row_y += row_height + row_gap;
1396
1548
  }
1397
1549
  }
1398
- _ = height;
1399
1550
  return next_index;
1400
1551
  }
1401
1552
 
@@ -1708,6 +1859,47 @@ fn emptyLayoutStyleInputV4() LayoutStyleInputV4 {
1708
1859
  };
1709
1860
  }
1710
1861
 
1862
+ fn emptyLayoutStyleInputV5() LayoutStyleInputV5 {
1863
+ const unset = std.math.nan(f32);
1864
+ return .{
1865
+ .direction = 0,
1866
+ .width = unset,
1867
+ .height = unset,
1868
+ .intrinsic_width = unset,
1869
+ .intrinsic_height = unset,
1870
+ .baseline = unset,
1871
+ .aspect_ratio = unset,
1872
+ .flex_basis = unset,
1873
+ .flex_grow = 0,
1874
+ .flex_shrink = 0,
1875
+ .min_width = unset,
1876
+ .max_width = unset,
1877
+ .min_height = unset,
1878
+ .max_height = unset,
1879
+ .gap = 0,
1880
+ .justify_content = 0,
1881
+ .align_items = 3,
1882
+ .align_self = 0,
1883
+ .flex_wrap = 0,
1884
+ .align_content = 5,
1885
+ .margin_top = 0,
1886
+ .margin_right = 0,
1887
+ .margin_bottom = 0,
1888
+ .margin_left = 0,
1889
+ .padding_top = 0,
1890
+ .padding_right = 0,
1891
+ .padding_bottom = 0,
1892
+ .padding_left = 0,
1893
+ .grid = 0,
1894
+ .grid_columns = 1,
1895
+ .row_gap = 0,
1896
+ .column_gap = 0,
1897
+ .reverse = 0,
1898
+ .grid_auto_rows = unset,
1899
+ .grid_rows = 0,
1900
+ };
1901
+ }
1902
+
1711
1903
  fn clampBounds(value: f32, lower: f32, upper: f32) f32 {
1712
1904
  var result = finiteNonNegative(value);
1713
1905
  if (std.math.isFinite(lower) and lower >= 0) result = @max(result, lower);
@@ -1818,6 +2010,38 @@ test "v4 tree layout carries auto rows through nested grids" {
1818
2010
  try std.testing.expectEqual(@as(f32, 18), frames[4].y);
1819
2011
  }
1820
2012
 
2013
+ test "v5 tree layout carries explicit equal grid rows through the shared ABI" {
2014
+ var root_style = emptyLayoutStyleInputV5();
2015
+ root_style.width = 100;
2016
+ root_style.height = 100;
2017
+ root_style.grid = 1;
2018
+ root_style.grid_columns = 2;
2019
+ root_style.row_gap = 4;
2020
+ root_style.grid_rows = 2;
2021
+
2022
+ var first_style = emptyLayoutStyleInputV5();
2023
+ first_style.intrinsic_width = 10;
2024
+ first_style.intrinsic_height = 6;
2025
+ var second_style = emptyLayoutStyleInputV5();
2026
+ second_style.intrinsic_width = 10;
2027
+ second_style.intrinsic_height = 7;
2028
+ var third_style = emptyLayoutStyleInputV5();
2029
+ third_style.intrinsic_width = 10;
2030
+ third_style.intrinsic_height = 5;
2031
+
2032
+ const nodes = [_]LayoutNodeInputV5{
2033
+ .{ .style = root_style, .first_child = 1, .child_count = 3 },
2034
+ .{ .style = first_style, .first_child = 0, .child_count = 0 },
2035
+ .{ .style = second_style, .first_child = 0, .child_count = 0 },
2036
+ .{ .style = third_style, .first_child = 0, .child_count = 0 },
2037
+ };
2038
+ var frames: [4]Frame = undefined;
2039
+ try std.testing.expectEqual(@as(c_int, 0), craft_native_layout_tree_v5(100, 100, &nodes, nodes.len, &frames, frames.len));
2040
+ try std.testing.expectEqual(@as(f32, 0), frames[1].y);
2041
+ try std.testing.expectEqual(@as(f32, 0), frames[2].y);
2042
+ try std.testing.expectEqual(@as(f32, 52), frames[3].y);
2043
+ }
2044
+
1821
2045
  test "lays out nested flat trees with absolute descendant frames" {
1822
2046
  var root_style = emptyLayoutStyleInput();
1823
2047
  root_style.width = 200;
@@ -54,6 +54,7 @@ async function verifyMobileMediaCache(options) {
54
54
  const scratch = await mkdtemp(join(tmpdir(), "craft-media-probe-"));
55
55
  const bytes = new Uint8Array(await Bun.file(join(fixtures, "demo.mp4")).arrayBuffer());
56
56
  const counts = new Map;
57
+ const resumed = new Set;
57
58
  const timers = new Set;
58
59
  const server = Bun.listen({
59
60
  hostname: "0.0.0.0",
@@ -72,6 +73,20 @@ async function verifyMobileMediaCache(options) {
72
73
  const path = socket.data.request.split(" ")[1];
73
74
  const n = (counts.get(path) || 0) + 1;
74
75
  counts.set(path, n);
76
+ const from = Number(/^range:\s*bytes=(\d+)-\s*$/im.exec(socket.data.request)?.[1] ?? Number.NaN);
77
+ if (path === "/android/restart.mp4" && n > 1 && from > 0 && from < bytes.length) {
78
+ resumed.add(path);
79
+ socket.write(`HTTP/1.1 206 Partial Content\r
80
+ Content-Type: video/mp4\r
81
+ Content-Range: bytes ${from}-${bytes.length - 1}/${bytes.length}\r
82
+ Content-Length: ${bytes.length - from}\r
83
+ Connection: close\r
84
+ \r
85
+ `);
86
+ socket.write(bytes.subarray(from));
87
+ socket.end();
88
+ return;
89
+ }
75
90
  const empty = path.endsWith("/empty.mp4");
76
91
  const partial = path.endsWith("/partial.mp4");
77
92
  const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
@@ -207,6 +222,7 @@ include(":app")`);
207
222
  `);
208
223
  await Bun.write(join(android, "gradle.properties"), `org.gradle.jvmargs=-Xmx768m
209
224
  org.gradle.workers.max=2
225
+ android.useAndroidX=true
210
226
  `);
211
227
  await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
212
228
  id("org.jetbrains.kotlin.android") }
@@ -264,11 +280,13 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
264
280
  if (counts.get("/android/restart.mp4") !== 1)
265
281
  throw new Error("Android process did not start interrupted download");
266
282
  await probe("recover");
283
+ if (!resumed.has("/android/restart.mp4"))
284
+ throw new Error("Android did not resume the interrupted download from its part file");
267
285
  if (counts.get("/android/slow.mp4") !== 1)
268
286
  throw new Error("Android duplicate fetch: keep did not coalesce");
269
287
  if (counts.get("/android/account/slow.mp4") !== 2)
270
288
  throw new Error("Android account switch did not start a fresh transfer");
271
- print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
289
+ print("PASS: Android emulator interruption, resumed restart, retry, prune and cached video decode");
272
290
  }
273
291
  }
274
292
  } finally {
@@ -459,7 +477,7 @@ ${errors || output}`);
459
477
  return { restart, installUpdate, installedBuild };
460
478
  }
461
479
  export {
462
- verifyMobileMediaCache,
480
+ createMobileTestDevice,
463
481
  recordIosSimulatorVideo,
464
- createMobileTestDevice
482
+ verifyMobileMediaCache
465
483
  };