craft-native 0.0.464 → 0.0.467
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/CraftNativeServices.kt.template +17 -1
- package/dist/android/templates/CraftNativeShell.kt.template +133 -33
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +21 -1
- package/dist/ios/templates/CraftNativeFiles.swift +149 -19
- package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
- package/dist/ios/templates/CraftNativeScreen.swift +6 -2
- package/dist/mobile-testing.js +12 -3
- package/dist/testing/media-cache/media-cache.kt +46 -3
- package/dist/testing/media-cache/media-cache.swift.fixture +42 -0
- package/package.json +1 -1
|
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
|
|
|
120
120
|
// Videos kept on the phone for offline (CraftNativeMedia).
|
|
121
121
|
add("media")
|
|
122
122
|
add("media-cache-key")
|
|
123
|
+
add("media-remove")
|
|
123
124
|
if (config.optBoolean("enableShare")) add("share")
|
|
124
125
|
if (config.optBoolean("enablePushNotifications")) add("push")
|
|
125
126
|
if (health != null || healthServices != null) {
|
|
@@ -188,7 +189,22 @@ internal class CraftNativeServices(
|
|
|
188
189
|
done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { index -> keep.optJSONObject(index)?.let { CraftNativeMedia.identity(it.optString("url"), it) } ?: keep.optString(index) })))
|
|
189
190
|
}
|
|
190
191
|
"usage" -> done(success(CraftNativeMedia.usage(context)))
|
|
191
|
-
"clear" ->
|
|
192
|
+
"clear" -> {
|
|
193
|
+
try { done(success(CraftNativeMedia.clear(context))) }
|
|
194
|
+
catch (error: java.io.IOException) { done(failure("IO_ERROR", error.message ?: "The media cache could not be cleared")) }
|
|
195
|
+
}
|
|
196
|
+
"remove" -> {
|
|
197
|
+
val options = args.optJSONObject(1)
|
|
198
|
+
val key = options?.opt("cacheKey")
|
|
199
|
+
if (key != null && key != JSONObject.NULL && (key !is String || key.isEmpty() || key.toByteArray().size > 1024)) {
|
|
200
|
+
done(failure("INVALID_ARGUMENT", "media.remove cacheKey must be a nonempty string of at most 1024 bytes"))
|
|
201
|
+
} else {
|
|
202
|
+
val address = CraftNativeMedia.identity(args.optString(0), options)
|
|
203
|
+
if (address.isBlank()) done(failure("INVALID_ARGUMENT", "media.remove needs a source or cache key"))
|
|
204
|
+
else try { done(success(CraftNativeMedia.remove(context, address))) }
|
|
205
|
+
catch (error: java.io.IOException) { done(failure("IO_ERROR", error.message ?: "The media copy could not be removed")) }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
192
208
|
else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
|
|
193
209
|
}
|
|
194
210
|
"Background" -> {
|
|
@@ -1381,7 +1381,7 @@ internal object CraftNativeMedia {
|
|
|
1381
1381
|
private const val PREFS = "craft.media"
|
|
1382
1382
|
private val lock = Any()
|
|
1383
1383
|
/** Keeps waiting in this process for an address's transfer. */
|
|
1384
|
-
private data class Waiting(val generation: Int, val answer: (CraftNativeCapabilityResult) -> Unit)
|
|
1384
|
+
private data class Waiting(val generation: Int, val revision: String, val answer: (CraftNativeCapabilityResult) -> Unit)
|
|
1385
1385
|
private val waiting = HashMap<String, MutableList<Waiting>>()
|
|
1386
1386
|
/** One transfer writes an address's part file at a time. */
|
|
1387
1387
|
private val writers = ConcurrentHashMap<String, Any>()
|
|
@@ -1400,10 +1400,12 @@ internal object CraftNativeMedia {
|
|
|
1400
1400
|
}
|
|
1401
1401
|
|
|
1402
1402
|
/** Where an address's transfer writes until the file is whole; kept between attempts to resume. */
|
|
1403
|
-
fun part(context: Context, address: String
|
|
1403
|
+
fun part(context: Context, address: String, started: Int = generation(context), ownerRevision: String = revision(context, address)): File =
|
|
1404
|
+
File(root(context), "${file(context, address).name}.g$started.r${ownerRevision.ifEmpty { "legacy" }}.part")
|
|
1404
1405
|
|
|
1405
|
-
fun local(context: Context, address: String): File? =
|
|
1406
|
-
address.takeIf { it.isNotBlank() }?.let { file(context, it) }?.takeIf { it.isFile && it.length() > 0 }
|
|
1406
|
+
fun local(context: Context, address: String): File? = synchronized(lock) {
|
|
1407
|
+
address.takeIf { it.isNotBlank() && permitted(context, it) }?.let { file(context, it) }?.takeIf { it.isFile && it.length() > 0 }
|
|
1408
|
+
}
|
|
1407
1409
|
|
|
1408
1410
|
private fun describe(address: String, file: File) = JSONObject().put("ok", true).put("url", address).put("path", file.absolutePath).put("size", file.length())
|
|
1409
1411
|
|
|
@@ -1412,6 +1414,46 @@ internal object CraftNativeMedia {
|
|
|
1412
1414
|
/** How many times the cache was cleared; a transfer started before the last clear does not land. */
|
|
1413
1415
|
fun generation(context: Context): Int = prefs(context).getInt("generation", 0)
|
|
1414
1416
|
|
|
1417
|
+
private fun revision(context: Context, address: String): String = prefs(context).getString("revision:${digest(address)}", "").orEmpty()
|
|
1418
|
+
|
|
1419
|
+
private fun permitted(context: Context, address: String): Boolean {
|
|
1420
|
+
val store = prefs(context)
|
|
1421
|
+
val key = digest(address)
|
|
1422
|
+
return !store.getBoolean("revoked:$key", false) && (!store.getBoolean("cleared", false) || store.getBoolean("authorized:$key", false))
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
private fun current(context: Context, address: String, started: Int, ownerRevision: String): Boolean =
|
|
1426
|
+
generation(context) == started && revision(context, address) == ownerRevision && permitted(context, address)
|
|
1427
|
+
|
|
1428
|
+
private fun mediaFiles(context: Context): Array<File> {
|
|
1429
|
+
val directory = root(context)
|
|
1430
|
+
if (!directory.exists()) return emptyArray()
|
|
1431
|
+
return directory.listFiles() ?: throw java.io.IOException("The media cache could not be read")
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private fun removeFiles(context: Context, address: String): Int {
|
|
1435
|
+
val prefix = digest(address) + "."
|
|
1436
|
+
var removed = 0
|
|
1437
|
+
var failure: java.io.IOException? = null
|
|
1438
|
+
mediaFiles(context).filter { it.name.startsWith(prefix) }.forEach { file ->
|
|
1439
|
+
if (file.delete()) removed++ else failure = java.io.IOException("The media copy could not be removed")
|
|
1440
|
+
}
|
|
1441
|
+
failure?.let { throw it }
|
|
1442
|
+
return removed
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** A new explicit authorized keep may lift a durable tombstone, after removing retired bytes. */
|
|
1446
|
+
private fun activate(context: Context, address: String) {
|
|
1447
|
+
if (permitted(context, address)) return
|
|
1448
|
+
removeFiles(context, address)
|
|
1449
|
+
val key = digest(address)
|
|
1450
|
+
if (!prefs(context).edit().putBoolean("revoked:$key", false).putBoolean("authorized:$key", true).commit()) {
|
|
1451
|
+
throw java.io.IOException("Media authorization could not be persisted")
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
private fun revisionTag(address: String, revision: String) = "$TAG:identity:${digest(address)}:${revision.ifEmpty { "legacy" }}"
|
|
1456
|
+
|
|
1415
1457
|
/** The unique job for an address and connection rule: `any` or `wifi`. */
|
|
1416
1458
|
private fun workName(address: String, wifiOnly: Boolean) = "$TAG:${digest(address)}:${if (wifiOnly) "wifi" else "any"}"
|
|
1417
1459
|
|
|
@@ -1423,7 +1465,10 @@ internal object CraftNativeMedia {
|
|
|
1423
1465
|
}
|
|
1424
1466
|
val app = context.applicationContext
|
|
1425
1467
|
var sourceChanged = false
|
|
1468
|
+
var ownerRevision = ""
|
|
1426
1469
|
val started = synchronized(lock) {
|
|
1470
|
+
try { activate(app, address) }
|
|
1471
|
+
catch (error: java.io.IOException) { return done(CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "Media authorization could not be persisted")) }
|
|
1427
1472
|
local(app, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1428
1473
|
if (cacheKey != null) {
|
|
1429
1474
|
val sourceKey = "source:${digest(address)}"
|
|
@@ -1431,7 +1476,8 @@ internal object CraftNativeMedia {
|
|
|
1431
1476
|
sourceChanged = prefs(app).getString(sourceKey, null) != authorization
|
|
1432
1477
|
if (!prefs(app).edit().putString(sourceKey, authorization).commit()) return done(CraftNativeCapabilityResult(code = "IO_ERROR", message = "Could not persist media authorization"))
|
|
1433
1478
|
}
|
|
1434
|
-
|
|
1479
|
+
ownerRevision = revision(app, address)
|
|
1480
|
+
generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, ownerRevision, done)) }
|
|
1435
1481
|
}
|
|
1436
1482
|
val wifiOnly = !cellular
|
|
1437
1483
|
val work = androidx.work.WorkManager.getInstance(app)
|
|
@@ -1439,20 +1485,22 @@ internal object CraftNativeMedia {
|
|
|
1439
1485
|
work.cancelUniqueWork(workName(address, !wifiOnly))
|
|
1440
1486
|
val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
1441
1487
|
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
|
|
1442
|
-
.setInputData(androidx.work.workDataOf("address" to address, "source" to source, "headers" to headers.toString(), "generation" to started))
|
|
1488
|
+
.setInputData(androidx.work.workDataOf("address" to address, "source" to source, "headers" to headers.toString(), "generation" to started, "revision" to ownerRevision))
|
|
1443
1489
|
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
|
|
1444
1490
|
.addTag(TAG)
|
|
1445
1491
|
.addTag("$TAG:generation:$started")
|
|
1446
1492
|
.addTag("$TAG:${digest(address)}")
|
|
1493
|
+
.addTag(revisionTag(address, ownerRevision))
|
|
1447
1494
|
.build()
|
|
1448
1495
|
// Join a running or freshly queued transfer. Only a failed attempt's
|
|
1449
1496
|
// backoff is replaced so an explicit retry can resume immediately.
|
|
1450
1497
|
val name = workName(address, wifiOnly)
|
|
1451
1498
|
background.execute {
|
|
1452
1499
|
try {
|
|
1453
|
-
if (
|
|
1500
|
+
if (current(app, address, started, ownerRevision)) {
|
|
1454
1501
|
val pending = work.getWorkInfosForUniqueWork(name).get().any {
|
|
1455
1502
|
"$TAG:generation:$started" in it.tags &&
|
|
1503
|
+
revisionTag(address, ownerRevision) in it.tags &&
|
|
1456
1504
|
!sourceChanged && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
|
|
1457
1505
|
(it.state == androidx.work.WorkInfo.State.RUNNING ||
|
|
1458
1506
|
(it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
|
|
@@ -1462,8 +1510,8 @@ internal object CraftNativeMedia {
|
|
|
1462
1510
|
}
|
|
1463
1511
|
} catch (error: Exception) {
|
|
1464
1512
|
if (error is InterruptedException) Thread.currentThread().interrupt()
|
|
1465
|
-
if (
|
|
1466
|
-
deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not schedule the download"))
|
|
1513
|
+
if (current(app, address, started, ownerRevision)) {
|
|
1514
|
+
deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not schedule the download"), ownerRevision)
|
|
1467
1515
|
}
|
|
1468
1516
|
}
|
|
1469
1517
|
}
|
|
@@ -1472,25 +1520,25 @@ internal object CraftNativeMedia {
|
|
|
1472
1520
|
wifiOnly && metered(app) -> "cellular"
|
|
1473
1521
|
else -> null
|
|
1474
1522
|
}
|
|
1475
|
-
if (reason != null) deliver(context, address, started, queued(address, reason))
|
|
1523
|
+
if (reason != null) deliver(context, address, started, queued(address, reason), ownerRevision)
|
|
1476
1524
|
}
|
|
1477
1525
|
|
|
1478
1526
|
private fun queued(address: String, reason: String) =
|
|
1479
1527
|
CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
|
|
1480
1528
|
|
|
1481
1529
|
/** Answers only keeps belonging to this transfer's cache generation. */
|
|
1482
|
-
fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult) {
|
|
1530
|
+
fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult, ownerRevision: String = "") {
|
|
1483
1531
|
val answered = synchronized(lock) {
|
|
1484
1532
|
val pending = waiting[address].orEmpty()
|
|
1485
|
-
val matching = pending.filter { it.generation == started }
|
|
1486
|
-
val remaining = pending.filter { it.generation != started }.toMutableList()
|
|
1533
|
+
val matching = pending.filter { it.generation == started && it.revision == ownerRevision }
|
|
1534
|
+
val remaining = pending.filter { it.generation != started || it.revision != ownerRevision }.toMutableList()
|
|
1487
1535
|
if (remaining.isEmpty()) waiting.remove(address) else waiting[address] = remaining
|
|
1488
1536
|
matching
|
|
1489
1537
|
}
|
|
1490
1538
|
if (answered.isNotEmpty()) main.post {
|
|
1491
|
-
val
|
|
1539
|
+
val answer = if (current(context, address, started, ownerRevision)) result else
|
|
1492
1540
|
CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
|
|
1493
|
-
answered.forEach { it.answer(
|
|
1541
|
+
answered.forEach { it.answer(answer) }
|
|
1494
1542
|
}
|
|
1495
1543
|
}
|
|
1496
1544
|
|
|
@@ -1501,7 +1549,7 @@ internal object CraftNativeMedia {
|
|
|
1501
1549
|
* file where it stopped, moves it into place whole, and answers whoever
|
|
1502
1550
|
* waits. RETRY leaves the part file for the next attempt.
|
|
1503
1551
|
*/
|
|
1504
|
-
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, source: String = address, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
|
|
1552
|
+
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, source: String = address, beforeAnswer: () -> Unit = {}, ownerRevision: String = "", stopped: () -> Boolean): Outcome {
|
|
1505
1553
|
fun answer(result: CraftNativeCapabilityResult) {
|
|
1506
1554
|
// The callback can immediately call keep again. Publish completion
|
|
1507
1555
|
// before that callback, while WorkManager may still say RUNNING.
|
|
@@ -1509,12 +1557,15 @@ internal object CraftNativeMedia {
|
|
|
1509
1557
|
// A keep registered after this marker belongs to the next
|
|
1510
1558
|
// attempt; it must not enter the old callback batch.
|
|
1511
1559
|
beforeAnswer()
|
|
1512
|
-
deliver(context, address, started, result)
|
|
1560
|
+
deliver(context, address, started, result, ownerRevision)
|
|
1513
1561
|
}
|
|
1514
1562
|
}
|
|
1515
1563
|
// clear() already answered the old generation's waiters. Never answer
|
|
1516
1564
|
// a new athlete's waiters from an obsolete worker for the same URL.
|
|
1517
|
-
if (
|
|
1565
|
+
if (!current(context, address, started, ownerRevision)) {
|
|
1566
|
+
part(context, address, started, ownerRevision).delete()
|
|
1567
|
+
return Outcome.DONE
|
|
1568
|
+
}
|
|
1518
1569
|
local(context, address)?.let {
|
|
1519
1570
|
answer(CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1520
1571
|
return Outcome.DONE
|
|
@@ -1523,21 +1574,25 @@ internal object CraftNativeMedia {
|
|
|
1523
1574
|
synchronized(writer) {
|
|
1524
1575
|
// Another worker can finish, or sign-out can clear the cache, while
|
|
1525
1576
|
// this worker waits for the address's writer.
|
|
1526
|
-
if (
|
|
1577
|
+
if (!current(context, address, started, ownerRevision)) {
|
|
1578
|
+
part(context, address, started, ownerRevision).delete()
|
|
1579
|
+
return Outcome.DONE
|
|
1580
|
+
}
|
|
1527
1581
|
if (stopped()) return Outcome.RETRY
|
|
1528
1582
|
local(context, address)?.let {
|
|
1529
1583
|
answer(CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1530
1584
|
return Outcome.DONE
|
|
1531
1585
|
}
|
|
1532
1586
|
val target = file(context, address)
|
|
1533
|
-
val staging = part(context, address)
|
|
1587
|
+
val staging = part(context, address, started, ownerRevision)
|
|
1534
1588
|
target.parentFile?.mkdirs()
|
|
1535
1589
|
val download = CraftResumableDownload()
|
|
1536
1590
|
val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
|
|
1537
1591
|
val fetched = try {
|
|
1538
1592
|
// A part that no longer lines up was dropped: the whole file, now, not after a backoff.
|
|
1539
|
-
|
|
1540
|
-
|
|
1593
|
+
val retired = { stopped() || !current(context, address, started, ownerRevision) }
|
|
1594
|
+
download.fetch(source, asked, staging, retired).let { first ->
|
|
1595
|
+
if (first is CraftResumableDownload.Fetched.Restart) download.fetch(source, asked, staging, retired) else first
|
|
1541
1596
|
}
|
|
1542
1597
|
} catch (error: Exception) {
|
|
1543
1598
|
val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
|
|
@@ -1548,13 +1603,17 @@ internal object CraftNativeMedia {
|
|
|
1548
1603
|
answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
|
|
1549
1604
|
return Outcome.DONE
|
|
1550
1605
|
}
|
|
1606
|
+
if (!current(context, address, started, ownerRevision)) { staging.delete(); return Outcome.DONE }
|
|
1551
1607
|
if (stopped()) return Outcome.RETRY
|
|
1552
1608
|
// Lost the connection part way: the part waits for the next attempt.
|
|
1553
1609
|
answer(if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
|
|
1554
1610
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1555
1611
|
}
|
|
1556
1612
|
when (fetched) {
|
|
1557
|
-
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart ->
|
|
1613
|
+
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> {
|
|
1614
|
+
if (!current(context, address, started, ownerRevision)) { staging.delete(); return Outcome.DONE }
|
|
1615
|
+
return Outcome.RETRY
|
|
1616
|
+
}
|
|
1558
1617
|
is CraftResumableDownload.Fetched.Refused -> {
|
|
1559
1618
|
answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
|
|
1560
1619
|
return Outcome.DONE
|
|
@@ -1566,7 +1625,7 @@ internal object CraftNativeMedia {
|
|
|
1566
1625
|
is CraftResumableDownload.Fetched.Complete -> Unit
|
|
1567
1626
|
}
|
|
1568
1627
|
synchronized(lock) {
|
|
1569
|
-
if (
|
|
1628
|
+
if (!current(context, address, started, ownerRevision)) {
|
|
1570
1629
|
staging.delete()
|
|
1571
1630
|
return Outcome.DONE
|
|
1572
1631
|
}
|
|
@@ -1603,8 +1662,8 @@ internal object CraftNativeMedia {
|
|
|
1603
1662
|
val active = waiting.keys.map { digest(it) }.toSet()
|
|
1604
1663
|
root(context).listFiles()?.forEach { file ->
|
|
1605
1664
|
val owner = file.name.substringBefore('.')
|
|
1606
|
-
|
|
1607
|
-
val stale = file.name.endsWith(".part") && file.name.
|
|
1665
|
+
val ownerRevision = prefs(context).getString("revision:$owner", "").orEmpty().ifEmpty { "legacy" }
|
|
1666
|
+
val stale = file.name.endsWith(".part") && !file.name.endsWith(".g${generation(context)}.r$ownerRevision.part")
|
|
1608
1667
|
if (owner !in active && (owner !in wanted || stale) && file.delete()) removed++
|
|
1609
1668
|
}
|
|
1610
1669
|
}
|
|
@@ -1628,20 +1687,60 @@ internal object CraftNativeMedia {
|
|
|
1628
1687
|
return removed
|
|
1629
1688
|
}
|
|
1630
1689
|
|
|
1690
|
+
/** Revoke one identity before cancelling its work or unlinking its bytes. */
|
|
1691
|
+
fun remove(context: Context, address: String): Int {
|
|
1692
|
+
require(address.isNotBlank()) { "media.remove needs a source or cache key" }
|
|
1693
|
+
var removed = 0
|
|
1694
|
+
var failure: java.io.IOException? = null
|
|
1695
|
+
val key = digest(address)
|
|
1696
|
+
val answered = synchronized(lock) {
|
|
1697
|
+
if (!prefs(context).edit().putString("revision:$key", java.util.UUID.randomUUID().toString()).putBoolean("revoked:$key", true).commit()) {
|
|
1698
|
+
failure = java.io.IOException("Media retirement could not be persisted")
|
|
1699
|
+
}
|
|
1700
|
+
val callbacks = waiting.remove(address).orEmpty()
|
|
1701
|
+
try { removed = removeFiles(context, address) }
|
|
1702
|
+
catch (error: java.io.IOException) { failure = error }
|
|
1703
|
+
callbacks
|
|
1704
|
+
}
|
|
1705
|
+
main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "Media ownership was retired")) } }
|
|
1706
|
+
val app = context.applicationContext
|
|
1707
|
+
background.execute {
|
|
1708
|
+
runCatching {
|
|
1709
|
+
val work = androidx.work.WorkManager.getInstance(app)
|
|
1710
|
+
work.getWorkInfosByTag("$TAG:$key").get().filter { !it.state.isFinished }.forEach { info ->
|
|
1711
|
+
synchronized(lock) {
|
|
1712
|
+
// A delayed enumeration must preserve a later explicit
|
|
1713
|
+
// re-grant, including one made after another remove.
|
|
1714
|
+
if (!permitted(app, address) || "$TAG:generation:${generation(app)}" !in info.tags || revisionTag(address, revision(app, address)) !in info.tags) {
|
|
1715
|
+
work.cancelWorkById(info.id)
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
failure?.let { throw it }
|
|
1722
|
+
return removed
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1631
1725
|
/** Stops every transfer and deletes every copy; none started before this lands after it. */
|
|
1632
1726
|
fun clear(context: Context): Int {
|
|
1633
1727
|
var removed = 0
|
|
1634
1728
|
var failure: java.io.IOException? = null
|
|
1635
1729
|
val answered = synchronized(lock) {
|
|
1636
1730
|
// Persisted before the files go: a transfer that outlives the process checks it too.
|
|
1637
|
-
|
|
1731
|
+
val previous = generation(context)
|
|
1732
|
+
if (!prefs(context).edit().clear().putInt("generation", previous + 1).putBoolean("cleared", true).commit()) {
|
|
1733
|
+
failure = java.io.IOException("Media retirement could not be persisted")
|
|
1734
|
+
}
|
|
1638
1735
|
val callbacks = waiting.values.flatten()
|
|
1639
1736
|
waiting.clear()
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1737
|
+
try {
|
|
1738
|
+
mediaFiles(context).forEach { file ->
|
|
1739
|
+
if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
|
|
1740
|
+
}
|
|
1741
|
+
} catch (error: java.io.IOException) { failure = error }
|
|
1643
1742
|
// Schedule cancellation before a new generation can enqueue its work.
|
|
1644
|
-
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1743
|
+
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag("$TAG:generation:$previous")
|
|
1645
1744
|
callbacks
|
|
1646
1745
|
}
|
|
1647
1746
|
main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
@@ -1733,10 +1832,11 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
|
|
|
1733
1832
|
val address = inputData.getString("address") ?: return Result.failure()
|
|
1734
1833
|
val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
|
|
1735
1834
|
val started = inputData.getInt("generation", 0)
|
|
1835
|
+
val ownerRevision = inputData.getString("revision").orEmpty()
|
|
1736
1836
|
return try {
|
|
1737
1837
|
setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
|
|
1738
1838
|
val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount, source = inputData.getString("source") ?: address,
|
|
1739
|
-
beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
|
|
1839
|
+
beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }, ownerRevision = ownerRevision
|
|
1740
1840
|
) { isStopped }
|
|
1741
1841
|
if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
|
|
1742
1842
|
} catch (error: Exception) {
|
|
@@ -1744,7 +1844,7 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
|
|
|
1744
1844
|
// A replacement owns the address's new waiters. A real progress
|
|
1745
1845
|
// storage failure still needs an answer instead of a lost callback.
|
|
1746
1846
|
if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
|
|
1747
|
-
CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"))
|
|
1847
|
+
CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"), ownerRevision)
|
|
1748
1848
|
if (isStopped) Result.retry() else Result.failure()
|
|
1749
1849
|
}
|
|
1750
1850
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3228,7 +3228,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3228
3228
|
`);
|
|
3229
3229
|
}
|
|
3230
3230
|
// package.json
|
|
3231
|
-
var version = "0.0.
|
|
3231
|
+
var version = "0.0.467";
|
|
3232
3232
|
|
|
3233
3233
|
// bin/cli.ts
|
|
3234
3234
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.js
CHANGED
|
@@ -1611,6 +1611,7 @@ function writeShell(output, config) {
|
|
|
1611
1611
|
writeSource(join4(output, "Sources", "CraftNativeUI.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeUI.swift"), "utf8"));
|
|
1612
1612
|
writeSource(join4(output, "Sources", "CraftNativeServices.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeServices.swift"), "utf8"));
|
|
1613
1613
|
writeSource(join4(output, "Sources", "CraftNativeFiles.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFiles.swift"), "utf8"));
|
|
1614
|
+
writeSource(join4(output, "Sources", "CraftNativeMediaOwnership.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeMediaOwnership.swift"), "utf8"));
|
|
1614
1615
|
writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
|
|
1615
1616
|
writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
|
|
1616
1617
|
writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
|
|
@@ -109,6 +109,7 @@ enum CraftNativeActions {
|
|
|
109
109
|
// which the native Video element plays in place of the address.
|
|
110
110
|
values.append("media")
|
|
111
111
|
values.append("media-cache-key")
|
|
112
|
+
values.append("media-remove")
|
|
112
113
|
if config.enableCamera { values.append("camera") }
|
|
113
114
|
if config.enableAudioRecording { values.append("audioRecording") }
|
|
114
115
|
// craft.crashReporter: the app's crash and error reporter (bughq).
|
|
@@ -333,6 +334,24 @@ enum CraftNativeActions {
|
|
|
333
334
|
return
|
|
334
335
|
case ("Media", "path"):
|
|
335
336
|
sync = .success(CraftNativeMedia.local(CraftNativeMedia.identity(args.first as? String ?? "", options: args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]))?.path ?? NSNull())
|
|
337
|
+
case ("Media", "remove"):
|
|
338
|
+
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
339
|
+
if let value = options["cacheKey"], !(value is NSNull) {
|
|
340
|
+
guard let key = value as? String, !key.isEmpty, key.utf8.count <= 1024 else {
|
|
341
|
+
sync = failure("INVALID_ARGUMENT", "media.remove cacheKey must be a nonempty string of at most 1024 bytes")
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
let address = CraftNativeMedia.identity(args.first as? String ?? "", options: options)
|
|
346
|
+
guard !address.isEmpty else {
|
|
347
|
+
sync = failure("INVALID_ARGUMENT", "media.remove needs a source or cache key")
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
DispatchQueue.global(qos: .utility).async {
|
|
351
|
+
do { complete(requestToken, .success(try CraftNativeMedia.remove(address)), completion) }
|
|
352
|
+
catch { complete(requestToken, failure("IO_ERROR", "The media copy could not be removed"), completion) }
|
|
353
|
+
}
|
|
354
|
+
return
|
|
336
355
|
case ("Media", "prune"):
|
|
337
356
|
let keep = (args.first as? [Any] ?? []).compactMap { value -> String? in
|
|
338
357
|
if let url = value as? String { return url }
|
|
@@ -1001,7 +1020,8 @@ enum CraftNativeActions {
|
|
|
1001
1020
|
// On the app's background session (CraftNativeMediaTransfers): it
|
|
1002
1021
|
// carries on while the app is suspended, and a keep the phone cannot
|
|
1003
1022
|
// fetch now is answered at once while its transfer waits with iOS.
|
|
1004
|
-
let
|
|
1023
|
+
guard let ownership = CraftNativeMedia.ownershipStamp(address, flight: flight) else { return }
|
|
1024
|
+
let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, ownership: ownership, wifiOnly: wifiOnly)
|
|
1005
1025
|
cancellationLock.lock()
|
|
1006
1026
|
// Unless it answered already (a queued transfer, or an instant failure).
|
|
1007
1027
|
if CraftNativeMedia.isWaiting(address, flight: flight) { fetchTasks[requestToken] = task }
|
|
@@ -943,9 +943,40 @@ enum CraftNativeMedia {
|
|
|
943
943
|
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
944
944
|
private struct Flight {
|
|
945
945
|
let id: UUID
|
|
946
|
+
let ownership: CraftNativeMediaOwnership.Stamp
|
|
946
947
|
var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
|
|
947
948
|
}
|
|
948
949
|
private static var inFlight: [String: Flight] = [:]
|
|
950
|
+
private static var owner: CraftNativeMediaOwnership?
|
|
951
|
+
|
|
952
|
+
/// Called under the same lock as cache commits. Keep the journal outside
|
|
953
|
+
/// the cache directory so clear/prune cannot erase revocation history.
|
|
954
|
+
private static func ownership() throws -> CraftNativeMediaOwnership {
|
|
955
|
+
if let owner { return owner }
|
|
956
|
+
let value = try CraftNativeMediaOwnership(url: root.deletingLastPathComponent().appendingPathComponent("craft-media-ownership.json"))
|
|
957
|
+
owner = value
|
|
958
|
+
return value
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private static func ownerKey(_ address: String) -> String {
|
|
962
|
+
String(file(for: address).lastPathComponent.prefix(64))
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/// Remove only this identity's complete and staging files. A failed
|
|
966
|
+
/// unlink remains observable; its durable tombstone still hides the copy.
|
|
967
|
+
private static func removeFiles(_ address: String) throws -> Int {
|
|
968
|
+
let fm = FileManager.default
|
|
969
|
+
guard fm.fileExists(atPath: root.path) else { return 0 }
|
|
970
|
+
let prefix = ownerKey(address) + "."
|
|
971
|
+
var removed = 0
|
|
972
|
+
var failure: Error?
|
|
973
|
+
for url in try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) where url.lastPathComponent.hasPrefix(prefix) {
|
|
974
|
+
do { try fm.removeItem(at: url); removed += 1 }
|
|
975
|
+
catch { failure = error }
|
|
976
|
+
}
|
|
977
|
+
if let failure { throw failure }
|
|
978
|
+
return removed
|
|
979
|
+
}
|
|
949
980
|
|
|
950
981
|
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
951
982
|
/// extension is the address's, because AVPlayer reads the type from it.
|
|
@@ -959,8 +990,12 @@ enum CraftNativeMedia {
|
|
|
959
990
|
/// The copy of `address` on this phone, or nil.
|
|
960
991
|
static func local(_ address: String) -> URL? {
|
|
961
992
|
guard !address.isEmpty else { return nil }
|
|
993
|
+
lock.lock()
|
|
994
|
+
defer { lock.unlock() }
|
|
995
|
+
guard (try? ownership().permitsLocal(ownerKey(address))) == true else { return nil }
|
|
962
996
|
let url = file(for: address)
|
|
963
|
-
|
|
997
|
+
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
|
998
|
+
return values?.isRegularFile == true && (values?.fileSize ?? 0) > 0 ? url : nil
|
|
964
999
|
}
|
|
965
1000
|
|
|
966
1001
|
static func describe(_ address: String, _ url: URL) -> [String: Any] {
|
|
@@ -971,14 +1006,35 @@ enum CraftNativeMedia {
|
|
|
971
1006
|
/// A new download's identity, or nil after joining an existing download.
|
|
972
1007
|
static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
|
|
973
1008
|
lock.lock()
|
|
974
|
-
defer { lock.unlock() }
|
|
975
1009
|
if inFlight[address] != nil {
|
|
976
1010
|
inFlight[address]!.waiting.append(done)
|
|
1011
|
+
lock.unlock()
|
|
1012
|
+
return nil
|
|
1013
|
+
}
|
|
1014
|
+
do {
|
|
1015
|
+
let stamp = try ownership().activate(ownerKey(address)) { _ = try removeFiles(address) }
|
|
1016
|
+
let id = UUID()
|
|
1017
|
+
inFlight[address] = Flight(id: id, ownership: stamp, waiting: [done])
|
|
1018
|
+
lock.unlock()
|
|
1019
|
+
return id
|
|
1020
|
+
} catch {
|
|
1021
|
+
lock.unlock()
|
|
1022
|
+
done(.failure(CraftNativeActionError(code: errorCode(error, fallback: "IO_ERROR"), message: "Media ownership could not be persisted")))
|
|
977
1023
|
return nil
|
|
978
1024
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
static func ownershipStamp(_ address: String, flight: UUID) -> CraftNativeMediaOwnership.Stamp? {
|
|
1028
|
+
lock.lock()
|
|
1029
|
+
defer { lock.unlock() }
|
|
1030
|
+
guard let current = inFlight[address], current.id == flight else { return nil }
|
|
1031
|
+
return current.ownership
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
static func acceptsTransfer(_ address: String, stamp: CraftNativeMediaOwnership.Stamp) -> Bool {
|
|
1035
|
+
lock.lock()
|
|
1036
|
+
defer { lock.unlock() }
|
|
1037
|
+
return (try? ownership().accepts(ownerKey(address), stamp: stamp)) == true
|
|
982
1038
|
}
|
|
983
1039
|
|
|
984
1040
|
/// Whether `flight` is still the download `address`'s keeps wait for.
|
|
@@ -997,7 +1053,7 @@ enum CraftNativeMedia {
|
|
|
997
1053
|
|
|
998
1054
|
/// A finished download moved into place (atomically, so a half-written
|
|
999
1055
|
/// file is never played), or a refusal with the server's status.
|
|
1000
|
-
static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil, stillWanted: (() -> Bool)? = nil) -> Result<Any, CraftNativeActionError> {
|
|
1056
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil, stamp: CraftNativeMediaOwnership.Stamp? = nil, stillWanted: (() -> Bool)? = nil) -> Result<Any, CraftNativeActionError> {
|
|
1001
1057
|
guard response.statusCode == 200 else {
|
|
1002
1058
|
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
1003
1059
|
}
|
|
@@ -1008,11 +1064,15 @@ enum CraftNativeMedia {
|
|
|
1008
1064
|
if let flight, inFlight[address]?.id != flight {
|
|
1009
1065
|
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
1010
1066
|
}
|
|
1067
|
+
let captured = stamp ?? flight.flatMap { inFlight[address]?.id == $0 ? inFlight[address]?.ownership : nil }
|
|
1068
|
+
guard let captured, (try? ownership().accepts(ownerKey(address), stamp: captured)) == true else {
|
|
1069
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "Media ownership was retired"))
|
|
1070
|
+
}
|
|
1011
1071
|
// A background transfer from before the cache was cleared (sign-out).
|
|
1012
1072
|
if let stillWanted, !stillWanted() {
|
|
1013
1073
|
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
1014
1074
|
}
|
|
1015
|
-
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
1075
|
+
let staging = root.appendingPathComponent(ownerKey(address) + "." + UUID().uuidString + ".part")
|
|
1016
1076
|
defer { try? fm.removeItem(at: staging) }
|
|
1017
1077
|
do {
|
|
1018
1078
|
// URL resource values can retain a previous size when a retry
|
|
@@ -1045,15 +1105,20 @@ enum CraftNativeMedia {
|
|
|
1045
1105
|
/// End an account's cache lifetime.
|
|
1046
1106
|
static func clear() throws -> Int {
|
|
1047
1107
|
lock.lock()
|
|
1108
|
+
var failure: Error?
|
|
1109
|
+
do { try ownership().clear() }
|
|
1110
|
+
catch { failure = error }
|
|
1048
1111
|
let waiting = inFlight.values.flatMap { $0.waiting }
|
|
1049
1112
|
inFlight.removeAll()
|
|
1050
1113
|
// Under the same lock a background transfer commits under: none
|
|
1051
1114
|
// started before this lands after it.
|
|
1052
1115
|
CraftNativeMediaTransfers.shared.cancelAll()
|
|
1053
1116
|
let fm = FileManager.default
|
|
1054
|
-
|
|
1117
|
+
var files: [URL] = []
|
|
1118
|
+
do {
|
|
1119
|
+
if fm.fileExists(atPath: root.path) { files = try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) }
|
|
1120
|
+
} catch { failure = error }
|
|
1055
1121
|
var removed = 0
|
|
1056
|
-
var failure: Error?
|
|
1057
1122
|
for url in files {
|
|
1058
1123
|
do { try fm.removeItem(at: url); removed += 1 }
|
|
1059
1124
|
catch { failure = error }
|
|
@@ -1064,6 +1129,24 @@ enum CraftNativeMedia {
|
|
|
1064
1129
|
return removed
|
|
1065
1130
|
}
|
|
1066
1131
|
|
|
1132
|
+
/// Revoke one cache identity without removing other authorized media.
|
|
1133
|
+
static func remove(_ address: String) throws -> Int {
|
|
1134
|
+
guard !address.isEmpty else { throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "media.remove needs a source or cache key") }
|
|
1135
|
+
lock.lock()
|
|
1136
|
+
var failure: Error?
|
|
1137
|
+
do { try ownership().retire(ownerKey(address)) }
|
|
1138
|
+
catch { failure = error }
|
|
1139
|
+
let waiting = inFlight.removeValue(forKey: address)?.waiting ?? []
|
|
1140
|
+
CraftNativeMediaTransfers.shared.cancelRetired(address: address)
|
|
1141
|
+
var removed = 0
|
|
1142
|
+
do { removed = try removeFiles(address) }
|
|
1143
|
+
catch { failure = error }
|
|
1144
|
+
lock.unlock()
|
|
1145
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "Media ownership was retired"))) }
|
|
1146
|
+
if let failure { throw failure }
|
|
1147
|
+
return removed
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1067
1150
|
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
1068
1151
|
static func prune(keep: [String]) -> Int {
|
|
1069
1152
|
CraftNativeMediaTransfers.shared.cancel(except: keep + Array(inFlightAddresses()))
|
|
@@ -1120,6 +1203,24 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1120
1203
|
let generation: Int
|
|
1121
1204
|
}
|
|
1122
1205
|
|
|
1206
|
+
private struct Description: Codable {
|
|
1207
|
+
let address: String
|
|
1208
|
+
let ownership: CraftNativeMediaOwnership.Stamp
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private static func describe(_ address: String, ownership: CraftNativeMediaOwnership.Stamp) -> String {
|
|
1212
|
+
// No network capability is added to the journal: only the task that
|
|
1213
|
+
// already owns the authorized request carries its cache identity.
|
|
1214
|
+
let data = try! JSONEncoder().encode(Description(address: address, ownership: ownership))
|
|
1215
|
+
return "craft-media-v2:" + data.base64EncodedString()
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
private static func description(_ task: URLSessionTask) -> Description? {
|
|
1219
|
+
guard let text = task.taskDescription, text.hasPrefix("craft-media-v2:"),
|
|
1220
|
+
let data = Data(base64Encoded: String(text.dropFirst("craft-media-v2:".count))) else { return nil }
|
|
1221
|
+
return try? JSONDecoder().decode(Description.self, from: data)
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1123
1224
|
private let lock = NSLock()
|
|
1124
1225
|
private var transfers: [Int: Transfer] = [:]
|
|
1125
1226
|
/// What each finished download came to, until its task completes.
|
|
@@ -1143,7 +1244,10 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1143
1244
|
guard let self else { return }
|
|
1144
1245
|
self.lock.lock()
|
|
1145
1246
|
for case let task as URLSessionDownloadTask in tasks where task.state == .running || task.state == .suspended {
|
|
1146
|
-
|
|
1247
|
+
// A pre-upgrade address-only description cannot establish
|
|
1248
|
+
// durable ownership. A new authorized keep can replace it.
|
|
1249
|
+
guard let description = Self.description(task) else { task.cancel(); continue }
|
|
1250
|
+
if self.transfers[task.taskIdentifier] == nil { self.restored[description.address] = task }
|
|
1147
1251
|
}
|
|
1148
1252
|
self.lock.unlock()
|
|
1149
1253
|
}
|
|
@@ -1154,11 +1258,12 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1154
1258
|
}
|
|
1155
1259
|
|
|
1156
1260
|
/// Fetches `request` into the copy for `address`, finishing `flight`.
|
|
1157
|
-
func start(_ request: URLRequest, address: String, flight: UUID, wifiOnly: Bool) -> URLSessionTask {
|
|
1261
|
+
func start(_ request: URLRequest, address: String, flight: UUID, ownership: CraftNativeMediaOwnership.Stamp, wifiOnly: Bool) -> URLSessionTask {
|
|
1158
1262
|
lock.lock()
|
|
1159
1263
|
let task: URLSessionDownloadTask
|
|
1160
1264
|
if let earlier = restored.removeValue(forKey: address), earlier.state == .running || earlier.state == .suspended {
|
|
1161
|
-
if
|
|
1265
|
+
if Self.description(earlier)?.ownership == ownership,
|
|
1266
|
+
earlier.originalRequest?.url == request.url && earlier.originalRequest?.allHTTPHeaderFields == request.allHTTPHeaderFields {
|
|
1162
1267
|
task = earlier
|
|
1163
1268
|
}
|
|
1164
1269
|
else {
|
|
@@ -1167,12 +1272,12 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1167
1272
|
earlier.taskDescription = nil
|
|
1168
1273
|
earlier.cancel()
|
|
1169
1274
|
task = session.downloadTask(with: request)
|
|
1170
|
-
task.taskDescription = address
|
|
1275
|
+
task.taskDescription = Self.describe(address, ownership: ownership)
|
|
1171
1276
|
}
|
|
1172
1277
|
}
|
|
1173
1278
|
else {
|
|
1174
1279
|
task = session.downloadTask(with: request)
|
|
1175
|
-
task.taskDescription = address
|
|
1280
|
+
task.taskDescription = Self.describe(address, ownership: ownership)
|
|
1176
1281
|
}
|
|
1177
1282
|
transfers[task.taskIdentifier] = Transfer(address: address, flight: flight, wifiOnly: wifiOnly, generation: generation)
|
|
1178
1283
|
lock.unlock()
|
|
@@ -1204,7 +1309,28 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1204
1309
|
generation += 1
|
|
1205
1310
|
restored.removeAll()
|
|
1206
1311
|
lock.unlock()
|
|
1207
|
-
|
|
1312
|
+
// The asynchronous enumeration may run after a new authorized keep.
|
|
1313
|
+
// Retire only obsolete stamps, never that new generation's task.
|
|
1314
|
+
session.getAllTasks { tasks in
|
|
1315
|
+
for task in tasks {
|
|
1316
|
+
guard let description = Self.description(task), CraftNativeMedia.acceptsTransfer(description.address, stamp: description.ownership) else {
|
|
1317
|
+
task.cancel()
|
|
1318
|
+
continue
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
func cancelRetired(address: String) {
|
|
1325
|
+
lock.lock()
|
|
1326
|
+
restored.removeValue(forKey: address)
|
|
1327
|
+
lock.unlock()
|
|
1328
|
+
session.getAllTasks { tasks in
|
|
1329
|
+
for task in tasks {
|
|
1330
|
+
guard let description = Self.description(task) else { task.cancel(); continue }
|
|
1331
|
+
if description.address == address && !CraftNativeMedia.acceptsTransfer(address, stamp: description.ownership) { task.cancel() }
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1208
1334
|
}
|
|
1209
1335
|
|
|
1210
1336
|
/// Stops the transfers for addresses no longer wanted.
|
|
@@ -1214,7 +1340,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1214
1340
|
restored = restored.filter { wanted.contains($0.key) }
|
|
1215
1341
|
lock.unlock()
|
|
1216
1342
|
session.getAllTasks { tasks in
|
|
1217
|
-
for task in tasks where !wanted.contains(task
|
|
1343
|
+
for task in tasks where !wanted.contains(Self.description(task)?.address ?? "") { task.cancel() }
|
|
1218
1344
|
}
|
|
1219
1345
|
}
|
|
1220
1346
|
|
|
@@ -1224,7 +1350,11 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1224
1350
|
lock.unlock()
|
|
1225
1351
|
// One left by an earlier launch belongs to the generation this launch began with.
|
|
1226
1352
|
let started = transfer?.generation ?? 0
|
|
1227
|
-
guard let
|
|
1353
|
+
guard let description = Self.description(downloadTask) else {
|
|
1354
|
+
record(downloadTask, .failure(CraftNativeActionError(code: "CANCELLED", message: "The media transfer has no durable ownership")))
|
|
1355
|
+
return
|
|
1356
|
+
}
|
|
1357
|
+
let address = description.address
|
|
1228
1358
|
guard let http = downloadTask.response as? HTTPURLResponse else {
|
|
1229
1359
|
record(downloadTask, .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep received a response that is not HTTP")))
|
|
1230
1360
|
return
|
|
@@ -1232,7 +1362,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1232
1362
|
// The system deletes `location` once this returns: move it now. It
|
|
1233
1363
|
// lands whether its keep is still waiting or was answered early; only
|
|
1234
1364
|
// a clear since it started (sign-out) keeps it out.
|
|
1235
|
-
record(downloadTask, CraftNativeMedia.store(address, at: location, response: http, stillWanted: { [weak self] in self?.isCurrent(started) ?? false }))
|
|
1365
|
+
record(downloadTask, CraftNativeMedia.store(address, at: location, response: http, stamp: description.ownership, stillWanted: { [weak self] in self?.isCurrent(started) ?? false }))
|
|
1236
1366
|
}
|
|
1237
1367
|
|
|
1238
1368
|
/// How many times the cache was cleared in this launch; a transfer started before the last clear does not land.
|
|
@@ -1256,7 +1386,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
|
|
|
1256
1386
|
lock.lock()
|
|
1257
1387
|
let transfer = transfers.removeValue(forKey: task.taskIdentifier)
|
|
1258
1388
|
let result = finished.removeValue(forKey: task.taskIdentifier)
|
|
1259
|
-
if let address = task
|
|
1389
|
+
if let address = Self.description(task)?.address, restored[address] === task { restored.removeValue(forKey: address) }
|
|
1260
1390
|
lock.unlock()
|
|
1261
1391
|
guard let transfer, let flight = transfer.flight else { return }
|
|
1262
1392
|
if let error {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Durable ownership of cached media and background transfers. The media
|
|
4
|
+
/// store serializes access to this journal with its file-commit lock.
|
|
5
|
+
final class CraftNativeMediaOwnership {
|
|
6
|
+
struct Stamp: Codable, Equatable {
|
|
7
|
+
let epoch: String
|
|
8
|
+
let revision: String
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private struct Journal: Codable {
|
|
12
|
+
var version = 1
|
|
13
|
+
var epoch = UUID().uuidString
|
|
14
|
+
var legacyCopiesAllowed = true
|
|
15
|
+
var revisions: [String: String] = [:]
|
|
16
|
+
var revoked: Set<String> = []
|
|
17
|
+
var authorized: Set<String> = []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private let url: URL
|
|
21
|
+
private var journal: Journal
|
|
22
|
+
private var persistenceFailure: Error?
|
|
23
|
+
|
|
24
|
+
init(url: URL) throws {
|
|
25
|
+
self.url = url
|
|
26
|
+
if FileManager.default.fileExists(atPath: url.path) {
|
|
27
|
+
journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: url))
|
|
28
|
+
guard journal.version == 1 else { throw CocoaError(.fileReadCorruptFile) }
|
|
29
|
+
} else {
|
|
30
|
+
journal = Journal()
|
|
31
|
+
try persist(journal)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func permitsLocal(_ identity: String) -> Bool {
|
|
36
|
+
persistenceFailure == nil && !journal.revoked.contains(identity)
|
|
37
|
+
&& (journal.legacyCopiesAllowed || journal.authorized.contains(identity))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func accepts(_ identity: String, stamp: Stamp) -> Bool {
|
|
41
|
+
permitsLocal(identity) && stamp.epoch == journal.epoch
|
|
42
|
+
&& stamp.revision == (journal.revisions[identity] ?? "")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// A new explicit authorized keep may use this identity again. Old bytes
|
|
46
|
+
/// must be removed before lifting a tombstone, including after a failed
|
|
47
|
+
/// unlink. Its old transfer stamp never becomes current again.
|
|
48
|
+
func activate(_ identity: String, removeRetiredFiles: () throws -> Void) throws -> Stamp {
|
|
49
|
+
if persistenceFailure != nil { try persist(journal) }
|
|
50
|
+
if !permitsLocal(identity) {
|
|
51
|
+
try removeRetiredFiles()
|
|
52
|
+
var next = journal
|
|
53
|
+
next.revoked.remove(identity)
|
|
54
|
+
next.authorized.insert(identity)
|
|
55
|
+
try persist(next)
|
|
56
|
+
}
|
|
57
|
+
return Stamp(epoch: journal.epoch, revision: journal.revisions[identity] ?? "")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Persist before cancelling or unlinking: a task that outlives this
|
|
61
|
+
/// process cannot regain ownership on the next launch.
|
|
62
|
+
func retire(_ identity: String) throws {
|
|
63
|
+
var next = journal
|
|
64
|
+
next.revisions[identity] = UUID().uuidString
|
|
65
|
+
next.revoked.insert(identity)
|
|
66
|
+
try persist(next)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Sign-out invalidates all existing stamps and all copies, even if a
|
|
70
|
+
/// filesystem error prevents unlinking one of them.
|
|
71
|
+
func clear() throws {
|
|
72
|
+
var next = Journal()
|
|
73
|
+
next.legacyCopiesAllowed = false
|
|
74
|
+
try persist(next)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func persist(_ next: Journal) throws {
|
|
78
|
+
// A failed journal write is observable and fails closed in this
|
|
79
|
+
// process. A later retire/clear can retry once storage is writable.
|
|
80
|
+
journal = next
|
|
81
|
+
do {
|
|
82
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
83
|
+
let encoder = JSONEncoder()
|
|
84
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
85
|
+
try encoder.encode(next).write(to: url, options: .atomic)
|
|
86
|
+
persistenceFailure = nil
|
|
87
|
+
} catch {
|
|
88
|
+
persistenceFailure = error
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -9301,7 +9301,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9301
9301
|
field.textColor = color(style["color"]) ?? .label
|
|
9302
9302
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
9303
9303
|
field.textAlignment = textAlignment(style["textAlign"])
|
|
9304
|
-
field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
|
|
9304
|
+
field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment, singleLine: true)
|
|
9305
9305
|
let desiredKeyboardType = keyboardType(props["keyboardType"])
|
|
9306
9306
|
if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
|
|
9307
9307
|
let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
|
|
@@ -10930,7 +10930,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10930
10930
|
private func inputTextAttributes(
|
|
10931
10931
|
_ style: [String: Any],
|
|
10932
10932
|
font: UIFont,
|
|
10933
|
-
alignment: NSTextAlignment
|
|
10933
|
+
alignment: NSTextAlignment,
|
|
10934
|
+
singleLine: Bool = false
|
|
10934
10935
|
) -> [NSAttributedString.Key: Any] {
|
|
10935
10936
|
var attributes: [NSAttributedString.Key: Any] = [
|
|
10936
10937
|
.foregroundColor: color(style["color"]) ?? .label,
|
|
@@ -10940,6 +10941,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10940
10941
|
// UIKit otherwise resets the field's alignment to natural when these attributes are applied.
|
|
10941
10942
|
let paragraph = NSMutableParagraphStyle()
|
|
10942
10943
|
paragraph.alignment = alignment
|
|
10944
|
+
// The default paragraph wraps even inside a UITextField. A long URL
|
|
10945
|
+
// then paints a second line outside the field instead of scrolling.
|
|
10946
|
+
if singleLine { paragraph.lineBreakMode = .byClipping }
|
|
10943
10947
|
if let lineHeight = number(style["lineHeight"]) {
|
|
10944
10948
|
paragraph.minimumLineHeight = lineHeight
|
|
10945
10949
|
paragraph.maximumLineHeight = lineHeight
|
package/dist/mobile-testing.js
CHANGED
|
@@ -201,6 +201,7 @@ ${errors}`);
|
|
|
201
201
|
try {
|
|
202
202
|
if (options.platform === "ios") {
|
|
203
203
|
const files = await Bun.file(join(root, "ios/templates/CraftNativeFiles.swift")).text();
|
|
204
|
+
const ownership = await Bun.file(join(root, "ios/templates/CraftNativeMediaOwnership.swift")).text();
|
|
204
205
|
const media = files.slice(files.indexOf("enum CraftNativeMedia {"));
|
|
205
206
|
if (!media.startsWith("enum CraftNativeMedia"))
|
|
206
207
|
throw new Error("iOS media implementation missing");
|
|
@@ -208,8 +209,10 @@ ${errors}`);
|
|
|
208
209
|
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") }');
|
|
209
210
|
await Bun.write(source, `import Foundation
|
|
210
211
|
import CryptoKit
|
|
212
|
+
import Network
|
|
211
213
|
struct CraftNativeActionError: Error { let code: String;
|
|
212
214
|
let message: String }
|
|
215
|
+
${ownership}
|
|
213
216
|
${isolated}
|
|
214
217
|
${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
215
218
|
const iosSDK = (await run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).trim();
|
|
@@ -229,6 +232,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
229
232
|
await run(["xcrun", "simctl", "bootstatus", simulator, "-b"]);
|
|
230
233
|
}
|
|
231
234
|
const base = `http://127.0.0.1:${server.port}/ios`;
|
|
235
|
+
const mediaRoot = join(scratch, "media");
|
|
232
236
|
const image = join(scratch, "cache-pressure.sparseimage");
|
|
233
237
|
const volume = join(scratch, "cache-volume");
|
|
234
238
|
await mkdir(volume);
|
|
@@ -240,14 +244,16 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
240
244
|
mountedVolume = "";
|
|
241
245
|
print("PASS: iOS native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
242
246
|
if (!storageOnly) {
|
|
243
|
-
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal"]));
|
|
244
|
-
const
|
|
247
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal", mediaRoot]));
|
|
248
|
+
for (const phase of ["retire", "retired-recover"])
|
|
249
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, phase, mediaRoot]));
|
|
250
|
+
const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt", mediaRoot], scratch, true);
|
|
245
251
|
print(interrupted);
|
|
246
252
|
if (!interrupted.includes("KILLING"))
|
|
247
253
|
throw new Error("iOS probe did not reach process-death checkpoint");
|
|
248
254
|
if (counts.get("/ios/restart.mp4") !== 1)
|
|
249
255
|
throw new Error("iOS process did not start interrupted download");
|
|
250
|
-
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
|
|
256
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover", mediaRoot]));
|
|
251
257
|
if (counts.get("/ios/slow.mp4") !== 1)
|
|
252
258
|
throw new Error("iOS duplicate fetch: keep did not coalesce");
|
|
253
259
|
if (counts.get("/ios/account/slow.mp4") !== 2)
|
|
@@ -328,6 +334,9 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
328
334
|
print("PASS: Android native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
329
335
|
if (!storageOnly) {
|
|
330
336
|
await probe("normal");
|
|
337
|
+
await probe("retire");
|
|
338
|
+
await run([adb, "-s", serial, "shell", "am", "force-stop", packageName]);
|
|
339
|
+
await probe("retired-recover");
|
|
331
340
|
await probe("interrupt", true);
|
|
332
341
|
if (counts.get("/android/restart.mp4") !== 1)
|
|
333
342
|
throw new Error("Android process did not start interrupted download");
|
|
@@ -58,7 +58,7 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
58
58
|
check(keep(retained), "existing video is kept before storage pressure")
|
|
59
59
|
val original = CraftNativeMedia.local(targetContext, retained)!!.readBytes()
|
|
60
60
|
val fresh = "$base/storage.mp4"
|
|
61
|
-
val staging =
|
|
61
|
+
val staging = CraftNativeMedia.part(targetContext, fresh)
|
|
62
62
|
val root = staging.parentFile!!
|
|
63
63
|
check(root.totalSpace in 1..(32L * 1024 * 1024), "storage pressure is confined to a bounded disposable volume")
|
|
64
64
|
val pressure = File(root, "pressure.mp4")
|
|
@@ -90,6 +90,44 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
90
90
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
91
91
|
prefs.edit().clear().commit()
|
|
92
92
|
}
|
|
93
|
+
"retire" -> {
|
|
94
|
+
val a = "$base/selective-a.mp4"
|
|
95
|
+
val b = "$base/selective-b.mp4"
|
|
96
|
+
check(keep(a) && keep(b), "two authorized videos are kept")
|
|
97
|
+
val oldGeneration = CraftNativeMedia.generation(targetContext)
|
|
98
|
+
check(targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().putInt("old-generation", oldGeneration).commit(), "old worker generation is retained for restart verification")
|
|
99
|
+
val cacheRoot = CraftNativeMedia.file(targetContext, a).parentFile!!
|
|
100
|
+
android.system.Os.chmod(cacheRoot.absolutePath, 365) // 0555: read/search, no unlink
|
|
101
|
+
var unlinkFailed = false
|
|
102
|
+
try { CraftNativeMedia.remove(targetContext, a) } catch (error: java.io.IOException) { unlinkFailed = true }
|
|
103
|
+
finally { android.system.Os.chmod(cacheRoot.absolutePath, 448) } // 0700
|
|
104
|
+
check(unlinkFailed, "a real unlink failure is reported")
|
|
105
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "failed unlink hides revoked A and preserves authorized B")
|
|
106
|
+
check(CraftNativeMedia.remove(targetContext, a) == 1, "removing A removes only its copy")
|
|
107
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "B stays available after A is revoked")
|
|
108
|
+
}
|
|
109
|
+
"retired-recover" -> {
|
|
110
|
+
val a = "$base/selective-a.mp4"
|
|
111
|
+
val b = "$base/selective-b.mp4"
|
|
112
|
+
val oldGeneration = targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).getInt("old-generation", -1)
|
|
113
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "selective revocation survives a fresh Android process")
|
|
114
|
+
fun obsoleteCompletion() {
|
|
115
|
+
val result = CraftNativeMedia.transfer(targetContext, a, JSONObject(), oldGeneration, 0, ownerRevision = "") { false }
|
|
116
|
+
check(result == CraftNativeMedia.Outcome.DONE, "obsolete worker retires without retry or publication")
|
|
117
|
+
}
|
|
118
|
+
obsoleteCompletion()
|
|
119
|
+
check(keep(a), "a new authorized keep can re-grant A")
|
|
120
|
+
val fresh = CraftNativeMedia.local(targetContext, a)!!.readBytes()
|
|
121
|
+
obsoleteCompletion()
|
|
122
|
+
check(CraftNativeMedia.local(targetContext, a)!!.readBytes().contentEquals(fresh), "obsolete worker cannot replace re-granted A")
|
|
123
|
+
val decoder = android.media.MediaMetadataRetriever()
|
|
124
|
+
try {
|
|
125
|
+
decoder.setDataSource(CraftNativeMedia.local(targetContext, b)!!.absolutePath)
|
|
126
|
+
check(decoder.getFrameAtTime(1000000) != null, "unrelated B still decodes offline after revocation and re-grant")
|
|
127
|
+
} finally { decoder.release() }
|
|
128
|
+
CraftNativeMedia.clear(targetContext)
|
|
129
|
+
targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().clear().commit()
|
|
130
|
+
}
|
|
93
131
|
"interrupt" -> {
|
|
94
132
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
95
133
|
CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
|
|
@@ -155,7 +193,8 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
155
193
|
val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
|
|
156
194
|
val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
|
|
157
195
|
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
|
|
158
|
-
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
196
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
197
|
+
.addTag("craft-media:identity:${CraftNativeMedia.digest(settled)}:legacy").build()
|
|
159
198
|
work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
|
|
160
199
|
val settledDeadline = System.currentTimeMillis() + 10000
|
|
161
200
|
var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
|
|
@@ -176,7 +215,8 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
176
215
|
.setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
177
216
|
.setInputData(androidx.work.workDataOf("address" to queued, "headers" to "{}", "generation" to CraftNativeMedia.generation(targetContext)))
|
|
178
217
|
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(queued)}")
|
|
179
|
-
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
218
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
219
|
+
.addTag("craft-media:identity:${CraftNativeMedia.digest(queued)}:legacy").build()
|
|
180
220
|
work.enqueueUniqueWork(queuedName, androidx.work.ExistingWorkPolicy.KEEP, queuedRequest).result.get()
|
|
181
221
|
val beforeJoin = work.getWorkInfoById(queuedRequest.id).get()!!
|
|
182
222
|
check(beforeJoin.state == androidx.work.WorkInfo.State.ENQUEUED && beforeJoin.runAttemptCount == 0, "the coalescing regression starts with a fresh queued transfer")
|
|
@@ -217,6 +257,9 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
217
257
|
check(currentDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && currentOK, "an obsolete worker cannot cancel the next generation's waiters")
|
|
218
258
|
|
|
219
259
|
val inflight = "$base/inflight/slow.mp4"
|
|
260
|
+
// This fixture starts the old worker directly rather than through keep.
|
|
261
|
+
// Give it the same persisted grant keep records after a clear.
|
|
262
|
+
check(targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putBoolean("authorized:${CraftNativeMedia.digest(inflight)}", true).commit(), "the direct worker starts with an explicit grant")
|
|
220
263
|
val inflightGeneration = CraftNativeMedia.generation(targetContext)
|
|
221
264
|
var obsoleteOutcome: CraftNativeMedia.Outcome? = null
|
|
222
265
|
val obsoleteWriter = Thread { obsoleteOutcome = CraftNativeMedia.transfer(targetContext, inflight, JSONObject(), inflightGeneration, 0) { false } }
|
|
@@ -79,6 +79,48 @@ if phase == "storage" {
|
|
|
79
79
|
_ = CraftNativeMedia.prune(keep: [])
|
|
80
80
|
exit(0)
|
|
81
81
|
}
|
|
82
|
+
if phase == "retire" || phase == "retired-recover" {
|
|
83
|
+
let a = base + "/selective-a.mp4"
|
|
84
|
+
let b = base + "/selective-b.mp4"
|
|
85
|
+
let saved = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-stamp.json")
|
|
86
|
+
if phase == "retire" {
|
|
87
|
+
check(keep(a) && keep(b), "two authorized videos are kept")
|
|
88
|
+
let flight = CraftNativeMedia.begin(a) { _ in }!
|
|
89
|
+
let stamp = CraftNativeMedia.ownershipStamp(a, flight: flight)!
|
|
90
|
+
try JSONEncoder().encode(stamp).write(to: saved)
|
|
91
|
+
try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: CraftNativeMedia.root.path)
|
|
92
|
+
var unlinkFailed = false
|
|
93
|
+
do { _ = try CraftNativeMedia.remove(a) } catch { unlinkFailed = true }
|
|
94
|
+
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: CraftNativeMedia.root.path)
|
|
95
|
+
check(unlinkFailed, "a real unlink failure is reported")
|
|
96
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "failed unlink hides revoked A and preserves authorized B")
|
|
97
|
+
let removed = try CraftNativeMedia.remove(a)
|
|
98
|
+
check(removed == 1, "removing A removes only its copy")
|
|
99
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "B stays available after A is revoked")
|
|
100
|
+
} else {
|
|
101
|
+
let stamp = try JSONDecoder().decode(CraftNativeMediaOwnership.Stamp.self, from: Data(contentsOf: saved))
|
|
102
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "selective revocation survives a fresh simulator process")
|
|
103
|
+
func obsoleteCompletion() throws {
|
|
104
|
+
let payload = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-download.mp4")
|
|
105
|
+
try Data(contentsOf: CraftNativeMedia.local(b)!).write(to: payload)
|
|
106
|
+
defer { try? fm.removeItem(at: payload) }
|
|
107
|
+
let response = HTTPURLResponse(url: URL(string: a)!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
|
108
|
+
let result = CraftNativeMedia.store(a, at: payload, response: response, stamp: stamp)
|
|
109
|
+
if case .failure(let error) = result { check(error.code == "CANCELLED", "obsolete transfer cannot publish after process restart") }
|
|
110
|
+
else { check(false, "obsolete transfer was accepted") }
|
|
111
|
+
}
|
|
112
|
+
try obsoleteCompletion()
|
|
113
|
+
check(keep(a), "a new authorized keep can re-grant A")
|
|
114
|
+
try obsoleteCompletion()
|
|
115
|
+
check(CraftNativeMedia.local(a) != nil && CraftNativeMedia.local(b) != nil, "old completion cannot replace re-granted A or remove B")
|
|
116
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: CraftNativeMedia.local(b)!))
|
|
117
|
+
_ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
|
|
118
|
+
check(true, "unrelated B still decodes offline after revocation and re-grant")
|
|
119
|
+
_ = try CraftNativeMedia.clear()
|
|
120
|
+
try fm.removeItem(at: saved)
|
|
121
|
+
}
|
|
122
|
+
exit(0)
|
|
123
|
+
}
|
|
82
124
|
if phase == "interrupt" {
|
|
83
125
|
_ = CraftNativeMedia.prune(keep: [])
|
|
84
126
|
start(restart) { _ in }
|