craft-native 0.0.249 → 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 +60 -28
- 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/CraftNativeScreen.swift +7 -19
- 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);
|
|
@@ -1238,7 +1238,8 @@ internal object CraftNativeMedia {
|
|
|
1238
1238
|
private const val PREFS = "craft.media"
|
|
1239
1239
|
private val lock = Any()
|
|
1240
1240
|
/** Keeps waiting in this process for an address's transfer. */
|
|
1241
|
-
private val
|
|
1241
|
+
private data class Waiting(val generation: Int, val answer: (CraftNativeCapabilityResult) -> Unit)
|
|
1242
|
+
private val waiting = HashMap<String, MutableList<Waiting>>()
|
|
1242
1243
|
/** One transfer writes an address's part file at a time. */
|
|
1243
1244
|
private val writers = ConcurrentHashMap<String, Any>()
|
|
1244
1245
|
private val background = Executors.newSingleThreadExecutor()
|
|
@@ -1276,44 +1277,69 @@ internal object CraftNativeMedia {
|
|
|
1276
1277
|
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
|
|
1277
1278
|
return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
|
|
1278
1279
|
}
|
|
1279
|
-
local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1280
1280
|
val app = context.applicationContext
|
|
1281
|
-
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
|
+
}
|
|
1282
1285
|
val wifiOnly = !cellular
|
|
1283
1286
|
val work = androidx.work.WorkManager.getInstance(app)
|
|
1284
1287
|
// The same address under the other rule gives way; its part file is resumed.
|
|
1285
1288
|
work.cancelUniqueWork(workName(address, !wifiOnly))
|
|
1286
1289
|
val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
1287
1290
|
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
|
|
1288
|
-
.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))
|
|
1289
1292
|
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
|
|
1290
1293
|
.addTag(TAG)
|
|
1294
|
+
.addTag("$TAG:generation:$started")
|
|
1291
1295
|
.addTag("$TAG:${digest(address)}")
|
|
1292
1296
|
.build()
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
// 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.
|
|
1296
1299
|
val name = workName(address, wifiOnly)
|
|
1297
1300
|
background.execute {
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
+
}
|
|
1301
1317
|
}
|
|
1302
1318
|
val reason = when {
|
|
1303
1319
|
!online(app) -> "offline"
|
|
1304
1320
|
wifiOnly && metered(app) -> "cellular"
|
|
1305
1321
|
else -> null
|
|
1306
1322
|
}
|
|
1307
|
-
if (reason != null) deliver(address, queued(address, reason))
|
|
1323
|
+
if (reason != null) deliver(context, address, started, queued(address, reason))
|
|
1308
1324
|
}
|
|
1309
1325
|
|
|
1310
1326
|
private fun queued(address: String, reason: String) =
|
|
1311
1327
|
CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
|
|
1312
1328
|
|
|
1313
|
-
/** Answers
|
|
1314
|
-
fun deliver(address: String, result: CraftNativeCapabilityResult) {
|
|
1315
|
-
val answered = synchronized(lock) {
|
|
1316
|
-
|
|
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
|
+
}
|
|
1317
1343
|
}
|
|
1318
1344
|
|
|
1319
1345
|
enum class Outcome { DONE, RETRY }
|
|
@@ -1324,16 +1350,23 @@ internal object CraftNativeMedia {
|
|
|
1324
1350
|
* waits. RETRY leaves the part file for the next attempt.
|
|
1325
1351
|
*/
|
|
1326
1352
|
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
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
|
|
1331
1356
|
local(context, address)?.let {
|
|
1332
|
-
deliver(address, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1357
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1333
1358
|
return Outcome.DONE
|
|
1334
1359
|
}
|
|
1335
1360
|
val writer = writers.getOrPut(address) { Any() }
|
|
1336
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
|
+
}
|
|
1337
1370
|
val target = file(context, address)
|
|
1338
1371
|
val staging = part(context, address)
|
|
1339
1372
|
target.parentFile?.mkdirs()
|
|
@@ -1350,22 +1383,22 @@ internal object CraftNativeMedia {
|
|
|
1350
1383
|
}
|
|
1351
1384
|
if (full) {
|
|
1352
1385
|
staging.delete()
|
|
1353
|
-
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"))
|
|
1354
1387
|
return Outcome.DONE
|
|
1355
1388
|
}
|
|
1356
1389
|
if (stopped()) return Outcome.RETRY
|
|
1357
1390
|
// Lost the connection part way: the part waits for the next attempt.
|
|
1358
|
-
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"))
|
|
1359
1392
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1360
1393
|
}
|
|
1361
1394
|
when (fetched) {
|
|
1362
1395
|
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
|
|
1363
1396
|
is CraftResumableDownload.Fetched.Refused -> {
|
|
1364
|
-
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)))
|
|
1365
1398
|
return Outcome.DONE
|
|
1366
1399
|
}
|
|
1367
1400
|
is CraftResumableDownload.Fetched.Incomplete -> {
|
|
1368
|
-
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"))
|
|
1369
1402
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1370
1403
|
}
|
|
1371
1404
|
is CraftResumableDownload.Fetched.Complete -> Unit
|
|
@@ -1373,16 +1406,15 @@ internal object CraftNativeMedia {
|
|
|
1373
1406
|
synchronized(lock) {
|
|
1374
1407
|
if (generation(context) != started) {
|
|
1375
1408
|
staging.delete()
|
|
1376
|
-
deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
|
|
1377
1409
|
return Outcome.DONE
|
|
1378
1410
|
}
|
|
1379
1411
|
target.delete()
|
|
1380
1412
|
if (!staging.renameTo(target)) {
|
|
1381
|
-
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"))
|
|
1382
1414
|
return Outcome.DONE
|
|
1383
1415
|
}
|
|
1384
1416
|
}
|
|
1385
|
-
deliver(address, CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1417
|
+
deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1386
1418
|
return Outcome.DONE
|
|
1387
1419
|
}
|
|
1388
1420
|
}
|
|
@@ -1441,7 +1473,7 @@ internal object CraftNativeMedia {
|
|
|
1441
1473
|
callbacks
|
|
1442
1474
|
}
|
|
1443
1475
|
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1444
|
-
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")) } }
|
|
1445
1477
|
failure?.let { throw it }
|
|
1446
1478
|
return removed
|
|
1447
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
|
@@ -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,9 +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 {
|
|
3996
|
-
//
|
|
3997
|
-
//
|
|
3998
|
-
|
|
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 }
|
|
3999
3989
|
if view is UIControl { return false }
|
|
4000
3990
|
return super.touchesShouldCancel(in: view)
|
|
4001
3991
|
}
|
|
@@ -7366,11 +7356,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7366
7356
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
7367
7357
|
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
7368
7358
|
}
|
|
7369
|
-
if visible { modal.clearDismissSuppression() }
|
|
7370
|
-
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
7371
7359
|
if wasVisible != visible, visible || wasVisible == true {
|
|
7372
7360
|
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
7373
|
-
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler)
|
|
7361
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
7374
7362
|
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
7375
7363
|
}
|
|
7376
7364
|
}
|
|
@@ -7448,10 +7436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7448
7436
|
// already-stopped view changes hidesWhenStopped to false.
|
|
7449
7437
|
// Android applies the requested visibility on every render;
|
|
7450
7438
|
// keep the two hosts in lockstep for dynamic prop changes.
|
|
7451
|
-
indicator.isHidden = indicator.hidesWhenStopped
|
|
7439
|
+
indicator.isHidden = style["display"] as? String == "none" || indicator.hidesWhenStopped
|
|
7452
7440
|
} else {
|
|
7453
7441
|
indicator.startAnimating()
|
|
7454
|
-
indicator.isHidden =
|
|
7442
|
+
indicator.isHidden = style["display"] as? String == "none"
|
|
7455
7443
|
}
|
|
7456
7444
|
case "Svg":
|
|
7457
7445
|
let drawing = result as! CraftNativeSvgView
|
|
@@ -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"
|