craft-native 0.0.250 → 0.0.254

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.
@@ -194,6 +194,7 @@ async function createAndroidEmulator(options) {
194
194
  if (options.acceleration === "software" && process.arch !== "x64")
195
195
  throw new Error("Android software emulation requires an x86_64 host and system image");
196
196
  const bootTimeoutMs = options.bootTimeoutMs ?? 600000;
197
+ const requiresPlayStore = options.image.split(";")[2] === "google_apis_playstore";
197
198
  const maximumBootTimeoutMs = options.acceleration === "software" ? 1200000 : 600000;
198
199
  if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
199
200
  throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`);
@@ -332,11 +333,13 @@ async function createAndroidEmulator(options) {
332
333
  if (completion.code === 0 && completion.stdout.trim() === "1") {
333
334
  const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
334
335
  const packages = await run([adb, "-s", serial, "shell", "pm", "path", "android"], undefined, probeTimeout(), true);
336
+ const playStore = requiresPlayStore ? await run([adb, "-s", serial, "shell", "pm", "path", "com.android.vending"], undefined, probeTimeout(), true) : undefined;
335
337
  const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
336
338
  const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-t", "1"], undefined, probeTimeout(), true);
337
339
  const home = await run([adb, "-s", serial, "shell", "cmd", "package", "resolve-activity", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.HOME"], undefined, probeTimeout(), true);
338
340
  const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? "";
339
- booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !homeActivity.startsWith("com.android.sdksetup/");
341
+ const temporaryHome = homeActivity.startsWith("com.android.sdksetup/") || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity);
342
+ booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && (!requiresPlayStore || playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome;
340
343
  }
341
344
  }
342
345
  const checkedAt = Date.now();
@@ -413,7 +413,6 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
413
413
  markers.values.forEach { builder.include(it.position); count++ }
414
414
  polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
415
415
  polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
416
- polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
417
416
  if (count == 0) return false
418
417
  post {
419
418
  if (width > 0 && height > 0) {
@@ -21,6 +21,9 @@ import android.widget.FrameLayout
21
21
  import android.widget.ImageView
22
22
  import android.widget.LinearLayout
23
23
  import android.widget.TextView
24
+ import androidx.core.view.AccessibilityDelegateCompat
25
+ import androidx.core.view.ViewCompat
26
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
24
27
  import androidx.core.content.ContextCompat
25
28
  import androidx.security.crypto.EncryptedSharedPreferences
26
29
  import androidx.security.crypto.MasterKey
@@ -838,7 +841,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
838
841
  cell.background = rippleBackground()
839
842
  cell.contentDescription = tab.title
840
843
  cell.tag = "craft-tab-${tab.id}"
844
+ cell.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
845
+ ViewCompat.setAccessibilityDelegate(cell, object : AccessibilityDelegateCompat() {
846
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
847
+ super.onInitializeAccessibilityNodeInfo(host, info)
848
+ info.viewIdResourceName = "craft-tab-${tab.id}"
849
+ info.className = android.widget.Button::class.java.name
850
+ info.isSelected = host.isSelected
851
+ }
852
+ })
841
853
  val icon = TextView(context)
854
+ icon.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
842
855
  icon.text = CraftNativeSymbols.glyph(tab.symbol)
843
856
  icon.textSize = 22f
844
857
  icon.minWidth = dp(26)
@@ -846,6 +859,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
846
859
  icon.gravity = Gravity.CENTER
847
860
  icon.includeFontPadding = false
848
861
  val label = TextView(context)
862
+ label.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
849
863
  label.text = tab.title
850
864
  label.textSize = 12f
851
865
  label.fontFeatureSettings = "tnum"
@@ -1234,6 +1248,7 @@ internal object CraftNativeSymbols {
1234
1248
  * transfer finishes on its own; the next keep answers from the copy.
1235
1249
  */
1236
1250
  internal object CraftNativeMedia {
1251
+ const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
1237
1252
  private const val TAG = "craft-media"
1238
1253
  private const val PREFS = "craft.media"
1239
1254
  private val lock = Any()
@@ -1301,7 +1316,7 @@ internal object CraftNativeMedia {
1301
1316
  try {
1302
1317
  if (generation(app) == started) {
1303
1318
  val pending = work.getWorkInfosForUniqueWork(name).get().any {
1304
- "$TAG:generation:$started" in it.tags &&
1319
+ "$TAG:generation:$started" in it.tags && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
1305
1320
  (it.state == androidx.work.WorkInfo.State.RUNNING ||
1306
1321
  (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
1307
1322
  }
@@ -1349,12 +1364,22 @@ internal object CraftNativeMedia {
1349
1364
  * file where it stopped, moves it into place whole, and answers whoever
1350
1365
  * waits. RETRY leaves the part file for the next attempt.
1351
1366
  */
1352
- fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
1367
+ fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
1368
+ fun answer(result: CraftNativeCapabilityResult) {
1369
+ // The callback can immediately call keep again. Publish completion
1370
+ // before that callback, while WorkManager may still say RUNNING.
1371
+ synchronized(lock) {
1372
+ // A keep registered after this marker belongs to the next
1373
+ // attempt; it must not enter the old callback batch.
1374
+ beforeAnswer()
1375
+ deliver(context, address, started, result)
1376
+ }
1377
+ }
1353
1378
  // clear() already answered the old generation's waiters. Never answer
1354
1379
  // a new athlete's waiters from an obsolete worker for the same URL.
1355
1380
  if (generation(context) != started) return Outcome.DONE
1356
1381
  local(context, address)?.let {
1357
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1382
+ answer(CraftNativeCapabilityResult(data = describe(address, it)))
1358
1383
  return Outcome.DONE
1359
1384
  }
1360
1385
  val writer = writers.getOrPut(address) { Any() }
@@ -1364,7 +1389,7 @@ internal object CraftNativeMedia {
1364
1389
  if (generation(context) != started) return Outcome.DONE
1365
1390
  if (stopped()) return Outcome.RETRY
1366
1391
  local(context, address)?.let {
1367
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1392
+ answer(CraftNativeCapabilityResult(data = describe(address, it)))
1368
1393
  return Outcome.DONE
1369
1394
  }
1370
1395
  val target = file(context, address)
@@ -1383,22 +1408,22 @@ internal object CraftNativeMedia {
1383
1408
  }
1384
1409
  if (full) {
1385
1410
  staging.delete()
1386
- deliver(context, address, started, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1411
+ answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1387
1412
  return Outcome.DONE
1388
1413
  }
1389
1414
  if (stopped()) return Outcome.RETRY
1390
1415
  // Lost the connection part way: the part waits for the next attempt.
1391
- deliver(context, address, started, if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
1416
+ answer(if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
1392
1417
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1393
1418
  }
1394
1419
  when (fetched) {
1395
1420
  is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
1396
1421
  is CraftResumableDownload.Fetched.Refused -> {
1397
- deliver(context, address, started, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1422
+ answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1398
1423
  return Outcome.DONE
1399
1424
  }
1400
1425
  is CraftResumableDownload.Fetched.Incomplete -> {
1401
- deliver(context, address, started, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1426
+ answer(CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1402
1427
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1403
1428
  }
1404
1429
  is CraftResumableDownload.Fetched.Complete -> Unit
@@ -1410,11 +1435,11 @@ internal object CraftNativeMedia {
1410
1435
  }
1411
1436
  target.delete()
1412
1437
  if (!staging.renameTo(target)) {
1413
- deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1438
+ answer(CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1414
1439
  return Outcome.DONE
1415
1440
  }
1416
1441
  }
1417
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
1442
+ answer(CraftNativeCapabilityResult(data = describe(address, target)))
1418
1443
  return Outcome.DONE
1419
1444
  }
1420
1445
  }
@@ -1432,13 +1457,13 @@ internal object CraftNativeMedia {
1432
1457
  capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
1433
1458
  }.getOrDefault(true)
1434
1459
 
1435
- /** Removes every copy whose address is not in `keep`, and stops their transfers; answers how many files went. */
1460
+ /** Removes idle copies outside `keep` and cancels their jobs; pending keeps retain ownership. */
1436
1461
  fun prune(context: Context, keep: List<String>): Int {
1437
1462
  val wanted = keep.map { digest(it) }.toSet()
1438
1463
  var removed = 0
1439
- // A keep still waiting owns its address's files and its transfer until it is answered.
1440
- val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
1441
1464
  synchronized(lock) {
1465
+ // A keep still waiting owns its files until it is answered.
1466
+ val active = waiting.keys.map { digest(it) }.toSet()
1442
1467
  root(context).listFiles()?.forEach { file ->
1443
1468
  val owner = file.name.substringBefore('.')
1444
1469
  // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
@@ -1451,8 +1476,16 @@ internal object CraftNativeMedia {
1451
1476
  runCatching {
1452
1477
  androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
1453
1478
  .filter { !it.state.isFinished }
1454
- .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
1455
- .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
1479
+ .forEach { info ->
1480
+ synchronized(lock) {
1481
+ // The query runs asynchronously: a new keep may now
1482
+ // own an address that was idle when prune was called.
1483
+ val active = waiting.keys.map { digest(it) }.toSet()
1484
+ if (info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted + active }) {
1485
+ androidx.work.WorkManager.getInstance(app).cancelWorkById(info.id)
1486
+ }
1487
+ }
1488
+ }
1456
1489
  }
1457
1490
  }
1458
1491
  return removed
@@ -1470,9 +1503,10 @@ internal object CraftNativeMedia {
1470
1503
  root(context).listFiles()?.forEach { file ->
1471
1504
  if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1472
1505
  }
1506
+ // Schedule cancellation before a new generation can enqueue its work.
1507
+ androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1473
1508
  callbacks
1474
1509
  }
1475
- androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1476
1510
  main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1477
1511
  failure?.let { throw it }
1478
1512
  return removed
@@ -1511,12 +1545,10 @@ internal class CraftResumableDownload(
1511
1545
  if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
1512
1546
  try {
1513
1547
  val status = connection.responseCode
1514
- // Part of a file nobody asked a part of: not something to keep.
1515
- if (status == 206 && have == 0L) return Fetched.Refused(status)
1516
1548
  val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
1517
1549
  if (status == 416 || (status == 206 && !resumed)) {
1518
1550
  part.delete()
1519
- return Fetched.Restart
1551
+ return if (have > 0) Fetched.Restart else Fetched.Refused(status)
1520
1552
  }
1521
1553
  if (status != 200 && !resumed) {
1522
1554
  part.delete()
@@ -1539,8 +1571,8 @@ internal class CraftResumableDownload(
1539
1571
  // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
1540
1572
  val encoded = connection.getHeaderField("Content-Encoding") != null
1541
1573
  if (size <= 0 || (!encoded && total >= 0 && size != total)) {
1542
- // Longer than the file says it is: nothing to resume from.
1543
- if (total in 0 until size) part.delete()
1574
+ // Empty or longer than the file says: nothing useful to resume.
1575
+ if (size <= 0 || total in 0 until size) part.delete()
1544
1576
  return Fetched.Incomplete
1545
1577
  }
1546
1578
  return Fetched.Complete(size)
@@ -1563,8 +1595,21 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
1563
1595
  override fun doWork(): Result {
1564
1596
  val address = inputData.getString("address") ?: return Result.failure()
1565
1597
  val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
1566
- val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, inputData.getInt("generation", 0), runAttemptCount) { isStopped }
1567
- return if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1598
+ val started = inputData.getInt("generation", 0)
1599
+ return try {
1600
+ setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
1601
+ val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
1602
+ beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
1603
+ ) { isStopped }
1604
+ if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1605
+ } catch (error: Exception) {
1606
+ if (error is InterruptedException) Thread.currentThread().interrupt()
1607
+ // A replacement owns the address's new waiters. A real progress
1608
+ // storage failure still needs an answer instead of a lost callback.
1609
+ if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
1610
+ CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"))
1611
+ if (isStopped) Result.retry() else Result.failure()
1612
+ }
1568
1613
  }
1569
1614
  }
1570
1615
 
@@ -1136,9 +1136,9 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1136
1136
  val rowHeights = if (gridRows != null) {
1137
1137
  val count = max(gridRows ?: 0, naturalRowHeights.size)
1138
1138
  while (rows.size < count) rows.add(mutableListOf())
1139
- val available = max(0f, height - paddingTop - paddingBottom - max(0, count - 1) * rowGap)
1139
+ val available = max(0f, (height - paddingTop - paddingBottom).toFloat() - max(0, count - 1) * rowGap)
1140
1140
  val equal = available / max(1, count).toFloat()
1141
- (naturalRowHeights + List(count - naturalRowHeights.size) { equal })
1141
+ (naturalRowHeights + List(count - naturalRowHeights.size) { 0f })
1142
1142
  .map { max(it, equal) }
1143
1143
  } else naturalRowHeights
1144
1144
  return GridLayout(rows, absolute, cellWidth, rowHeights)
@@ -1438,12 +1438,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1438
1438
  put(base, 26, node.padding[3].toFloat())
1439
1439
  put(base, 27, node.padding[0].toFloat())
1440
1440
  putEnum(base, 28, if (node.grid) 1 else 0)
1441
- put(base, 29, node.gridColumns.toFloat())
1441
+ putEnum(base, 29, node.gridColumns)
1442
1442
  put(base, 30, node.rowGap)
1443
1443
  put(base, 31, node.columnGap)
1444
1444
  if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
1445
1445
  if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
1446
- if (explicitRowsAware) put(base, 34, (node.gridRows ?: 0).toFloat())
1446
+ if (explicitRowsAware) putEnum(base, 34, node.gridRows ?: 0)
1447
1447
  val firstChild = cursor
1448
1448
  node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
1449
1449
  treeMeta[index * 2] = firstChild.toFloat()
@@ -1527,7 +1527,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1527
1527
  put(base, 26, padding[3].toFloat())
1528
1528
  put(base, 27, padding[0].toFloat())
1529
1529
  putEnum(base, 28, 0)
1530
- put(base, 29, 1f)
1530
+ putEnum(base, 29, 1)
1531
1531
  put(base, 30, if (root) rowGapValue else 0f)
1532
1532
  put(base, 31, if (root) columnGapValue else 0f)
1533
1533
  }
package/dist/cli.js CHANGED
@@ -2842,7 +2842,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2842
2842
  `);
