craft-native 0.0.249 → 0.0.251

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.
@@ -316,10 +316,18 @@ async function createAndroidEmulator(options) {
316
316
  `);
317
317
  const deadline = bootStartedAt + bootTimeoutMs;
318
318
  const probeTimeout = () => Math.max(1, Math.min(1e4, deadline - Date.now()));
319
+ let connected = false;
320
+ let lastReconnectAt = 0;
319
321
  while (Date.now() < deadline && child.exitCode === null) {
320
322
  let booted = false;
321
323
  const connection = await run([adb, "devices"], undefined, probeTimeout(), true);
322
- if (connection.code === 0 && connection.stdout.includes(`${serial} device`)) {
324
+ const state = connection.stdout.split(/\r?\n/).find((line) => line.startsWith(`${serial} `))?.split(/\s+/)[1];
325
+ if (connection.code === 0 && state === "offline" && connected && Date.now() - lastReconnectAt >= 30000 && Date.now() < deadline) {
326
+ lastReconnectAt = Date.now();
327
+ await run([adb, "-s", serial, "reconnect"], undefined, probeTimeout(), true);
328
+ }
329
+ if (connection.code === 0 && state === "device") {
330
+ connected = true;
323
331
  const completion = await run([adb, "-s", serial, "shell", "getprop", "sys.boot_completed"], undefined, probeTimeout(), true);
324
332
  if (completion.code === 0 && completion.stdout.trim() === "1") {
325
333
  const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
@@ -1238,7 +1238,8 @@ internal object CraftNativeMedia {
1238
1238
  private const val PREFS = "craft.media"
1239
1239
  private val lock = Any()
1240
1240
  /** Keeps waiting in this process for an address's transfer. */
1241
- private val waiting = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
1241
+ private data class Waiting(val generation: Int, val answer: (CraftNativeCapabilityResult) -> Unit)
1242
+ private val waiting = HashMap<String, MutableList<Waiting>>()
1242
1243
  /** One transfer writes an address's part file at a time. */
1243
1244
  private val writers = ConcurrentHashMap<String, Any>()
1244
1245
  private val background = Executors.newSingleThreadExecutor()
@@ -1276,44 +1277,69 @@ internal object CraftNativeMedia {
1276
1277
  if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
1277
1278
  return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
1278
1279
  }
1279
- local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
1280
1280
  val app = context.applicationContext
1281
- synchronized(lock) { waiting.getOrPut(address) { mutableListOf() }.add(done) }
1281
+ val started = synchronized(lock) {
1282
+ local(app, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
1283
+ generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, done)) }
1284
+ }
1282
1285
  val wifiOnly = !cellular
1283
1286
  val work = androidx.work.WorkManager.getInstance(app)
1284
1287
  // The same address under the other rule gives way; its part file is resumed.
1285
1288
  work.cancelUniqueWork(workName(address, !wifiOnly))
1286
1289
  val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
1287
1290
  .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
1288
- .setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to generation(app)))
1291
+ .setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to started))
1289
1292
  .setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
1290
1293
  .addTag(TAG)
1294
+ .addTag("$TAG:generation:$started")
1291
1295
  .addTag("$TAG:${digest(address)}")
1292
1296
  .build()
1293
- // A transfer running for this address is joined (KEEP), not started again. One
1294
- // only queued, or waiting out a retry's backoff, is replaced: asked for again,
1295
- // it runs now and resumes the part the last attempt left.
1297
+ // Join a running or freshly queued transfer. Only a failed attempt's
1298
+ // backoff is replaced so an explicit retry can resume immediately.
1296
1299
  val name = workName(address, wifiOnly)
1297
1300
  background.execute {
1298
- val running = runCatching { work.getWorkInfosForUniqueWork(name).get() }.getOrDefault(emptyList())
1299
- .any { it.state == androidx.work.WorkInfo.State.RUNNING }
1300
- work.enqueueUniqueWork(name, if (running) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request)
1301
+ try {
1302
+ if (generation(app) == started) {
1303
+ val pending = work.getWorkInfosForUniqueWork(name).get().any {
1304
+ "$TAG:generation:$started" in it.tags &&
1305
+ (it.state == androidx.work.WorkInfo.State.RUNNING ||
1306
+ (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
1307
+ }
1308
+ // The next serial decision must see this enqueue's committed state.
1309
+ work.enqueueUniqueWork(name, if (pending) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request).result.get()
1310
+ }
1311
+ } catch (error: Exception) {
1312
+ if (error is InterruptedException) Thread.currentThread().interrupt()
1313
+ if (generation(app) == started) {
1314
+ deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not schedule the download"))
1315
+ }
1316
+ }
1301
1317
  }
1302
1318
  val reason = when {
1303
1319
  !online(app) -> "offline"
1304
1320
  wifiOnly && metered(app) -> "cellular"
1305
1321
  else -> null
1306
1322
  }
1307
- if (reason != null) deliver(address, queued(address, reason))
1323
+ if (reason != null) deliver(context, address, started, queued(address, reason))
1308
1324
  }
1309
1325
 
1310
1326
  private fun queued(address: String, reason: String) =
1311
1327
  CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
1312
1328
 
1313
- /** Answers every keep waiting for `address`. */
1314
- fun deliver(address: String, result: CraftNativeCapabilityResult) {
1315
- val answered = synchronized(lock) { waiting.remove(address).orEmpty() }
1316
- if (answered.isNotEmpty()) main.post { answered.forEach { it(result) } }
1329
+ /** Answers only keeps belonging to this transfer's cache generation. */
1330
+ fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult) {
1331
+ val answered = synchronized(lock) {
1332
+ val pending = waiting[address].orEmpty()
1333
+ val matching = pending.filter { it.generation == started }
1334
+ val remaining = pending.filter { it.generation != started }.toMutableList()
1335
+ if (remaining.isEmpty()) waiting.remove(address) else waiting[address] = remaining
1336
+ matching
1337
+ }
1338
+ if (answered.isNotEmpty()) main.post {
1339
+ val current = if (generation(context) == started) result else
1340
+ CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
1341
+ answered.forEach { it.answer(current) }
1342
+ }
1317
1343
  }
1318
1344
 
1319
1345
  enum class Outcome { DONE, RETRY }
@@ -1324,16 +1350,23 @@ internal object CraftNativeMedia {
1324
1350
  * waits. RETRY leaves the part file for the next attempt.
1325
1351
  */
1326
1352
  fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
1327
- if (generation(context) != started) {
1328
- deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
1329
- return Outcome.DONE
1330
- }
1353
+ // clear() already answered the old generation's waiters. Never answer
1354
+ // a new athlete's waiters from an obsolete worker for the same URL.
1355
+ if (generation(context) != started) return Outcome.DONE
1331
1356
  local(context, address)?.let {
1332
- deliver(address, CraftNativeCapabilityResult(data = describe(address, it)))
1357
+ deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1333
1358
  return Outcome.DONE
1334
1359
  }
1335
1360
  val writer = writers.getOrPut(address) { Any() }
1336
1361
  synchronized(writer) {
1362
+ // Another worker can finish, or sign-out can clear the cache, while
1363
+ // this worker waits for the address's writer.
1364
+ if (generation(context) != started) return Outcome.DONE
1365
+ if (stopped()) return Outcome.RETRY
1366
+ local(context, address)?.let {
1367
+ deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1368
+ return Outcome.DONE
1369
+ }
1337
1370
  val target = file(context, address)
1338
1371
  val staging = part(context, address)
1339
1372
  target.parentFile?.mkdirs()
@@ -1350,22 +1383,22 @@ internal object CraftNativeMedia {
1350
1383
  }
1351
1384
  if (full) {
1352
1385
  staging.delete()
1353
- deliver(address, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1386
+ deliver(context, address, started, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1354
1387
  return Outcome.DONE
1355
1388
  }
1356
1389
  if (stopped()) return Outcome.RETRY
1357
1390
  // Lost the connection part way: the part waits for the next attempt.
1358
- deliver(address, 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"))
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"))
1359
1392
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1360
1393
  }
1361
1394
  when (fetched) {
1362
1395
  is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
1363
1396
  is CraftResumableDownload.Fetched.Refused -> {
1364
- deliver(address, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1397
+ deliver(context, address, started, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1365
1398
  return Outcome.DONE
1366
1399
  }
1367
1400
  is CraftResumableDownload.Fetched.Incomplete -> {
1368
- deliver(address, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1401
+ deliver(context, address, started, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1369
1402
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1370
1403
  }
1371
1404
  is CraftResumableDownload.Fetched.Complete -> Unit
@@ -1373,16 +1406,15 @@ internal object CraftNativeMedia {
1373
1406
  synchronized(lock) {
1374
1407
  if (generation(context) != started) {
1375
1408
  staging.delete()
1376
- deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
1377
1409
  return Outcome.DONE
1378
1410
  }
1379
1411
  target.delete()
1380
1412
  if (!staging.renameTo(target)) {
1381
- deliver(address, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1413
+ deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1382
1414
  return Outcome.DONE
1383
1415
  }
1384
1416
  }
1385
- deliver(address, CraftNativeCapabilityResult(data = describe(address, target)))
1417
+ deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
1386
1418
  return Outcome.DONE
1387
1419
  }
1388
1420
  }
@@ -1441,7 +1473,7 @@ internal object CraftNativeMedia {
1441
1473
  callbacks
1442
1474
  }
1443
1475
  androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1444
- main.post { answered.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1476
+ main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1445
1477
  failure?.let { throw it }
1446
1478
  return removed
1447
1479
  }
@@ -2557,7 +2557,6 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2557
2557
  val content = CraftNativeFlexLayout(context)
2558
2558
  private val blocker = View(context)
2559
2559
  private var renderedVisible = false
2560
- private var dismissSuppressedByRequest = false
2561
2560
  private var animationGeneration = 0
2562
2561
  var focusToRestore: View? = null
2563
2562
  var onRequestClose: ((String) -> Unit)? = null
@@ -2582,22 +2581,11 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2582
2581
 
2583
2582
  fun requestClose(reason: String = "backdrop") {
2584
2583
  val handler = onRequestClose ?: return
2585
- dismissSuppressedByRequest = true
2586
2584
  isClickable = false
2587
2585
  blocker.isEnabled = false
2588
2586
  handler(reason)
2589
2587
  }
2590
2588
 
2591
- fun consumeDismissSuppression(): Boolean {
2592
- val suppressed = dismissSuppressedByRequest
2593
- dismissSuppressedByRequest = false
2594
- return suppressed
2595
- }
2596
-
2597
- fun clearDismissSuppression() {
2598
- dismissSuppressedByRequest = false
2599
- }
2600
-
2601
2589
  fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
2602
2590
  if (visible) {
2603
2591
  isClickable = true
@@ -2656,7 +2644,6 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2656
2644
  alpha = 0f
2657
2645
  isClickable = true
2658
2646
  blocker.isEnabled = true
2659
- dismissSuppressedByRequest = false
2660
2647
  onRequestClose = null
2661
2648
  }
2662
2649
  }
@@ -5410,15 +5397,13 @@ class MainActivity : AppCompatActivity() {
5410
5397
  if (restore.isAttachedToWindow) restore.requestFocus()
5411
5398
  }
5412
5399
  }
5413
- if (visible) clearDismissSuppression()
5414
- val suppressDismiss = !visible && consumeDismissSuppression()
5415
5400
  if (wasVisible != visible && (visible || wasVisible == true)) {
5416
5401
  val lifecycleHandler = if (visible) {
5417
5402
  craftNativeOptionalString(events.opt("onShow"))
5418
5403
  } else {
5419
5404
  craftNativeOptionalString(events.opt("onDismiss"))
5420
5405
  }
5421
- if (!lifecycleHandler.isNullOrBlank() && !suppressDismiss) {
5406
+ if (!lifecycleHandler.isNullOrBlank()) {
5422
5407
  // Deliver lifecycle callbacks after reconciliation returns to
5423
5408
  // the queue; the handler may immediately schedule another
5424
5409
  // render against this native view.
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.249";
2845
+ var version = "0.0.251";
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.249";
376
+ var version = "0.0.251";
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.249";
120
+ var version = "0.0.251";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -3788,7 +3788,6 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3788
3788
  private let blocker = UIView()
3789
3789
  private let backdropTap = UITapGestureRecognizer()
3790
3790
  private var renderedVisible = false
3791
- private var dismissSuppressedByRequest = false
3792
3791
  private var animationGeneration = 0
3793
3792
  var onRequestClose: ((String) -> Void)?
3794
3793
 
@@ -3877,25 +3876,15 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3877
3876
  transform = .identity
3878
3877
  alpha = 0
3879
3878
  isUserInteractionEnabled = true
3880
- dismissSuppressedByRequest = false
3881
3879
  onRequestClose = nil
3882
3880
  }
3883
3881
 
3884
3882
  func requestClose(reason: String = "backdrop") {
3885
3883
  guard let onRequestClose else { return }
3886
- dismissSuppressedByRequest = true
3887
3884
  isUserInteractionEnabled = false
3888
3885
  onRequestClose(reason)
3889
3886
  }
3890
3887
 
3891
- func consumeDismissSuppression() -> Bool {
3892
- let suppressed = dismissSuppressedByRequest
3893
- dismissSuppressedByRequest = false
3894
- return suppressed
3895
- }
3896
-
3897
- func clearDismissSuppression() { dismissSuppressedByRequest = false }
3898
-
3899
3888
  @objc private func backdropTapped() { requestClose(reason: "backdrop") }
3900
3889
 
3901
3890
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
@@ -3993,9 +3982,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3993
3982
  required init?(coder: NSCoder) { nil }
3994
3983
 
3995
3984
  override func touchesShouldCancel(in view: UIView) -> Bool {
3996
- // A drag beginning on a button scrolls; lifting without dragging
3997
- // remains a tap. Sliders, switches and text fields keep tracking.
3998
- if view is UIButton { return true }
3985
+ // Buttons and text fields yield once a scroll drag begins; an
3986
+ // ordinary tap still reaches them immediately. Value controls such
3987
+ // as sliders, switches and date pickers keep their tracking gesture.
3988
+ if view is UIButton || view is UITextField { return true }
3999
3989
  if view is UIControl { return false }
4000
3990
  return super.touchesShouldCancel(in: view)
4001
3991
  }
@@ -7366,11 +7356,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7366
7356
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
7367
7357
  { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
7368
7358
  }
7369
- if visible { modal.clearDismissSuppression() }
7370
- let suppressDismiss = !visible && modal.consumeDismissSuppression()
7371
7359
  if wasVisible != visible, visible || wasVisible == true {
7372
7360
  let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
7373
- if let lifecycleHandler = nonEmptyHandler(lifecycleHandler), !suppressDismiss {
7361
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
7374
7362
  send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
7375
7363
  }
7376
7364
  }
@@ -7448,10 +7436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7448
7436
  // already-stopped view changes hidesWhenStopped to false.
7449
7437
  // Android applies the requested visibility on every render;
7450
7438
  // keep the two hosts in lockstep for dynamic prop changes.
7451
- indicator.isHidden = indicator.hidesWhenStopped
7439
+ indicator.isHidden = style["display"] as? String == "none" || indicator.hidesWhenStopped
7452
7440
  } else {
7453
7441
  indicator.startAnimating()
7454
- indicator.isHidden = false
7442
+ indicator.isHidden = style["display"] as? String == "none"
7455
7443
  }
7456
7444
  case "Svg":
7457
7445
  let drawing = result as! CraftNativeSvgView
@@ -690,8 +690,15 @@ enum CraftNativePush {
690
690
 
691
691
  // MARK: - Share sheet
692
692
 
693
- /// A URL stays a URL in Messages, AirDrop and Copy. Its title belongs in the
693
+ /// A URL stays a URL in Messages and AirDrop. Its title belongs in the
694
694
  /// system preview and mail subject, rather than becoming a second text item.
695
+ ///
696
+ /// Copy is the exception: it writes the address as text. A URL-only
697
+ /// pasteboard item has no textual representation, so `UIPasteboard.string`
698
+ /// is nil, a web page's `navigator.clipboard.readText()` gets nothing, and
699
+ /// so does the simulator's `pbpaste` - the shared link copied as nothing
700
+ /// anywhere text is read. Text that is an address is still detected as a
701
+ /// link wherever it is pasted.
695
702
  final class CraftShareLink: NSObject, UIActivityItemSource {
696
703
  let url: URL
697
704
  let title: String
@@ -703,7 +710,9 @@ final class CraftShareLink: NSObject, UIActivityItemSource {
703
710
 
704
711
  func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { url }
705
712
 
706
- func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { url }
713
+ func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
714
+ activityType == .copyToPasteboard ? url.absoluteString : url
715
+ }
707
716
 
708
717
  func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { title }
709
718
 
@@ -139,6 +139,57 @@ final class DriverTests: XCTestCase {
139
139
  return nil
140
140
  }
141
141
 
142
+ /// About a third of a screen of scrolling, as a held drag.
143
+ ///
144
+ /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
145
+ /// depends on how its touches arrive: on a loaded machine, with a map
146
+ /// keeping the app busy, eight of them moved a trail page less than one
147
+ /// screen. A drag that stops before it lifts moves the content by its
148
+ /// own length every time. It runs between the middle of the screen and
149
+ /// the upper part, where a flick began too, so it stays off the keys when
150
+ /// a keyboard is up - an iOS 27 simulator reports its keyboard's frame
151
+ /// below the screen while drawing it over the lower half - and it stops
152
+ /// short of a keyboard whose frame is known.
153
+ func scrollPage(towardTop: Bool) {
154
+ let frame = app.frame
155
+ var lower = frame.minY + frame.height * 0.55
156
+ let keyboard = app.keyboards.firstMatch
157
+ if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
158
+ lower = min(lower, keyboard.frame.minY - 24)
159
+ }
160
+ let upper = max(frame.minY + frame.height * 0.15, lower - frame.height * 0.4)
161
+ let point = { (y: CGFloat) in self.app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: frame.midX, dy: y)) }
162
+ let (from, to) = towardTop ? (point(upper), point(lower)) : (point(lower), point(upper))
163
+ from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .default, thenHoldForDuration: 0.2)
164
+ }
165
+
166
+ /// Whether `target`, or a field inside it, holds the keyboard.
167
+ func hasKeyboardFocus(_ target: XCUIElement) -> Bool {
168
+ if (target.value(forKey: "hasKeyboardFocus") as? Bool) == true { return true }
169
+ return target.descendants(matching: .any).matching(NSPredicate(format: "hasKeyboardFocus == 1")).firstMatch.exists
170
+ }
171
+
172
+ /// Taps `target` until it holds the keyboard, for up to `seconds`.
173
+ ///
174
+ /// A tap is not focus. The first keyboard a freshly created simulator
175
+ /// shows takes its keyboard process many seconds to start - one CI run
176
+ /// spent 20s delivering the tap - and the field had not become first
177
+ /// responder when the text was typed, which failed with "Neither element
178
+ /// nor any descendant has keyboard focus". A later tap lands once the app
179
+ /// is idle again.
180
+ func focus(_ target: XCUIElement, seconds: Double = 15) -> Bool {
181
+ let end = Date().addingTimeInterval(seconds)
182
+ repeat {
183
+ target.tap()
184
+ let settle = Date().addingTimeInterval(3)
185
+ repeat {
186
+ if hasKeyboardFocus(target) { return true }
187
+ Thread.sleep(forTimeInterval: 0.25)
188
+ } while Date() < settle
189
+ } while Date() < end
190
+ return hasKeyboardFocus(target)
191
+ }
192
+
142
193
  func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
143
194
  poll(seconds) { element(containing: text) }
144
195
  }
@@ -375,8 +426,10 @@ final class DriverTests: XCTestCase {
375
426
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
376
427
  let target = identified(idParts[0])
377
428
  if target.waitForExistence(timeout: 10) {
378
- target.tap()
379
- Thread.sleep(forTimeInterval: 0.8)
429
+ guard focus(target) else {
430
+ check(false, "typeid \(idParts[0]) takes keyboard focus")
431
+ return
432
+ }
380
433
  app.typeText(idParts.count > 1 ? idParts[1] : "")
381
434
  note("typeid \(idParts[0]): ok")
382
435
  } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
@@ -402,8 +455,10 @@ final class DriverTests: XCTestCase {
402
455
  let index = Int(fieldParts[0]) ?? 0
403
456
  let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
404
457
  if index >= 0 && index < fields.count {
405
- fields[index].tap()
406
- Thread.sleep(forTimeInterval: 0.6)
458
+ guard focus(fields[index]) else {
459
+ check(false, "field \(index) takes keyboard focus")
460
+ return
461
+ }
407
462
  app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
408
463
  note("field \(index): ok")
409
464
  } else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
@@ -447,14 +502,11 @@ final class DriverTests: XCTestCase {
447
502
  else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
448
503
  case "scrollto":
449
504
  var found = element(containing: arg)
450
- var swipes = 0
451
- while (found == nil || !(found!.isHittable)) && swipes < 8 {
452
- if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
453
- app.swipeDown()
454
- } else {
455
- app.swipeUp()
456
- }
457
- swipes += 1
505
+ var drags = 0
506
+ while (found == nil || !(found!.isHittable)) && drags < 12 {
507
+ let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
508
+ scrollPage(towardTop: towardTop)
509
+ drags += 1
458
510
  found = element(containing: arg)
459
511
  }
460
512
  check(found != nil && found!.isHittable, "scrollto \(arg)")
@@ -55,6 +55,9 @@ async function verifyMobileMediaCache(options) {
55
55
  const bytes = new Uint8Array(await Bun.file(join(fixtures, "demo.mp4")).arrayBuffer());
56
56
  const counts = new Map;
57
57
  const resumed = new Set;
58
+ let lockedReleased = false;
59
+ let finishLocked;
60
+ let finishObsolete;
58
61
  const timers = new Set;
59
62
  const server = Bun.listen({
60
63
  hostname: "0.0.0.0",
@@ -73,6 +76,43 @@ async function verifyMobileMediaCache(options) {
73
76
  const path = socket.data.request.split(" ")[1];
74
77
  const n = (counts.get(path) || 0) + 1;
75
78
  counts.set(path, n);
79
+ if (path === "/android/inflight/slow.mp4" && n === 1) {
80
+ finishObsolete = () => socket.end(`HTTP/1.1 403 Forbidden\r
81
+ Content-Length: 0\r
82
+ Connection: close\r
83
+ \r
84
+ `);
85
+ return;
86
+ }
87
+ if (path === "/android/inflight/status") {
88
+ socket.end(`HTTP/1.1 200 OK\r
89
+ Content-Length: 1\r
90
+ Connection: close\r
91
+ \r
92
+ ${finishObsolete ? "1" : "0"}`);
93
+ return;
94
+ }
95
+ if (path === "/android/inflight/release") {
96
+ finishObsolete?.();
97
+ finishObsolete = undefined;
98
+ socket.end(`HTTP/1.1 200 OK\r
99
+ Content-Length: 0\r
100
+ Connection: close\r
101
+ \r
102
+ `);
103
+ return;
104
+ }
105
+ if (path === "/android/locked/release") {
106
+ lockedReleased = true;
107
+ finishLocked?.();
108
+ finishLocked = undefined;
109
+ socket.end(`HTTP/1.1 200 OK\r
110
+ Content-Length: 0\r
111
+ Connection: close\r
112
+ \r
113
+ `);
114
+ return;
115
+ }
76
116
  const from = Number(/^range:\s*bytes=(\d+)-\s*$/im.exec(socket.data.request)?.[1] ?? Number.NaN);
77
117
  if (path === "/android/restart.mp4" && n > 1 && from > 0 && from < bytes.length) {
78
118
  resumed.add(path);
@@ -111,6 +151,13 @@ Connection: close\r
111
151
  socket.write(bytes.subarray(Math.floor(bytes.length / 2)));
112
152
  socket.end();
113
153
  };
154
+ if (path === "/android/locked/slow.mp4") {
155
+ if (lockedReleased)
156
+ finish();
157
+ else
158
+ finishLocked = finish;
159
+ return;
160
+ }
114
161
  if (slow) {
115
162
  const timer = setTimeout(() => {
116
163
  timers.delete(timer);
@@ -284,6 +331,16 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
284
331
  throw new Error("Android did not resume the interrupted download from its part file");
285
332
  if (counts.get("/android/slow.mp4") !== 1)
286
333
  throw new Error("Android duplicate fetch: keep did not coalesce");
334
+ if (counts.get("/android/queued.mp4") !== 1)
335
+ throw new Error("Android queued work was replaced instead of joined");
336
+ if (counts.get("/android/locked/slow.mp4") !== 1)
337
+ throw new Error("Android waiting writer fetched an already completed video");
338
+ if (counts.get("/android/stale/slow.mp4") !== 1)
339
+ throw new Error("Android obsolete worker fetched across a cache generation");
340
+ if (counts.get("/android/inflight/slow.mp4") !== 2)
341
+ throw new Error("Android in-flight generations did not retain exactly their own transfers");
342
+ if (counts.get("/android/late-queued.mp4") !== 1)
343
+ throw new Error("Android kept obsolete queued work across a cache generation");
287
344
  if (counts.get("/android/account/slow.mp4") !== 2)
288
345
  throw new Error("Android account switch did not start a fresh transfer");
289
346
  print("PASS: Android emulator interruption, resumed restart, retry, prune and cached video decode");
@@ -102,6 +102,89 @@ class DownloadProbe : android.app.Instrumentation() {
102
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
103
  check(keep(broken), "same URL retries successfully, at once")
104
104
  check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
105
+ val queued = "$base/queued.mp4"
106
+ val work = androidx.work.WorkManager.getInstance(targetContext)
107
+ val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
108
+ val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
109
+ .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
110
+ .setInputData(androidx.work.workDataOf("address" to queued, "headers" to "{}", "generation" to CraftNativeMedia.generation(targetContext)))
111
+ .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(queued)}")
112
+ .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
113
+ work.enqueueUniqueWork(queuedName, androidx.work.ExistingWorkPolicy.KEEP, queuedRequest).result.get()
114
+ val beforeJoin = work.getWorkInfoById(queuedRequest.id).get()!!
115
+ check(beforeJoin.state == androidx.work.WorkInfo.State.ENQUEUED && beforeJoin.runAttemptCount == 0, "the coalescing regression starts with a fresh queued transfer")
116
+ check(keep(queued), "keep joins the fresh queued transfer")
117
+ val afterJoin = work.getWorkInfosForUniqueWork(queuedName).get()
118
+ check(afterJoin.size == 1 && afterJoin.single().id == queuedRequest.id && afterJoin.single().state != androidx.work.WorkInfo.State.CANCELLED, "a second keep retains the original queued work identity")
119
+
120
+ val locked = "$base/locked/slow.mp4"
121
+ val started = CraftNativeMedia.generation(targetContext)
122
+ var firstOutcome: CraftNativeMedia.Outcome? = null
123
+ var secondOutcome: CraftNativeMedia.Outcome? = null
124
+ val firstWriter = Thread { firstOutcome = CraftNativeMedia.transfer(targetContext, locked, JSONObject(), started, 0) { false } }
125
+ val secondWriter = Thread { secondOutcome = CraftNativeMedia.transfer(targetContext, locked, JSONObject(), started, 0) { false } }
126
+ firstWriter.start()
127
+ val writingDeadline = System.currentTimeMillis() + 25000
128
+ while (CraftNativeMedia.part(targetContext, locked).length() == 0L && System.currentTimeMillis() < writingDeadline) Thread.sleep(10)
129
+ check(CraftNativeMedia.part(targetContext, locked).length() > 0L, "the first writer owns an active partial download")
130
+ secondWriter.start()
131
+ val blockedDeadline = System.currentTimeMillis() + 5000
132
+ while (secondWriter.state != Thread.State.BLOCKED && secondWriter.isAlive && System.currentTimeMillis() < blockedDeadline) Thread.sleep(1)
133
+ check(secondWriter.state == Thread.State.BLOCKED, "the second writer waits on the same address lock")
134
+ java.net.URL("$base/locked/release").openConnection().getInputStream().use { it.readBytes() }
135
+ firstWriter.join(25000)
136
+ secondWriter.join(25000)
137
+ check(!firstWriter.isAlive && !secondWriter.isAlive && firstOutcome == CraftNativeMedia.Outcome.DONE && secondOutcome == CraftNativeMedia.Outcome.DONE, "both address writers finish from the same cached copy")
138
+ check(CraftNativeMedia.local(targetContext, locked) != null, "the coalesced writer leaves a complete local video")
139
+
140
+ val stale = "$base/stale/slow.mp4"
141
+ val staleGeneration = CraftNativeMedia.generation(targetContext)
142
+ CraftNativeMedia.clear(targetContext)
143
+ val currentDone = java.util.concurrent.CountDownLatch(1)
144
+ var currentOK = false
145
+ CraftNativeMedia.keep(targetContext, stale, JSONObject(), true) { currentOK = (it.data as? JSONObject)?.optBoolean("ok") == true; currentDone.countDown() }
146
+ val currentDeadline = System.currentTimeMillis() + 25000
147
+ while (CraftNativeMedia.part(targetContext, stale).length() == 0L && System.currentTimeMillis() < currentDeadline) Thread.sleep(10)
148
+ check(CraftNativeMedia.part(targetContext, stale).length() > 0L, "the next generation has its own active transfer")
149
+ check(CraftNativeMedia.transfer(targetContext, stale, JSONObject(), staleGeneration, 0) { false } == CraftNativeMedia.Outcome.DONE, "an obsolete worker exits without fetching")
150
+ check(currentDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && currentOK, "an obsolete worker cannot cancel the next generation's waiters")
151
+
152
+ val inflight = "$base/inflight/slow.mp4"
153
+ val inflightGeneration = CraftNativeMedia.generation(targetContext)
154
+ var obsoleteOutcome: CraftNativeMedia.Outcome? = null
155
+ val obsoleteWriter = Thread { obsoleteOutcome = CraftNativeMedia.transfer(targetContext, inflight, JSONObject(), inflightGeneration, 0) { false } }
156
+ obsoleteWriter.start()
157
+ fun oldRequestReachedServer() = java.net.URL("$base/inflight/status").readText() == "1"
158
+ val inflightDeadline = System.currentTimeMillis() + 25000
159
+ while (!oldRequestReachedServer() && System.currentTimeMillis() < inflightDeadline) Thread.sleep(10)
160
+ check(oldRequestReachedServer(), "the obsolete generation is waiting for its HTTP response")
161
+ CraftNativeMedia.clear(targetContext)
162
+ val nextDone = java.util.concurrent.CountDownLatch(1)
163
+ var nextOK = false
164
+ CraftNativeMedia.keep(targetContext, inflight, JSONObject(), true) { nextOK = (it.data as? JSONObject)?.optBoolean("ok") == true; nextDone.countDown() }
165
+ java.net.URL("$base/inflight/release").openConnection().getInputStream().use { it.readBytes() }
166
+ obsoleteWriter.join(25000)
167
+ check(!obsoleteWriter.isAlive && obsoleteOutcome == CraftNativeMedia.Outcome.DONE, "the old generation's delayed refusal finishes")
168
+ check(nextDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && nextOK, "an old in-flight refusal cannot consume the next generation's callback")
169
+
170
+ val late = "$base/late-queued.mp4"
171
+ val lateGeneration = CraftNativeMedia.generation(targetContext)
172
+ CraftNativeMedia.clear(targetContext)
173
+ val lateName = "craft-media:${CraftNativeMedia.digest(late)}:any"
174
+ // Reproduce an obsolete scheduler enqueue landing after clear's cancellation.
175
+ val lateRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
176
+ .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
177
+ .setInputData(androidx.work.workDataOf("address" to late, "headers" to "{}", "generation" to lateGeneration))
178
+ .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(late)}")
179
+ .addTag("craft-media:generation:$lateGeneration").build()
180
+ work.enqueueUniqueWork(lateName, androidx.work.ExistingWorkPolicy.KEEP, lateRequest).result.get()
181
+ check(work.getWorkInfoById(lateRequest.id).get()!!.state == androidx.work.WorkInfo.State.ENQUEUED, "the obsolete queued request lands after clear")
182
+ check(keep(late), "a new generation replaces obsolete queued work")
183
+ val currentWork = work.getWorkInfosForUniqueWork(lateName).get()
184
+ check(currentWork.size == 1 && currentWork.single().id != lateRequest.id &&
185
+ "craft-media:generation:${CraftNativeMedia.generation(targetContext)}" in currentWork.single().tags &&
186
+ currentWork.single().state != androidx.work.WorkInfo.State.CANCELLED,
187
+ "obsolete queued work is replaced by a current-generation work identity")
105
188
  check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
106
189
  check(!keep("$base/empty.mp4"), "empty file is refused")
107
190
  val slow = "$base/slow.mp4"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.249",
3
+ "version": "0.0.251",
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>",