@craft-native/android 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.464",
3
+ "version": "0.0.467",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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" -> done(success(CraftNativeMedia.clear(context)))
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): 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")
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
- generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, done)) }
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 (generation(app) == started) {
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 (generation(app) == started) {
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 current = if (generation(context) == started) result else
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(current) }
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 (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
+ }
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 (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
+ }
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
- download.fetch(source, asked, staging, stopped).let { first ->
1540
- if (first is CraftResumableDownload.Fetched.Restart) download.fetch(source, 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
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 -> 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
+ }
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 (generation(context) != started) {
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
- // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
1607
- 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")
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
- 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
+ }
1638
1735
  val callbacks = waiting.values.flatten()
1639
1736
  waiting.clear()
1640
- root(context).listFiles()?.forEach { file ->
1641
- if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
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
  }