2843
2843
  }
2844
2844
  // package.json
2845
- var version = "0.0.250";
2845
+ var version = "0.0.254";
2846
2846
 
2847
2847
  // bin/cli.ts
2848
2848
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -373,7 +373,7 @@ function craftBinaryNotFoundMessage(triedPath) {
373
373
  `);
374
374
  }
375
375
  // package.json
376
- var version = "0.0.250";
376
+ var version = "0.0.254";
377
377
 
378
378
  // src/sdk-version.ts
379
379
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.250";
120
+ var version = "0.0.254";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -2080,7 +2080,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2080
2080
  /// The edge swipe goes ahead of the web view's own gestures, which
2081
2081
  /// otherwise hold every touch over the page until the page has answered it.
2082
2082
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2083
- gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
2083
+ if gestureRecognizer === visibleStack?.interactivePopGestureRecognizer { return true }
2084
+ guard tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }),
2085
+ let scroll = otherGestureRecognizer.view as? UIScrollView,
2086
+ otherGestureRecognizer === scroll.panGestureRecognizer,
2087
+ scroll.isScrollEnabled else { return false }
2088
+ // A vertical list must not suppress the shell's horizontal tab pan.
2089
+ // Horizontal content keeps ownership of its own drag instead.
2090
+ return !scroll.alwaysBounceHorizontal && scroll.contentSize.width <= scroll.bounds.width + 1
2084
2091
  }
2085
2092
 
2086
2093
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -371,24 +371,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
371
371
  @objc private func keyboardTap() {
372
372
  guard keyboardShouldPersistTaps != "always" else { return }
373
373
  let point = keyboardTapGesture.location(in: self)
374
- var hit = hitTest(point, with: nil)
375
- var onControl = false
376
- var onButton = false
377
- while let view = hit, view !== self {
378
- if view is UIButton { onButton = true }
379
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
380
- hit = view.superview
374
+ craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
375
+ self.craftScreenController?.forgetFocusedInput()
381
376
  }
382
- if keyboardShouldPersistTaps == "handled", onControl { return }
383
- // A button keeps the keyboard: its press gives the focus straight
384
- // back. Ending editing first only let the keyboard's inset go while
385
- // the finger was down, the button moved out from under it, and the
386
- // press was lost.
387
- if onButton { return }
388
- // Otherwise the keyboard goes; after a tap on nothing, for good (the
389
- // next render must not bring the last input back).
390
- if !onControl { craftScreenController?.forgetFocusedInput() }
391
- window?.endEditing(true)
392
377
  }
393
378
 
394
379
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -3954,6 +3954,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur
3954
3954
  return false
3955
3955
  }
3956
3956
 
3957
+ /// Scroll containers observe taps without cancelling native input focus or
3958
+ /// selection. Text views are not UIControls, and their selection subviews may
3959
+ /// have recognizers of their own, so inspect the whole ancestor chain.
3960
+ func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: String, forgetFocus: () -> Void) {
3961
+ guard persistTaps != "always" else { return }
3962
+ var candidate = hit
3963
+ var onControl = false
3964
+ while let view = candidate, view !== container {
3965
+ if view is UITextField || view is UITextView || view is UIButton { return }
3966
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true }
3967
+ candidate = view.superview
3968
+ }
3969
+ if persistTaps == "handled", onControl { return }
3970
+ if !onControl { forgetFocus() }
3971
+ container.window?.endEditing(true)
3972
+ }
3973
+
3957
3974
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
3958
3975
  let contentStack = CraftNativeFlowView()
3959
3976
  private let keyboardTapGesture = UITapGestureRecognizer()
@@ -4226,24 +4243,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4226
4243
  @objc private func keyboardTap() {
4227
4244
  guard keyboardShouldPersistTaps != "always" else { return }
4228
4245
  let point = keyboardTapGesture.location(in: self)
4229
- var hit = hitTest(point, with: nil)
4230
- var onControl = false
4231
- var onButton = false
4232
- while let view = hit, view !== self {
4233
- if view is UIButton { onButton = true }
4234
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
4235
- hit = view.superview
4236
- }
4237
- if keyboardShouldPersistTaps == "handled", onControl { return }
4238
- // A button keeps the keyboard: its press gives the focus straight
4239
- // back. Ending editing first only let the keyboard's inset go while
4240
- // the finger was down, the button moved out from under it, and the
4241
- // press was lost.
4242
- if onButton { return }
4243
- // Otherwise the keyboard goes; after a tap on nothing, for good (the
4244
- // next render must not bring the last input back).
4245
- if !onControl { craftScreenController?.forgetFocusedInput() }
4246
- window?.endEditing(true)
4246
+ craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
4247
+ self.craftScreenController?.forgetFocusedInput()
4248
+ }
4247
4249
  }
4248
4250
 
4249
4251
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -68,6 +68,26 @@ final class DriverTests: XCTestCase {
68
68
  return result
69
69
  }
70
70
 
71
+ /// The journey, from the runner's control server, asked for again
72
+ /// until a minute has passed.
73
+ ///
74
+ /// The server is up before the test starts, but a freshly created
75
+ /// simulator's first request can outlast one wait: the first journey of
76
+ /// a CI run spent 15s on it and failed before doing anything. XCTWaiter
77
+ /// reports a timeout instead of failing the test, so a slow first answer
78
+ /// is retried rather than fatal.
79
+ func remoteScript(seconds: Double = 60) -> Data? {
80
+ let end = Date().addingTimeInterval(seconds)
81
+ repeat {
82
+ var result: Data?
83
+ let done = XCTestExpectation(description: "/script")
84
+ URLSession.shared.dataTask(with: URL(string: control + "/script")!) { data, _, _ in result = data; done.fulfill() }.resume()
85
+ if XCTWaiter().wait(for: [done], timeout: 15) == .completed, let result, !result.isEmpty { return result }
86
+ Thread.sleep(forTimeInterval: 1)
87
+ } while Date() < end
88
+ return nil
89
+ }
90
+
71
91
  func post(_ path: String, _ body: Data) {
72
92
  if bundledJourney {
73
93
  guard path.hasPrefix("/save?"),
@@ -139,6 +159,30 @@ final class DriverTests: XCTestCase {
139
159
  return nil
140
160
  }
141
161
 
162
+ /// About a third of a screen of scrolling, as a held drag.
163
+ ///
164
+ /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
165
+ /// depends on how its touches arrive: on a loaded machine, with a map
166
+ /// keeping the app busy, eight of them moved a trail page less than one
167
+ /// screen. A drag that stops before it lifts moves the content by its
168
+ /// own length every time. It runs between the middle of the screen and
169
+ /// the upper part, where a flick began too, so it stays off the keys when
170
+ /// a keyboard is up - an iOS 27 simulator reports its keyboard's frame
171
+ /// below the screen while drawing it over the lower half - and it stops
172
+ /// short of a keyboard whose frame is known.
173
+ func scrollPage(towardTop: Bool) {
174
+ let frame = app.frame
175
+ var lower = frame.minY + frame.height * 0.55
176
+ let keyboard = app.keyboards.firstMatch
177
+ if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
178
+ lower = min(lower, keyboard.frame.minY - 24)
179
+ }
180
+ let upper = max(frame.minY + frame.height * 0.15, lower - frame.height * 0.4)
181
+ let point = { (y: CGFloat) in self.app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: frame.midX, dy: y)) }
182
+ let (from, to) = towardTop ? (point(upper), point(lower)) : (point(lower), point(upper))
183
+ from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .default, thenHoldForDuration: 0.2)
184
+ }
185
+
142
186
  /// Whether `target`, or a field inside it, holds the keyboard.
143
187
  func hasKeyboardFocus(_ target: XCUIElement) -> Bool {
144
188
  if (target.value(forKey: "hasKeyboardFocus") as? Bool) == true { return true }
@@ -222,7 +266,7 @@ final class DriverTests: XCTestCase {
222
266
  bundledJourney = true
223
267
  data = try Data(contentsOf: resource)
224
268
  } else {
225
- data = get("/script")
269
+ data = remoteScript()
226
270
  }
227
271
  guard let data, let script = String(data: data, encoding: .utf8) else {
228
272
  XCTFail("no script")
@@ -478,14 +522,11 @@ final class DriverTests: XCTestCase {
478
522
  else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
479
523
  case "scrollto":
480
524
  var found = element(containing: arg)
481
- var swipes = 0
482
- while (found == nil || !(found!.isHittable)) && swipes < 8 {
483
- if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
484
- app.swipeDown()
485
- } else {
486
- app.swipeUp()
487
- }
488
- swipes += 1
525
+ var drags = 0
526
+ while (found == nil || !(found!.isHittable)) && drags < 12 {
527
+ let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
528
+ scrollPage(towardTop: towardTop)
529
+ drags += 1
489
530
  found = element(containing: arg)
490
531
  }
491
532
  check(found != nil && found!.isHittable, "scrollto \(arg)")
@@ -11,6 +11,8 @@ export interface MobileMediaCacheTestOptions {
11
11
  online?: boolean;
12
12
  log?: (line: string) => void;
13
13
  }
14
+ /** Keep the probe on the complete shipped downloader, including its durable worker. */
15
+ export declare function androidMediaProbeSource(shell: string): string;
14
16
  /** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
15
17
  export declare function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{
16
18
  platform: string;
@@ -37,6 +37,16 @@ ${stderr}`.trim() };
37
37
  }
