craft-native 0.0.221 → 0.0.223
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/README.md +29 -0
- package/dist/android/templates/CraftNativeShell.kt.template +5 -1
- package/dist/android/templates/MainActivityNative.kt.template +35 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHeartRate.swift +115 -12
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeFiles.swift +15 -1
- package/dist/mobile-media-testing.d.ts +18 -0
- package/dist/mobile-testing.d.ts +2 -0
- package/dist/mobile-testing.js +260 -3
- package/dist/testing/media-cache/demo.mp4 +0 -0
- package/dist/testing/media-cache/media-cache.kt +123 -0
- package/dist/testing/media-cache/media-cache.swift.fixture +118 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -536,6 +536,35 @@ it does not change the application's animations or test assertions.
|
|
|
536
536
|
|
|
537
537
|
We love contributions! Please see our [contributing guide](../../CONTRIBUTING.md).
|
|
538
538
|
|
|
539
|
+
### Offline video storage failures
|
|
540
|
+
|
|
541
|
+
On iOS and Android native screens, `craft.media.keep(url)` rejects with
|
|
542
|
+
`code: 'NO_SPACE'` when the filesystem runs out of room, including wrapped
|
|
543
|
+
URLSession and Android write errors. Treat that separately from connectivity:
|
|
544
|
+
stop downloading and let the person free space before retrying the same URL.
|
|
545
|
+
A failed transfer is never published as a playable cached video.
|
|
546
|
+
`craft.media.prune(keepUrls)` removes only unwanted files in the media cache;
|
|
547
|
+
it does not clear app storage, workout progress, snapshots or databases.
|
|
548
|
+
|
|
549
|
+
The Bun-only testing entrypoint verifies those guarantees on native devices:
|
|
550
|
+
|
|
551
|
+
```ts
|
|
552
|
+
import { verifyMobileMediaCache } from 'craft-native/testing'
|
|
553
|
+
|
|
554
|
+
await verifyMobileMediaCache({ platform: 'ios' }) // creates and deletes its own simulator
|
|
555
|
+
await verifyMobileMediaCache({ platform: 'android', device: 'emulator-5554', online: true })
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Android requires an explicitly selected rootable AOSP emulator and an installed
|
|
559
|
+
Gradle CLI. Xcode and an available simulator runtime are required for iOS. The
|
|
560
|
+
probe uses a bounded 16 MB disk image on iOS and a 16 MB tmpfs mounted only over
|
|
561
|
+
the probe's cache on Android. The Android cache keeps its app UID and SELinux
|
|
562
|
+
label; enforcement stays on. It never fills the host disk or an athlete's data partition. It
|
|
563
|
+
checks `NO_SPACE`, partial-file cleanup, preserved state outside the cache,
|
|
564
|
+
redownloading, interrupted transfers and decoding a local video frame. Set
|
|
565
|
+
`storageOnly: true` for only the disk-full cases, or `sourceRoot` to Craft's
|
|
566
|
+
absolute `packages` path during upstream development before publication.
|
|
567
|
+
|
|
539
568
|
## License
|
|
540
569
|
|
|
541
570
|
MIT © [Chris Breuer](https://github.com/chrisbbreuer)
|
|
@@ -894,6 +894,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
894
894
|
accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
|
|
895
895
|
accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
|
|
896
896
|
tabViews.forEachIndexed { index, (icon, label) ->
|
|
897
|
+
tabBar.getChildAt(index).isSelected = index == selectedTab
|
|
897
898
|
val color = if (index == selectedTab) accent else muted
|
|
898
899
|
val number = tabIconText[tabs[index].id]
|
|
899
900
|
icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
|
|
@@ -1092,7 +1093,10 @@ internal object CraftNativeMedia {
|
|
|
1092
1093
|
connection.disconnect()
|
|
1093
1094
|
}
|
|
1094
1095
|
}.getOrElse { error ->
|
|
1095
|
-
|
|
1096
|
+
val full = generateSequence(error) { it.cause }.take(16).any {
|
|
1097
|
+
it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
|
|
1098
|
+
}
|
|
1099
|
+
CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
|
|
1096
1100
|
}
|
|
1097
1101
|
val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
|
|
1098
1102
|
main.post { waiting.forEach { it(result) } }
|
|
@@ -3780,6 +3780,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
3780
3780
|
private var tabSwipeTarget: Screen? = null
|
|
3781
3781
|
private var tabSwipeTargetIndex = -1
|
|
3782
3782
|
private var tabSwipeDirection = 0
|
|
3783
|
+
private var tabSwipeGeneration = 0
|
|
3784
|
+
private var tabSwipeSettling = false
|
|
3783
3785
|
/** The app script's isolate (`nativeApp`), when the app has one. */
|
|
3784
3786
|
private var appScreen: Screen? = null
|
|
3785
3787
|
private val urlSchemes = mutableSetOf<String>()
|
|
@@ -3960,6 +3962,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3960
3962
|
bars.onAccessory = { path -> navigateOpen(path) }
|
|
3961
3963
|
bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
|
|
3962
3964
|
bars.onTab = { index ->
|
|
3965
|
+
interruptTabSwipe()
|
|
3963
3966
|
haptic()
|
|
3964
3967
|
if (index == currentTab) popToRoot() else selectTab(index)
|
|
3965
3968
|
}
|
|
@@ -3996,6 +3999,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3996
3999
|
/** A tab chosen: its stack shows, its root screen opened the first time. */
|
|
3997
4000
|
private fun selectTab(index: Int) {
|
|
3998
4001
|
if (index !in tabStacks.indices) return
|
|
4002
|
+
interruptTabSwipe()
|
|
3999
4003
|
currentTab = index
|
|
4000
4004
|
screens = tabStacks[index]
|
|
4001
4005
|
chrome?.selectTab(index)
|
|
@@ -4017,6 +4021,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4017
4021
|
|
|
4018
4022
|
/** Start an interactive horizontal transition to the adjacent tab. */
|
|
4019
4023
|
private fun beginTabSwipe(dx: Float, x: Float, y: Float): Boolean {
|
|
4024
|
+
if (tabSwipeSettling) interruptTabSwipe()
|
|
4020
4025
|
if (tabSwipeTarget != null || screens.size != 1 || chrome?.tabSwipeEnabled != true) return false
|
|
4021
4026
|
val direction = if (dx < 0) 1 else -1
|
|
4022
4027
|
val targetIndex = currentTab + direction
|
|
@@ -4055,16 +4060,23 @@ class MainActivity : AppCompatActivity() {
|
|
|
4055
4060
|
val host = chrome?.content ?: return
|
|
4056
4061
|
val width = host.width.toFloat().coerceAtLeast(1f)
|
|
4057
4062
|
val complete = !cancelled && kotlin.math.abs(dx) >= width * 0.28f
|
|
4063
|
+
val generation = ++tabSwipeGeneration
|
|
4064
|
+
val targetIndex = tabSwipeTargetIndex
|
|
4065
|
+
tabSwipeSettling = true
|
|
4058
4066
|
val endOffset = if (complete) {
|
|
4059
4067
|
if (tabSwipeDirection > 0) -width else width
|
|
4060
4068
|
} else 0f
|
|
4061
4069
|
val targetOffset = if (complete) 0f else if (tabSwipeDirection > 0) width else -width
|
|
4062
4070
|
current.view.animate().translationX(endOffset).setDuration(180).start()
|
|
4063
4071
|
target.view.animate().translationX(targetOffset).setDuration(180).withEndAction {
|
|
4072
|
+
// A later tab tap or gesture owns the display even if Android
|
|
4073
|
+
// delivers this animator's completion after it was cancelled.
|
|
4074
|
+
if (generation != tabSwipeGeneration || tabSwipeTarget !== target || visibleScreen !== current) return@withEndAction
|
|
4064
4075
|
target.view.animate().setListener(null)
|
|
4076
|
+
tabSwipeSettling = false
|
|
4065
4077
|
if (complete) {
|
|
4066
4078
|
current.view.translationX = 0f
|
|
4067
|
-
currentTab =
|
|
4079
|
+
currentTab = targetIndex
|
|
4068
4080
|
screens = tabStacks[currentTab]
|
|
4069
4081
|
tabSwipeTarget = null
|
|
4070
4082
|
tabSwipeTargetIndex = -1
|
|
@@ -4082,6 +4094,24 @@ class MainActivity : AppCompatActivity() {
|
|
|
4082
4094
|
}.start()
|
|
4083
4095
|
}
|
|
4084
4096
|
|
|
4097
|
+
/** Restore the owning stack before a newer navigation action takes over. */
|
|
4098
|
+
private fun interruptTabSwipe() {
|
|
4099
|
+
val target = tabSwipeTarget ?: return
|
|
4100
|
+
++tabSwipeGeneration
|
|
4101
|
+
tabSwipeTarget = null
|
|
4102
|
+
tabSwipeTargetIndex = -1
|
|
4103
|
+
tabSwipeDirection = 0
|
|
4104
|
+
tabSwipeSettling = false
|
|
4105
|
+
val current = visibleScreen
|
|
4106
|
+
current?.view?.animate()?.withEndAction(null)?.setListener(null)?.cancel()
|
|
4107
|
+
target.view.animate().withEndAction(null).setListener(null).cancel()
|
|
4108
|
+
current?.view?.translationX = 0f
|
|
4109
|
+
target.view.translationX = 0f
|
|
4110
|
+
if (target !== current) (target.view.parent as? ViewGroup)?.removeView(target.view)
|
|
4111
|
+
chrome?.selectTab(currentTab)
|
|
4112
|
+
current?.let(::applyChrome)
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4085
4115
|
/**
|
|
4086
4116
|
* Returns true when the exact screen gesture point is over a horizontal
|
|
4087
4117
|
* child that still has room in the gesture's direction. Screen coordinates
|
|
@@ -4172,6 +4202,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4172
4202
|
}
|
|
4173
4203
|
|
|
4174
4204
|
private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
|
|
4205
|
+
if (showImmediately && !headless) interruptTabSwipe()
|
|
4175
4206
|
val engine = sandbox ?: return null
|
|
4176
4207
|
val isolate = engine.createIsolate()
|
|
4177
4208
|
val view = CraftNativeFlexLayout(this).apply {
|
|
@@ -6943,6 +6974,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6943
6974
|
|
|
6944
6975
|
/** Back to the first screen in one move. */
|
|
6945
6976
|
private fun popToRoot() {
|
|
6977
|
+
interruptTabSwipe()
|
|
6946
6978
|
val stack = screens
|
|
6947
6979
|
val removed = mutableListOf<Screen>()
|
|
6948
6980
|
while (stack.size > 1) removed.add(stack.removeAt(stack.lastIndex))
|
|
@@ -6954,6 +6986,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6954
6986
|
|
|
6955
6987
|
/** Back: the screen below in this tab, then the first tab, then out of the app. */
|
|
6956
6988
|
private fun back() {
|
|
6989
|
+
interruptTabSwipe()
|
|
6957
6990
|
// A previous back is still draining its screen's teardown messages.
|
|
6958
6991
|
if (visibleScreen == null) return
|
|
6959
6992
|
if (screens.size > 1) {
|
|
@@ -7281,6 +7314,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7281
7314
|
}
|
|
7282
7315
|
|
|
7283
7316
|
override fun onDestroy() {
|
|
7317
|
+
interruptTabSwipe()
|
|
7284
7318
|
if (animationScaleObserverRegistered) contentResolver.unregisterContentObserver(animationScaleObserver)
|
|
7285
7319
|
CraftNativeBackground.foreground = null
|
|
7286
7320
|
backgroundWaiters.values.forEach { it(false) }
|
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.223";
|
|
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
|
@@ -135,6 +135,13 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
135
135
|
/// Fourteen hours of one reading a second: more is not one workout.
|
|
136
136
|
static let maxRecordedSamples = 50_000
|
|
137
137
|
|
|
138
|
+
private let clock: () -> Date
|
|
139
|
+
|
|
140
|
+
init(now: @escaping () -> Date = Date.init) {
|
|
141
|
+
self.clock = now
|
|
142
|
+
super.init()
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
/// The web view's coordinator, which sends an event to the page and every
|
|
139
146
|
/// native screen. False (or unset, in an app without a web view) and the
|
|
140
147
|
/// event goes to the native screens alone.
|
|
@@ -155,6 +162,12 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
155
162
|
private var connectDeadline: DispatchWorkItem?
|
|
156
163
|
private var latest: CraftHeartRateMeasurement?
|
|
157
164
|
private var latestAt: Date?
|
|
165
|
+
// Packet freshness is separate from a valid BPM: a monitor reporting no
|
|
166
|
+
// skin contact is still delivering notifications and needs no reconnect.
|
|
167
|
+
private var packetAt: Date?
|
|
168
|
+
private var subscriptionAt: Date?
|
|
169
|
+
private var recoveryTimer: Timer?
|
|
170
|
+
private var hasSubscribed = false
|
|
158
171
|
|
|
159
172
|
private var recordingId: String?
|
|
160
173
|
private var recordingStartedAt: Double?
|
|
@@ -189,7 +202,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
189
202
|
DispatchQueue.main.async {
|
|
190
203
|
self.restoreRecording()
|
|
191
204
|
switch method {
|
|
192
|
-
case "getState":
|
|
205
|
+
case "getState":
|
|
206
|
+
self.recoverQuietBroadcast()
|
|
207
|
+
completion(.success(self.state()))
|
|
193
208
|
case "requestAuthorization": self.requestAuthorization(completion: completion)
|
|
194
209
|
case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
|
|
195
210
|
case "stopScan":
|
|
@@ -255,7 +270,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
func state() -> [String: Any] {
|
|
258
|
-
let fresh = latestAt.map {
|
|
273
|
+
let fresh = latestAt.map { clock().timeIntervalSince($0) <= Self.staleAfter } ?? false
|
|
259
274
|
let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
|
|
260
275
|
return [
|
|
261
276
|
"available": bluetooth != "unsupported",
|
|
@@ -393,8 +408,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
393
408
|
}
|
|
394
409
|
whenReady {
|
|
395
410
|
if let reason = self.unusable() { return completion(.failure(reason)) }
|
|
396
|
-
//
|
|
411
|
+
// A connected link can lose its notification subscription when a
|
|
412
|
+
// broadcasting watch restarts. Keep healthy links, repair quiet ones.
|
|
397
413
|
if self.deviceId == uuid && self.wantsConnection {
|
|
414
|
+
self.recoverQuietBroadcast()
|
|
398
415
|
if self.connection == "connected" || !wait { return completion(.success(self.state())) }
|
|
399
416
|
self.connectWaiters.append(completion)
|
|
400
417
|
self.armConnectDeadline()
|
|
@@ -416,6 +433,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
416
433
|
self.deviceName = name
|
|
417
434
|
self.wantsConnection = true
|
|
418
435
|
self.connection = "connecting"
|
|
436
|
+
self.subscriptionAt = self.clock()
|
|
437
|
+
self.startRecoveryWatch()
|
|
419
438
|
self.remember(id: uuid, name: name)
|
|
420
439
|
if wait {
|
|
421
440
|
self.connectWaiters.append(completion)
|
|
@@ -454,6 +473,11 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
454
473
|
/// Lets go of the current strap without telling anyone.
|
|
455
474
|
private func release() {
|
|
456
475
|
wantsConnection = false
|
|
476
|
+
hasSubscribed = false
|
|
477
|
+
recoveryTimer?.invalidate()
|
|
478
|
+
recoveryTimer = nil
|
|
479
|
+
packetAt = nil
|
|
480
|
+
subscriptionAt = nil
|
|
457
481
|
simulatedTimer?.invalidate()
|
|
458
482
|
simulatedTimer = nil
|
|
459
483
|
if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
|
|
@@ -474,7 +498,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
474
498
|
}
|
|
475
499
|
|
|
476
500
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
477
|
-
guard peripheral.identifier == deviceId else { return }
|
|
501
|
+
guard peripheral.identifier == deviceId, wantsConnection else { return }
|
|
502
|
+
packetAt = nil
|
|
503
|
+
subscriptionAt = clock()
|
|
478
504
|
peripheral.delegate = self
|
|
479
505
|
peripheral.discoverServices([Self.serviceUUID])
|
|
480
506
|
}
|
|
@@ -491,6 +517,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
491
517
|
guard peripheral.identifier == deviceId else { return }
|
|
492
518
|
latest = nil
|
|
493
519
|
latestAt = nil
|
|
520
|
+
packetAt = nil
|
|
521
|
+
subscriptionAt = clock()
|
|
494
522
|
if wantsConnection {
|
|
495
523
|
connection = "reconnecting"
|
|
496
524
|
central.connect(peripheral)
|
|
@@ -501,7 +529,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
501
529
|
}
|
|
502
530
|
|
|
503
531
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
532
|
+
guard peripheral.identifier == deviceId, wantsConnection else { return }
|
|
533
|
+
if error != nil { return restartBroadcast() }
|
|
504
534
|
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
|
535
|
+
if hasSubscribed { return restartBroadcast() }
|
|
505
536
|
notAHeartRateMonitor(peripheral)
|
|
506
537
|
return
|
|
507
538
|
}
|
|
@@ -509,7 +540,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
509
540
|
}
|
|
510
541
|
|
|
511
542
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
543
|
+
guard peripheral.identifier == deviceId, wantsConnection, service.uuid == Self.serviceUUID else { return }
|
|
544
|
+
if error != nil { return restartBroadcast() }
|
|
512
545
|
guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
|
|
546
|
+
if hasSubscribed { return restartBroadcast() }
|
|
513
547
|
notAHeartRateMonitor(peripheral)
|
|
514
548
|
return
|
|
515
549
|
}
|
|
@@ -517,36 +551,104 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
517
551
|
}
|
|
518
552
|
|
|
519
553
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
520
|
-
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
|
|
521
|
-
if characteristic.isNotifying {
|
|
554
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
|
|
555
|
+
if characteristic.isNotifying && error == nil {
|
|
556
|
+
hasSubscribed = true
|
|
557
|
+
subscriptionAt = clock()
|
|
522
558
|
connection = "connected"
|
|
523
559
|
emitState()
|
|
524
560
|
settleConnect(.success(state()))
|
|
525
|
-
} else
|
|
526
|
-
|
|
561
|
+
} else {
|
|
562
|
+
restartBroadcast()
|
|
527
563
|
}
|
|
528
564
|
}
|
|
529
565
|
|
|
566
|
+
// Garmin may invalidate its service when HR broadcast is toggled without
|
|
567
|
+
// dropping the physical link. Discover and subscribe to the replacement.
|
|
568
|
+
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
|
569
|
+
guard peripheral.identifier == deviceId, wantsConnection,
|
|
570
|
+
invalidatedServices.contains(where: { $0.uuid == Self.serviceUUID }) else { return }
|
|
571
|
+
connection = "reconnecting"
|
|
572
|
+
latest = nil
|
|
573
|
+
latestAt = nil
|
|
574
|
+
packetAt = nil
|
|
575
|
+
subscriptionAt = clock()
|
|
576
|
+
peripheral.discoverServices([Self.serviceUUID])
|
|
577
|
+
emitState()
|
|
578
|
+
}
|
|
579
|
+
|
|
530
580
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
531
|
-
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId,
|
|
581
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
|
|
582
|
+
if error != nil { return restartBroadcast() }
|
|
583
|
+
guard let value = characteristic.value else { return }
|
|
532
584
|
received([UInt8](value))
|
|
533
585
|
}
|
|
534
586
|
|
|
535
587
|
private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
|
|
536
588
|
wantsConnection = false
|
|
589
|
+
hasSubscribed = false
|
|
590
|
+
recoveryTimer?.invalidate()
|
|
591
|
+
recoveryTimer = nil
|
|
537
592
|
central?.cancelPeripheralConnection(peripheral)
|
|
538
593
|
connection = "disconnected"
|
|
539
594
|
settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
|
|
540
595
|
emitState()
|
|
541
596
|
}
|
|
542
597
|
|
|
598
|
+
/// Run independently of any player or big-timer screen. A pending radio
|
|
599
|
+
/// connection stays pending until the watch returns; only a connected link
|
|
600
|
+
/// with a silent subscription is restarted.
|
|
601
|
+
private func startRecoveryWatch() {
|
|
602
|
+
recoveryTimer?.invalidate()
|
|
603
|
+
recoveryTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
604
|
+
self?.recoverQuietBroadcast()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private func recoverQuietBroadcast() {
|
|
609
|
+
guard wantsConnection, let since = packetAt ?? subscriptionAt else { return }
|
|
610
|
+
let quiet = clock().timeIntervalSince(since)
|
|
611
|
+
if connection == "connected" && quiet > 20 {
|
|
612
|
+
restartBroadcast()
|
|
613
|
+
} else if connection != "connected", peripheral?.state == .connected, quiet > 30 {
|
|
614
|
+
restartBroadcast()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// Keep the recording and device choice while replacing the subscription.
|
|
619
|
+
/// CoreBluetooth's disconnect callback starts the next connection once the
|
|
620
|
+
/// previous one has actually closed.
|
|
621
|
+
private func restartBroadcast() {
|
|
622
|
+
guard wantsConnection else { return }
|
|
623
|
+
connection = "reconnecting"
|
|
624
|
+
latest = nil
|
|
625
|
+
latestAt = nil
|
|
626
|
+
packetAt = nil
|
|
627
|
+
subscriptionAt = clock()
|
|
628
|
+
emitState()
|
|
629
|
+
if simulated {
|
|
630
|
+
simulatedTimer?.invalidate()
|
|
631
|
+
simulatedTimer = nil
|
|
632
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
|
|
633
|
+
} else if let peripheral, let central, central.state == .poweredOn {
|
|
634
|
+
if peripheral.state == .disconnected { central.connect(peripheral) }
|
|
635
|
+
else if peripheral.state != .disconnecting { central.cancelPeripheralConnection(peripheral) }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
543
639
|
// MARK: Readings
|
|
544
640
|
|
|
545
641
|
private func received(_ bytes: [UInt8]) {
|
|
546
642
|
// A strap reads 0 between losing the skin and giving up; that is no
|
|
547
643
|
// heart rate, and neither is anything a heart could not do.
|
|
548
|
-
|
|
549
|
-
let
|
|
644
|
+
packetAt = clock()
|
|
645
|
+
guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.sensorContact != false, reading.bpm >= 20, reading.bpm <= 250 else {
|
|
646
|
+
latest = nil
|
|
647
|
+
latestAt = nil
|
|
648
|
+
emitState()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
let now = clock()
|
|
550
652
|
latest = reading
|
|
551
653
|
latestAt = now
|
|
552
654
|
let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
|
|
@@ -567,6 +669,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
567
669
|
private func simulatedConnected() {
|
|
568
670
|
guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
|
|
569
671
|
connection = "connected"
|
|
672
|
+
subscriptionAt = clock()
|
|
570
673
|
emitState()
|
|
571
674
|
settleConnect(.success(state()))
|
|
572
675
|
simulatedTimer?.invalidate()
|
|
@@ -671,7 +774,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
671
774
|
return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
|
|
672
775
|
}
|
|
673
776
|
recordingId = UUID().uuidString
|
|
674
|
-
recordingStartedAt = (
|
|
777
|
+
recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
|
|
675
778
|
recordingPaused = false
|
|
676
779
|
recordedCount = 0
|
|
677
780
|
persistRecording()
|
|
@@ -794,7 +794,7 @@ enum CraftNativeActions {
|
|
|
794
794
|
return
|
|
795
795
|
}
|
|
796
796
|
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
797
|
-
CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
|
|
797
|
+
CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
798
798
|
return
|
|
799
799
|
}
|
|
800
800
|
guard let http = response as? HTTPURLResponse, let location else {
|
|
@@ -492,6 +492,20 @@ enum CraftNativeFiles {
|
|
|
492
492
|
// JavaScript. The screen decides what to keep (`keep`) and what to drop
|
|
493
493
|
// (`prune`); nothing is evicted behind its back.
|
|
494
494
|
enum CraftNativeMedia {
|
|
495
|
+
/// Foundation often wraps ENOSPC inside a URL or Cocoa file error.
|
|
496
|
+
static func errorCode(_ error: Error, fallback: String) -> String {
|
|
497
|
+
var cause: NSError? = error as NSError
|
|
498
|
+
var seen = Set<ObjectIdentifier>()
|
|
499
|
+
while let current = cause, seen.insert(ObjectIdentifier(current)).inserted {
|
|
500
|
+
if (current.domain == NSCocoaErrorDomain && current.code == NSFileWriteOutOfSpaceError)
|
|
501
|
+
|| (current.domain == NSPOSIXErrorDomain && current.code == Int(POSIXError.Code.ENOSPC.rawValue)) {
|
|
502
|
+
return "NO_SPACE"
|
|
503
|
+
}
|
|
504
|
+
cause = current.userInfo[NSUnderlyingErrorKey] as? NSError
|
|
505
|
+
}
|
|
506
|
+
return fallback
|
|
507
|
+
}
|
|
508
|
+
|
|
495
509
|
static var root: URL {
|
|
496
510
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
497
511
|
?? FileManager.default.temporaryDirectory
|
|
@@ -578,7 +592,7 @@ enum CraftNativeMedia {
|
|
|
578
592
|
return .success(describe(address, target))
|
|
579
593
|
}
|
|
580
594
|
catch {
|
|
581
|
-
return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
|
|
595
|
+
return .failure(CraftNativeActionError(code: errorCode(error, fallback: "IO_ERROR"), message: "media.keep could not store the file: \(error.localizedDescription)"))
|
|
582
596
|
}
|
|
583
597
|
}
|
|
584
598
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface MobileMediaCacheTestOptions {
|
|
2
|
+
platform: 'ios' | 'android';
|
|
3
|
+
/** An owned simulator UUID, or emulator serial. iOS creates a disposable simulator when omitted. */
|
|
4
|
+
device?: string;
|
|
5
|
+
/** Unpublished Craft's packages directory; installed SDK templates are used by default. */
|
|
6
|
+
sourceRoot?: string;
|
|
7
|
+
storageOnly?: boolean;
|
|
8
|
+
/** Installed Gradle CLI (8.11.1 or compatible). */
|
|
9
|
+
gradle?: string;
|
|
10
|
+
/** Allow Gradle to resolve build dependencies. Defaults to offline. */
|
|
11
|
+
online?: boolean;
|
|
12
|
+
log?: (line: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
|
|
15
|
+
export declare function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{
|
|
16
|
+
platform: string;
|
|
17
|
+
requests: Record<string, number>;
|
|
18
|
+
}>;
|
package/dist/mobile-testing.d.ts
CHANGED
package/dist/mobile-testing.js
CHANGED
|
@@ -1,14 +1,270 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/mobile-testing.ts
|
|
3
3
|
import { access } from "fs/promises";
|
|
4
|
+
import { isAbsolute as isAbsolute2, join as join2 } from "path";
|
|
5
|
+
|
|
6
|
+
// src/mobile-media-testing.ts
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { mkdir, mkdtemp, rm } from "fs/promises";
|
|
9
|
+
import { homedir, tmpdir } from "os";
|
|
4
10
|
import { isAbsolute, join } from "path";
|
|
11
|
+
async function verifyMobileMediaCache(options) {
|
|
12
|
+
if (options.platform !== "ios" && options.platform !== "android")
|
|
13
|
+
throw new Error("Choose ios or android");
|
|
14
|
+
if (options.platform === "android" && !/^emulator-\d+$/.test(options.device || ""))
|
|
15
|
+
throw new Error("An explicit disposable Android emulator serial is required");
|
|
16
|
+
if (options.platform === "ios" && options.device && !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(options.device))
|
|
17
|
+
throw new Error("An explicit iOS simulator UUID is required");
|
|
18
|
+
if (options.sourceRoot && !isAbsolute(options.sourceRoot))
|
|
19
|
+
throw new Error("sourceRoot must be an absolute local path");
|
|
20
|
+
const root = options.sourceRoot || import.meta.dir;
|
|
21
|
+
const packagedFixtures = join(import.meta.dir, "testing/media-cache");
|
|
22
|
+
const fixtures = existsSync(packagedFixtures) ? packagedFixtures : join(import.meta.dir, "../fixtures/media-cache");
|
|
23
|
+
const print = options.log || console.log;
|
|
24
|
+
const storageOnly = options.storageOnly === true;
|
|
25
|
+
const scratch = await mkdtemp(join(tmpdir(), "craft-media-probe-"));
|
|
26
|
+
const bytes = new Uint8Array(await Bun.file(join(fixtures, "demo.mp4")).arrayBuffer());
|
|
27
|
+
const counts = new Map;
|
|
28
|
+
const timers = new Set;
|
|
29
|
+
const server = Bun.listen({
|
|
30
|
+
hostname: "0.0.0.0",
|
|
31
|
+
port: 0,
|
|
32
|
+
socket: {
|
|
33
|
+
open(socket) {
|
|
34
|
+
socket.data = { request: "", answered: false };
|
|
35
|
+
},
|
|
36
|
+
data(socket, input) {
|
|
37
|
+
socket.data.request += new TextDecoder().decode(input);
|
|
38
|
+
if (socket.data.answered || !socket.data.request.includes(`\r
|
|
39
|
+
\r
|
|
40
|
+
`))
|
|
41
|
+
return;
|
|
42
|
+
socket.data.answered = true;
|
|
43
|
+
const path = socket.data.request.split(" ")[1];
|
|
44
|
+
const n = (counts.get(path) || 0) + 1;
|
|
45
|
+
counts.set(path, n);
|
|
46
|
+
const empty = path.endsWith("/empty.mp4");
|
|
47
|
+
const partial = path.endsWith("/partial.mp4");
|
|
48
|
+
const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
|
|
49
|
+
const slow = path.endsWith("/slow.mp4") || path.endsWith("/restart.mp4") && n === 1;
|
|
50
|
+
socket.write(`HTTP/1.1 ${partial ? "206 Partial Content" : "200 OK"}\r
|
|
51
|
+
Content-Type: video/mp4\r
|
|
52
|
+
Content-Length: ${empty ? 0 : bytes.length}\r
|
|
53
|
+
Connection: close\r
|
|
54
|
+
\r
|
|
55
|
+
`);
|
|
56
|
+
if (empty) {
|
|
57
|
+
socket.end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
socket.write(bytes.subarray(0, Math.floor(bytes.length / 2)));
|
|
61
|
+
if (cut && !slow) {
|
|
62
|
+
socket.end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const finish = () => {
|
|
66
|
+
if (!cut)
|
|
67
|
+
socket.write(bytes.subarray(Math.floor(bytes.length / 2)));
|
|
68
|
+
socket.end();
|
|
69
|
+
};
|
|
70
|
+
if (slow) {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
timers.delete(timer);
|
|
73
|
+
finish();
|
|
74
|
+
}, cut ? 60000 : 700);
|
|
75
|
+
timers.add(timer);
|
|
76
|
+
} else
|
|
77
|
+
finish();
|
|
78
|
+
},
|
|
79
|
+
error() {},
|
|
80
|
+
drain() {}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
async function run(args, cwd = scratch, allowedFailure = false) {
|
|
84
|
+
const proc = Bun.spawn(args, { cwd, env: { ...process.env, ANDROID_HOME: process.env.ANDROID_HOME || join(homedir(), "Library/Android/sdk") }, stdout: "pipe", stderr: "pipe", timeout: 180000 });
|
|
85
|
+
const [output, errors, status] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
86
|
+
if (status && !allowedFailure)
|
|
87
|
+
throw new Error(`${args[0]} failed (${status}):
|
|
88
|
+
${output}
|
|
89
|
+
${errors}`);
|
|
90
|
+
return output + errors;
|
|
91
|
+
}
|
|
92
|
+
let simulator = "";
|
|
93
|
+
let ownsSimulator = false;
|
|
94
|
+
const packageName = "dev.craft.mediaprobe";
|
|
95
|
+
const sdk = process.env.ANDROID_HOME || join(homedir(), "Library/Android/sdk");
|
|
96
|
+
const adb = join(sdk, "platform-tools/adb");
|
|
97
|
+
let serial = "";
|
|
98
|
+
let mountedVolume = "";
|
|
99
|
+
let mountedAndroidCache = "";
|
|
100
|
+
try {
|
|
101
|
+
if (options.platform === "ios") {
|
|
102
|
+
const files = await Bun.file(join(root, "ios/templates/CraftNativeFiles.swift")).text();
|
|
103
|
+
const media = files.slice(files.indexOf("enum CraftNativeMedia {"));
|
|
104
|
+
if (!media.startsWith("enum CraftNativeMedia"))
|
|
105
|
+
throw new Error("iOS media implementation missing");
|
|
106
|
+
const source = join(scratch, "main.swift");
|
|
107
|
+
const isolated = media.replace(/static var root: URL \{[\s\S]*?\n \}/, 'static var root: URL { CommandLine.arguments.count > 3 ? URL(fileURLWithPath: CommandLine.arguments[3]) : URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("hq-download-probe-media") }');
|
|
108
|
+
await Bun.write(source, `import Foundation
|
|
109
|
+
import CryptoKit
|
|
110
|
+
struct CraftNativeActionError: Error { let code: String;
|
|
111
|
+
let message: String }
|
|
112
|
+
${isolated}
|
|
113
|
+
${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
114
|
+
const iosSDK = (await run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).trim();
|
|
115
|
+
const executable = join(scratch, "CraftMediaProbe");
|
|
116
|
+
const architecture = process.arch === "arm64" ? "arm64" : "x86_64";
|
|
117
|
+
await run(["xcrun", "swiftc", "-sdk", iosSDK, "-target", `${architecture}-apple-ios17.0-simulator`, source, "-o", executable]);
|
|
118
|
+
await run(["codesign", "--force", "--sign", "-", executable]);
|
|
119
|
+
simulator = options.device || "";
|
|
120
|
+
if (!simulator) {
|
|
121
|
+
const runtimes = JSON.parse(await run(["xcrun", "simctl", "list", "runtimes", "-j"])).runtimes;
|
|
122
|
+
const runtime = runtimes.filter((r) => r.isAvailable && /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-/.test(r.identifier)).sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
|
|
123
|
+
if (!runtime)
|
|
124
|
+
throw new Error("Install an available iOS simulator runtime");
|
|
125
|
+
simulator = (await run(["xcrun", "simctl", "create", "Craft Media Cache QA", "com.apple.CoreSimulator.SimDeviceType.iPhone-16", runtime.identifier])).trim();
|
|
126
|
+
ownsSimulator = true;
|
|
127
|
+
await run(["xcrun", "simctl", "boot", simulator]);
|
|
128
|
+
await run(["xcrun", "simctl", "bootstatus", simulator, "-b"]);
|
|
129
|
+
}
|
|
130
|
+
const base = `http://127.0.0.1:${server.port}/ios`;
|
|
131
|
+
const image = join(scratch, "cache-pressure.sparseimage");
|
|
132
|
+
const volume = join(scratch, "cache-volume");
|
|
133
|
+
await mkdir(volume);
|
|
134
|
+
await run(["hdiutil", "create", "-size", "16m", "-fs", "HFS+", "-volname", "HQCachePressure", "-type", "SPARSE", image]);
|
|
135
|
+
await run(["hdiutil", "attach", "-nobrowse", "-mountpoint", volume, image]);
|
|
136
|
+
mountedVolume = volume;
|
|
137
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "storage", join(volume, "craft-media")]));
|
|
138
|
+
await run(["hdiutil", "detach", mountedVolume]);
|
|
139
|
+
mountedVolume = "";
|
|
140
|
+
print("PASS: iOS native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
141
|
+
if (!storageOnly) {
|
|
142
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal"]));
|
|
143
|
+
const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt"], scratch, true);
|
|
144
|
+
print(interrupted);
|
|
145
|
+
if (!interrupted.includes("KILLING"))
|
|
146
|
+
throw new Error("iOS probe did not reach process-death checkpoint");
|
|
147
|
+
if (counts.get("/ios/restart.mp4") !== 1)
|
|
148
|
+
throw new Error("iOS process did not start interrupted download");
|
|
149
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
|
|
150
|
+
if (counts.get("/ios/slow.mp4") !== 1)
|
|
151
|
+
throw new Error("iOS duplicate fetch: keep did not coalesce");
|
|
152
|
+
print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (options.platform === "android") {
|
|
156
|
+
serial = options.device || "";
|
|
157
|
+
if (!/^emulator-\d+$/.test(serial))
|
|
158
|
+
throw new Error("Select a disposable Android emulator with ANDROID_SERIAL");
|
|
159
|
+
const shell = await Bun.file(join(root, "android/templates/CraftNativeShell.kt.template")).text();
|
|
160
|
+
const media = shell.match(/internal object CraftNativeMedia \{[\s\S]*?\n\}/)?.[0];
|
|
161
|
+
if (!media)
|
|
162
|
+
throw new Error("Android media implementation missing");
|
|
163
|
+
const android = join(scratch, "android");
|
|
164
|
+
const app = join(android, "app");
|
|
165
|
+
const sources = join(app, "src/main/java/dev/craft/mediaprobe");
|
|
166
|
+
await mkdir(sources, { recursive: true });
|
|
167
|
+
await Bun.write(join(android, "settings.gradle.kts"), `pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
|
168
|
+
dependencyResolutionManagement { repositories { google(); mavenCentral() } }
|
|
169
|
+
rootProject.name = "HQDownloadProbe"
|
|
170
|
+
include(":app")`);
|
|
171
|
+
await Bun.write(join(android, "build.gradle.kts"), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }');
|
|
172
|
+
await Bun.write(join(android, "local.properties"), `sdk.dir=${sdk}
|
|
173
|
+
`);
|
|
174
|
+
await Bun.write(join(android, "gradle.properties"), `org.gradle.jvmargs=-Xmx768m
|
|
175
|
+
org.gradle.workers.max=2
|
|
176
|
+
`);
|
|
177
|
+
await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
|
|
178
|
+
id("org.jetbrains.kotlin.android") }
|
|
179
|
+
android { namespace = "${packageName}";
|
|
180
|
+
compileSdk = 36;
|
|
181
|
+
defaultConfig { applicationId = "${packageName}";
|
|
182
|
+
minSdk = 26;
|
|
183
|
+
targetSdk = 35 };
|
|
184
|
+
compileOptions { sourceCompatibility = JavaVersion.VERSION_17;
|
|
185
|
+
targetCompatibility = JavaVersion.VERSION_17 };
|
|
186
|
+
kotlinOptions { jvmTarget = "17" } }`);
|
|
187
|
+
await Bun.write(join(app, "src/main/AndroidManifest.xml"), `<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/><application android:usesCleartextTraffic="true" android:label="HQ Download Probe"/><instrumentation android:name=".DownloadProbe" android:targetPackage="${packageName}"/></manifest>`);
|
|
188
|
+
await Bun.write(join(sources, "DownloadProbe.kt"), `package ${packageName}
|
|
189
|
+
import android.content.Context
|
|
190
|
+
import android.net.Uri
|
|
191
|
+
import android.os.Handler
|
|
192
|
+
import android.os.Looper
|
|
193
|
+
import org.json.JSONObject
|
|
194
|
+
import java.io.File
|
|
195
|
+
import java.util.concurrent.Executors
|
|
196
|
+
internal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)
|
|
197
|
+
${media}
|
|
198
|
+
${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
199
|
+
await run([options.gradle || process.env.GRADLE_EXECUTABLE || "gradle", ...options.online ? [] : ["--offline"], "--no-daemon", ":app:assembleDebug"], android);
|
|
200
|
+
print(await run([adb, "-s", serial, "install", "-r", join(app, "build/outputs/apk/debug/app-debug.apk")]));
|
|
201
|
+
const base = `http://10.0.2.2:${server.port}/android`;
|
|
202
|
+
const probe = async (phase, interrupted = false) => {
|
|
203
|
+
const result = await run([adb, "-s", serial, "shell", "am", "instrument", "-w", "-e", "base", base, "-e", "phase", phase, `${packageName}/.DownloadProbe`], scratch, interrupted);
|
|
204
|
+
if (!interrupted && (!result.includes("PASS:") || result.includes("FAIL:")))
|
|
205
|
+
throw new Error(result);
|
|
206
|
+
print(result);
|
|
207
|
+
};
|
|
208
|
+
await run([adb, "-s", serial, "root"]);
|
|
209
|
+
await run([adb, "-s", serial, "wait-for-device"]);
|
|
210
|
+
if ((await run([adb, "-s", serial, "shell", "id", "-u"])).trim() !== "0")
|
|
211
|
+
throw new Error("Storage fault tests require a rootable disposable AOSP emulator");
|
|
212
|
+
await run([adb, "-s", serial, "shell", "run-as", packageName, "mkdir", "-p", "files/craft-media"]);
|
|
213
|
+
const cache = `/data/user/0/${packageName}/files/craft-media`;
|
|
214
|
+
const owner = (await run([adb, "-s", serial, "shell", "stat", "-c", "%u:%g", cache])).trim();
|
|
215
|
+
const label = (await run([adb, "-s", serial, "shell", "ls", "-Zd", cache])).trim().split(/\s+/)[0];
|
|
216
|
+
if (!/^\d+:\d+$/.test(owner) || !/^u:object_r:app_data_file:s0(?::c\d+(?:,c\d+)*)?$/.test(label))
|
|
217
|
+
throw new Error("Unexpected probe cache ownership or SELinux label");
|
|
218
|
+
const [uid, gid] = owner.split(":");
|
|
219
|
+
await run([adb, "-s", serial, "shell", "mount", "-t", "tmpfs", "-o", `size=16m,mode=700,uid=${uid},gid=${gid}`, "tmpfs", cache]);
|
|
220
|
+
mountedAndroidCache = cache;
|
|
221
|
+
await run([adb, "-s", serial, "shell", "chcon", label, cache]);
|
|
222
|
+
await probe("storage");
|
|
223
|
+
await run([adb, "-s", serial, "shell", "am", "force-stop", packageName]);
|
|
224
|
+
await run([adb, "-s", serial, "shell", "umount", mountedAndroidCache]);
|
|
225
|
+
mountedAndroidCache = "";
|
|
226
|
+
print("PASS: Android native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
227
|
+
if (!storageOnly) {
|
|
228
|
+
await probe("normal");
|
|
229
|
+
await probe("interrupt", true);
|
|
230
|
+
if (counts.get("/android/restart.mp4") !== 1)
|
|
231
|
+
throw new Error("Android process did not start interrupted download");
|
|
232
|
+
await probe("recover");
|
|
233
|
+
if (counts.get("/android/slow.mp4") !== 1)
|
|
234
|
+
throw new Error("Android duplicate fetch: keep did not coalesce");
|
|
235
|
+
print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
print(JSON.stringify({ requests: Object.fromEntries(counts) }));
|
|
240
|
+
if (mountedVolume)
|
|
241
|
+
await run(["hdiutil", "detach", mountedVolume]);
|
|
242
|
+
if (mountedAndroidCache) {
|
|
243
|
+
await run([adb, "-s", serial, "shell", "am", "force-stop", packageName], scratch, true);
|
|
244
|
+
await run([adb, "-s", serial, "shell", "umount", mountedAndroidCache]);
|
|
245
|
+
}
|
|
246
|
+
if (simulator && ownsSimulator) {
|
|
247
|
+
await run(["xcrun", "simctl", "shutdown", simulator], scratch, true);
|
|
248
|
+
await run(["xcrun", "simctl", "delete", simulator], scratch, true);
|
|
249
|
+
}
|
|
250
|
+
if (serial)
|
|
251
|
+
await run([adb, "-s", serial, "uninstall", packageName], scratch, true);
|
|
252
|
+
for (const timer of timers)
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
server.stop(true);
|
|
255
|
+
await rm(scratch, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
return { platform: options.platform, requests: Object.fromEntries(counts) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/mobile-testing.ts
|
|
5
261
|
function requireIosSimulator(device) {
|
|
6
262
|
if (!/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(device))
|
|
7
263
|
throw new Error("An explicit iOS simulator UUID is required");
|
|
8
264
|
}
|
|
9
265
|
async function recordIosSimulatorVideo(options) {
|
|
10
266
|
requireIosSimulator(options.device);
|
|
11
|
-
if (!
|
|
267
|
+
if (!isAbsolute2(options.path))
|
|
12
268
|
throw new Error("A local absolute recording path is required");
|
|
13
269
|
const timeout = options.timeoutMs ?? 30000;
|
|
14
270
|
if (!Number.isSafeInteger(timeout) || timeout < 1)
|
|
@@ -120,7 +376,7 @@ ${errors || output}`);
|
|
|
120
376
|
async function installedBuild() {
|
|
121
377
|
if (options.platform === "ios") {
|
|
122
378
|
const app = await ios("get_app_container", options.device, options.appId, "app");
|
|
123
|
-
return run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleVersion",
|
|
379
|
+
return run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleVersion", join2(app, "Info.plist")]);
|
|
124
380
|
}
|
|
125
381
|
const result = await android("shell", "dumpsys", "package", options.appId);
|
|
126
382
|
const version = result.match(/\bversionCode=(\d+)/)?.[1];
|
|
@@ -154,7 +410,7 @@ ${errors || output}`);
|
|
|
154
410
|
async function installUpdate(artifact) {
|
|
155
411
|
const previousBuild = await installedBuild();
|
|
156
412
|
if (options.platform === "ios") {
|
|
157
|
-
const id = await run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier",
|
|
413
|
+
const id = await run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", join2(artifact, "Info.plist")]);
|
|
158
414
|
if (id !== options.appId)
|
|
159
415
|
throw new Error("Update bundle identifier differs from the installed app");
|
|
160
416
|
await ios("install", options.device, artifact);
|
|
@@ -167,6 +423,7 @@ ${errors || output}`);
|
|
|
167
423
|
return { restart, installUpdate, installedBuild };
|
|
168
424
|
}
|
|
169
425
|
export {
|
|
426
|
+
verifyMobileMediaCache,
|
|
170
427
|
recordIosSimulatorVideo,
|
|
171
428
|
createMobileTestDevice
|
|
172
429
|
};
|
|
Binary file
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
class DownloadProbe : android.app.Instrumentation() {
|
|
2
|
+
override fun onCreate(arguments: android.os.Bundle?) {
|
|
3
|
+
super.onCreate(arguments)
|
|
4
|
+
args = arguments ?: android.os.Bundle()
|
|
5
|
+
start()
|
|
6
|
+
}
|
|
7
|
+
private var args = android.os.Bundle()
|
|
8
|
+
private var latestErrorCode: String? = null
|
|
9
|
+
private val proof = StringBuilder()
|
|
10
|
+
private fun check(yes: Boolean, message: String) {
|
|
11
|
+
if (!yes) throw IllegalStateException(message)
|
|
12
|
+
proof.append("PASS: ").append(message).append('\n')
|
|
13
|
+
android.util.Log.i("CraftMediaProbe", "PASS: $message")
|
|
14
|
+
}
|
|
15
|
+
private fun keep(address: String): Boolean {
|
|
16
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
17
|
+
var ok = false
|
|
18
|
+
CraftNativeMedia.keep(targetContext, address, JSONObject(), true) {
|
|
19
|
+
latestErrorCode = it.code
|
|
20
|
+
ok = (it.data as? JSONObject)?.optBoolean("ok") == true
|
|
21
|
+
done.countDown()
|
|
22
|
+
}
|
|
23
|
+
check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
|
|
24
|
+
return ok
|
|
25
|
+
}
|
|
26
|
+
override fun onStart() {
|
|
27
|
+
val result = android.os.Bundle()
|
|
28
|
+
try {
|
|
29
|
+
val base = args.getString("base")!!
|
|
30
|
+
val restart = "$base/restart.mp4"
|
|
31
|
+
when (args.getString("phase")) {
|
|
32
|
+
"storage" -> {
|
|
33
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
34
|
+
val prefs = targetContext.getSharedPreferences("workout-recovery-probe", Context.MODE_PRIVATE)
|
|
35
|
+
val progress = """{"sets":["A1"],"note":"Keep this workout","rpe":7}"""
|
|
36
|
+
check(prefs.edit().putString("progress", progress).commit(), "workout progress is durable before storage failure")
|
|
37
|
+
val prefsDisk = File(targetContext.applicationInfo.dataDir, "shared_prefs/workout-recovery-probe.xml")
|
|
38
|
+
val savedPrefs = prefsDisk.readBytes()
|
|
39
|
+
val retained = "$base/retained.mp4"
|
|
40
|
+
check(keep(retained), "existing video is kept before storage pressure")
|
|
41
|
+
val original = CraftNativeMedia.local(targetContext, retained)!!.readBytes()
|
|
42
|
+
val fresh = "$base/storage.mp4"
|
|
43
|
+
val staging = File(CraftNativeMedia.file(targetContext, fresh).absolutePath + ".part")
|
|
44
|
+
val root = staging.parentFile!!
|
|
45
|
+
check(root.totalSpace in 1..(32L * 1024 * 1024), "storage pressure is confined to a bounded disposable volume")
|
|
46
|
+
val pressure = File(root, "pressure.mp4")
|
|
47
|
+
var written = 0L
|
|
48
|
+
pressure.outputStream().use { output ->
|
|
49
|
+
for (size in listOf(65536, 1024)) {
|
|
50
|
+
val chunk = ByteArray(size) { 0x5A.toByte() }
|
|
51
|
+
while (written < 32L * 1024 * 1024) {
|
|
52
|
+
try { output.write(chunk); written += size }
|
|
53
|
+
catch (error: java.io.IOException) { break }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
check(written > 0 && root.usableSpace < 1024, "the probe cache filesystem is actually full")
|
|
58
|
+
check(!keep(fresh), "out-of-space write refuses a new video")
|
|
59
|
+
check(latestErrorCode == "NO_SPACE", "a full disk is reported as NO_SPACE, not a network failure")
|
|
60
|
+
check(CraftNativeMedia.local(targetContext, fresh) == null, "storage failure never publishes a partial video")
|
|
61
|
+
check(!staging.exists(), "storage failure removes staging files")
|
|
62
|
+
check(CraftNativeMedia.local(targetContext, retained)!!.readBytes().contentEquals(original), "storage failure preserves an existing video")
|
|
63
|
+
check(CraftNativeMedia.prune(targetContext, listOf(retained)) == 1, "cache cleanup frees the obsolete file and preserves the retained video")
|
|
64
|
+
check(prefs.getString("progress", null) == progress && prefsDisk.readBytes().contentEquals(savedPrefs), "cache cleanup preserves workout progress and feedback on disk")
|
|
65
|
+
check(keep(fresh), "same missing video downloads after cleanup")
|
|
66
|
+
val decoder = android.media.MediaMetadataRetriever()
|
|
67
|
+
try {
|
|
68
|
+
decoder.setDataSource(CraftNativeMedia.local(targetContext, fresh)!!.absolutePath)
|
|
69
|
+
check(decoder.getFrameAtTime(1000000) != null, "downloaded replacement plays from its local file")
|
|
70
|
+
} finally { decoder.release() }
|
|
71
|
+
check(prefs.getString("progress", null) == progress && prefsDisk.readBytes().contentEquals(savedPrefs), "redownloading leaves workout progress and feedback unchanged on disk")
|
|
72
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
73
|
+
prefs.edit().clear().commit()
|
|
74
|
+
}
|
|
75
|
+
"interrupt" -> {
|
|
76
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
77
|
+
CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
|
|
78
|
+
val part = File(CraftNativeMedia.file(targetContext, restart).absolutePath + ".part")
|
|
79
|
+
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")
|
|
82
|
+
android.os.Process.killProcess(android.os.Process.myPid())
|
|
83
|
+
}
|
|
84
|
+
"recover" -> {
|
|
85
|
+
check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
|
|
86
|
+
check(keep(restart), "retry after process death downloads a complete video")
|
|
87
|
+
val media = android.media.MediaMetadataRetriever()
|
|
88
|
+
try {
|
|
89
|
+
media.setDataSource(CraftNativeMedia.local(targetContext, restart)!!.absolutePath)
|
|
90
|
+
check(media.getFrameAtTime(1000000) != null, "recovered local video decodes a frame without network")
|
|
91
|
+
} finally { media.release() }
|
|
92
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
93
|
+
}
|
|
94
|
+
else -> {
|
|
95
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
96
|
+
val broken = "$base/broken.mp4"
|
|
97
|
+
check(!keep(broken), "truncated transfer fails")
|
|
98
|
+
check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
|
|
99
|
+
val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
|
|
100
|
+
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "failed transfer cleans staging files")
|
|
101
|
+
check(keep(broken), "same URL retries successfully")
|
|
102
|
+
check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
|
|
103
|
+
check(!keep("$base/empty.mp4"), "empty file is refused")
|
|
104
|
+
val slow = "$base/slow.mp4"
|
|
105
|
+
val done = java.util.concurrent.CountDownLatch(2)
|
|
106
|
+
val answers = java.util.Collections.synchronizedList(mutableListOf<Boolean>())
|
|
107
|
+
repeat(2) { CraftNativeMedia.keep(targetContext, slow, JSONObject(), true) { answers.add((it.data as? JSONObject)?.optBoolean("ok") == true); done.countDown() } }
|
|
108
|
+
Thread.sleep(150)
|
|
109
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
110
|
+
check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "coalesced downloads settled")
|
|
111
|
+
check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
|
|
112
|
+
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
|
|
113
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
|
|
117
|
+
finish(-1, result)
|
|
118
|
+
} catch (error: Throwable) {
|
|
119
|
+
result.putString("stream", "${proof}FAIL: ${error.stackTraceToString()}")
|
|
120
|
+
finish(1, result)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Compiled with the released CraftNativeMedia into an iOS simulator process.
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
let base = CommandLine.arguments[1]
|
|
5
|
+
let phase = CommandLine.arguments[2]
|
|
6
|
+
let fm = FileManager.default
|
|
7
|
+
func check(_ yes: @autoclosure () -> Bool, _ message: String) {
|
|
8
|
+
guard yes() else { print("FAIL: \(message)"); exit(1) }
|
|
9
|
+
print("PASS: \(message)")
|
|
10
|
+
}
|
|
11
|
+
func wait(_ ready: () -> Bool) {
|
|
12
|
+
let limit = Date().addingTimeInterval(25)
|
|
13
|
+
while !ready() && Date() < limit { RunLoop.current.run(until: Date().addingTimeInterval(0.01)) }
|
|
14
|
+
check(ready(), "operation settled")
|
|
15
|
+
}
|
|
16
|
+
var latestTask: URLSessionTask?
|
|
17
|
+
var latestErrorCode: String?
|
|
18
|
+
func start(_ address: String, _ done: @escaping (Bool) -> Void) {
|
|
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
|
|
21
|
+
if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
|
|
22
|
+
else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
|
|
23
|
+
}) { return }
|
|
24
|
+
let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
|
|
25
|
+
let result: Result<Any, CraftNativeActionError>
|
|
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) }
|
|
28
|
+
else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
|
|
29
|
+
CraftNativeMedia.finish(address, result)
|
|
30
|
+
}
|
|
31
|
+
latestTask = task
|
|
32
|
+
task.resume()
|
|
33
|
+
}
|
|
34
|
+
func keep(_ address: String) -> Bool {
|
|
35
|
+
var result: Bool?
|
|
36
|
+
start(address) { result = $0 }
|
|
37
|
+
wait { result != nil }
|
|
38
|
+
return result!
|
|
39
|
+
}
|
|
40
|
+
let restart = base + "/restart.mp4"
|
|
41
|
+
if phase == "storage" {
|
|
42
|
+
let volume = CraftNativeMedia.root.deletingLastPathComponent()
|
|
43
|
+
let capacity = try volume.resourceValues(forKeys: [.volumeTotalCapacityKey]).volumeTotalCapacity ?? 0
|
|
44
|
+
check(capacity > 0 && capacity <= 32 * 1024 * 1024, "storage pressure is confined to a bounded disposable volume")
|
|
45
|
+
let progress = volume.appendingPathComponent("workout-progress.json")
|
|
46
|
+
let saved = Data(#"{"sets":["A1"],"note":"Keep this workout","rpe":7}"#.utf8)
|
|
47
|
+
try saved.write(to: progress)
|
|
48
|
+
let pressure = volume.appendingPathComponent("pressure")
|
|
49
|
+
defer { try? fm.removeItem(at: pressure); try? fm.removeItem(at: progress) }
|
|
50
|
+
let retained = base + "/retained.mp4"
|
|
51
|
+
check(keep(retained), "existing video is kept before storage pressure")
|
|
52
|
+
let original = try Data(contentsOf: CraftNativeMedia.local(retained)!)
|
|
53
|
+
fm.createFile(atPath: pressure.path, contents: nil)
|
|
54
|
+
let writer = try FileHandle(forWritingTo: pressure)
|
|
55
|
+
var written = 0
|
|
56
|
+
for size in [65536, 1024] {
|
|
57
|
+
let chunk = Data(repeating: 0x5A, count: size)
|
|
58
|
+
while written < 32 * 1024 * 1024 {
|
|
59
|
+
do { try writer.write(contentsOf: chunk); written += size }
|
|
60
|
+
catch { break }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try writer.close()
|
|
64
|
+
let fresh = base + "/storage.mp4"
|
|
65
|
+
check(!keep(fresh), "full cache volume refuses a new video")
|
|
66
|
+
check(latestErrorCode == "NO_SPACE", "a full disk is reported as NO_SPACE, not a network failure")
|
|
67
|
+
check(CraftNativeMedia.local(fresh) == nil, "storage failure never publishes a partial video")
|
|
68
|
+
check((try? Data(contentsOf: CraftNativeMedia.local(retained)!)) == original, "storage failure preserves an existing video")
|
|
69
|
+
check(((try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path)) ?? []).allSatisfy { !$0.hasSuffix(".part") }, "storage failure removes staging files")
|
|
70
|
+
check(CraftNativeMedia.prune(keep: []) == 1, "cache cleanup frees only the retained video")
|
|
71
|
+
check((try? Data(contentsOf: progress)) == saved, "cache cleanup preserves workout progress and feedback")
|
|
72
|
+
check(keep(fresh), "same missing video downloads after cache cleanup frees space")
|
|
73
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: CraftNativeMedia.local(fresh)!))
|
|
74
|
+
do { _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil) }
|
|
75
|
+
catch { check(false, "downloaded replacement decodes: \(error)") }
|
|
76
|
+
check(true, "downloaded replacement plays from its local file")
|
|
77
|
+
check((try? Data(contentsOf: progress)) == saved, "redownloading leaves workout progress and feedback unchanged")
|
|
78
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
79
|
+
exit(0)
|
|
80
|
+
}
|
|
81
|
+
if phase == "interrupt" {
|
|
82
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
83
|
+
start(restart) { _ in }
|
|
84
|
+
print("INTERRUPTING"); fflush(stdout)
|
|
85
|
+
wait { (latestTask?.countOfBytesReceived ?? 0) > 0 }
|
|
86
|
+
print("KILLING"); fflush(stdout)
|
|
87
|
+
raise(SIGKILL)
|
|
88
|
+
exit(2)
|
|
89
|
+
}
|
|
90
|
+
if phase == "recover" {
|
|
91
|
+
check(CraftNativeMedia.local(restart) == nil, "interrupted process never published a partial video")
|
|
92
|
+
check(keep(restart), "retry after process death downloads a complete video")
|
|
93
|
+
let local = CraftNativeMedia.local(restart)!
|
|
94
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: local))
|
|
95
|
+
do { _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil) }
|
|
96
|
+
catch { check(false, "recovered video decodes offline: \(error)") }
|
|
97
|
+
check(true, "recovered local video decodes a frame without network")
|
|
98
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
99
|
+
exit(0)
|
|
100
|
+
}
|
|
101
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
102
|
+
let broken = base + "/broken.mp4"
|
|
103
|
+
check(!keep(broken), "truncated transfer fails")
|
|
104
|
+
check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable cache")
|
|
105
|
+
check(keep(broken), "same URL retries successfully")
|
|
106
|
+
check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
|
|
107
|
+
check(!keep(base + "/empty.mp4"), "empty file is refused")
|
|
108
|
+
let slow = base + "/slow.mp4"
|
|
109
|
+
var answers = [Bool]()
|
|
110
|
+
start(slow) { answers.append($0) }
|
|
111
|
+
start(slow) { answers.append($0) }
|
|
112
|
+
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
|
113
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
114
|
+
wait { answers.count == 2 }
|
|
115
|
+
check(answers == [true, true], "concurrent downloads coalesce and survive prune")
|
|
116
|
+
let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
|
|
117
|
+
check(parts.isEmpty, "no abandoned staging files remain")
|
|
118
|
+
_ = CraftNativeMedia.prune(keep: [])
|