craft-native 0.0.251 → 0.0.254
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 +4 -1
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
- package/dist/android/templates/CraftNativeShell.kt.template +68 -23
- package/dist/android/templates/MainActivityNative.kt.template +5 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +8 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +2 -17
- package/dist/ios/templates/CraftNativeScreen.swift +20 -18
- package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
- package/dist/mobile-media-testing.d.ts +2 -0
- package/dist/mobile-testing.js +19 -10
- package/dist/testing/media-cache/media-cache.kt +47 -6
- package/package.json +1 -1
|
@@ -194,6 +194,7 @@ async function createAndroidEmulator(options) {
|
|
|
194
194
|
if (options.acceleration === "software" && process.arch !== "x64")
|
|
195
195
|
throw new Error("Android software emulation requires an x86_64 host and system image");
|
|
196
196
|
const bootTimeoutMs = options.bootTimeoutMs ?? 600000;
|
|
197
|
+
const requiresPlayStore = options.image.split(";")[2] === "google_apis_playstore";
|
|
197
198
|
const maximumBootTimeoutMs = options.acceleration === "software" ? 1200000 : 600000;
|
|
198
199
|
if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
|
|
199
200
|
throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`);
|
|
@@ -332,11 +333,13 @@ async function createAndroidEmulator(options) {
|
|
|
332
333
|
if (completion.code === 0 && completion.stdout.trim() === "1") {
|
|
333
334
|
const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
|
|
334
335
|
const packages = await run([adb, "-s", serial, "shell", "pm", "path", "android"], undefined, probeTimeout(), true);
|
|
336
|
+
const playStore = requiresPlayStore ? await run([adb, "-s", serial, "shell", "pm", "path", "com.android.vending"], undefined, probeTimeout(), true) : undefined;
|
|
335
337
|
const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
|
|
336
338
|
const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-t", "1"], undefined, probeTimeout(), true);
|
|
337
339
|
const home = await run([adb, "-s", serial, "shell", "cmd", "package", "resolve-activity", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.HOME"], undefined, probeTimeout(), true);
|
|
338
340
|
const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? "";
|
|
339
|
-
|
|
341
|
+
const temporaryHome = homeActivity.startsWith("com.android.sdksetup/") || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity);
|
|
342
|
+
booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && (!requiresPlayStore || playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome;
|
|
340
343
|
}
|
|
341
344
|
}
|
|
342
345
|
const checkedAt = Date.now();
|
|
@@ -413,7 +413,6 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
|
|
|
413
413
|
markers.values.forEach { builder.include(it.position); count++ }
|
|
414
414
|
polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
|
|
415
415
|
polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
|
|
416
|
-
polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
|
|
417
416
|
if (count == 0) return false
|
|
418
417
|
post {
|
|
419
418
|
if (width > 0 && height > 0) {
|
|
@@ -21,6 +21,9 @@ import android.widget.FrameLayout
|
|
|
21
21
|
import android.widget.ImageView
|
|
22
22
|
import android.widget.LinearLayout
|
|
23
23
|
import android.widget.TextView
|
|
24
|
+
import androidx.core.view.AccessibilityDelegateCompat
|
|
25
|
+
import androidx.core.view.ViewCompat
|
|
26
|
+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
24
27
|
import androidx.core.content.ContextCompat
|
|
25
28
|
import androidx.security.crypto.EncryptedSharedPreferences
|
|
26
29
|
import androidx.security.crypto.MasterKey
|
|
@@ -838,7 +841,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
838
841
|
cell.background = rippleBackground()
|
|
839
842
|
cell.contentDescription = tab.title
|
|
840
843
|
cell.tag = "craft-tab-${tab.id}"
|
|
844
|
+
cell.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
|
845
|
+
ViewCompat.setAccessibilityDelegate(cell, object : AccessibilityDelegateCompat() {
|
|
846
|
+
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
|
|
847
|
+
super.onInitializeAccessibilityNodeInfo(host, info)
|
|
848
|
+
info.viewIdResourceName = "craft-tab-${tab.id}"
|
|
849
|
+
info.className = android.widget.Button::class.java.name
|
|
850
|
+
info.isSelected = host.isSelected
|
|
851
|
+
}
|
|
852
|
+
})
|
|
841
853
|
val icon = TextView(context)
|
|
854
|
+
icon.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
842
855
|
icon.text = CraftNativeSymbols.glyph(tab.symbol)
|
|
843
856
|
icon.textSize = 22f
|
|
844
857
|
icon.minWidth = dp(26)
|
|
@@ -846,6 +859,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
846
859
|
icon.gravity = Gravity.CENTER
|
|
847
860
|
icon.includeFontPadding = false
|
|
848
861
|
val label = TextView(context)
|
|
862
|
+
label.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
849
863
|
label.text = tab.title
|
|
850
864
|
label.textSize = 12f
|
|
851
865
|
label.fontFeatureSettings = "tnum"
|
|
@@ -1234,6 +1248,7 @@ internal object CraftNativeSymbols {
|
|
|
1234
1248
|
* transfer finishes on its own; the next keep answers from the copy.
|
|
1235
1249
|
*/
|
|
1236
1250
|
internal object CraftNativeMedia {
|
|
1251
|
+
const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
|
|
1237
1252
|
private const val TAG = "craft-media"
|
|
1238
1253
|
private const val PREFS = "craft.media"
|
|
1239
1254
|
private val lock = Any()
|
|
@@ -1301,7 +1316,7 @@ internal object CraftNativeMedia {
|
|
|
1301
1316
|
try {
|
|
1302
1317
|
if (generation(app) == started) {
|
|
1303
1318
|
val pending = work.getWorkInfosForUniqueWork(name).get().any {
|
|
1304
|
-
"$TAG:generation:$started" in it.tags &&
|
|
1319
|
+
"$TAG:generation:$started" in it.tags && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
|
|
1305
1320
|
(it.state == androidx.work.WorkInfo.State.RUNNING ||
|
|
1306
1321
|
(it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
|
|
1307
1322
|
}
|
|
@@ -1349,12 +1364,22 @@ internal object CraftNativeMedia {
|
|
|
1349
1364
|
* file where it stopped, moves it into place whole, and answers whoever
|
|
1350
1365
|
* waits. RETRY leaves the part file for the next attempt.
|
|
1351
1366
|
*/
|
|
1352
|
-
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
|
|
1367
|
+
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
|
|
1368
|
+
fun answer(result: CraftNativeCapabilityResult) {
|
|
1369
|
+
// The callback can immediately call keep again. Publish completion
|
|
1370
|
+
// before that callback, while WorkManager may still say RUNNING.
|
|
1371
|
+
synchronized(lock) {
|
|
1372
|
+
// A keep registered after this marker belongs to the next
|
|
1373
|
+
// attempt; it must not enter the old callback batch.
|
|
1374
|
+
beforeAnswer()
|
|
1375
|
+
deliver(context, address, started, result)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1353
1378
|
// clear() already answered the old generation's waiters. Never answer
|
|
1354
1379
|
// a new athlete's waiters from an obsolete worker for the same URL.
|
|
1355
1380
|
if (generation(context) != started) return Outcome.DONE
|
|
1356
1381
|
local(context, address)?.let {
|
|
1357
|
-
|
|
1382
|
+
answer(CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1358
1383
|
return Outcome.DONE
|
|
1359
1384
|
}
|
|
1360
1385
|
val writer = writers.getOrPut(address) { Any() }
|
|
@@ -1364,7 +1389,7 @@ internal object CraftNativeMedia {
|
|
|
1364
1389
|
if (generation(context) != started) return Outcome.DONE
|
|
1365
1390
|
if (stopped()) return Outcome.RETRY
|
|
1366
1391
|
local(context, address)?.let {
|
|
1367
|
-
|
|
1392
|
+
answer(CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1368
1393
|
return Outcome.DONE
|
|
1369
1394
|
}
|
|
1370
1395
|
val target = file(context, address)
|
|
@@ -1383,22 +1408,22 @@ internal object CraftNativeMedia {
|
|
|
1383
1408
|
}
|
|
1384
1409
|
if (full) {
|
|
1385
1410
|
staging.delete()
|
|
1386
|
-
|
|
1411
|
+
answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
|
|
1387
1412
|
return Outcome.DONE
|
|
1388
1413
|
}
|
|
1389
1414
|
if (stopped()) return Outcome.RETRY
|
|
1390
1415
|
// Lost the connection part way: the part waits for the next attempt.
|
|
1391
|
-
|
|
1416
|
+
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"))
|
|
1392
1417
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1393
1418
|
}
|
|
1394
1419
|
when (fetched) {
|
|
1395
1420
|
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
|
|
1396
1421
|
is CraftResumableDownload.Fetched.Refused -> {
|
|
1397
|
-
|
|
1422
|
+
answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
|
|
1398
1423
|
return Outcome.DONE
|
|
1399
1424
|
}
|
|
1400
1425
|
is CraftResumableDownload.Fetched.Incomplete -> {
|
|
1401
|
-
|
|
1426
|
+
answer(CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
|
|
1402
1427
|
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1403
1428
|
}
|
|
1404
1429
|
is CraftResumableDownload.Fetched.Complete -> Unit
|
|
@@ -1410,11 +1435,11 @@ internal object CraftNativeMedia {
|
|
|
1410
1435
|
}
|
|
1411
1436
|
target.delete()
|
|
1412
1437
|
if (!staging.renameTo(target)) {
|
|
1413
|
-
|
|
1438
|
+
answer(CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
|
|
1414
1439
|
return Outcome.DONE
|
|
1415
1440
|
}
|
|
1416
1441
|
}
|
|
1417
|
-
|
|
1442
|
+
answer(CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1418
1443
|
return Outcome.DONE
|
|
1419
1444
|
}
|
|
1420
1445
|
}
|
|
@@ -1432,13 +1457,13 @@ internal object CraftNativeMedia {
|
|
|
1432
1457
|
capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
|
1433
1458
|
}.getOrDefault(true)
|
|
1434
1459
|
|
|
1435
|
-
/** Removes
|
|
1460
|
+
/** Removes idle copies outside `keep` and cancels their jobs; pending keeps retain ownership. */
|
|
1436
1461
|
fun prune(context: Context, keep: List<String>): Int {
|
|
1437
1462
|
val wanted = keep.map { digest(it) }.toSet()
|
|
1438
1463
|
var removed = 0
|
|
1439
|
-
// A keep still waiting owns its address's files and its transfer until it is answered.
|
|
1440
|
-
val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
|
|
1441
1464
|
synchronized(lock) {
|
|
1465
|
+
// A keep still waiting owns its files until it is answered.
|
|
1466
|
+
val active = waiting.keys.map { digest(it) }.toSet()
|
|
1442
1467
|
root(context).listFiles()?.forEach { file ->
|
|
1443
1468
|
val owner = file.name.substringBefore('.')
|
|
1444
1469
|
// Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
|
|
@@ -1451,8 +1476,16 @@ internal object CraftNativeMedia {
|
|
|
1451
1476
|
runCatching {
|
|
1452
1477
|
androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
|
|
1453
1478
|
.filter { !it.state.isFinished }
|
|
1454
|
-
.
|
|
1455
|
-
|
|
1479
|
+
.forEach { info ->
|
|
1480
|
+
synchronized(lock) {
|
|
1481
|
+
// The query runs asynchronously: a new keep may now
|
|
1482
|
+
// own an address that was idle when prune was called.
|
|
1483
|
+
val active = waiting.keys.map { digest(it) }.toSet()
|
|
1484
|
+
if (info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted + active }) {
|
|
1485
|
+
androidx.work.WorkManager.getInstance(app).cancelWorkById(info.id)
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1456
1489
|
}
|
|
1457
1490
|
}
|
|
1458
1491
|
return removed
|
|
@@ -1470,9 +1503,10 @@ internal object CraftNativeMedia {
|
|
|
1470
1503
|
root(context).listFiles()?.forEach { file ->
|
|
1471
1504
|
if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
|
|
1472
1505
|
}
|
|
1506
|
+
// Schedule cancellation before a new generation can enqueue its work.
|
|
1507
|
+
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1473
1508
|
callbacks
|
|
1474
1509
|
}
|
|
1475
|
-
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1476
1510
|
main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1477
1511
|
failure?.let { throw it }
|
|
1478
1512
|
return removed
|
|
@@ -1511,12 +1545,10 @@ internal class CraftResumableDownload(
|
|
|
1511
1545
|
if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
|
|
1512
1546
|
try {
|
|
1513
1547
|
val status = connection.responseCode
|
|
1514
|
-
// Part of a file nobody asked a part of: not something to keep.
|
|
1515
|
-
if (status == 206 && have == 0L) return Fetched.Refused(status)
|
|
1516
1548
|
val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
|
|
1517
1549
|
if (status == 416 || (status == 206 && !resumed)) {
|
|
1518
1550
|
part.delete()
|
|
1519
|
-
return Fetched.Restart
|
|
1551
|
+
return if (have > 0) Fetched.Restart else Fetched.Refused(status)
|
|
1520
1552
|
}
|
|
1521
1553
|
if (status != 200 && !resumed) {
|
|
1522
1554
|
part.delete()
|
|
@@ -1539,8 +1571,8 @@ internal class CraftResumableDownload(
|
|
|
1539
1571
|
// HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
|
|
1540
1572
|
val encoded = connection.getHeaderField("Content-Encoding") != null
|
|
1541
1573
|
if (size <= 0 || (!encoded && total >= 0 && size != total)) {
|
|
1542
|
-
//
|
|
1543
|
-
if (total in 0 until size) part.delete()
|
|
1574
|
+
// Empty or longer than the file says: nothing useful to resume.
|
|
1575
|
+
if (size <= 0 || total in 0 until size) part.delete()
|
|
1544
1576
|
return Fetched.Incomplete
|
|
1545
1577
|
}
|
|
1546
1578
|
return Fetched.Complete(size)
|
|
@@ -1563,8 +1595,21 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
|
|
|
1563
1595
|
override fun doWork(): Result {
|
|
1564
1596
|
val address = inputData.getString("address") ?: return Result.failure()
|
|
1565
1597
|
val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
|
|
1566
|
-
val
|
|
1567
|
-
return
|
|
1598
|
+
val started = inputData.getInt("generation", 0)
|
|
1599
|
+
return try {
|
|
1600
|
+
setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
|
|
1601
|
+
val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
|
|
1602
|
+
beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
|
|
1603
|
+
) { isStopped }
|
|
1604
|
+
if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
|
|
1605
|
+
} catch (error: Exception) {
|
|
1606
|
+
if (error is InterruptedException) Thread.currentThread().interrupt()
|
|
1607
|
+
// A replacement owns the address's new waiters. A real progress
|
|
1608
|
+
// storage failure still needs an answer instead of a lost callback.
|
|
1609
|
+
if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
|
|
1610
|
+
CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"))
|
|
1611
|
+
if (isStopped) Result.retry() else Result.failure()
|
|
1612
|
+
}
|
|
1568
1613
|
}
|
|
1569
1614
|
}
|
|
1570
1615
|
|
|
@@ -1136,9 +1136,9 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1136
1136
|
val rowHeights = if (gridRows != null) {
|
|
1137
1137
|
val count = max(gridRows ?: 0, naturalRowHeights.size)
|
|
1138
1138
|
while (rows.size < count) rows.add(mutableListOf())
|
|
1139
|
-
val available = max(0f, height - paddingTop - paddingBottom - max(0, count - 1) * rowGap)
|
|
1139
|
+
val available = max(0f, (height - paddingTop - paddingBottom).toFloat() - max(0, count - 1) * rowGap)
|
|
1140
1140
|
val equal = available / max(1, count).toFloat()
|
|
1141
|
-
(naturalRowHeights + List(count - naturalRowHeights.size) {
|
|
1141
|
+
(naturalRowHeights + List(count - naturalRowHeights.size) { 0f })
|
|
1142
1142
|
.map { max(it, equal) }
|
|
1143
1143
|
} else naturalRowHeights
|
|
1144
1144
|
return GridLayout(rows, absolute, cellWidth, rowHeights)
|
|
@@ -1438,12 +1438,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1438
1438
|
put(base, 26, node.padding[3].toFloat())
|
|
1439
1439
|
put(base, 27, node.padding[0].toFloat())
|
|
1440
1440
|
putEnum(base, 28, if (node.grid) 1 else 0)
|
|
1441
|
-
|
|
1441
|
+
putEnum(base, 29, node.gridColumns)
|
|
1442
1442
|
put(base, 30, node.rowGap)
|
|
1443
1443
|
put(base, 31, node.columnGap)
|
|
1444
1444
|
if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
|
|
1445
1445
|
if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
|
|
1446
|
-
if (explicitRowsAware)
|
|
1446
|
+
if (explicitRowsAware) putEnum(base, 34, node.gridRows ?: 0)
|
|
1447
1447
|
val firstChild = cursor
|
|
1448
1448
|
node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
|
|
1449
1449
|
treeMeta[index * 2] = firstChild.toFloat()
|
|
@@ -1527,7 +1527,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1527
1527
|
put(base, 26, padding[3].toFloat())
|
|
1528
1528
|
put(base, 27, padding[0].toFloat())
|
|
1529
1529
|
putEnum(base, 28, 0)
|
|
1530
|
-
|
|
1530
|
+
putEnum(base, 29, 1)
|
|
1531
1531
|
put(base, 30, if (root) rowGapValue else 0f)
|
|
1532
1532
|
put(base, 31, if (root) columnGapValue else 0f)
|
|
1533
1533
|
}
|
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.254";
|
|
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
|
@@ -2080,7 +2080,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2080
2080
|
/// The edge swipe goes ahead of the web view's own gestures, which
|
|
2081
2081
|
/// otherwise hold every touch over the page until the page has answered it.
|
|
2082
2082
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
2083
|
-
gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
|
|
2083
|
+
if gestureRecognizer === visibleStack?.interactivePopGestureRecognizer { return true }
|
|
2084
|
+
guard tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }),
|
|
2085
|
+
let scroll = otherGestureRecognizer.view as? UIScrollView,
|
|
2086
|
+
otherGestureRecognizer === scroll.panGestureRecognizer,
|
|
2087
|
+
scroll.isScrollEnabled else { return false }
|
|
2088
|
+
// A vertical list must not suppress the shell's horizontal tab pan.
|
|
2089
|
+
// Horizontal content keeps ownership of its own drag instead.
|
|
2090
|
+
return !scroll.alwaysBounceHorizontal && scroll.contentSize.width <= scroll.bounds.width + 1
|
|
2084
2091
|
}
|
|
2085
2092
|
|
|
2086
2093
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
@@ -371,24 +371,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
371
371
|
@objc private func keyboardTap() {
|
|
372
372
|
guard keyboardShouldPersistTaps != "always" else { return }
|
|
373
373
|
let point = keyboardTapGesture.location(in: self)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
var onButton = false
|
|
377
|
-
while let view = hit, view !== self {
|
|
378
|
-
if view is UIButton { onButton = true }
|
|
379
|
-
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
|
|
380
|
-
hit = view.superview
|
|
374
|
+
craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
|
|
375
|
+
self.craftScreenController?.forgetFocusedInput()
|
|
381
376
|
}
|
|
382
|
-
if keyboardShouldPersistTaps == "handled", onControl { return }
|
|
383
|
-
// A button keeps the keyboard: its press gives the focus straight
|
|
384
|
-
// back. Ending editing first only let the keyboard's inset go while
|
|
385
|
-
// the finger was down, the button moved out from under it, and the
|
|
386
|
-
// press was lost.
|
|
387
|
-
if onButton { return }
|
|
388
|
-
// Otherwise the keyboard goes; after a tap on nothing, for good (the
|
|
389
|
-
// next render must not bring the last input back).
|
|
390
|
-
if !onControl { craftScreenController?.forgetFocusedInput() }
|
|
391
|
-
window?.endEditing(true)
|
|
392
377
|
}
|
|
393
378
|
|
|
394
379
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
@@ -3954,6 +3954,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur
|
|
|
3954
3954
|
return false
|
|
3955
3955
|
}
|
|
3956
3956
|
|
|
3957
|
+
/// Scroll containers observe taps without cancelling native input focus or
|
|
3958
|
+
/// selection. Text views are not UIControls, and their selection subviews may
|
|
3959
|
+
/// have recognizers of their own, so inspect the whole ancestor chain.
|
|
3960
|
+
func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: String, forgetFocus: () -> Void) {
|
|
3961
|
+
guard persistTaps != "always" else { return }
|
|
3962
|
+
var candidate = hit
|
|
3963
|
+
var onControl = false
|
|
3964
|
+
while let view = candidate, view !== container {
|
|
3965
|
+
if view is UITextField || view is UITextView || view is UIButton { return }
|
|
3966
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true }
|
|
3967
|
+
candidate = view.superview
|
|
3968
|
+
}
|
|
3969
|
+
if persistTaps == "handled", onControl { return }
|
|
3970
|
+
if !onControl { forgetFocus() }
|
|
3971
|
+
container.window?.endEditing(true)
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3957
3974
|
private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
|
|
3958
3975
|
let contentStack = CraftNativeFlowView()
|
|
3959
3976
|
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
@@ -4226,24 +4243,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4226
4243
|
@objc private func keyboardTap() {
|
|
4227
4244
|
guard keyboardShouldPersistTaps != "always" else { return }
|
|
4228
4245
|
let point = keyboardTapGesture.location(in: self)
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
while let view = hit, view !== self {
|
|
4233
|
-
if view is UIButton { onButton = true }
|
|
4234
|
-
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
|
|
4235
|
-
hit = view.superview
|
|
4236
|
-
}
|
|
4237
|
-
if keyboardShouldPersistTaps == "handled", onControl { return }
|
|
4238
|
-
// A button keeps the keyboard: its press gives the focus straight
|
|
4239
|
-
// back. Ending editing first only let the keyboard's inset go while
|
|
4240
|
-
// the finger was down, the button moved out from under it, and the
|
|
4241
|
-
// press was lost.
|
|
4242
|
-
if onButton { return }
|
|
4243
|
-
// Otherwise the keyboard goes; after a tap on nothing, for good (the
|
|
4244
|
-
// next render must not bring the last input back).
|
|
4245
|
-
if !onControl { craftScreenController?.forgetFocusedInput() }
|
|
4246
|
-
window?.endEditing(true)
|
|
4246
|
+
craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
|
|
4247
|
+
self.craftScreenController?.forgetFocusedInput()
|
|
4248
|
+
}
|
|
4247
4249
|
}
|
|
4248
4250
|
|
|
4249
4251
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
@@ -68,6 +68,26 @@ final class DriverTests: XCTestCase {
|
|
|
68
68
|
return result
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/// The journey, from the runner's control server, asked for again
|
|
72
|
+
/// until a minute has passed.
|
|
73
|
+
///
|
|
74
|
+
/// The server is up before the test starts, but a freshly created
|
|
75
|
+
/// simulator's first request can outlast one wait: the first journey of
|
|
76
|
+
/// a CI run spent 15s on it and failed before doing anything. XCTWaiter
|
|
77
|
+
/// reports a timeout instead of failing the test, so a slow first answer
|
|
78
|
+
/// is retried rather than fatal.
|
|
79
|
+
func remoteScript(seconds: Double = 60) -> Data? {
|
|
80
|
+
let end = Date().addingTimeInterval(seconds)
|
|
81
|
+
repeat {
|
|
82
|
+
var result: Data?
|
|
83
|
+
let done = XCTestExpectation(description: "/script")
|
|
84
|
+
URLSession.shared.dataTask(with: URL(string: control + "/script")!) { data, _, _ in result = data; done.fulfill() }.resume()
|
|
85
|
+
if XCTWaiter().wait(for: [done], timeout: 15) == .completed, let result, !result.isEmpty { return result }
|
|
86
|
+
Thread.sleep(forTimeInterval: 1)
|
|
87
|
+
} while Date() < end
|
|
88
|
+
return nil
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
func post(_ path: String, _ body: Data) {
|
|
72
92
|
if bundledJourney {
|
|
73
93
|
guard path.hasPrefix("/save?"),
|
|
@@ -246,7 +266,7 @@ final class DriverTests: XCTestCase {
|
|
|
246
266
|
bundledJourney = true
|
|
247
267
|
data = try Data(contentsOf: resource)
|
|
248
268
|
} else {
|
|
249
|
-
data =
|
|
269
|
+
data = remoteScript()
|
|
250
270
|
}
|
|
251
271
|
guard let data, let script = String(data: data, encoding: .utf8) else {
|
|
252
272
|
XCTFail("no script")
|
|
@@ -11,6 +11,8 @@ export interface MobileMediaCacheTestOptions {
|
|
|
11
11
|
online?: boolean;
|
|
12
12
|
log?: (line: string) => void;
|
|
13
13
|
}
|
|
14
|
+
/** Keep the probe on the complete shipped downloader, including its durable worker. */
|
|
15
|
+
export declare function androidMediaProbeSource(shell: string): string;
|
|
14
16
|
/** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
|
|
15
17
|
export declare function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{
|
|
16
18
|
platform: string;
|
package/dist/mobile-testing.js
CHANGED
|
@@ -37,6 +37,16 @@ ${stderr}`.trim() };
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// src/mobile-media-testing.ts
|
|
40
|
+
function androidMediaProbeSource(shell) {
|
|
41
|
+
return ["internal object CraftNativeMedia", "internal class CraftResumableDownload", "internal class CraftNativeMediaWorker"].map((declaration) => {
|
|
42
|
+
const source = shell.match(new RegExp(`${declaration}\\b[\\s\\S]*?\\n\\}`))?.[0];
|
|
43
|
+
if (!source)
|
|
44
|
+
throw new Error(`Android media implementation missing: ${declaration}`);
|
|
45
|
+
return source;
|
|
46
|
+
}).join(`
|
|
47
|
+
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
40
50
|
async function verifyMobileMediaCache(options) {
|
|
41
51
|
if (options.platform !== "ios" && options.platform !== "android")
|
|
42
52
|
throw new Error("Choose ios or android");
|
|
@@ -129,7 +139,7 @@ Connection: close\r
|
|
|
129
139
|
}
|
|
130
140
|
const empty = path.endsWith("/empty.mp4");
|
|
131
141
|
const partial = path.endsWith("/partial.mp4");
|
|
132
|
-
const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
|
|
142
|
+
const cut = (path.endsWith("/broken.mp4") || path.endsWith("/retry.mp4") || path.endsWith("/restart.mp4")) && n === 1;
|
|
133
143
|
const slow = path.endsWith("/slow.mp4") || path.endsWith("/restart.mp4") && n === 1;
|
|
134
144
|
socket.write(`HTTP/1.1 ${partial ? "206 Partial Content" : "200 OK"}\r
|
|
135
145
|
Content-Type: video/mp4\r
|
|
@@ -250,12 +260,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
250
260
|
if (!/^emulator-\d+$/.test(serial))
|
|
251
261
|
throw new Error("Select a disposable Android emulator with ANDROID_SERIAL");
|
|
252
262
|
const shell = await Bun.file(join(root, "android/templates/CraftNativeShell.kt.template")).text();
|
|
253
|
-
const
|
|
254
|
-
if (pieces.some((piece) => !piece))
|
|
255
|
-
throw new Error("Android media implementation missing");
|
|
256
|
-
const media = pieces.join(`
|
|
257
|
-
|
|
258
|
-
`);
|
|
263
|
+
const media = androidMediaProbeSource(shell);
|
|
259
264
|
const android = join(scratch, "android");
|
|
260
265
|
const app = join(android, "app");
|
|
261
266
|
const sources = join(app, "src/main/java/dev/craft/mediaprobe");
|
|
@@ -267,9 +272,9 @@ include(":app")`);
|
|
|
267
272
|
await Bun.write(join(android, "build.gradle.kts"), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }');
|
|
268
273
|
await Bun.write(join(android, "local.properties"), `sdk.dir=${sdk}
|
|
269
274
|
`);
|
|
270
|
-
await Bun.write(join(android, "gradle.properties"), `
|
|
275
|
+
await Bun.write(join(android, "gradle.properties"), `android.useAndroidX=true
|
|
276
|
+
org.gradle.jvmargs=-Xmx768m
|
|
271
277
|
org.gradle.workers.max=2
|
|
272
|
-
android.useAndroidX=true
|
|
273
278
|
`);
|
|
274
279
|
await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
|
|
275
280
|
id("org.jetbrains.kotlin.android") }
|
|
@@ -291,8 +296,8 @@ import android.os.Handler
|
|
|
291
296
|
import android.os.Looper
|
|
292
297
|
import org.json.JSONObject
|
|
293
298
|
import java.io.File
|
|
294
|
-
import java.util.concurrent.ConcurrentHashMap
|
|
295
299
|
import java.util.concurrent.Executors
|
|
300
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
296
301
|
internal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)
|
|
297
302
|
${media}
|
|
298
303
|
${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
@@ -327,6 +332,10 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
327
332
|
if (counts.get("/android/restart.mp4") !== 1)
|
|
328
333
|
throw new Error("Android process did not start interrupted download");
|
|
329
334
|
await probe("recover");
|
|
335
|
+
if (counts.get("/android/retry.mp4") !== 2)
|
|
336
|
+
throw new Error("Android explicit retry did not fetch once more immediately");
|
|
337
|
+
if (counts.get("/android/settled.mp4") !== 1)
|
|
338
|
+
throw new Error("Android explicit keep did not replace the settled running attempt exactly once");
|
|
330
339
|
if (!resumed.has("/android/restart.mp4"))
|
|
331
340
|
throw new Error("Android did not resume the interrupted download from its part file");
|
|
332
341
|
if (counts.get("/android/slow.mp4") !== 1)
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Holds a completed attempt in RUNNING, before WorkManager commits its result. */
|
|
2
|
+
class SettledMediaAttemptWorker(context: Context, parameters: androidx.work.WorkerParameters) : androidx.work.Worker(context, parameters) {
|
|
3
|
+
private val released = java.util.concurrent.CountDownLatch(1)
|
|
4
|
+
override fun doWork(): Result {
|
|
5
|
+
setProgressAsync(androidx.work.workDataOf("craft.media.attemptSettled" to true)).get()
|
|
6
|
+
released.await(30, java.util.concurrent.TimeUnit.SECONDS)
|
|
7
|
+
return Result.retry()
|
|
8
|
+
}
|
|
9
|
+
override fun onStopped() { released.countDown() }
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
class DownloadProbe : android.app.Instrumentation() {
|
|
2
13
|
override fun onCreate(arguments: android.os.Bundle?) {
|
|
3
14
|
super.onCreate(arguments)
|
|
@@ -23,6 +34,13 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
23
34
|
check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
|
|
24
35
|
return ok
|
|
25
36
|
}
|
|
37
|
+
private fun awaitCached(address: String) {
|
|
38
|
+
// The worker authors a 30-second first retry. Allow that backoff plus
|
|
39
|
+
// the existing 25-second transfer budget, without issuing another keep.
|
|
40
|
+
val until = System.currentTimeMillis() + 60000
|
|
41
|
+
while (CraftNativeMedia.local(targetContext, address) == null && System.currentTimeMillis() < until) Thread.sleep(25)
|
|
42
|
+
check(CraftNativeMedia.local(targetContext, address) != null, "the durable worker recovers the complete file without another keep")
|
|
43
|
+
}
|
|
26
44
|
override fun onStart() {
|
|
27
45
|
val result = android.os.Bundle()
|
|
28
46
|
try {
|
|
@@ -80,10 +98,11 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
80
98
|
val until = System.currentTimeMillis() + 25000
|
|
81
99
|
while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
|
|
82
100
|
check(partial(), "download is in progress before process death")
|
|
101
|
+
check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
|
|
83
102
|
android.os.Process.killProcess(android.os.Process.myPid())
|
|
84
103
|
}
|
|
85
104
|
"recover" -> {
|
|
86
|
-
|
|
105
|
+
awaitCached(restart)
|
|
87
106
|
check(keep(restart), "retry after process death downloads a complete video")
|
|
88
107
|
val media = android.media.MediaMetadataRetriever()
|
|
89
108
|
try {
|
|
@@ -98,12 +117,34 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
98
117
|
check(!keep(broken), "truncated transfer fails")
|
|
99
118
|
check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
|
|
100
119
|
val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
check(keep(broken), "same URL retries successfully
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
check(CraftNativeMedia.part(targetContext, broken).length() > 0, "a truncated transfer retains bytes for resumption")
|
|
121
|
+
awaitCached(broken)
|
|
122
|
+
check(keep(broken), "same URL retries successfully")
|
|
123
|
+
val retry = "$base/retry.mp4"
|
|
124
|
+
check(!keep(retry), "a separate interrupted transfer reports its failure")
|
|
125
|
+
check(keep(retry), "an explicit retry runs immediately, without waiting for backoff")
|
|
126
|
+
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "finished retries leave no staging files")
|
|
106
127
|
val work = androidx.work.WorkManager.getInstance(targetContext)
|
|
128
|
+
val settled = "$base/settled.mp4"
|
|
129
|
+
val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
|
|
130
|
+
val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
|
|
131
|
+
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
|
|
132
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
|
|
133
|
+
work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
|
|
134
|
+
val settledDeadline = System.currentTimeMillis() + 10000
|
|
135
|
+
var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
|
|
136
|
+
while ((settledInfo.state != androidx.work.WorkInfo.State.RUNNING || !settledInfo.progress.getBoolean("craft.media.attemptSettled", false)) && System.currentTimeMillis() < settledDeadline) {
|
|
137
|
+
Thread.sleep(25)
|
|
138
|
+
settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
|
|
139
|
+
}
|
|
140
|
+
check(settledInfo.state == androidx.work.WorkInfo.State.RUNNING && settledInfo.progress.getBoolean("craft.media.attemptSettled", false), "the settled attempt still has WorkManager's running identity")
|
|
141
|
+
check(keep(settled), "an explicit keep replaces a settled running attempt immediately")
|
|
142
|
+
// REPLACE can prune the old WorkSpec. Inspect the current
|
|
143
|
+
// unique chain rather than requiring the removed row.
|
|
144
|
+
val replacement = work.getWorkInfosForUniqueWork(settledName).get()
|
|
145
|
+
check(replacement.size == 1 && replacement.single().id != settledRequest.id && replacement.single().state != androidx.work.WorkInfo.State.CANCELLED, "the settled work identity was replaced, not joined")
|
|
146
|
+
|
|
147
|
+
val queued = "$base/queued.mp4"
|
|
107
148
|
val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
|
|
108
149
|
val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
109
150
|
.setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
|