38
38
 
39
39
  // src/mobile-media-testing.ts
40
+ function androidMediaProbeSource(shell) {
41
+ return ["internal object CraftNativeMedia", "internal class CraftResumableDownload", "internal class CraftNativeMediaWorker"].map((declaration) => {
42
+ const source = shell.match(new RegExp(`${declaration}\\b[\\s\\S]*?\\n\\}`))?.[0];
43
+ if (!source)
44
+ throw new Error(`Android media implementation missing: ${declaration}`);
45
+ return source;
46
+ }).join(`
47
+
48
+ `);
49
+ }
40
50
  async function verifyMobileMediaCache(options) {
41
51
  if (options.platform !== "ios" && options.platform !== "android")
42
52
  throw new Error("Choose ios or android");
@@ -129,7 +139,7 @@ Connection: close\r
129
139
  }
130
140
  const empty = path.endsWith("/empty.mp4");
131
141
  const partial = path.endsWith("/partial.mp4");
132
- const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
142
+ const cut = (path.endsWith("/broken.mp4") || path.endsWith("/retry.mp4") || path.endsWith("/restart.mp4")) && n === 1;
133
143
  const slow = path.endsWith("/slow.mp4") || path.endsWith("/restart.mp4") && n === 1;
134
144
  socket.write(`HTTP/1.1 ${partial ? "206 Partial Content" : "200 OK"}\r
135
145
  Content-Type: video/mp4\r
@@ -250,12 +260,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
250
260
  if (!/^emulator-\d+$/.test(serial))
251
261
  throw new Error("Select a disposable Android emulator with ANDROID_SERIAL");
252
262
  const shell = await Bun.file(join(root, "android/templates/CraftNativeShell.kt.template")).text();
253
- const pieces = ["internal object CraftNativeMedia {", "internal class CraftResumableDownload(", "internal class CraftNativeMediaWorker("].map((start) => shell.match(new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?\\n\\}`))?.[0]);
254
- if (pieces.some((piece) => !piece))
255
- throw new Error("Android media implementation missing");
256
- const media = pieces.join(`
257
-
258
- `);
263
+ const media = androidMediaProbeSource(shell);
259
264
  const android = join(scratch, "android");
