@craft-native/android 0.0.451 → 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.
@@ -98,18 +98,21 @@ internal data class CraftNativeChromeFonts(val body: Typeface? = null, val headi
98
98
 
99
99
  /** The app's paths (`nativeScreens`), its tabs and its site. */
100
100
  internal class CraftNativeRoutes(config: JSONObject) {
101
- private data class Route(val segments: List<String>, val screen: String)
101
+ private data class Route(val segments: List<String>, val screen: String, val constraints: Any?)
102
102
 
103
103
  /**
104
104
  * The iOS shell's order (CraftHybrid.swift's route table): a trailing
105
105
  * `*` last, then more fixed segments first, then longer patterns, then
106
106
  * by name, so `/m/store/library` is never taken for `/m/store/:slug`.
107
107
  */
108
+ private val validConstraints = !config.has("nativeScreenConstraints") || config.opt("nativeScreenConstraints") is JSONObject
109
+
108
110
  private val routes: List<Route> = buildList {
109
111
  val screens = config.optJSONObject("nativeScreens") ?: JSONObject()
112
+ val constraints = config.optJSONObject("nativeScreenConstraints")
110
113
  screens.keys().forEach { pattern ->
111
114
  val screen = screens.optString(pattern)
112
- if (screen.isNotBlank()) add(Route(segmentsOf(pattern), screen))
115
+ if (screen.isNotBlank()) add(Route(segmentsOf(pattern), screen, constraints?.opt(pattern)))
113
116
  }
114
117
  }.sortedWith(
115
118
  compareBy<Route> { if (it.segments.lastOrNull() == "*") 1 else 0 }
@@ -189,11 +192,13 @@ internal class CraftNativeRoutes(config: JSONObject) {
189
192
 
190
193
  /** The screen that draws `path`, with its parameters and query as params. */
191
194
  fun match(path: String): CraftNativeRouteMatch? {
195
+ if (!validConstraints) return null
192
196
  val bare = normalize(path.substringBefore('#').substringBefore('?'))
193
197
  val query = path.substringBefore('#').substringAfter('?', "")
194
198
  val parts = segmentsOf(bare)
195
199
  for (route in routes) {
196
200
  val params = bind(route.segments, parts) ?: continue
201
+ if (!parametersAllowed(params, route.constraints)) continue
197
202
  if (query.isNotBlank()) {
198
203
  query.split('&').filter { it.isNotBlank() }.forEach { pair ->
199
204
  val key = Uri.decode(pair.substringBefore('='))
@@ -205,6 +210,33 @@ internal class CraftNativeRoutes(config: JSONObject) {
205
210
  return null
206
211
  }
207
212
 
213
+ private fun parametersAllowed(params: JSONObject, raw: Any?): Boolean {
214
+ if (raw == null) return true
215
+ val rules = raw as? JSONObject ?: return false
216
+ for (name in rules.keys()) {
217
+ var value = params.opt(name) as? String ?: return false
218
+ val rule = rules.optJSONObject(name) ?: return false
219
+ if (rule.keys().asSequence().any { it !in setOf("pattern", "exclude", "normalize") }) return false
220
+ if (rule.has("normalize") && rule.opt("normalize") != "lowercase") return false
221
+ val normalize = rule.has("normalize")
222
+ if (normalize) value = value.lowercase(java.util.Locale.ROOT)
223
+ if (rule.has("exclude")) {
224
+ val excluded = rule.optJSONArray("exclude") ?: return false
225
+ for (index in 0 until excluded.length()) {
226
+ val item = excluded.opt(index) as? String ?: return false
227
+ if (value == if (normalize) item.lowercase(java.util.Locale.ROOT) else item) return false
228
+ }
229
+ }
230
+ if (rule.has("pattern")) {
231
+ val pattern = rule.opt("pattern") as? String ?: return false
232
+ val expression = try { Regex(pattern) } catch (_: Exception) { return false }
233
+ if (!expression.matches(value)) return false
234
+ }
235
+ params.put(name, value)
236
+ }
237
+ return true
238
+ }
239
+
208
240
  private fun bind(pattern: List<String>, parts: List<String>): JSONObject? {
209
241
  val params = JSONObject()
210
242
  for (index in pattern.indices) {
@@ -739,44 +771,48 @@ internal object CraftNativePrelude {
739
771
  // Native capability ownership wins over any browser-shaped dialog
740
772
  // object the bundle installed before the bridge ran. Otherwise an
741
773
  // action sheet silently falls back to the DOM renderer.
742
- var nextDialogRequest = 0;
743
- function dialogRequest(method, args) {
774
+ var nextCapabilityRequest = 0;
775
+ function nativeRequest(module, method, args, untimed) {
744
776
  return new Promise(function(resolve, reject) {
745
- var id = 'craft-dialog-' + (++nextDialogRequest);
777
+ var id = 'craft-host-' + (++nextCapabilityRequest);
746
778
  var remove = __stxNativeBridge.addMessageListener(function(raw) {
747
779
  var message;
748
780
  try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
749
781
  var payload = message && message.payload;
750
782
  if (!payload || payload.requestId !== id) return;
783
+ if (message.type !== 'API_ERROR' && message.type !== 'API_RESPONSE') return;
751
784
  remove();
752
785
  if (message.type === 'API_ERROR') {
753
- var failure = new Error(payload.message || 'Native dialog failed');
786
+ var failure = new Error(payload.message || 'Native capability failed');
754
787
  failure.code = payload.code || 'NATIVE_CALL_FAILED';
755
788
  reject(failure);
756
789
  } else if (message.type === 'API_RESPONSE') {
757
790
  resolve(payload.data === undefined ? null : payload.data);
758
791
  }
759
792
  });
760
- __stxNativeBridge.postMessage(JSON.stringify({
793
+ try { __stxNativeBridge.postMessage(JSON.stringify({
761
794
  type: 'API_REQUEST',
762
795
  id: id,
763
796
  payload: {
764
797
  version: __stxNativeBridge.capabilityProtocolVersion || 1,
765
- module: 'Dialog',
798
+ module: module,
766
799
  method: method,
767
800
  args: args,
768
- untimed: true,
801
+ untimed: untimed === true,
769
802
  },
770
- }));
803
+ })); } catch (error) { remove(); reject(error); }
771
804
  });
772
805
  }
773
806
  function installNativeDialog() {
774
807
  var craft = globalThis.craft = globalThis.craft || {};
808
+ craft.accessibility = {
809
+ announce: function(text, options) { return nativeRequest('Accessibility', 'announce', [text, options == null ? {} : options], false).then(function() {}); },
810
+ };
775
811
  if (Array.isArray(__stxNativeBridge.capabilities) && __stxNativeBridge.capabilities.indexOf('dialog') >= 0) {
776
812
  craft.dialog = {
777
- alert: function(options) { return dialogRequest('alert', [options || {}]).then(function() {}); },
778
- confirm: function(options) { return dialogRequest('confirm', [options || {}]).then(Boolean); },
779
- actionSheet: function(options) { return dialogRequest('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); },
813
+ alert: function(options) { return nativeRequest('Dialog', 'alert', [options || {}], true).then(function() {}); },
814
+ confirm: function(options) { return nativeRequest('Dialog', 'confirm', [options || {}], true).then(Boolean); },
815
+ actionSheet: function(options) { return nativeRequest('Dialog', 'actionSheet', [options || {}], true).then(function(id) { return typeof id === 'string' ? id : null; }); },
780
816
  };
781
817
  }
782
818
  }
@@ -1334,12 +1370,18 @@ internal object CraftNativeSymbols {
1334
1370
  * transfer finishes on its own; the next keep answers from the copy.
1335
1371
  */
1336
1372
  internal object CraftNativeMedia {
1373
+ /** Caller-owned account/resource/version identity; the request retains its authorized URL. */
1374
+ fun identity(source: String, options: JSONObject?): String {
1375
+ val key = options?.opt("cacheKey") as? String
1376
+ return if (key != null && key.isNotEmpty() && key.toByteArray().size <= 1024) "craft-media-key:$key" else source
1377
+ }
1378
+
1337
1379
  const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
1338
1380
  private const val TAG = "craft-media"
1339
1381
  private const val PREFS = "craft.media"
1340
1382
  private val lock = Any()
1341
1383
  /** Keeps waiting in this process for an address's transfer. */
1342
- 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)
1343
1385
  private val waiting = HashMap<String, MutableList<Waiting>>()
1344
1386
  /** One transfer writes an address's part file at a time. */
1345
1387
  private val writers = ConcurrentHashMap<String, Any>()
@@ -1358,10 +1400,12 @@ internal object CraftNativeMedia {
1358
1400
  }
1359
1401
 
1360
1402
  /** Where an address's transfer writes until the file is whole; kept between attempts to resume. */
1361
- fun part(context: Context, address: String): File = File(root(context), "${file(context, address).name}.part")
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")
1362
1405
 
1363
- fun local(context: Context, address: String): File? =
1364
- 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
+ }
1365
1409
 
1366
1410
  private fun describe(address: String, file: File) = JSONObject().put("ok", true).put("url", address).put("path", file.absolutePath).put("size", file.length())
1367
1411
 
@@ -1370,18 +1414,70 @@ internal object CraftNativeMedia {
1370
1414
  /** How many times the cache was cleared; a transfer started before the last clear does not land. */
1371
1415
  fun generation(context: Context): Int = prefs(context).getInt("generation", 0)
1372
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
+
1373
1457
  /** The unique job for an address and connection rule: `any` or `wifi`. */
1374
1458
  private fun workName(address: String, wifiOnly: Boolean) = "$TAG:${digest(address)}:${if (wifiOnly) "wifi" else "any"}"
1375
1459
 
1376
- fun keep(context: Context, address: String, headers: JSONObject, cellular: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
1377
- val uri = runCatching { Uri.parse(address) }.getOrNull()
1460
+ fun keep(context: Context, source: String, headers: JSONObject, cellular: Boolean, cacheKey: String? = null, done: (CraftNativeCapabilityResult) -> Unit) {
1461
+ val address = identity(source, JSONObject().put("cacheKey", cacheKey))
1462
+ val uri = runCatching { Uri.parse(source) }.getOrNull()
1378
1463
  if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
1379
1464
  return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
1380
1465
  }
1381
1466
  val app = context.applicationContext
1467
+ var sourceChanged = false
1468
+ var ownerRevision = ""
1382
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")) }
1383
1472
  local(app, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
1384
- generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, done)) }
1473
+ if (cacheKey != null) {
1474
+ val sourceKey = "source:${digest(address)}"
1475
+ val authorization = digest(source + "\n" + headers.keys().asSequence().sorted().joinToString("\n") { "$it:${headers.optString(it)}" })
1476
+ sourceChanged = prefs(app).getString(sourceKey, null) != authorization
1477
+ if (!prefs(app).edit().putString(sourceKey, authorization).commit()) return done(CraftNativeCapabilityResult(code = "IO_ERROR", message = "Could not persist media authorization"))
1478
+ }
1479
+ ownerRevision = revision(app, address)
1480
+ generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, ownerRevision, done)) }
1385
1481
  }
1386
1482
  val wifiOnly = !cellular
1387
1483
  val work = androidx.work.WorkManager.getInstance(app)
@@ -1389,20 +1485,23 @@ internal object CraftNativeMedia {
1389
1485
  work.cancelUniqueWork(workName(address, !wifiOnly))
1390
1486
  val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
1391
1487
  .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
1392
- .setInputData(androidx.work.workDataOf("address" to address, "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))
1393
1489
  .setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
1394
1490
  .addTag(TAG)
1395
1491
  .addTag("$TAG:generation:$started")
1396
1492
  .addTag("$TAG:${digest(address)}")
1493
+ .addTag(revisionTag(address, ownerRevision))
1397
1494
  .build()
1398
1495
  // Join a running or freshly queued transfer. Only a failed attempt's
1399
1496
  // backoff is replaced so an explicit retry can resume immediately.
1400
1497
  val name = workName(address, wifiOnly)
1401
1498
  background.execute {
1402
1499
  try {
1403
- if (generation(app) == started) {
1500
+ if (current(app, address, started, ownerRevision)) {
1404
1501
  val pending = work.getWorkInfosForUniqueWork(name).get().any {
1405
- "$TAG:generation:$started" in it.tags && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
1502
+ "$TAG:generation:$started" in it.tags &&
1503
+ revisionTag(address, ownerRevision) in it.tags &&
1504
+ !sourceChanged && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
1406
1505
  (it.state == androidx.work.WorkInfo.State.RUNNING ||
1407
1506
  (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
1408
1507
  }
@@ -1411,8 +1510,8 @@ internal object CraftNativeMedia {
1411
1510
  }
1412
1511
  } catch (error: Exception) {
1413
1512
  if (error is InterruptedException) Thread.currentThread().interrupt()
1414
- if (generation(app) == started) {
1415
- 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)
1416
1515
  }
1417
1516
  }
1418
1517
  }
@@ -1421,25 +1520,25 @@ internal object CraftNativeMedia {
1421
1520
  wifiOnly && metered(app) -> "cellular"
1422
1521
  else -> null
1423
1522
  }
1424
- if (reason != null) deliver(context, address, started, queued(address, reason))
1523
+ if (reason != null) deliver(context, address, started, queued(address, reason), ownerRevision)
1425
1524
  }
1426
1525
 
1427
1526
  private fun queued(address: String, reason: String) =
1428
1527
  CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
1429
1528
 
1430
1529
  /** Answers only keeps belonging to this transfer's cache generation. */
1431
- fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult) {
1530
+ fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult, ownerRevision: String = "") {
1432
1531
  val answered = synchronized(lock) {
1433
1532
  val pending = waiting[address].orEmpty()
1434
- val matching = pending.filter { it.generation == started }
1435
- 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()
1436
1535
  if (remaining.isEmpty()) waiting.remove(address) else waiting[address] = remaining
1437
1536
  matching
1438
1537
  }
1439
1538
  if (answered.isNotEmpty()) main.post {
1440
- val current = if (generation(context) == started) result else
1539
+ val answer = if (current(context, address, started, ownerRevision)) result else
1441
1540
  CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
1442
- answered.forEach { it.answer(current) }
1541
+ answered.forEach { it.answer(answer) }
1443
1542
  }
1444
1543
  }
1445
1544
 
@@ -1450,7 +1549,7 @@ internal object CraftNativeMedia {
1450
1549
  * file where it stopped, moves it into place whole, and answers whoever
1451
1550
  * waits. RETRY leaves the part file for the next attempt.
1452
1551
  */
1453
- fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, 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 {
1454
1553
  fun answer(result: CraftNativeCapabilityResult) {
1455
1554
  // The callback can immediately call keep again. Publish completion
1456
1555
  // before that callback, while WorkManager may still say RUNNING.
@@ -1458,12 +1557,15 @@ internal object CraftNativeMedia {
1458
1557
  // A keep registered after this marker belongs to the next
1459
1558
  // attempt; it must not enter the old callback batch.
1460
1559
  beforeAnswer()
1461
- deliver(context, address, started, result)
1560
+ deliver(context, address, started, result, ownerRevision)
1462
1561
  }
1463
1562
  }
1464
1563
  // clear() already answered the old generation's waiters. Never answer
1465
1564
  // a new athlete's waiters from an obsolete worker for the same URL.
1466
- if (generation(context) != started) return Outcome.DONE
1565
+ if (!current(context, address, started, ownerRevision)) {
1566
+ part(context, address, started, ownerRevision).delete()
1567
+ return Outcome.DONE
1568
+ }
1467
1569
  local(context, address)?.let {
1468
1570
  answer(CraftNativeCapabilityResult(data = describe(address, it)))
1469
1571
  return Outcome.DONE
@@ -1472,21 +1574,25 @@ internal object CraftNativeMedia {
1472
1574
  synchronized(writer) {
1473
1575
  // Another worker can finish, or sign-out can clear the cache, while
1474
1576
  // this worker waits for the address's writer.
1475
- if (generation(context) != started) return Outcome.DONE
1577
+ if (!current(context, address, started, ownerRevision)) {
1578
+ part(context, address, started, ownerRevision).delete()
1579
+ return Outcome.DONE
1580
+ }
1476
1581
  if (stopped()) return Outcome.RETRY
1477
1582
  local(context, address)?.let {
1478
1583
  answer(CraftNativeCapabilityResult(data = describe(address, it)))
1479
1584
  return Outcome.DONE
1480
1585
  }
1481
1586
  val target = file(context, address)
1482
- val staging = part(context, address)
1587
+ val staging = part(context, address, started, ownerRevision)
1483
1588
  target.parentFile?.mkdirs()
1484
1589
  val download = CraftResumableDownload()
1485
1590
  val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
1486
1591
  val fetched = try {
1487
1592
  // A part that no longer lines up was dropped: the whole file, now, not after a backoff.
1488
- download.fetch(address, asked, staging, stopped).let { first ->
1489
- if (first is CraftResumableDownload.Fetched.Restart) download.fetch(address, asked, staging, stopped) else first
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
1490
1596
  }
1491
1597
  } catch (error: Exception) {
1492
1598
  val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
@@ -1497,13 +1603,17 @@ internal object CraftNativeMedia {
1497
1603
  answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1498
1604
  return Outcome.DONE
1499
1605
  }
1606
+ if (!current(context, address, started, ownerRevision)) { staging.delete(); return Outcome.DONE }
1500
1607
  if (stopped()) return Outcome.RETRY
1501
1608
  // Lost the connection part way: the part waits for the next attempt.
1502
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"))
1503
1610
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1504
1611
  }
1505
1612
  when (fetched) {
1506
- is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
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
+ }
1507
1617
  is CraftResumableDownload.Fetched.Refused -> {
1508
1618
  answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1509
1619
  return Outcome.DONE
@@ -1515,7 +1625,7 @@ internal object CraftNativeMedia {
1515
1625
  is CraftResumableDownload.Fetched.Complete -> Unit
1516
1626
  }
1517
1627
  synchronized(lock) {
1518
- if (generation(context) != started) {
1628
+ if (!current(context, address, started, ownerRevision)) {
1519
1629
  staging.delete()
1520
1630
  return Outcome.DONE
1521
1631
  }
@@ -1552,8 +1662,8 @@ internal object CraftNativeMedia {
1552
1662
  val active = waiting.keys.map { digest(it) }.toSet()
1553
1663
  root(context).listFiles()?.forEach { file ->
1554
1664
  val owner = file.name.substringBefore('.')
1555
- // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
1556
- val stale = file.name.endsWith(".part") && file.name.count { it == '.' } > 2
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")
1557
1667
  if (owner !in active && (owner !in wanted || stale) && file.delete()) removed++
1558
1668
  }
1559
1669
  }
@@ -1577,20 +1687,60 @@ internal object CraftNativeMedia {
1577
1687
  return removed
1578
1688
  }
1579
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
+
1580
1725
  /** Stops every transfer and deletes every copy; none started before this lands after it. */
1581
1726
  fun clear(context: Context): Int {
1582
1727
  var removed = 0
1583
1728
  var failure: java.io.IOException? = null
1584
1729
  val answered = synchronized(lock) {
1585
1730
  // Persisted before the files go: a transfer that outlives the process checks it too.
1586
- prefs(context).edit().putInt("generation", generation(context) + 1).commit()
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
+ }
1587
1735
  val callbacks = waiting.values.flatten()
1588
1736
  waiting.clear()
1589
- root(context).listFiles()?.forEach { file ->
1590
- if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1591
- }
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 }
1592
1742
  // Schedule cancellation before a new generation can enqueue its work.
1593
- androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1743
+ androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag("$TAG:generation:$previous")
1594
1744
  callbacks
1595
1745
  }
1596
1746
  main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
@@ -1682,10 +1832,11 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
1682
1832
  val address = inputData.getString("address") ?: return Result.failure()
1683
1833
  val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
1684
1834
  val started = inputData.getInt("generation", 0)
1835
+ val ownerRevision = inputData.getString("revision").orEmpty()
1685
1836
  return try {
1686
1837
  setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
1687
- val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
1688
- beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
1838
+ val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount, source = inputData.getString("source") ?: address,
1839
+ beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }, ownerRevision = ownerRevision
1689
1840
  ) { isStopped }
1690
1841
  if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1691
1842
  } catch (error: Exception) {
@@ -1693,7 +1844,7 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
1693
1844
  // A replacement owns the address's new waiters. A real progress
1694
1845
  // storage failure still needs an answer instead of a lost callback.
1695
1846
  if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
1696
- 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)
1697
1848
  if (isStopped) Result.retry() else Result.failure()
1698
1849
  }
1699
1850
  }