craft-native 0.0.248 → 0.0.250
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/src/index.js +9 -1
- package/dist/android/templates/CraftNativeShell.kt.template +67 -29
- package/dist/android/templates/MainActivityNative.kt.template +1 -16
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +7 -1
- package/dist/ios/templates/CraftHybrid.swift +12 -3
- package/dist/ios/templates/CraftNativeScreen.swift +18 -21
- package/dist/ios/templates/CraftNativeServices.swift +11 -2
- package/dist/ios/templates/testing/DriverTests.swift.template +35 -4
- package/dist/mobile-testing.js +57 -0
- package/dist/testing/media-cache/media-cache.kt +83 -0
- package/package.json +1 -1
|
@@ -316,10 +316,18 @@ async function createAndroidEmulator(options) {
|
|
|
316
316
|
`);
|
|
317
317
|
const deadline = bootStartedAt + bootTimeoutMs;
|
|
318
318
|
const probeTimeout = () => Math.max(1, Math.min(1e4, deadline - Date.now()));
|
|
319
|
+
let connected = false;
|
|
320
|
+
let lastReconnectAt = 0;
|
|
319
321
|
while (Date.now() < deadline && child.exitCode === null) {
|
|
320
322
|
let booted = false;
|
|
321
323
|
const connection = await run([adb, "devices"], undefined, probeTimeout(), true);
|
|
322
|
-
|
|
324
|
+
const state = connection.stdout.split(/\r?\n/).find((line) => line.startsWith(`${serial} `))?.split(/\s+/)[1];
|
|
325
|
+
if (connection.code === 0 && state === "offline" && connected && Date.now() - lastReconnectAt >= 30000 && Date.now() < deadline) {
|
|
326
|
+
lastReconnectAt = Date.now();
|
|
327
|
+
await run([adb, "-s", serial, "reconnect"], undefined, probeTimeout(), true);
|
|
328
|
+
}
|
|
329
|
+
if (connection.code === 0 && state === "device") {
|
|
330
|
+
connected = true;
|
|
323
331
|
const completion = await run([adb, "-s", serial, "shell", "getprop", "sys.boot_completed"], undefined, probeTimeout(), true);
|
|
324
332
|
if (completion.code === 0 && completion.stdout.trim() === "1") {
|
|
325
333
|
const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
|
|
@@ -766,6 +766,8 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
766
766
|
private val tabIconTints = mutableMapOf<String, Int>()
|
|
767
767
|
private val tabIconPulses = mutableSetOf<String>()
|
|
768
768
|
private val selectedSymbols = mutableMapOf<String, String>()
|
|
769
|
+
/** The page's own symbol for a tab while it says so, in place of the configured one. */
|
|
770
|
+
private val tabSymbols = mutableMapOf<String, String>()
|
|
769
771
|
private val selectedIconTints = mutableMapOf<String, Int>()
|
|
770
772
|
private val selectedIconPulses = mutableMapOf<String, Boolean>()
|
|
771
773
|
private val tabImageURLs = mutableMapOf<String, String>()
|
|
@@ -1000,6 +1002,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
1000
1002
|
if (item.has("iconPulse")) {
|
|
1001
1003
|
if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
|
|
1002
1004
|
}
|
|
1005
|
+
if (item.has("symbol")) {
|
|
1006
|
+
tabSymbols.remove(id)
|
|
1007
|
+
item.optString("symbol").trim().takeUnless { item.isNull("symbol") || it.isBlank() }?.let { tabSymbols[id] = it }
|
|
1008
|
+
}
|
|
1003
1009
|
if (item.has("selectedSymbol")) {
|
|
1004
1010
|
selectedSymbols.remove(id)
|
|
1005
1011
|
item.optString("selectedSymbol").trim().takeUnless { item.isNull("selectedSymbol") || it.isBlank() }?.let { selectedSymbols[id] = it }
|
|
@@ -1042,7 +1048,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
1042
1048
|
val tab = tabs[index]
|
|
1043
1049
|
val selected = index == selectedTab
|
|
1044
1050
|
return CraftTabIconAppearance(
|
|
1045
|
-
if (selected) selectedSymbols[tab.id] ?: tab.symbol else tab.symbol,
|
|
1051
|
+
if (selected) selectedSymbols[tab.id] ?: tabSymbols[tab.id] ?: tab.symbol else tabSymbols[tab.id] ?: tab.symbol,
|
|
1046
1052
|
if (selected) selectedIconTints[tab.id] ?: tabIconTints[tab.id] else tabIconTints[tab.id],
|
|
1047
1053
|
(if (selected) selectedIconPulses[tab.id] ?: tabIconPulses.contains(tab.id) else tabIconPulses.contains(tab.id)) && motionAllowed,
|
|
1048
1054
|
)
|
|
@@ -1232,7 +1238,8 @@ internal object CraftNativeMedia {
|
|
|
1232
1238
|
private const val PREFS = "craft.media"
|
|
1233
1239
|
private val lock = Any()
|
|
1234
1240
|
/** Keeps waiting in this process for an address's transfer. */
|
|
1235
|
-
private val
|
|
1241
|
+
private data class Waiting(val generation: Int, val answer: (CraftNativeCapabilityResult) -> Unit)
|
|
1242
|
+
private val waiting = HashMap<String, MutableList<Waiting>>()
|
|
1236
1243
|
/** One transfer writes an address's part file at a time. */
|
|
1237
1244
|
private val writers = ConcurrentHashMap<String, Any>()
|
|
1238
1245
|
private val background = Executors.newSingleThreadExecutor()
|
|
@@ -1270,44 +1277,69 @@ internal object CraftNativeMedia {
|
|
|
1270
1277
|
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
|
|
1271
1278
|
return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
|
|
1272
1279
|
}
|
|
1273
|
-
local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1274
1280
|
val app = context.applicationContext
|
|
1275
|
-
synchronized(lock) {
|
|
1281
|
+
val started = synchronized(lock) {
|
|
1282
|
+
local(app, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1283
|
+
generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, done)) }
|
|
1284
|
+
}
|
|
1276
1285
|
val wifiOnly = !cellular
|
|
1277
1286
|
val work = androidx.work.WorkManager.getInstance(app)
|
|
1278
1287
|
// The same address under the other rule gives way; its part file is resumed.
|
|
1279
1288
|
work.cancelUniqueWork(workName(address, !wifiOnly))
|
|
1280
1289
|
val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
1281
1290
|
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
|
|
1282
|
-
.setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to
|
|
1291
|
+
.setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to started))
|
|
1283
1292
|
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
|
|
1284
1293
|
.addTag(TAG)
|
|
1294
|
+
.addTag("$TAG:generation:$started")
|
|
1285
1295
|
.addTag("$TAG:${digest(address)}")
|
|
1286
1296
|
.build()
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
// it runs now and resumes the part the last attempt left.
|
|
1297
|
+
// Join a running or freshly queued transfer. Only a failed attempt's
|
|
1298
|
+
// backoff is replaced so an explicit retry can resume immediately.
|
|
1290
1299
|
val name = workName(address, wifiOnly)
|
|
1291
1300
|
background.execute {
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1301
|
+
try {
|
|
1302
|
+
if (generation(app) == started) {
|
|
1303
|
+
val pending = work.getWorkInfosForUniqueWork(name).get().any {
|
|
1304
|
+
"$TAG:generation:$started" in it.tags &&
|
|
1305
|
+
(it.state == androidx.work.WorkInfo.State.RUNNING ||
|
|
1306
|
+
(it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
|
|
1307
|
+
}
|
|
1308
|
+
// The next serial decision must see this enqueue's committed state.
|
|
1309
|
+
work.enqueueUniqueWork(name, if (pending) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request).result.get()
|
|
1310
|
+
}
|
|
1311
|
+
} catch (error: Exception) {
|
|
1312
|
+
if (error is InterruptedException) Thread.currentThread().interrupt()
|
|
1313
|
+
if (generation(app) == started) {
|
|
1314
|
+
deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not schedule the download"))
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1295
1317
|
}
|
|
1296
1318
|
val reason = when {
|
|
1297
1319
|
!online(app) -> "offline"
|
|
1298
1320
|
wifiOnly && metered(app) -> "cellular"
|
|
1299
1321
|
else -> null
|
|
1300
1322
|
}
|
|
1301
|
-
if (reason != null) deliver(address, queued(address, reason))
|
|
1323
|
+
if (reason != null) deliver(context, address, started, queued(address, reason))
|
|
1302
1324
|
}
|
|
1303
1325
|
|
|
1304
1326
|
private fun queued(address: String, reason: String) =
|
|
1305
1327
|
CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
|
|
1306
1328
|
|
|
1307
|
-
/** Answers
|
|
1308
|
-
fun deliver(address: String, result: CraftNativeCapabilityResult) {
|
|
1309
|
-
val answered = synchronized(lock) {
|
|
1310
|
-
|
|
1329
|
+
/** Answers only keeps belonging to this transfer's cache generation. */
|
|
1330
|
+
fun deliver(context: Context, address: String, started: Int, result: CraftNativeCapabilityResult) {
|
|
1331
|
+
val answered = synchronized(lock) {
|
|
1332
|
+
val pending = waiting[address].orEmpty()
|
|
1333
|
+
val matching = pending.filter { it.generation == started }
|
|
1334
|
+
val remaining = pending.filter { it.generation != started }.toMutableList()
|
|
1335
|
+
if (remaining.isEmpty()) waiting.remove(address) else waiting[address] = remaining
|
|
1336
|
+
matching
|
|
1337
|
+
}
|
|
1338
|
+
if (answered.isNotEmpty()) main.post {
|
|
1339
|
+
val current = if (generation(context) == started) result else
|
|
1340
|
+
CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
|
|
1341
|
+
answered.forEach { it.answer(current) }
|
|
1342
|
+
}
|
|
1311
1343
|
}
|
|
1312
1344
|
|
|
1313
1345
|
enum class Outcome { DONE, RETRY }
|
|
@@ -1318,16 +1350,23 @@ internal object CraftNativeMedia {
|
|
|
1318
1350
|
* waits. RETRY leaves the part file for the next attempt.
|
|
1319
1351
|
*/
|
|
1320
1352
|
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
}
|
|
1353
|
+
// clear() already answered the old generation's waiters. Never answer
|
|
1354
|
+
// a new athlete's waiters from an obsolete worker for the same URL.
|
|
1355
|
+
if (generation(context) != started) return Outcome.DONE
|
|
1325
1356
|
local(context, address)?.let {
|
|
1326
|
-
deliver(address, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1357
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1327
1358
|
return Outcome.DONE
|
|
1328
1359
|
}
|
|
1329
1360
|
val writer = writers.getOrPut(address) { Any() }
|
|
1330
1361
|
synchronized(writer) {
|
|
1362
|
+
// Another worker can finish, or sign-out can clear the cache, while
|
|
1363
|
+
// this worker waits for the address's writer.
|
|
1364
|
+
if (generation(context) != started) return Outcome.DONE
|
|
1365
|
+
if (stopped()) return Outcome.RETRY
|
|
1366
|
+
local(context, address)?.let {
|
|
1367
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1368
|
+
return Outcome.DONE
|
|
1369
|
+
}
|
|
1331
1370
|
val target = file(context, address)
|
|
1332
1371
|
val staging = part(context, address)
|
|
1333
1372
|
target.parentFile?.mkdirs()
|
|
@@ -1344,22 +1383,22 @@ internal object CraftNativeMedia {
|
|
|
1344
1383
|
}
|
|
1345
1384
|
if (full) {
|
|
1346
1385
|
staging.delete()
|
|
1347
|
-
deliver(address, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
|
|
1386
|
+
deliver(context, address, started, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
|
|
1348
1387
|
return Outcome.DONE
|
|
1349
1388
|
}
|
|
1350
1389
|
if (stopped()) return Outcome.RETRY
|
|
1351
1390
|
// Lost the connection part way: the part waits for the next attempt.
|
|
1352
|
-
deliver(address, 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"))
|
|
1391
|
+
deliver(context, address, started, 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"))
|
|
1353
1392
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1354
1393
|
}
|
|
1355
1394
|
when (fetched) {
|
|
1356
1395
|
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
|
|
1357
1396
|
is CraftResumableDownload.Fetched.Refused -> {
|
|
1358
|
-
deliver(address, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
|
|
1397
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
|
|
1359
1398
|
return Outcome.DONE
|
|
1360
1399
|
}
|
|
1361
1400
|
is CraftResumableDownload.Fetched.Incomplete -> {
|
|
1362
|
-
deliver(address, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
|
|
1401
|
+
deliver(context, address, started, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
|
|
1363
1402
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1364
1403
|
}
|
|
1365
1404
|
is CraftResumableDownload.Fetched.Complete -> Unit
|
|
@@ -1367,16 +1406,15 @@ internal object CraftNativeMedia {
|
|
|
1367
1406
|
synchronized(lock) {
|
|
1368
1407
|
if (generation(context) != started) {
|
|
1369
1408
|
staging.delete()
|
|
1370
|
-
deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
|
|
1371
1409
|
return Outcome.DONE
|
|
1372
1410
|
}
|
|
1373
1411
|
target.delete()
|
|
1374
1412
|
if (!staging.renameTo(target)) {
|
|
1375
|
-
deliver(address, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
|
|
1413
|
+
deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
|
|
1376
1414
|
return Outcome.DONE
|
|
1377
1415
|
}
|
|
1378
1416
|
}
|
|
1379
|
-
deliver(address, CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1417
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1380
1418
|
return Outcome.DONE
|
|
1381
1419
|
}
|
|
1382
1420
|
}
|
|
@@ -1435,7 +1473,7 @@ internal object CraftNativeMedia {
|
|
|
1435
1473
|
callbacks
|
|
1436
1474
|
}
|
|
1437
1475
|
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1438
|
-
main.post { answered.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1476
|
+
main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1439
1477
|
failure?.let { throw it }
|
|
1440
1478
|
return removed
|
|
1441
1479
|
}
|
|
@@ -2557,7 +2557,6 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2557
2557
|
val content = CraftNativeFlexLayout(context)
|
|
2558
2558
|
private val blocker = View(context)
|
|
2559
2559
|
private var renderedVisible = false
|
|
2560
|
-
private var dismissSuppressedByRequest = false
|
|
2561
2560
|
private var animationGeneration = 0
|
|
2562
2561
|
var focusToRestore: View? = null
|
|
2563
2562
|
var onRequestClose: ((String) -> Unit)? = null
|
|
@@ -2582,22 +2581,11 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2582
2581
|
|
|
2583
2582
|
fun requestClose(reason: String = "backdrop") {
|
|
2584
2583
|
val handler = onRequestClose ?: return
|
|
2585
|
-
dismissSuppressedByRequest = true
|
|
2586
2584
|
isClickable = false
|
|
2587
2585
|
blocker.isEnabled = false
|
|
2588
2586
|
handler(reason)
|
|
2589
2587
|
}
|
|
2590
2588
|
|
|
2591
|
-
fun consumeDismissSuppression(): Boolean {
|
|
2592
|
-
val suppressed = dismissSuppressedByRequest
|
|
2593
|
-
dismissSuppressedByRequest = false
|
|
2594
|
-
return suppressed
|
|
2595
|
-
}
|
|
2596
|
-
|
|
2597
|
-
fun clearDismissSuppression() {
|
|
2598
|
-
dismissSuppressedByRequest = false
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
2589
|
fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
|
|
2602
2590
|
if (visible) {
|
|
2603
2591
|
isClickable = true
|
|
@@ -2656,7 +2644,6 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2656
2644
|
alpha = 0f
|
|
2657
2645
|
isClickable = true
|
|
2658
2646
|
blocker.isEnabled = true
|
|
2659
|
-
dismissSuppressedByRequest = false
|
|
2660
2647
|
onRequestClose = null
|
|
2661
2648
|
}
|
|
2662
2649
|
}
|
|
@@ -5410,15 +5397,13 @@ class MainActivity : AppCompatActivity() {
|
|
|
5410
5397
|
if (restore.isAttachedToWindow) restore.requestFocus()
|
|
5411
5398
|
}
|
|
5412
5399
|
}
|
|
5413
|
-
if (visible) clearDismissSuppression()
|
|
5414
|
-
val suppressDismiss = !visible && consumeDismissSuppression()
|
|
5415
5400
|
if (wasVisible != visible && (visible || wasVisible == true)) {
|
|
5416
5401
|
val lifecycleHandler = if (visible) {
|
|
5417
5402
|
craftNativeOptionalString(events.opt("onShow"))
|
|
5418
5403
|
} else {
|
|
5419
5404
|
craftNativeOptionalString(events.opt("onDismiss"))
|
|
5420
5405
|
}
|
|
5421
|
-
if (!lifecycleHandler.isNullOrBlank()
|
|
5406
|
+
if (!lifecycleHandler.isNullOrBlank()) {
|
|
5422
5407
|
// Deliver lifecycle callbacks after reconciliation returns to
|
|
5423
5408
|
// the queue; the handler may immediately schedule another
|
|
5424
5409
|
// render against this native view.
|
package/dist/cli.js
CHANGED
|
@@ -2842,7 +2842,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
2842
2842
|
`);
|
|
2843
2843
|
}
|
|
2844
2844
|
// package.json
|
|
2845
|
-
var version = "0.0.
|
|
2845
|
+
var version = "0.0.250";
|
|
2846
2846
|
|
|
2847
2847
|
// bin/cli.ts
|
|
2848
2848
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -648,6 +648,9 @@ final class CraftChrome: ObservableObject {
|
|
|
648
648
|
var titleOverride: String? = nil
|
|
649
649
|
var iconTint: String? = nil
|
|
650
650
|
var iconPulse = false
|
|
651
|
+
/// The page's own symbol for the tab while it says so (a live
|
|
652
|
+
/// reading's filled heart), in place of the configured one.
|
|
653
|
+
var iconSymbol: String? = nil
|
|
651
654
|
var selectedSymbol: String? = nil
|
|
652
655
|
var selectedIconTint: String? = nil
|
|
653
656
|
var selectedIconPulse: Bool? = nil
|
|
@@ -658,7 +661,7 @@ final class CraftChrome: ObservableObject {
|
|
|
658
661
|
}
|
|
659
662
|
|
|
660
663
|
func iconAppearance(selected: Bool, motionAllowed: Bool) -> (symbol: String, tint: String?, pulse: Bool) {
|
|
661
|
-
let name = selected ? selectedSymbol ?? symbol : symbol
|
|
664
|
+
let name = selected ? selectedSymbol ?? iconSymbol ?? symbol : iconSymbol ?? symbol
|
|
662
665
|
let tint = selected ? selectedIconTint ?? iconTint : iconTint
|
|
663
666
|
let pulse = selected ? selectedIconPulse ?? iconPulse : iconPulse
|
|
664
667
|
return (name, tint, pulse && motionAllowed)
|
|
@@ -750,6 +753,9 @@ final class CraftChrome: ObservableObject {
|
|
|
750
753
|
if item.keys.contains("iconPulse") {
|
|
751
754
|
next.iconPulse = item["iconPulse"] as? Bool ?? false
|
|
752
755
|
}
|
|
756
|
+
if item.keys.contains("symbol") {
|
|
757
|
+
next.iconSymbol = Tab.normalizedSelectedSymbol(item["symbol"] as? String)
|
|
758
|
+
}
|
|
753
759
|
if item.keys.contains("selectedSymbol") {
|
|
754
760
|
next.selectedSymbol = Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String)
|
|
755
761
|
}
|
|
@@ -1249,8 +1249,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1249
1249
|
// create a second web root here: the shared WebView cannot draw
|
|
1250
1250
|
// both pages during the same gesture.
|
|
1251
1251
|
guard hybrid.routes.match(id) != nil else { return false }
|
|
1252
|
-
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
|
|
1253
|
-
(target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1252
|
+
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id, hidden: true) }
|
|
1254
1253
|
}
|
|
1255
1254
|
guard current.view !== target.view,
|
|
1256
1255
|
!(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
|
|
@@ -1546,6 +1545,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1546
1545
|
|
|
1547
1546
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1548
1547
|
|
|
1548
|
+
/// A swipe preview can be visible without owning navigation or focus.
|
|
1549
|
+
/// UIKit appearance callbacks must follow the committed native stack.
|
|
1550
|
+
func isFocusedScreen(_ screen: CraftNativeScreenController) -> Bool {
|
|
1551
|
+
visibleStack?.topViewController === screen
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1549
1554
|
var webIsVisible: Bool {
|
|
1550
1555
|
guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
|
|
1551
1556
|
return webHost.view.superview === slot.view
|
|
@@ -1557,7 +1562,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1557
1562
|
/// the view hierarchy, so its screen never takes the shell's chrome (the
|
|
1558
1563
|
/// bar, the tab accessory) from the one on screen.
|
|
1559
1564
|
private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
|
|
1560
|
-
let
|
|
1565
|
+
let root = entry(for: rootPath, isTabRoot: true, depth: nil)
|
|
1566
|
+
// Constructing the navigation controller can load the root's view and
|
|
1567
|
+
// mount its script immediately. Set its initial focus before that.
|
|
1568
|
+
if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
|
|
1569
|
+
let nav = UINavigationController(rootViewController: root)
|
|
1561
1570
|
nav.delegate = self
|
|
1562
1571
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1563
1572
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -3788,7 +3788,6 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
3788
3788
|
private let blocker = UIView()
|
|
3789
3789
|
private let backdropTap = UITapGestureRecognizer()
|
|
3790
3790
|
private var renderedVisible = false
|
|
3791
|
-
private var dismissSuppressedByRequest = false
|
|
3792
3791
|
private var animationGeneration = 0
|
|
3793
3792
|
var onRequestClose: ((String) -> Void)?
|
|
3794
3793
|
|
|
@@ -3877,25 +3876,15 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
3877
3876
|
transform = .identity
|
|
3878
3877
|
alpha = 0
|
|
3879
3878
|
isUserInteractionEnabled = true
|
|
3880
|
-
dismissSuppressedByRequest = false
|
|
3881
3879
|
onRequestClose = nil
|
|
3882
3880
|
}
|
|
3883
3881
|
|
|
3884
3882
|
func requestClose(reason: String = "backdrop") {
|
|
3885
3883
|
guard let onRequestClose else { return }
|
|
3886
|
-
dismissSuppressedByRequest = true
|
|
3887
3884
|
isUserInteractionEnabled = false
|
|
3888
3885
|
onRequestClose(reason)
|
|
3889
3886
|
}
|
|
3890
3887
|
|
|
3891
|
-
func consumeDismissSuppression() -> Bool {
|
|
3892
|
-
let suppressed = dismissSuppressedByRequest
|
|
3893
|
-
dismissSuppressedByRequest = false
|
|
3894
|
-
return suppressed
|
|
3895
|
-
}
|
|
3896
|
-
|
|
3897
|
-
func clearDismissSuppression() { dismissSuppressedByRequest = false }
|
|
3898
|
-
|
|
3899
3888
|
@objc private func backdropTapped() { requestClose(reason: "backdrop") }
|
|
3900
3889
|
|
|
3901
3890
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
@@ -3993,6 +3982,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
3993
3982
|
required init?(coder: NSCoder) { nil }
|
|
3994
3983
|
|
|
3995
3984
|
override func touchesShouldCancel(in view: UIView) -> Bool {
|
|
3985
|
+
// Buttons and text fields yield once a scroll drag begins; an
|
|
3986
|
+
// ordinary tap still reaches them immediately. Value controls such
|
|
3987
|
+
// as sliders, switches and date pickers keep their tracking gesture.
|
|
3988
|
+
if view is UIButton || view is UITextField { return true }
|
|
3996
3989
|
if view is UIControl { return false }
|
|
3997
3990
|
return super.touchesShouldCancel(in: view)
|
|
3998
3991
|
}
|
|
@@ -5143,6 +5136,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5143
5136
|
/// sees, not a tab prepared behind it.
|
|
5144
5137
|
private var isShownOnScreen: Bool {
|
|
5145
5138
|
guard let window = view.window else { return false }
|
|
5139
|
+
if let shell = navigationController?.parent as? CraftHybridController,
|
|
5140
|
+
!shell.isFocusedScreen(self) { return false }
|
|
5146
5141
|
var candidate: UIView? = view
|
|
5147
5142
|
while let current = candidate, current !== window {
|
|
5148
5143
|
if current.isHidden { return false }
|
|
@@ -5326,13 +5321,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5326
5321
|
}
|
|
5327
5322
|
|
|
5328
5323
|
/// What a screen's script starts with that only the main thread may read:
|
|
5329
|
-
/// the app's state, the appearance, what the device can do.
|
|
5324
|
+
/// the app's state, the appearance, initial visibility, what the device can do.
|
|
5330
5325
|
private struct ScriptInputs {
|
|
5331
5326
|
let capabilityJSON: String
|
|
5332
5327
|
let appState: String
|
|
5333
5328
|
let colorScheme: String
|
|
5334
5329
|
let appearanceJSON: String
|
|
5335
5330
|
let sessionTokenKey: String
|
|
5331
|
+
let focused: Bool?
|
|
5336
5332
|
}
|
|
5337
5333
|
|
|
5338
5334
|
private func scriptInputs() -> ScriptInputs {
|
|
@@ -5343,7 +5339,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5343
5339
|
appState: CraftNativeActions.currentAppState(),
|
|
5344
5340
|
colorScheme: colorScheme,
|
|
5345
5341
|
appearanceJSON: appearanceJSON(),
|
|
5346
|
-
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
|
|
5342
|
+
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config)),
|
|
5343
|
+
focused: reportedVisible
|
|
5347
5344
|
)
|
|
5348
5345
|
}
|
|
5349
5346
|
|
|
@@ -5355,7 +5352,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5355
5352
|
jsContext.setObject(routeProps as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5356
5353
|
// Hidden before its script ran: the SCREEN_FOCUS saying so reached no
|
|
5357
5354
|
// callback, so the script reads it as it starts instead.
|
|
5358
|
-
if
|
|
5355
|
+
if inputs.focused == false {
|
|
5359
5356
|
jsContext.setObject(false, forKeyedSubscript: "__stxNativeFocused" as NSString)
|
|
5360
5357
|
}
|
|
5361
5358
|
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
@@ -7359,11 +7356,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7359
7356
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
7360
7357
|
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
7361
7358
|
}
|
|
7362
|
-
if visible { modal.clearDismissSuppression() }
|
|
7363
|
-
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
7364
7359
|
if wasVisible != visible, visible || wasVisible == true {
|
|
7365
7360
|
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
7366
|
-
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler)
|
|
7361
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
7367
7362
|
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
7368
7363
|
}
|
|
7369
7364
|
}
|
|
@@ -7441,10 +7436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7441
7436
|
// already-stopped view changes hidesWhenStopped to false.
|
|
7442
7437
|
// Android applies the requested visibility on every render;
|
|
7443
7438
|
// keep the two hosts in lockstep for dynamic prop changes.
|
|
7444
|
-
indicator.isHidden = indicator.hidesWhenStopped
|
|
7439
|
+
indicator.isHidden = style["display"] as? String == "none" || indicator.hidesWhenStopped
|
|
7445
7440
|
} else {
|
|
7446
7441
|
indicator.startAnimating()
|
|
7447
|
-
indicator.isHidden =
|
|
7442
|
+
indicator.isHidden = style["display"] as? String == "none"
|
|
7448
7443
|
}
|
|
7449
7444
|
case "Svg":
|
|
7450
7445
|
let drawing = result as! CraftNativeSvgView
|
|
@@ -9097,7 +9092,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9097
9092
|
// A video with its system controls is a group too: VoiceOver reaches
|
|
9098
9093
|
// play, the scrubber, skipping and full screen inside it.
|
|
9099
9094
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9100
|
-
|
|
9095
|
+
// UIPickerView exposes adjustable component wheels. Making the
|
|
9096
|
+
// picker itself a leaf hides those wheels and their selected values.
|
|
9097
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
9101
9098
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9102
9099
|
|| controlledVideo
|
|
9103
9100
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -9108,7 +9105,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9108
9105
|
// screen omits redundant accessibility metadata.
|
|
9109
9106
|
var traits: UIAccessibilityTraits = []
|
|
9110
9107
|
switch type {
|
|
9111
|
-
case "Slider", "
|
|
9108
|
+
case "Slider", "DatePicker": traits.insert(.adjustable)
|
|
9112
9109
|
default: break
|
|
9113
9110
|
}
|
|
9114
9111
|
switch role ?? type.lowercased() {
|
|
@@ -690,8 +690,15 @@ enum CraftNativePush {
|
|
|
690
690
|
|
|
691
691
|
// MARK: - Share sheet
|
|
692
692
|
|
|
693
|
-
/// A URL stays a URL in Messages
|
|
693
|
+
/// A URL stays a URL in Messages and AirDrop. Its title belongs in the
|
|
694
694
|
/// system preview and mail subject, rather than becoming a second text item.
|
|
695
|
+
///
|
|
696
|
+
/// Copy is the exception: it writes the address as text. A URL-only
|
|
697
|
+
/// pasteboard item has no textual representation, so `UIPasteboard.string`
|
|
698
|
+
/// is nil, a web page's `navigator.clipboard.readText()` gets nothing, and
|
|
699
|
+
/// so does the simulator's `pbpaste` - the shared link copied as nothing
|
|
700
|
+
/// anywhere text is read. Text that is an address is still detected as a
|
|
701
|
+
/// link wherever it is pasted.
|
|
695
702
|
final class CraftShareLink: NSObject, UIActivityItemSource {
|
|
696
703
|
let url: URL
|
|
697
704
|
let title: String
|
|
@@ -703,7 +710,9 @@ final class CraftShareLink: NSObject, UIActivityItemSource {
|
|
|
703
710
|
|
|
704
711
|
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { url }
|
|
705
712
|
|
|
706
|
-
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
713
|
+
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
714
|
+
activityType == .copyToPasteboard ? url.absoluteString : url
|
|
715
|
+
}
|
|
707
716
|
|
|
708
717
|
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { title }
|
|
709
718
|
|
|
@@ -139,6 +139,33 @@ final class DriverTests: XCTestCase {
|
|
|
139
139
|
return nil
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/// Whether `target`, or a field inside it, holds the keyboard.
|
|
143
|
+
func hasKeyboardFocus(_ target: XCUIElement) -> Bool {
|
|
144
|
+
if (target.value(forKey: "hasKeyboardFocus") as? Bool) == true { return true }
|
|
145
|
+
return target.descendants(matching: .any).matching(NSPredicate(format: "hasKeyboardFocus == 1")).firstMatch.exists
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Taps `target` until it holds the keyboard, for up to `seconds`.
|
|
149
|
+
///
|
|
150
|
+
/// A tap is not focus. The first keyboard a freshly created simulator
|
|
151
|
+
/// shows takes its keyboard process many seconds to start - one CI run
|
|
152
|
+
/// spent 20s delivering the tap - and the field had not become first
|
|
153
|
+
/// responder when the text was typed, which failed with "Neither element
|
|
154
|
+
/// nor any descendant has keyboard focus". A later tap lands once the app
|
|
155
|
+
/// is idle again.
|
|
156
|
+
func focus(_ target: XCUIElement, seconds: Double = 15) -> Bool {
|
|
157
|
+
let end = Date().addingTimeInterval(seconds)
|
|
158
|
+
repeat {
|
|
159
|
+
target.tap()
|
|
160
|
+
let settle = Date().addingTimeInterval(3)
|
|
161
|
+
repeat {
|
|
162
|
+
if hasKeyboardFocus(target) { return true }
|
|
163
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
164
|
+
} while Date() < settle
|
|
165
|
+
} while Date() < end
|
|
166
|
+
return hasKeyboardFocus(target)
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
|
|
143
170
|
poll(seconds) { element(containing: text) }
|
|
144
171
|
}
|
|
@@ -375,8 +402,10 @@ final class DriverTests: XCTestCase {
|
|
|
375
402
|
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
376
403
|
let target = identified(idParts[0])
|
|
377
404
|
if target.waitForExistence(timeout: 10) {
|
|
378
|
-
target
|
|
379
|
-
|
|
405
|
+
guard focus(target) else {
|
|
406
|
+
check(false, "typeid \(idParts[0]) takes keyboard focus")
|
|
407
|
+
return
|
|
408
|
+
}
|
|
380
409
|
app.typeText(idParts.count > 1 ? idParts[1] : "")
|
|
381
410
|
note("typeid \(idParts[0]): ok")
|
|
382
411
|
} else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
|
|
@@ -402,8 +431,10 @@ final class DriverTests: XCTestCase {
|
|
|
402
431
|
let index = Int(fieldParts[0]) ?? 0
|
|
403
432
|
let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
|
|
404
433
|
if index >= 0 && index < fields.count {
|
|
405
|
-
fields[index]
|
|
406
|
-
|
|
434
|
+
guard focus(fields[index]) else {
|
|
435
|
+
check(false, "field \(index) takes keyboard focus")
|
|
436
|
+
return
|
|
437
|
+
}
|
|
407
438
|
app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
|
|
408
439
|
note("field \(index): ok")
|
|
409
440
|
} else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
|
package/dist/mobile-testing.js
CHANGED
|
@@ -55,6 +55,9 @@ async function verifyMobileMediaCache(options) {
|
|
|
55
55
|
const bytes = new Uint8Array(await Bun.file(join(fixtures, "demo.mp4")).arrayBuffer());
|
|
56
56
|
const counts = new Map;
|
|
57
57
|
const resumed = new Set;
|
|
58
|
+
let lockedReleased = false;
|
|
59
|
+
let finishLocked;
|
|
60
|
+
let finishObsolete;
|
|
58
61
|
const timers = new Set;
|
|
59
62
|
const server = Bun.listen({
|
|
60
63
|
hostname: "0.0.0.0",
|
|
@@ -73,6 +76,43 @@ async function verifyMobileMediaCache(options) {
|
|
|
73
76
|
const path = socket.data.request.split(" ")[1];
|
|
74
77
|
const n = (counts.get(path) || 0) + 1;
|
|
75
78
|
counts.set(path, n);
|
|
79
|
+
if (path === "/android/inflight/slow.mp4" && n === 1) {
|
|
80
|
+
finishObsolete = () => socket.end(`HTTP/1.1 403 Forbidden\r
|
|
81
|
+
Content-Length: 0\r
|
|
82
|
+
Connection: close\r
|
|
83
|
+
\r
|
|
84
|
+
`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (path === "/android/inflight/status") {
|
|
88
|
+
socket.end(`HTTP/1.1 200 OK\r
|
|
89
|
+
Content-Length: 1\r
|
|
90
|
+
Connection: close\r
|
|
91
|
+
\r
|
|
92
|
+
${finishObsolete ? "1" : "0"}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (path === "/android/inflight/release") {
|
|
96
|
+
finishObsolete?.();
|
|
97
|
+
finishObsolete = undefined;
|
|
98
|
+
socket.end(`HTTP/1.1 200 OK\r
|
|
99
|
+
Content-Length: 0\r
|
|
100
|
+
Connection: close\r
|
|
101
|
+
\r
|
|
102
|
+
`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (path === "/android/locked/release") {
|
|
106
|
+
lockedReleased = true;
|
|
107
|
+
finishLocked?.();
|
|
108
|
+
finishLocked = undefined;
|
|
109
|
+
socket.end(`HTTP/1.1 200 OK\r
|
|
110
|
+
Content-Length: 0\r
|
|
111
|
+
Connection: close\r
|
|
112
|
+
\r
|
|
113
|
+
`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
76
116
|
const from = Number(/^range:\s*bytes=(\d+)-\s*$/im.exec(socket.data.request)?.[1] ?? Number.NaN);
|
|
77
117
|
if (path === "/android/restart.mp4" && n > 1 && from > 0 && from < bytes.length) {
|
|
78
118
|
resumed.add(path);
|
|
@@ -111,6 +151,13 @@ Connection: close\r
|
|
|
111
151
|
socket.write(bytes.subarray(Math.floor(bytes.length / 2)));
|
|
112
152
|
socket.end();
|
|
113
153
|
};
|
|
154
|
+
if (path === "/android/locked/slow.mp4") {
|
|
155
|
+
if (lockedReleased)
|
|
156
|
+
finish();
|
|
157
|
+
else
|
|
158
|
+
finishLocked = finish;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
114
161
|
if (slow) {
|
|
115
162
|
const timer = setTimeout(() => {
|
|
116
163
|
timers.delete(timer);
|
|
@@ -284,6 +331,16 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
284
331
|
throw new Error("Android did not resume the interrupted download from its part file");
|
|
285
332
|
if (counts.get("/android/slow.mp4") !== 1)
|
|
286
333
|
throw new Error("Android duplicate fetch: keep did not coalesce");
|
|
334
|
+
if (counts.get("/android/queued.mp4") !== 1)
|
|
335
|
+
throw new Error("Android queued work was replaced instead of joined");
|
|
336
|
+
if (counts.get("/android/locked/slow.mp4") !== 1)
|
|
337
|
+
throw new Error("Android waiting writer fetched an already completed video");
|
|
338
|
+
if (counts.get("/android/stale/slow.mp4") !== 1)
|
|
339
|
+
throw new Error("Android obsolete worker fetched across a cache generation");
|
|
340
|
+
if (counts.get("/android/inflight/slow.mp4") !== 2)
|
|
341
|
+
throw new Error("Android in-flight generations did not retain exactly their own transfers");
|
|
342
|
+
if (counts.get("/android/late-queued.mp4") !== 1)
|
|
343
|
+
throw new Error("Android kept obsolete queued work across a cache generation");
|
|
287
344
|
if (counts.get("/android/account/slow.mp4") !== 2)
|
|
288
345
|
throw new Error("Android account switch did not start a fresh transfer");
|
|
289
346
|
print("PASS: Android emulator interruption, resumed restart, retry, prune and cached video decode");
|
|
@@ -102,6 +102,89 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
102
102
|
check(root.listFiles()?.none { it.name.endsWith(".part") && it.name != "${brokenTarget.name}.part" } != false, "a failed transfer leaves only its own part, to resume")
|
|
103
103
|
check(keep(broken), "same URL retries successfully, at once")
|
|
104
104
|
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
|
|
105
|
+
val queued = "$base/queued.mp4"
|
|
106
|
+
val work = androidx.work.WorkManager.getInstance(targetContext)
|
|
107
|
+
val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
|
|
108
|
+
val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
109
|
+
.setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
110
|
+
.setInputData(androidx.work.workDataOf("address" to queued, "headers" to "{}", "generation" to CraftNativeMedia.generation(targetContext)))
|
|
111
|
+
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(queued)}")
|
|
112
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
|
|
113
|
+
work.enqueueUniqueWork(queuedName, androidx.work.ExistingWorkPolicy.KEEP, queuedRequest).result.get()
|
|
114
|
+
val beforeJoin = work.getWorkInfoById(queuedRequest.id).get()!!
|
|
115
|
+
check(beforeJoin.state == androidx.work.WorkInfo.State.ENQUEUED && beforeJoin.runAttemptCount == 0, "the coalescing regression starts with a fresh queued transfer")
|
|
116
|
+
check(keep(queued), "keep joins the fresh queued transfer")
|
|
117
|
+
val afterJoin = work.getWorkInfosForUniqueWork(queuedName).get()
|
|
118
|
+
check(afterJoin.size == 1 && afterJoin.single().id == queuedRequest.id && afterJoin.single().state != androidx.work.WorkInfo.State.CANCELLED, "a second keep retains the original queued work identity")
|
|
119
|
+
|
|
120
|
+
val locked = "$base/locked/slow.mp4"
|
|
121
|
+
val started = CraftNativeMedia.generation(targetContext)
|
|
122
|
+
var firstOutcome: CraftNativeMedia.Outcome? = null
|
|
123
|
+
var secondOutcome: CraftNativeMedia.Outcome? = null
|
|
124
|
+
val firstWriter = Thread { firstOutcome = CraftNativeMedia.transfer(targetContext, locked, JSONObject(), started, 0) { false } }
|
|
125
|
+
val secondWriter = Thread { secondOutcome = CraftNativeMedia.transfer(targetContext, locked, JSONObject(), started, 0) { false } }
|
|
126
|
+
firstWriter.start()
|
|
127
|
+
val writingDeadline = System.currentTimeMillis() + 25000
|
|
128
|
+
while (CraftNativeMedia.part(targetContext, locked).length() == 0L && System.currentTimeMillis() < writingDeadline) Thread.sleep(10)
|
|
129
|
+
check(CraftNativeMedia.part(targetContext, locked).length() > 0L, "the first writer owns an active partial download")
|
|
130
|
+
secondWriter.start()
|
|
131
|
+
val blockedDeadline = System.currentTimeMillis() + 5000
|
|
132
|
+
while (secondWriter.state != Thread.State.BLOCKED && secondWriter.isAlive && System.currentTimeMillis() < blockedDeadline) Thread.sleep(1)
|
|
133
|
+
check(secondWriter.state == Thread.State.BLOCKED, "the second writer waits on the same address lock")
|
|
134
|
+
java.net.URL("$base/locked/release").openConnection().getInputStream().use { it.readBytes() }
|
|
135
|
+
firstWriter.join(25000)
|
|
136
|
+
secondWriter.join(25000)
|
|
137
|
+
check(!firstWriter.isAlive && !secondWriter.isAlive && firstOutcome == CraftNativeMedia.Outcome.DONE && secondOutcome == CraftNativeMedia.Outcome.DONE, "both address writers finish from the same cached copy")
|
|
138
|
+
check(CraftNativeMedia.local(targetContext, locked) != null, "the coalesced writer leaves a complete local video")
|
|
139
|
+
|
|
140
|
+
val stale = "$base/stale/slow.mp4"
|
|
141
|
+
val staleGeneration = CraftNativeMedia.generation(targetContext)
|
|
142
|
+
CraftNativeMedia.clear(targetContext)
|
|
143
|
+
val currentDone = java.util.concurrent.CountDownLatch(1)
|
|
144
|
+
var currentOK = false
|
|
145
|
+
CraftNativeMedia.keep(targetContext, stale, JSONObject(), true) { currentOK = (it.data as? JSONObject)?.optBoolean("ok") == true; currentDone.countDown() }
|
|
146
|
+
val currentDeadline = System.currentTimeMillis() + 25000
|
|
147
|
+
while (CraftNativeMedia.part(targetContext, stale).length() == 0L && System.currentTimeMillis() < currentDeadline) Thread.sleep(10)
|
|
148
|
+
check(CraftNativeMedia.part(targetContext, stale).length() > 0L, "the next generation has its own active transfer")
|
|
149
|
+
check(CraftNativeMedia.transfer(targetContext, stale, JSONObject(), staleGeneration, 0) { false } == CraftNativeMedia.Outcome.DONE, "an obsolete worker exits without fetching")
|
|
150
|
+
check(currentDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && currentOK, "an obsolete worker cannot cancel the next generation's waiters")
|
|
151
|
+
|
|
152
|
+
val inflight = "$base/inflight/slow.mp4"
|
|
153
|
+
val inflightGeneration = CraftNativeMedia.generation(targetContext)
|
|
154
|
+
var obsoleteOutcome: CraftNativeMedia.Outcome? = null
|
|
155
|
+
val obsoleteWriter = Thread { obsoleteOutcome = CraftNativeMedia.transfer(targetContext, inflight, JSONObject(), inflightGeneration, 0) { false } }
|
|
156
|
+
obsoleteWriter.start()
|
|
157
|
+
fun oldRequestReachedServer() = java.net.URL("$base/inflight/status").readText() == "1"
|
|
158
|
+
val inflightDeadline = System.currentTimeMillis() + 25000
|
|
159
|
+
while (!oldRequestReachedServer() && System.currentTimeMillis() < inflightDeadline) Thread.sleep(10)
|
|
160
|
+
check(oldRequestReachedServer(), "the obsolete generation is waiting for its HTTP response")
|
|
161
|
+
CraftNativeMedia.clear(targetContext)
|
|
162
|
+
val nextDone = java.util.concurrent.CountDownLatch(1)
|
|
163
|
+
var nextOK = false
|
|
164
|
+
CraftNativeMedia.keep(targetContext, inflight, JSONObject(), true) { nextOK = (it.data as? JSONObject)?.optBoolean("ok") == true; nextDone.countDown() }
|
|
165
|
+
java.net.URL("$base/inflight/release").openConnection().getInputStream().use { it.readBytes() }
|
|
166
|
+
obsoleteWriter.join(25000)
|
|
167
|
+
check(!obsoleteWriter.isAlive && obsoleteOutcome == CraftNativeMedia.Outcome.DONE, "the old generation's delayed refusal finishes")
|
|
168
|
+
check(nextDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && nextOK, "an old in-flight refusal cannot consume the next generation's callback")
|
|
169
|
+
|
|
170
|
+
val late = "$base/late-queued.mp4"
|
|
171
|
+
val lateGeneration = CraftNativeMedia.generation(targetContext)
|
|
172
|
+
CraftNativeMedia.clear(targetContext)
|
|
173
|
+
val lateName = "craft-media:${CraftNativeMedia.digest(late)}:any"
|
|
174
|
+
// Reproduce an obsolete scheduler enqueue landing after clear's cancellation.
|
|
175
|
+
val lateRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
176
|
+
.setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
177
|
+
.setInputData(androidx.work.workDataOf("address" to late, "headers" to "{}", "generation" to lateGeneration))
|
|
178
|
+
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(late)}")
|
|
179
|
+
.addTag("craft-media:generation:$lateGeneration").build()
|
|
180
|
+
work.enqueueUniqueWork(lateName, androidx.work.ExistingWorkPolicy.KEEP, lateRequest).result.get()
|
|
181
|
+
check(work.getWorkInfoById(lateRequest.id).get()!!.state == androidx.work.WorkInfo.State.ENQUEUED, "the obsolete queued request lands after clear")
|
|
182
|
+
check(keep(late), "a new generation replaces obsolete queued work")
|
|
183
|
+
val currentWork = work.getWorkInfosForUniqueWork(lateName).get()
|
|
184
|
+
check(currentWork.size == 1 && currentWork.single().id != lateRequest.id &&
|
|
185
|
+
"craft-media:generation:${CraftNativeMedia.generation(targetContext)}" in currentWork.single().tags &&
|
|
186
|
+
currentWork.single().state != androidx.work.WorkInfo.State.CANCELLED,
|
|
187
|
+
"obsolete queued work is replaced by a current-generation work identity")
|
|
105
188
|
check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
|
|
106
189
|
check(!keep("$base/empty.mp4"), "empty file is refused")
|
|
107
190
|
val slow = "$base/slow.mp4"
|