260
265
  const app = join(android, "app");
261
266
  const sources = join(app, "src/main/java/dev/craft/mediaprobe");
@@ -267,9 +272,9 @@ include(":app")`);
267
272
  await Bun.write(join(android, "build.gradle.kts"), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }');
268
273
  await Bun.write(join(android, "local.properties"), `sdk.dir=${sdk}
269
274
  `);
270
- await Bun.write(join(android, "gradle.properties"), `org.gradle.jvmargs=-Xmx768m
275
+ await Bun.write(join(android, "gradle.properties"), `android.useAndroidX=true
276
+ org.gradle.jvmargs=-Xmx768m
271
277
  org.gradle.workers.max=2
272
- android.useAndroidX=true
273
278
  `);
274
279
  await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
275
280
  id("org.jetbrains.kotlin.android") }
@@ -291,8 +296,8 @@ import android.os.Handler
291
296
  import android.os.Looper
292
297
  import org.json.JSONObject
293
298
  import java.io.File
294
- import java.util.concurrent.ConcurrentHashMap
295
299
  import java.util.concurrent.Executors
300
+ import java.util.concurrent.ConcurrentHashMap
296
301
  internal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)
297
302
  ${media}
298
303
  ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
@@ -327,6 +332,10 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
327
332
  if (counts.get("/android/restart.mp4") !== 1)
