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.
- package/dist/android/templates/CraftNativeImageCache.kt.template +1 -0
- package/dist/android/templates/CraftNativeServices.kt.template +1 -0
- package/dist/android/templates/CraftNativeShell.kt.template +32 -9
- package/dist/android/templates/MainActivityNative.kt.template +6 -0
- package/dist/browser.js +33 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +12 -6
- package/dist/ios/templates/CraftNativeFiles.swift +37 -10
- package/dist/ios/templates/CraftNativeScreen.swift +2 -2
- package/dist/mobile-testing.js +4 -0
- package/dist/testing/media-cache/media-cache.kt +24 -3
- package/dist/testing/media-cache/media-cache.swift.fixture +20 -4
- package/package.json +1 -1
|
@@ -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
|
|
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] =
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
///
|
|
541
|
-
static func
|
|
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
|
|
549
|
+
inFlight[address]!.waiting.append(done)
|
|
550
|
+
return nil
|
|
547
551
|
}
|
|
548
|
-
|
|
549
|
-
|
|
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)
|
package/dist/mobile-testing.js
CHANGED
|
@@ -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
|
|
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 (
|
|
81
|
-
check(
|
|
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
|
-
|
|
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")
|