craft-native 0.0.225 → 0.0.226

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.
@@ -100,6 +100,7 @@ internal class CraftNativeImageCache(
100
100
  true
101
101
  }
102
102
  catch (error: Exception) { false }
103
+ catch (error: OutOfMemoryError) { false }
103
104
  finally { staging?.delete() }
104
105
  }
105
106
 
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
120
120
  done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
121
121
  }
122
122
  "usage" -> done(success(CraftNativeMedia.usage(context)))
123
+ "clear" -> done(success(CraftNativeMedia.clear(context)))
123
124
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
124
125
  }
125
126
  "Background" -> {
@@ -1021,7 +1021,8 @@ internal object CraftNativeSymbols {
1021
1021
  * A native Video given an address with a copy here plays the copy.
1022
1022
  */
1023
1023
  internal object CraftNativeMedia {
1024
- private val inFlight = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
1024
+ private data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
1025
+ private val inFlight = HashMap<String, Flight>()
1025
1026
  private val executor = Executors.newFixedThreadPool(2)
1026
1027
  private val main = Handler(Looper.getMainLooper())
1027
1028
 
@@ -1054,12 +1055,12 @@ internal object CraftNativeMedia {
1054
1055
  if (!cellular && metered(context)) {
1055
1056
  return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
1056
1057
  }
1057
- synchronized(inFlight) {
1058
+ val flight = synchronized(inFlight) {
1058
1059
  inFlight[address]?.let { waiting ->
1059
- waiting.add(done)
1060
+ waiting.waiting.add(done)
1060
1061
  return
1061
1062
  }
1062
- inFlight[address] = mutableListOf(done)
1063
+ Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
1063
1064
  }
1064
1065
  executor.execute {
1065
1066
  val result = runCatching {
@@ -1073,7 +1074,7 @@ internal object CraftNativeMedia {
1073
1074
  val target = file(context, address)
1074
1075
  target.parentFile?.mkdirs()
1075
1076
  // Written aside and moved into place, so a half-written file is never played.
1076
- val staging = File(target.parentFile, "${target.name}.part")
1077
+ val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
1077
1078
  try {
1078
1079
  val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
1079
1080
  val expected = connection.contentLengthLong
@@ -1081,6 +1082,9 @@ internal object CraftNativeMedia {
1081
1082
  return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
1082
1083
  }
1083
1084
  synchronized(inFlight) {
1085
+ if (inFlight[address]?.id != flight.id) {
1086
+ return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
1087
+ }
1084
1088
  if (!staging.renameTo(target)) {
1085
1089
  return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
1086
1090
  }
@@ -1098,7 +1102,7 @@ internal object CraftNativeMedia {
1098
1102
  }
1099
1103
  CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
1100
1104
  }
1101
- val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
1105
+ val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
1102
1106
  main.post { waiting.forEach { it(result) } }
1103
1107
  }
1104
1108
  }
@@ -1115,12 +1119,31 @@ internal object CraftNativeMedia {
1115
1119
  var removed = 0
1116
1120
  synchronized(inFlight) {
1117
1121
  // A concurrent keep owns its target and staging file until it finishes.
1118
- val active = inFlight.keys.map { file(context, it).name }.toSet()
1122
+ val active = inFlight.flatMap { (address, flight) ->
1123
+ val name = file(context, address).name
1124
+ listOf(name, "$name.${flight.id}.part")
1125
+ }.toSet()
1126
+ root(context).listFiles()?.forEach { file ->
1127
+ if (file.name !in active && file.name !in wanted && file.delete()) removed++
1128
+ }
1129
+ }
1130
+ return removed
1131
+ }
1132
+
1133
+ /** Invalidates old transfers before deleting files; a later keep starts afresh. */
1134
+ fun clear(context: Context): Int {
1135
+ var removed = 0
1136
+ var failure: java.io.IOException? = null
1137
+ val waiting = synchronized(inFlight) {
1138
+ val callbacks = inFlight.values.flatMap { it.waiting }
1139
+ inFlight.clear()
1119
1140
  root(context).listFiles()?.forEach { file ->
1120
- val target = file.name.removeSuffix(".part")
1121
- if (target !in active && file.name !in wanted && file.delete()) removed++
1141
+ if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1122
1142
  }
1143
+ callbacks
1123
1144
  }
1145
+ main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1146
+ failure?.let { throw it }
1124
1147
  return removed
1125
1148
  }
1126
1149
 
@@ -4091,6 +4091,7 @@ class MainActivity : AppCompatActivity() {
4091
4091
  show(target)
4092
4092
  } else {
4093
4093
  host.removeView(target.view)
4094
+ target.view.translationX = 0f
4094
4095
  current.view.translationX = 0f
4095
4096
  chrome?.selectTab(currentTab)
4096
4097
  tabSwipeTarget = null
@@ -4504,6 +4505,11 @@ class MainActivity : AppCompatActivity() {
4504
4505
  * screen shown before hears true (its `navigation.onAppear`).
4505
4506
  */
4506
4507
  private fun show(screen: Screen) {
4508
+ // A retained tab may have been the offscreen preview of a cancelled
4509
+ // swipe, or the outgoing view of a completed swipe. Its animator can
4510
+ // finish after the peer animator, so clear that ownership on display.
4511
+ screen.view.animate().withEndAction(null).setListener(null).cancel()
4512
+ screen.view.translationX = 0f
4507
4513
  // A navigation breadcrumb: the trail a crash report carries.
4508
4514
  if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
4509
4515
  val bars = chrome
package/dist/browser.js CHANGED
@@ -283,6 +283,13 @@ async function createBrowserPlatform(options = {}) {
283
283
  };
284
284
  const lifetime = new AbortController;
285
285
  let account = new AbortController;
286
+ let mediaTransfers = new AbortController;
287
+ let mediaWrites = Promise.resolve();
288
+ const writeMedia = (work) => {
289
+ const write = mediaWrites.then(work);
290
+ mediaWrites = write.catch(() => {});
291
+ return write;
292
+ };
286
293
  let mediaScope = persistence.scope();
287
294
  let wakeLock = null;
288
295
  let disposed = false;
@@ -365,6 +372,7 @@ async function createBrowserPlatform(options = {}) {
365
372
  if (!("caches" in window))
366
373
  return url;
367
374
  const scope = persistence.scope();
375
+ const transfer = mediaTransfers.signal;
368
376
  const existing = mediaURLs.get(url);
369
377
  if (existing)
370
378
  return existing;
@@ -372,7 +380,7 @@ async function createBrowserPlatform(options = {}) {
372
380
  if (!saved)
373
381
  return url;
374
382
  const local = URL.createObjectURL(await saved.blob());
375
- if (disposed || scope !== persistence.scope()) {
383
+ if (disposed || transfer.aborted || scope !== persistence.scope()) {
376
384
  URL.revokeObjectURL(local);
377
385
  return url;
378
386
  }
@@ -629,6 +637,25 @@ async function createBrowserPlatform(options = {}) {
629
637
  if (module === "Media") {
630
638
  if (!("caches" in window))
631
639
  return unsupported(module, method);
640
+ if (method === "clear") {
641
+ mediaTransfers.abort();
642
+ mediaTransfers = new AbortController;
643
+ const name = cacheName();
644
+ return writeMedia(async () => {
645
+ const cache2 = await caches.open(name);
646
+ let removed = 0;
647
+ for (const entry of await cache2.keys()) {
648
+ if (await cache2.delete(entry))
649
+ removed++;
650
+ const local = mediaURLs.get(entry.url);
651
+ if (local)
652
+ URL.revokeObjectURL(local);
653
+ mediaURLs.delete(entry.url);
654
+ }
655
+ return removed;
656
+ });
657
+ }
658
+ signal = AbortSignal.any([signal, mediaTransfers.signal]);
632
659
  const cache = await caches.open(cacheName());
633
660
  if (method === "keep") {
634
661
  const url = String(args[0]);
@@ -646,7 +673,11 @@ async function createBrowserPlatform(options = {}) {
646
673
  signal.throwIfAborted();
647
674
  const headers = new Headers(response.headers);
648
675
  headers.set("x-craft-size", String(body.size));
649
- await cache.put(url, new Response(body, { headers, status: response.status }));
676
+ await writeMedia(async () => {
677
+ signal.throwIfAborted();
678
+ await cache.put(url, new Response(body, { headers, status: response.status }));
679
+ signal.throwIfAborted();
680
+ });
650
681
  return { ok: true, url, path: url, size: body.size };
651
682
  }
652
683
  if (method === "path")
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.225";
3427
+ var version = "0.0.226";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.225";
312
+ var version = "0.0.226";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.225";
60
+ var version = "0.0.226";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -254,6 +254,12 @@ enum CraftNativeActions {
254
254
  return
255
255
  case ("Media", "usage"):
256
256
  sync = .success(CraftNativeMedia.usage())
257
+ case ("Media", "clear"):
258
+ DispatchQueue.global(qos: .utility).async {
259
+ do { complete(requestToken, .success(try CraftNativeMedia.clear()), completion) }
260
+ catch { complete(requestToken, failure("IO_ERROR", "The video cache could not be cleared"), completion) }
261
+ }
262
+ return
257
263
  case ("Lifecycle", "getState"):
258
264
  sync = .success(currentAppState())
259
265
  case ("DeepLinks", "getInitialURL"):
@@ -782,7 +788,7 @@ enum CraftNativeActions {
782
788
  request.allowsExpensiveNetworkAccess = false
783
789
  request.allowsConstrainedNetworkAccess = false
784
790
  }
785
- if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
791
+ guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
786
792
  let task = fetchSession.downloadTask(with: request) { location, response, error in
787
793
  cancellationLock.lock()
788
794
  fetchTasks.removeValue(forKey: requestToken)
@@ -790,25 +796,25 @@ enum CraftNativeActions {
790
796
  if let error = error {
791
797
  // Refused only because the connection is metered: not a failure, a wait.
792
798
  if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
793
- CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
799
+ CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
794
800
  return
795
801
  }
796
802
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
797
- CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
803
+ CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
798
804
  return
799
805
  }
800
806
  guard let http = response as? HTTPURLResponse, let location else {
801
- CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
807
+ CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
802
808
  return
803
809
  }
804
810
  // The system deletes `location` once this returns: move it now.
805
- CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
811
+ CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
806
812
  }
807
813
  cancellationLock.lock()
808
814
  let alreadyCancelled = cancelledRequests.contains(requestToken)
809
815
  if !alreadyCancelled { fetchTasks[requestToken] = task }
810
816
  cancellationLock.unlock()
811
- if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
817
+ if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
812
818
  else { task.resume() }
813
819
  }
814
820
 
@@ -514,7 +514,11 @@ enum CraftNativeMedia {
514
514
 
515
515
  private static let lock = NSLock()
516
516
  /// Addresses being fetched now, so a second `keep` waits for the first.
517
- private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
517
+ private struct Flight {
518
+ let id: UUID
519
+ var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
520
+ }
521
+ private static var inFlight: [String: Flight] = [:]
518
522
 
519
523
  /// Where `address`'s copy lives (whether or not it is there yet). The
520
524
  /// extension is the address's, because AVPlayer reads the type from it.
@@ -537,28 +541,29 @@ enum CraftNativeMedia {
537
541
  return ["ok": true, "url": address, "path": url.path, "size": size]
538
542
  }
539
543
 
540
- /// Joins a fetch of `address` already under way; true when one was.
541
- static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
544
+ /// A new download's identity, or nil after joining an existing download.
545
+ static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
542
546
  lock.lock()
543
547
  defer { lock.unlock() }
544
548
  if inFlight[address] != nil {
545
- inFlight[address]!.append(done)
546
- return true
549
+ inFlight[address]!.waiting.append(done)
550
+ return nil
547
551
  }
548
- inFlight[address] = [done]
549
- return false
552
+ let id = UUID()
553
+ inFlight[address] = Flight(id: id, waiting: [done])
554
+ return id
550
555
  }
551
556
 
552
- static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
557
+ static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
553
558
  lock.lock()
554
- let waiting = inFlight.removeValue(forKey: address) ?? []
559
+ let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
555
560
  lock.unlock()
556
561
  for done in waiting { done(result) }
557
562
  }
558
563
 
559
564
  /// A finished download moved into place (atomically, so a half-written
560
565
  /// file is never played), or a refusal with the server's status.
561
- static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
566
+ static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
562
567
  guard response.statusCode == 200 else {
563
568
  return .success(["ok": false, "url": address, "status": response.statusCode])
564
569
  }
@@ -566,6 +571,9 @@ enum CraftNativeMedia {
566
571
  // Commit and prune share a lock; neither can remove a file being committed.
567
572
  lock.lock()
568
573
  defer { lock.unlock() }
574
+ if let flight, inFlight[address]?.id != flight {
575
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
576
+ }
569
577
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
570
578
  defer { try? fm.removeItem(at: staging) }
571
579
  do {
@@ -596,6 +604,25 @@ enum CraftNativeMedia {
596
604
  }
597
605
  }
598
606
 
607
+ /// End an account's cache lifetime.
608
+ static func clear() throws -> Int {
609
+ lock.lock()
610
+ let waiting = inFlight.values.flatMap { $0.waiting }
611
+ inFlight.removeAll()
612
+ let fm = FileManager.default
613
+ let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
614
+ var removed = 0
615
+ var failure: Error?
616
+ for url in files {
617
+ do { try fm.removeItem(at: url); removed += 1 }
618
+ catch { failure = error }
619
+ }
620
+ lock.unlock()
621
+ for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
622
+ if let failure { throw failure }
623
+ return removed
624
+ }
625
+
599
626
  /// Removes every copy whose address is not in `keep`; answers how many.
600
627
  static func prune(keep: [String]) -> Int {
601
628
  lock.lock()
@@ -6071,7 +6071,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6071
6071
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
6072
6072
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6073
6073
  image.contentMode = imageContentMode(requestedResizeMode)
6074
- image.clipsToBounds = image.contentMode == .scaleAspectFill
6074
+ image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
6075
6075
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
6076
6076
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
6077
6077
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
@@ -6090,7 +6090,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6090
6090
  if let current = background.image { background.image = imageForDisplay(current, view: background) }
6091
6091
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6092
6092
  background.contentMode = imageContentMode(requestedResizeMode)
6093
- background.clipsToBounds = background.contentMode == .scaleAspectFill
6093
+ background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
6094
6094
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
6095
6095
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
6096
6096
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
@@ -149,6 +149,8 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
149
149
  print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
150
150
  if (counts.get("/ios/slow.mp4") !== 1)
151
151
  throw new Error("iOS duplicate fetch: keep did not coalesce");
152
+ if (counts.get("/ios/account/slow.mp4") !== 2)
153
+ throw new Error("iOS account switch did not start a fresh transfer");
152
154
  print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
153
155
  }
154
156
  }
@@ -232,6 +234,8 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
232
234
  await probe("recover");
233
235
  if (counts.get("/android/slow.mp4") !== 1)
234
236
  throw new Error("Android duplicate fetch: keep did not coalesce");
237
+ if (counts.get("/android/account/slow.mp4") !== 2)
238
+ throw new Error("Android account switch did not start a fresh transfer");
235
239
  print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
236
240
  }
237
241
  }
@@ -75,10 +75,11 @@ class DownloadProbe : android.app.Instrumentation() {
75
75
  "interrupt" -> {
76
76
  CraftNativeMedia.prune(targetContext, emptyList())
77
77
  CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
78
- val part = File(CraftNativeMedia.file(targetContext, restart).absolutePath + ".part")
78
+ val target = CraftNativeMedia.file(targetContext, restart)
79
+ fun partial() = target.parentFile!!.listFiles()?.any { it.name.startsWith(target.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
79
80
  val until = System.currentTimeMillis() + 25000
80
- while ((!part.exists() || part.length() == 0L) && System.currentTimeMillis() < until) Thread.sleep(25)
81
- check(part.length() > 0, "download is in progress before process death")
81
+ while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
82
+ check(partial(), "download is in progress before process death")
82
83
  android.os.Process.killProcess(android.os.Process.myPid())
83
84
  }
84
85
  "recover" -> {
@@ -111,6 +112,26 @@ class DownloadProbe : android.app.Instrumentation() {
111
112
  check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
112
113
  check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
113
114
  CraftNativeMedia.prune(targetContext, emptyList())
115
+ val privateVideo = "$base/account/slow.mp4"
116
+ val previous = java.util.concurrent.CountDownLatch(1)
117
+ var cancelled = false
118
+ CraftNativeMedia.keep(targetContext, privateVideo, JSONObject(), true) { cancelled = it.code == "CANCELLED"; previous.countDown() }
119
+ val privateTarget = CraftNativeMedia.file(targetContext, privateVideo)
120
+ fun downloading() = root.listFiles()?.any { it.name.startsWith(privateTarget.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
121
+ val deadline = System.currentTimeMillis() + 25000
122
+ while (!downloading() && System.currentTimeMillis() < deadline) Thread.sleep(10)
123
+ check(downloading(), "the previous athlete's download is in progress before sign-out")
124
+ CraftNativeMedia.clear(targetContext)
125
+ check(previous.await(5, java.util.concurrent.TimeUnit.SECONDS) && cancelled, "sign-out cancels the previous athlete's download")
126
+ check(CraftNativeMedia.local(targetContext, privateVideo) == null, "signed-out video is not playable from cache")
127
+ check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
128
+ val media = android.media.MediaMetadataRetriever()
129
+ try {
130
+ media.setDataSource(CraftNativeMedia.local(targetContext, privateVideo)!!.absolutePath)
131
+ check(media.getFrameAtTime(1000000) != null, "the next athlete's local video decodes after the old download settles")
132
+ } finally { media.release() }
133
+ CraftNativeMedia.clear(targetContext)
134
+ check(CraftNativeMedia.local(targetContext, privateVideo) == null, "sign-out removes the next athlete's cached copy too")
114
135
  }
115
136
  }
116
137
  result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
@@ -17,16 +17,16 @@ var latestTask: URLSessionTask?
17
17
  var latestErrorCode: String?
18
18
  func start(_ address: String, _ done: @escaping (Bool) -> Void) {
19
19
  if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
20
- if CraftNativeMedia.join(address, { result in
20
+ guard let flight = CraftNativeMedia.begin(address, { result in
21
21
  if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
22
22
  else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
23
- }) { return }
23
+ }) else { return }
24
24
  let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
25
25
  let result: Result<Any, CraftNativeActionError>
26
26
  if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
27
- else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response) }
27
+ else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
28
28
  else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
29
- CraftNativeMedia.finish(address, result)
29
+ CraftNativeMedia.finish(address, flight: flight, result)
30
30
  }
31
31
  latestTask = task
32
32
  task.resume()
@@ -116,3 +116,19 @@ check(answers == [true, true], "concurrent downloads coalesce and survive prune"
116
116
  let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
117
117
  check(parts.isEmpty, "no abandoned staging files remain")
118
118
  _ = CraftNativeMedia.prune(keep: [])
119
+ let privateVideo = base + "/account/slow.mp4"
120
+ var previousAnswer: Bool?
121
+ start(privateVideo) { previousAnswer = $0 }
122
+ let previousTask = latestTask!
123
+ wait { previousTask.countOfBytesReceived > 0 }
124
+ _ = try CraftNativeMedia.clear()
125
+ check(previousAnswer == false && latestErrorCode == "CANCELLED", "sign-out cancels the previous athlete's download")
126
+ check(CraftNativeMedia.local(privateVideo) == nil, "signed-out video is not playable from cache")
127
+ check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
128
+ wait { previousTask.state == .completed }
129
+ let cached = CraftNativeMedia.local(privateVideo)!
130
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: cached))
131
+ _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
132
+ check(true, "the next athlete's local video decodes after the old download settles")
133
+ _ = try CraftNativeMedia.clear()
134
+ check(CraftNativeMedia.local(privateVideo) == nil, "sign-out removes the next athlete's cached copy too")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.225",
3
+ "version": "0.0.226",
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>",