328
333
  throw new Error("Android process did not start interrupted download");
329
334
  await probe("recover");
335
+ if (counts.get("/android/retry.mp4") !== 2)
336
+ throw new Error("Android explicit retry did not fetch once more immediately");
337
+ if (counts.get("/android/settled.mp4") !== 1)
338
+ throw new Error("Android explicit keep did not replace the settled running attempt exactly once");
330
339
  if (!resumed.has("/android/restart.mp4"))
331
340
  throw new Error("Android did not resume the interrupted download from its part file");
332
341
  if (counts.get("/android/slow.mp4") !== 1)
@@ -1,3 +1,14 @@
1
+ /** Holds a completed attempt in RUNNING, before WorkManager commits its result. */
2
+ class SettledMediaAttemptWorker(context: Context, parameters: androidx.work.WorkerParameters) : androidx.work.Worker(context, parameters) {
3
+ private val released = java.util.concurrent.CountDownLatch(1)
4
+ override fun doWork(): Result {
5
+ setProgressAsync(androidx.work.workDataOf("craft.media.attemptSettled" to true)).get()
6
+ released.await(30, java.util.concurrent.TimeUnit.SECONDS)
7
+ return Result.retry()
8
+ }
9
+ override fun onStopped() { released.countDown() }
10
+ }
11
+
1
12
  class DownloadProbe : android.app.Instrumentation() {
2
13
  override fun onCreate(arguments: android.os.Bundle?) {
3
14
  super.onCreate(arguments)
@@ -23,6 +34,13 @@ class DownloadProbe : android.app.Instrumentation() {
23
34
  check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
24
35
  return ok
25
36
  }
37
+ private fun awaitCached(address: String) {
38
+ // The worker authors a 30-second first retry. Allow that backoff plus
39
+ // the existing 25-second transfer budget, without issuing another keep.
40
+ val until = System.currentTimeMillis() + 60000
41
+ while (CraftNativeMedia.local(targetContext, address) == null && System.currentTimeMillis() < until) Thread.sleep(25)
42
+ check(CraftNativeMedia.local(targetContext, address) != null, "the durable worker recovers the complete file without another keep")
43
+ }
26
44
  override fun onStart() {
27
45
  val result = android.os.Bundle()
28
46
  try {
@@ -80,10 +98,11 @@ class DownloadProbe : android.app.Instrumentation() {
80
98
  val until = System.currentTimeMillis() + 25000
81
99
  while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
82
100
  check(partial(), "download is in progress before process death")
101
+ check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
83
102
  android.os.Process.killProcess(android.os.Process.myPid())
84
103
  }
85
104
  "recover" -> {
86
- check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
105
+ awaitCached(restart)
87
106
  check(keep(restart), "retry after process death downloads a complete video")
88
107
  val media = android.media.MediaMetadataRetriever()
89
108
  try {
@@ -98,12 +117,34 @@ class DownloadProbe : android.app.Instrumentation() {
98
117
  check(!keep(broken), "truncated transfer fails")
99
118
  check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
100
119
  val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
101
- val brokenTarget = CraftNativeMedia.file(targetContext, broken)
102
- check(root.listFiles()?.none { it.name.endsWith(".part") && it.name != "${brokenTarget.name}.part" } != false, "a failed transfer leaves only its own part, to resume")
103
- check(keep(broken), "same URL retries successfully, at once")
104
- check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
105
- val queued = "$base/queued.mp4"
120
+ check(CraftNativeMedia.part(targetContext, broken).length() > 0, "a truncated transfer retains bytes for resumption")
121
+ awaitCached(broken)
122
+ check(keep(broken), "same URL retries successfully")
123
+ val retry = "$base/retry.mp4"
124
+ check(!keep(retry), "a separate interrupted transfer reports its failure")
125
+ check(keep(retry), "an explicit retry runs immediately, without waiting for backoff")
126
+ check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "finished retries leave no staging files")
106
127
  val work = androidx.work.WorkManager.getInstance(targetContext)
128
+ val settled = "$base/settled.mp4"
129
+ val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
130
+ val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
131
+ .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
132
+ .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
133
+ work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
134
+ val settledDeadline = System.currentTimeMillis() + 10000
135
+ var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
136
+ while ((settledInfo.state != androidx.work.WorkInfo.State.RUNNING || !settledInfo.progress.getBoolean("craft.media.attemptSettled", false)) && System.currentTimeMillis() < settledDeadline) {
137
+ Thread.sleep(25)
138
+ settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
139
+ }
140
+ check(settledInfo.state == androidx.work.WorkInfo.State.RUNNING && settledInfo.progress.getBoolean("craft.media.attemptSettled", false), "the settled attempt still has WorkManager's running identity")
141
+ check(keep(settled), "an explicit keep replaces a settled running attempt immediately")
142
+ // REPLACE can prune the old WorkSpec. Inspect the current
143
+ // unique chain rather than requiring the removed row.
144
+ val replacement = work.getWorkInfosForUniqueWork(settledName).get()
145
+ check(replacement.size == 1 && replacement.single().id != settledRequest.id && replacement.single().state != androidx.work.WorkInfo.State.CANCELLED, "the settled work identity was replaced, not joined")
146
+
147
+ val queued = "$base/queued.mp4"
107
148
  val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
108
149
  val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
109
150
  .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.250",
3
+ "version": "0.0.254",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",