@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.
- package/README.md +19 -1
- package/dist/index.d.ts +1 -350
- package/dist/index.js +228 -31
- package/dist/index.js.map +7 -5
- package/dist/internal/android/src/index.d.ts +358 -0
- package/dist/internal/android/src/testing.d.ts +2 -0
- package/dist/internal/typescript/src/native-notifications.d.ts +17 -0
- package/dist/internal/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
- package/templates/CraftNativeBackground.kt.template +65 -25
- package/templates/CraftNativeCapabilities.kt.template +3 -1
- package/templates/CraftNativeMessagingService.kt.template +8 -28
- package/templates/CraftNativeNotificationReceiver.kt.template +213 -15
- package/templates/CraftNativeServices.kt.template +23 -5
- package/templates/CraftNativeShell.kt.template +201 -50
- package/templates/MainActivityNative.kt.template +227 -12
- package/dist/testing.d.ts +0 -2
- /package/dist/{emulator.d.ts → internal/android/src/emulator.d.ts} +0 -0
- /package/dist/{native-map.d.ts → internal/android/src/native-map.d.ts} +0 -0
- /package/dist/{promise-runtime.d.ts → internal/android/src/promise-runtime.d.ts} +0 -0
|
@@ -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
|
|
743
|
-
function
|
|
774
|
+
var nextCapabilityRequest = 0;
|
|
775
|
+
function nativeRequest(module, method, args, untimed) {
|
|
744
776
|
return new Promise(function(resolve, reject) {
|
|
745
|
-
var id = 'craft-
|
|
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
|
|
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:
|
|
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
|
|
778
|
-
confirm: function(options) { return
|
|
779
|
-
actionSheet: function(options) { return
|
|
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
|
|
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,
|
|
1377
|
-
val
|
|
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
|
-
|
|
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 (
|
|
1500
|
+
if (current(app, address, started, ownerRevision)) {
|
|
1404
1501
|
val pending = work.getWorkInfosForUniqueWork(name).get().any {
|
|
1405
|
-
"$TAG:generation:$started" in it.tags &&
|
|
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 (
|
|
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
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1489
|
-
|
|
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 ->
|
|
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 (
|
|
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
|
-
|
|
1556
|
-
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")
|
|
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
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
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
|
}
|