craft-native 0.0.224 → 0.0.226
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 +1 -0
- package/dist/android/templates/CraftNativeImageCache.kt.template +142 -0
- package/dist/android/templates/CraftNativeServices.kt.template +1 -0
- package/dist/android/templates/CraftNativeShell.kt.template +32 -9
- package/dist/android/templates/MainActivityNative.kt.template +56 -4
- package/dist/browser.js +33 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +16 -1
- package/dist/ios/templates/CraftNativeActions.swift +12 -6
- package/dist/ios/templates/CraftNativeFiles.swift +37 -10
- package/dist/ios/templates/CraftNativeScreen.swift +7 -2
- package/dist/mobile-testing.js +4 -0
- package/dist/testing/media-cache/media-cache.kt +24 -3
- package/dist/testing/media-cache/media-cache.swift.fixture +20 -4
- package/package.json +1 -1
|
@@ -549,6 +549,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
|
549
549
|
};
|
|
550
550
|
write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
|
|
551
551
|
write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
|
|
552
|
+
write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
|
|
552
553
|
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
553
554
|
write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
|
|
554
555
|
write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.BufferedInputStream
|
|
5
|
+
import java.io.DataInputStream
|
|
6
|
+
import java.io.DataOutputStream
|
|
7
|
+
import java.io.IOException
|
|
8
|
+
import java.io.OutputStream
|
|
9
|
+
import java.security.MessageDigest
|
|
10
|
+
import java.util.Locale
|
|
11
|
+
|
|
12
|
+
internal data class CraftNativeImagePolicy(val expiresAt: Long)
|
|
13
|
+
|
|
14
|
+
internal fun craftNativeImagePolicy(
|
|
15
|
+
requestHeaders: Map<String, String>,
|
|
16
|
+
responseHeaders: Map<String, List<String>>,
|
|
17
|
+
now: Long,
|
|
18
|
+
): CraftNativeImagePolicy? {
|
|
19
|
+
// Persist only the anonymous content the server explicitly calls public.
|
|
20
|
+
// A custom header can carry an account credential just like Authorization.
|
|
21
|
+
if (requestHeaders.isNotEmpty()) return null
|
|
22
|
+
fun values(name: String) = responseHeaders.entries
|
|
23
|
+
.filter { it.key.equals(name, ignoreCase = true) }.flatMap { it.value }
|
|
24
|
+
val variants = values("Vary").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
|
|
25
|
+
if (values("Set-Cookie").isNotEmpty() || variants.any { it in setOf("*", "cookie", "authorization") }) return null
|
|
26
|
+
val directives = values("Cache-Control").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
|
|
27
|
+
if ("public" !in directives || directives.any { it.substringBefore('=').trim() in setOf("private", "no-store", "no-cache") }) return null
|
|
28
|
+
val ages = directives.filter { it.substringBefore('=').trim() == "max-age" }
|
|
29
|
+
if (ages.isEmpty()) return null
|
|
30
|
+
val seconds = ages.map {
|
|
31
|
+
it.substringAfter('=', "").trim().removeSurrounding("\"").toLongOrNull()?.takeIf { age -> age > 0 } ?: return null
|
|
32
|
+
}.min().coerceAtMost(31_536_000L)
|
|
33
|
+
val ageHeader = values("Age").firstOrNull()
|
|
34
|
+
val age = if (ageHeader == null) 0L else ageHeader.trim().toLongOrNull()?.takeIf { it >= 0 } ?: return null
|
|
35
|
+
val remaining = seconds - age
|
|
36
|
+
if (remaining <= 0) return null
|
|
37
|
+
return runCatching { CraftNativeImagePolicy(Math.addExact(now, remaining * 1000)) }.getOrNull()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
internal class CraftNativeImageCache(
|
|
41
|
+
private val root: File,
|
|
42
|
+
private val maxBytes: Long = 32L * 1024 * 1024,
|
|
43
|
+
private val maxEntryBytes: Long = 8L * 1024 * 1024,
|
|
44
|
+
private val maxEntries: Int = 128,
|
|
45
|
+
private val now: () -> Long = System::currentTimeMillis,
|
|
46
|
+
) {
|
|
47
|
+
init {
|
|
48
|
+
require(maxBytes > 0 && maxEntryBytes > 0 && maxEntries > 0)
|
|
49
|
+
// A process killed while encoding can leave a staging file behind.
|
|
50
|
+
// All instances share this lock, so another writer cannot own it now.
|
|
51
|
+
synchronized(lock) {
|
|
52
|
+
runCatching {
|
|
53
|
+
root.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }.forEach(File::delete)
|
|
54
|
+
prune()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun file(address: String): File {
|
|
60
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(address.toByteArray(Charsets.UTF_8))
|
|
61
|
+
val name = digest.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
|
|
62
|
+
return File(root, "$name.cache")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun read(address: String): ByteArray? = synchronized(lock) {
|
|
66
|
+
runCatching {
|
|
67
|
+
val target = file(address)
|
|
68
|
+
if (!target.isFile) return@runCatching null
|
|
69
|
+
if (target.length() <= HEADER_BYTES || target.length() > HEADER_BYTES + minOf(maxEntryBytes, maxBytes)) {
|
|
70
|
+
target.delete()
|
|
71
|
+
return@runCatching null
|
|
72
|
+
}
|
|
73
|
+
val data = DataInputStream(BufferedInputStream(target.inputStream())).use { input ->
|
|
74
|
+
if (input.readInt() != MAGIC || input.readLong() <= now()) null else input.readBytes()
|
|
75
|
+
}
|
|
76
|
+
if (data == null || data.isEmpty()) { target.delete(); null }
|
|
77
|
+
else { target.setLastModified(now()); data }
|
|
78
|
+
}.getOrNull()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun write(address: String, policy: CraftNativeImagePolicy, encode: (OutputStream) -> Boolean): Boolean = synchronized(lock) {
|
|
82
|
+
var staging: File? = null
|
|
83
|
+
try {
|
|
84
|
+
if (policy.expiresAt <= now() || (!root.isDirectory && !root.mkdirs())) return@synchronized false
|
|
85
|
+
val target = file(address)
|
|
86
|
+
staging = File.createTempFile(target.name, ".tmp", root)
|
|
87
|
+
staging.outputStream().buffered().use { raw ->
|
|
88
|
+
val header = DataOutputStream(raw)
|
|
89
|
+
header.writeInt(MAGIC)
|
|
90
|
+
header.writeLong(policy.expiresAt)
|
|
91
|
+
val payload = BoundedImageOutput(raw, minOf(maxEntryBytes, maxBytes))
|
|
92
|
+
if (!encode(payload) || payload.written == 0L) throw IOException("Incomplete cached image")
|
|
93
|
+
payload.flush()
|
|
94
|
+
}
|
|
95
|
+
// Freshness and pixels are committed together; a failed write
|
|
96
|
+
// never replaces an earlier, complete image with a partial file.
|
|
97
|
+
if (!staging.renameTo(target)) return@synchronized false
|
|
98
|
+
target.setLastModified(now())
|
|
99
|
+
prune()
|
|
100
|
+
true
|
|
101
|
+
}
|
|
102
|
+
catch (error: Exception) { false }
|
|
103
|
+
catch (error: OutOfMemoryError) { false }
|
|
104
|
+
finally { staging?.delete() }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fun remove(address: String) = synchronized(lock) { runCatching { file(address).delete() }; Unit }
|
|
108
|
+
|
|
109
|
+
private fun prune() {
|
|
110
|
+
val entries = root.listFiles().orEmpty().filter { entryName.matches(it.name) }.sortedBy(File::lastModified)
|
|
111
|
+
var count = entries.size
|
|
112
|
+
var bytes = entries.sumOf { (it.length() - HEADER_BYTES).coerceAtLeast(0) }
|
|
113
|
+
for (entry in entries) {
|
|
114
|
+
if (count <= maxEntries && bytes <= maxBytes) break
|
|
115
|
+
val size = (entry.length() - HEADER_BYTES).coerceAtLeast(0)
|
|
116
|
+
if (entry.delete()) { count--; bytes -= size }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private class BoundedImageOutput(private val output: OutputStream, private val limit: Long) : OutputStream() {
|
|
121
|
+
var written = 0L
|
|
122
|
+
private set
|
|
123
|
+
override fun write(value: Int) {
|
|
124
|
+
if (written >= limit) throw IOException("Cached image is too large")
|
|
125
|
+
output.write(value)
|
|
126
|
+
written++
|
|
127
|
+
}
|
|
128
|
+
override fun write(bytes: ByteArray, offset: Int, length: Int) {
|
|
129
|
+
if (written + length > limit) throw IOException("Cached image is too large")
|
|
130
|
+
output.write(bytes, offset, length)
|
|
131
|
+
written += length
|
|
132
|
+
}
|
|
133
|
+
override fun flush() = output.flush()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private companion object {
|
|
137
|
+
const val MAGIC = 0x43494d31
|
|
138
|
+
const val HEADER_BYTES = 12L
|
|
139
|
+
val entryName = Regex("^[a-f0-9]{64}\\.cache$")
|
|
140
|
+
val lock = Any()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
|
|
|
120
120
|
done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
|
|
121
121
|
}
|
|
122
122
|
"usage" -> done(success(CraftNativeMedia.usage(context)))
|
|
123
|
+
"clear" -> done(success(CraftNativeMedia.clear(context)))
|
|
123
124
|
else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
|
|
124
125
|
}
|
|
125
126
|
"Background" -> {
|
|
@@ -1021,7 +1021,8 @@ internal object CraftNativeSymbols {
|
|
|
1021
1021
|
* A native Video given an address with a copy here plays the copy.
|
|
1022
1022
|
*/
|
|
1023
1023
|
internal object CraftNativeMedia {
|
|
1024
|
-
private val
|
|
1024
|
+
private data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
|
|
1025
|
+
private val inFlight = HashMap<String, Flight>()
|
|
1025
1026
|
private val executor = Executors.newFixedThreadPool(2)
|
|
1026
1027
|
private val main = Handler(Looper.getMainLooper())
|
|
1027
1028
|
|
|
@@ -1054,12 +1055,12 @@ internal object CraftNativeMedia {
|
|
|
1054
1055
|
if (!cellular && metered(context)) {
|
|
1055
1056
|
return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
|
|
1056
1057
|
}
|
|
1057
|
-
synchronized(inFlight) {
|
|
1058
|
+
val flight = synchronized(inFlight) {
|
|
1058
1059
|
inFlight[address]?.let { waiting ->
|
|
1059
|
-
waiting.add(done)
|
|
1060
|
+
waiting.waiting.add(done)
|
|
1060
1061
|
return
|
|
1061
1062
|
}
|
|
1062
|
-
inFlight[address] =
|
|
1063
|
+
Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
|
|
1063
1064
|
}
|
|
1064
1065
|
executor.execute {
|
|
1065
1066
|
val result = runCatching {
|
|
@@ -1073,7 +1074,7 @@ internal object CraftNativeMedia {
|
|
|
1073
1074
|
val target = file(context, address)
|
|
1074
1075
|
target.parentFile?.mkdirs()
|
|
1075
1076
|
// Written aside and moved into place, so a half-written file is never played.
|
|
1076
|
-
val staging = File(target.parentFile, "${target.name}.part")
|
|
1077
|
+
val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
|
|
1077
1078
|
try {
|
|
1078
1079
|
val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
|
|
1079
1080
|
val expected = connection.contentLengthLong
|
|
@@ -1081,6 +1082,9 @@ internal object CraftNativeMedia {
|
|
|
1081
1082
|
return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
|
|
1082
1083
|
}
|
|
1083
1084
|
synchronized(inFlight) {
|
|
1085
|
+
if (inFlight[address]?.id != flight.id) {
|
|
1086
|
+
return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
|
|
1087
|
+
}
|
|
1084
1088
|
if (!staging.renameTo(target)) {
|
|
1085
1089
|
return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
|
|
1086
1090
|
}
|
|
@@ -1098,7 +1102,7 @@ internal object CraftNativeMedia {
|
|
|
1098
1102
|
}
|
|
1099
1103
|
CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
|
|
1100
1104
|
}
|
|
1101
|
-
val waiting = synchronized(inFlight) { inFlight.remove(address)
|
|
1105
|
+
val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
|
|
1102
1106
|
main.post { waiting.forEach { it(result) } }
|
|
1103
1107
|
}
|
|
1104
1108
|
}
|
|
@@ -1115,12 +1119,31 @@ internal object CraftNativeMedia {
|
|
|
1115
1119
|
var removed = 0
|
|
1116
1120
|
synchronized(inFlight) {
|
|
1117
1121
|
// A concurrent keep owns its target and staging file until it finishes.
|
|
1118
|
-
val active = inFlight.
|
|
1122
|
+
val active = inFlight.flatMap { (address, flight) ->
|
|
1123
|
+
val name = file(context, address).name
|
|
1124
|
+
listOf(name, "$name.${flight.id}.part")
|
|
1125
|
+
}.toSet()
|
|
1126
|
+
root(context).listFiles()?.forEach { file ->
|
|
1127
|
+
if (file.name !in active && file.name !in wanted && file.delete()) removed++
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return removed
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Invalidates old transfers before deleting files; a later keep starts afresh. */
|
|
1134
|
+
fun clear(context: Context): Int {
|
|
1135
|
+
var removed = 0
|
|
1136
|
+
var failure: java.io.IOException? = null
|
|
1137
|
+
val waiting = synchronized(inFlight) {
|
|
1138
|
+
val callbacks = inFlight.values.flatMap { it.waiting }
|
|
1139
|
+
inFlight.clear()
|
|
1119
1140
|
root(context).listFiles()?.forEach { file ->
|
|
1120
|
-
|
|
1121
|
-
if (target !in active && file.name !in wanted && file.delete()) removed++
|
|
1141
|
+
if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
|
|
1122
1142
|
}
|
|
1143
|
+
callbacks
|
|
1123
1144
|
}
|
|
1145
|
+
main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1146
|
+
failure?.let { throw it }
|
|
1124
1147
|
return removed
|
|
1125
1148
|
}
|
|
1126
1149
|
|
|
@@ -122,6 +122,8 @@ import com.google.android.gms.maps.model.Polyline
|
|
|
122
122
|
import com.google.android.gms.maps.model.PolylineOptions
|
|
123
123
|
import org.json.JSONArray
|
|
124
124
|
import org.json.JSONObject
|
|
125
|
+
import java.io.File
|
|
126
|
+
import java.net.HttpURLConnection
|
|
125
127
|
import java.net.URL
|
|
126
128
|
import java.text.SimpleDateFormat
|
|
127
129
|
import java.util.Calendar
|
|
@@ -3805,6 +3807,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3805
3807
|
private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
|
|
3806
3808
|
private var useMessagePorts = false
|
|
3807
3809
|
private val imageExecutor = Executors.newFixedThreadPool(2)
|
|
3810
|
+
private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
|
|
3808
3811
|
/** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
|
|
3809
3812
|
private val preludeExecutor = Executors.newSingleThreadExecutor()
|
|
3810
3813
|
private val imageSources = mutableMapOf<ImageView, String>()
|
|
@@ -4088,6 +4091,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4088
4091
|
show(target)
|
|
4089
4092
|
} else {
|
|
4090
4093
|
host.removeView(target.view)
|
|
4094
|
+
target.view.translationX = 0f
|
|
4091
4095
|
current.view.translationX = 0f
|
|
4092
4096
|
chrome?.selectTab(currentTab)
|
|
4093
4097
|
tabSwipeTarget = null
|
|
@@ -4156,13 +4160,22 @@ class MainActivity : AppCompatActivity() {
|
|
|
4156
4160
|
* page on the app's site in the in-app browser. Another site's address
|
|
4157
4161
|
* opens in the browser too.
|
|
4158
4162
|
*/
|
|
4159
|
-
private fun navigateOpen(target: String, fromLink: Boolean = false) {
|
|
4163
|
+
private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
|
|
4160
4164
|
val path = routes.appPath(target, urlSchemes)
|
|
4161
4165
|
if (path == null) {
|
|
4162
4166
|
openExternal(target)
|
|
4163
4167
|
return
|
|
4164
4168
|
}
|
|
4165
4169
|
val match = routes.match(path)
|
|
4170
|
+
if (replace && match != null && screens.isNotEmpty()) {
|
|
4171
|
+
val stack = screens
|
|
4172
|
+
val removed = stack.removeAt(stack.lastIndex)
|
|
4173
|
+
val previous = stack.lastOrNull()
|
|
4174
|
+
closeScreen(removed) {
|
|
4175
|
+
if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
|
|
4176
|
+
}
|
|
4177
|
+
return
|
|
4178
|
+
}
|
|
4166
4179
|
val tab = routes.tabFor(path)
|
|
4167
4180
|
if (tab != null) {
|
|
4168
4181
|
selectTab(routes.tabs.indexOf(tab))
|
|
@@ -4492,6 +4505,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
4492
4505
|
* screen shown before hears true (its `navigation.onAppear`).
|
|
4493
4506
|
*/
|
|
4494
4507
|
private fun show(screen: Screen) {
|
|
4508
|
+
// A retained tab may have been the offscreen preview of a cancelled
|
|
4509
|
+
// swipe, or the outgoing view of a completed swipe. Its animator can
|
|
4510
|
+
// finish after the peer animator, so clear that ownership on display.
|
|
4511
|
+
screen.view.animate().withEndAction(null).setListener(null).cancel()
|
|
4512
|
+
screen.view.translationX = 0f
|
|
4495
4513
|
// A navigation breadcrumb: the trail a crash report carries.
|
|
4496
4514
|
if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
|
|
4497
4515
|
val bars = chrome
|
|
@@ -4770,6 +4788,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
4770
4788
|
// app script has no stack of its own; it uses NAVIGATE_OPEN.
|
|
4771
4789
|
if (visibleScreen !== screen) return
|
|
4772
4790
|
val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
|
|
4791
|
+
// Resolve and replace in one host operation. A pop followed by
|
|
4792
|
+
// open would send the second message from a closing isolate.
|
|
4793
|
+
if (message.optString("type") == "NAVIGATE_REPLACE" && route.startsWith("/")) {
|
|
4794
|
+
navigateOpen(route, replace = true)
|
|
4795
|
+
return
|
|
4796
|
+
}
|
|
4773
4797
|
if (message.optString("type") == "NAVIGATE_REPLACE") {
|
|
4774
4798
|
val stack = screens
|
|
4775
4799
|
val removed = stack.removeAt(stack.lastIndex)
|
|
@@ -6101,7 +6125,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
6101
6125
|
null
|
|
6102
6126
|
} else defaultBackground
|
|
6103
6127
|
val overflow = craftNativeOptionalString(style.opt("overflow"))
|
|
6104
|
-
|
|
6128
|
+
// A replaced image clips its own pixels to border-radius, including
|
|
6129
|
+
// when its surrounding layout permits visible overflow.
|
|
6130
|
+
view.clipToOutline = overflow == "hidden" || (view is ImageView && (radius > 0 || hasCornerRadii))
|
|
6105
6131
|
if (view is ViewGroup) view.clipChildren = overflow == "hidden"
|
|
6106
6132
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
|
6107
6133
|
(style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
|
|
@@ -6499,12 +6525,38 @@ class MainActivity : AppCompatActivity() {
|
|
|
6499
6525
|
}
|
|
6500
6526
|
imageJobs[view] = imageExecutor.submit {
|
|
6501
6527
|
val bitmap = runCatching {
|
|
6502
|
-
val
|
|
6528
|
+
val publicRequest = headers.isEmpty() && Uri.parse(uri).userInfo.isNullOrEmpty()
|
|
6529
|
+
if (publicRequest) {
|
|
6530
|
+
val bytes = imageCache.read(uri)
|
|
6531
|
+
if (bytes != null) {
|
|
6532
|
+
val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
6533
|
+
if (saved != null) return@runCatching saved
|
|
6534
|
+
imageCache.remove(uri)
|
|
6535
|
+
}
|
|
6536
|
+
}
|
|
6537
|
+
val connection = (URL(uri).openConnection() as HttpURLConnection).apply {
|
|
6503
6538
|
connectTimeout = 10_000
|
|
6504
6539
|
readTimeout = 10_000
|
|
6540
|
+
// This loader owns its bounded public cache. Do not let
|
|
6541
|
+
// an application's global ResponseCache retain requests.
|
|
6542
|
+
useCaches = false
|
|
6505
6543
|
headers.forEach { (name, value) -> setRequestProperty(name, value) }
|
|
6506
6544
|
}
|
|
6507
|
-
|
|
6545
|
+
try {
|
|
6546
|
+
val downloaded = connection.inputStream.use(BitmapFactory::decodeStream)
|
|
6547
|
+
if (publicRequest) {
|
|
6548
|
+
val responseHeaders = connection.headerFields.entries.filter { it.key != null }
|
|
6549
|
+
.associate { it.key!! to it.value.orEmpty() }
|
|
6550
|
+
val policy = if (connection.responseCode == HttpURLConnection.HTTP_OK)
|
|
6551
|
+
craftNativeImagePolicy(headers, responseHeaders, System.currentTimeMillis()) else null
|
|
6552
|
+
if (downloaded != null && policy != null) {
|
|
6553
|
+
// Encode only after a complete decode, then commit
|
|
6554
|
+
// before onLoad so a cold offline reopen is safe.
|
|
6555
|
+
imageCache.write(uri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
|
6556
|
+
} else imageCache.remove(uri)
|
|
6557
|
+
}
|
|
6558
|
+
downloaded
|
|
6559
|
+
} finally { connection.disconnect() }
|
|
6508
6560
|
}.getOrNull()
|
|
6509
6561
|
runOnUiThread {
|
|
6510
6562
|
if (imageSources[view] != requestKey) return@runOnUiThread
|
package/dist/browser.js
CHANGED
|
@@ -283,6 +283,13 @@ async function createBrowserPlatform(options = {}) {
|
|
|
283
283
|
};
|
|
284
284
|
const lifetime = new AbortController;
|
|
285
285
|
let account = new AbortController;
|
|
286
|
+
let mediaTransfers = new AbortController;
|
|
287
|
+
let mediaWrites = Promise.resolve();
|
|
288
|
+
const writeMedia = (work) => {
|
|
289
|
+
const write = mediaWrites.then(work);
|
|
290
|
+
mediaWrites = write.catch(() => {});
|
|
291
|
+
return write;
|
|
292
|
+
};
|
|
286
293
|
let mediaScope = persistence.scope();
|
|
287
294
|
let wakeLock = null;
|
|
288
295
|
let disposed = false;
|
|
@@ -365,6 +372,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
365
372
|
if (!("caches" in window))
|
|
366
373
|
return url;
|
|
367
374
|
const scope = persistence.scope();
|
|
375
|
+
const transfer = mediaTransfers.signal;
|
|
368
376
|
const existing = mediaURLs.get(url);
|
|
369
377
|
if (existing)
|
|
370
378
|
return existing;
|
|
@@ -372,7 +380,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
372
380
|
if (!saved)
|
|
373
381
|
return url;
|
|
374
382
|
const local = URL.createObjectURL(await saved.blob());
|
|
375
|
-
if (disposed || scope !== persistence.scope()) {
|
|
383
|
+
if (disposed || transfer.aborted || scope !== persistence.scope()) {
|
|
376
384
|
URL.revokeObjectURL(local);
|
|
377
385
|
return url;
|
|
378
386
|
}
|
|
@@ -629,6 +637,25 @@ async function createBrowserPlatform(options = {}) {
|
|
|
629
637
|
if (module === "Media") {
|
|
630
638
|
if (!("caches" in window))
|
|
631
639
|
return unsupported(module, method);
|
|
640
|
+
if (method === "clear") {
|
|
641
|
+
mediaTransfers.abort();
|
|
642
|
+
mediaTransfers = new AbortController;
|
|
643
|
+
const name = cacheName();
|
|
644
|
+
return writeMedia(async () => {
|
|
645
|
+
const cache2 = await caches.open(name);
|
|
646
|
+
let removed = 0;
|
|
647
|
+
for (const entry of await cache2.keys()) {
|
|
648
|
+
if (await cache2.delete(entry))
|
|
649
|
+
removed++;
|
|
650
|
+
const local = mediaURLs.get(entry.url);
|
|
651
|
+
if (local)
|
|
652
|
+
URL.revokeObjectURL(local);
|
|
653
|
+
mediaURLs.delete(entry.url);
|
|
654
|
+
}
|
|
655
|
+
return removed;
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
signal = AbortSignal.any([signal, mediaTransfers.signal]);
|
|
632
659
|
const cache = await caches.open(cacheName());
|
|
633
660
|
if (method === "keep") {
|
|
634
661
|
const url = String(args[0]);
|
|
@@ -646,7 +673,11 @@ async function createBrowserPlatform(options = {}) {
|
|
|
646
673
|
signal.throwIfAborted();
|
|
647
674
|
const headers = new Headers(response.headers);
|
|
648
675
|
headers.set("x-craft-size", String(body.size));
|
|
649
|
-
await
|
|
676
|
+
await writeMedia(async () => {
|
|
677
|
+
signal.throwIfAborted();
|
|
678
|
+
await cache.put(url, new Response(body, { headers, status: response.status }));
|
|
679
|
+
signal.throwIfAborted();
|
|
680
|
+
});
|
|
650
681
|
return { ok: true, url, path: url, size: body.size };
|
|
651
682
|
}
|
|
652
683
|
if (method === "path")
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.226";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -266,6 +266,8 @@ protocol CraftHybridScreenEvents: AnyObject {
|
|
|
266
266
|
func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
|
|
267
267
|
/// `craft.navigation.open(path)`.
|
|
268
268
|
func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
|
|
269
|
+
/// `craft.navigation.replace(path)`: finish the current flow atomically.
|
|
270
|
+
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
// MARK: - The shell
|
|
@@ -938,6 +940,10 @@ extension CraftHybrid: CraftHybridScreenEvents {
|
|
|
938
940
|
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
939
941
|
open(path)
|
|
940
942
|
}
|
|
943
|
+
|
|
944
|
+
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
945
|
+
controller?.showNative(path: path, replace: true)
|
|
946
|
+
}
|
|
941
947
|
}
|
|
942
948
|
|
|
943
949
|
/// The page's `craftHybrid` channel, only from a trusted origin's main frame.
|
|
@@ -1519,6 +1525,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1519
1525
|
interruptTabSwipe(restoringSelection: true)
|
|
1520
1526
|
let tabs = hybrid.tabIds
|
|
1521
1527
|
if let tab = tabs.first(where: { CraftHybridRoutes.normalize($0) == match.path }) {
|
|
1528
|
+
// Finish the flow in its original tab before selecting another.
|
|
1529
|
+
if replace, let current = visibleStack, current.viewControllers.count > 1 {
|
|
1530
|
+
current.setViewControllers(Array(current.viewControllers.dropLast()), animated: false)
|
|
1531
|
+
}
|
|
1522
1532
|
if tab != currentTab {
|
|
1523
1533
|
selectTabAsIfTapped(tab)
|
|
1524
1534
|
}
|
|
@@ -1541,7 +1551,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1541
1551
|
return
|
|
1542
1552
|
}
|
|
1543
1553
|
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1544
|
-
if replace, nav.viewControllers.
|
|
1554
|
+
if replace, !nav.viewControllers.isEmpty {
|
|
1545
1555
|
nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
|
|
1546
1556
|
} else {
|
|
1547
1557
|
nav.pushViewController(screen, animated: true)
|
|
@@ -1900,6 +1910,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1900
1910
|
open(path)
|
|
1901
1911
|
}
|
|
1902
1912
|
|
|
1913
|
+
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
1914
|
+
guard screen.navigationController?.topViewController === screen else { return }
|
|
1915
|
+
showNative(path: path, replace: true)
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1903
1918
|
private func log(_ message: String) {
|
|
1904
1919
|
#if DEBUG
|
|
1905
1920
|
NSLog("[craft hybrid] %@", message)
|
|
@@ -254,6 +254,12 @@ enum CraftNativeActions {
|
|
|
254
254
|
return
|
|
255
255
|
case ("Media", "usage"):
|
|
256
256
|
sync = .success(CraftNativeMedia.usage())
|
|
257
|
+
case ("Media", "clear"):
|
|
258
|
+
DispatchQueue.global(qos: .utility).async {
|
|
259
|
+
do { complete(requestToken, .success(try CraftNativeMedia.clear()), completion) }
|
|
260
|
+
catch { complete(requestToken, failure("IO_ERROR", "The video cache could not be cleared"), completion) }
|
|
261
|
+
}
|
|
262
|
+
return
|
|
257
263
|
case ("Lifecycle", "getState"):
|
|
258
264
|
sync = .success(currentAppState())
|
|
259
265
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -782,7 +788,7 @@ enum CraftNativeActions {
|
|
|
782
788
|
request.allowsExpensiveNetworkAccess = false
|
|
783
789
|
request.allowsConstrainedNetworkAccess = false
|
|
784
790
|
}
|
|
785
|
-
|
|
791
|
+
guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
|
|
786
792
|
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
787
793
|
cancellationLock.lock()
|
|
788
794
|
fetchTasks.removeValue(forKey: requestToken)
|
|
@@ -790,25 +796,25 @@ enum CraftNativeActions {
|
|
|
790
796
|
if let error = error {
|
|
791
797
|
// Refused only because the connection is metered: not a failure, a wait.
|
|
792
798
|
if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
|
|
793
|
-
CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
799
|
+
CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
794
800
|
return
|
|
795
801
|
}
|
|
796
802
|
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
797
|
-
CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
803
|
+
CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
798
804
|
return
|
|
799
805
|
}
|
|
800
806
|
guard let http = response as? HTTPURLResponse, let location else {
|
|
801
|
-
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
807
|
+
CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
802
808
|
return
|
|
803
809
|
}
|
|
804
810
|
// The system deletes `location` once this returns: move it now.
|
|
805
|
-
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
811
|
+
CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
|
|
806
812
|
}
|
|
807
813
|
cancellationLock.lock()
|
|
808
814
|
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
809
815
|
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
810
816
|
cancellationLock.unlock()
|
|
811
|
-
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
817
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
|
|
812
818
|
else { task.resume() }
|
|
813
819
|
}
|
|
814
820
|
|
|
@@ -514,7 +514,11 @@ enum CraftNativeMedia {
|
|
|
514
514
|
|
|
515
515
|
private static let lock = NSLock()
|
|
516
516
|
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
517
|
-
private
|
|
517
|
+
private struct Flight {
|
|
518
|
+
let id: UUID
|
|
519
|
+
var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
|
|
520
|
+
}
|
|
521
|
+
private static var inFlight: [String: Flight] = [:]
|
|
518
522
|
|
|
519
523
|
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
520
524
|
/// extension is the address's, because AVPlayer reads the type from it.
|
|
@@ -537,28 +541,29 @@ enum CraftNativeMedia {
|
|
|
537
541
|
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
538
542
|
}
|
|
539
543
|
|
|
540
|
-
///
|
|
541
|
-
static func
|
|
544
|
+
/// A new download's identity, or nil after joining an existing download.
|
|
545
|
+
static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
|
|
542
546
|
lock.lock()
|
|
543
547
|
defer { lock.unlock() }
|
|
544
548
|
if inFlight[address] != nil {
|
|
545
|
-
inFlight[address]!.append(done)
|
|
546
|
-
return
|
|
549
|
+
inFlight[address]!.waiting.append(done)
|
|
550
|
+
return nil
|
|
547
551
|
}
|
|
548
|
-
|
|
549
|
-
|
|
552
|
+
let id = UUID()
|
|
553
|
+
inFlight[address] = Flight(id: id, waiting: [done])
|
|
554
|
+
return id
|
|
550
555
|
}
|
|
551
556
|
|
|
552
|
-
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
557
|
+
static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
|
|
553
558
|
lock.lock()
|
|
554
|
-
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
559
|
+
let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
|
|
555
560
|
lock.unlock()
|
|
556
561
|
for done in waiting { done(result) }
|
|
557
562
|
}
|
|
558
563
|
|
|
559
564
|
/// A finished download moved into place (atomically, so a half-written
|
|
560
565
|
/// file is never played), or a refusal with the server's status.
|
|
561
|
-
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
566
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
|
|
562
567
|
guard response.statusCode == 200 else {
|
|
563
568
|
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
564
569
|
}
|
|
@@ -566,6 +571,9 @@ enum CraftNativeMedia {
|
|
|
566
571
|
// Commit and prune share a lock; neither can remove a file being committed.
|
|
567
572
|
lock.lock()
|
|
568
573
|
defer { lock.unlock() }
|
|
574
|
+
if let flight, inFlight[address]?.id != flight {
|
|
575
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
|
|
576
|
+
}
|
|
569
577
|
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
570
578
|
defer { try? fm.removeItem(at: staging) }
|
|
571
579
|
do {
|
|
@@ -596,6 +604,25 @@ enum CraftNativeMedia {
|
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
/// End an account's cache lifetime.
|
|
608
|
+
static func clear() throws -> Int {
|
|
609
|
+
lock.lock()
|
|
610
|
+
let waiting = inFlight.values.flatMap { $0.waiting }
|
|
611
|
+
inFlight.removeAll()
|
|
612
|
+
let fm = FileManager.default
|
|
613
|
+
let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
614
|
+
var removed = 0
|
|
615
|
+
var failure: Error?
|
|
616
|
+
for url in files {
|
|
617
|
+
do { try fm.removeItem(at: url); removed += 1 }
|
|
618
|
+
catch { failure = error }
|
|
619
|
+
}
|
|
620
|
+
lock.unlock()
|
|
621
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
|
|
622
|
+
if let failure { throw failure }
|
|
623
|
+
return removed
|
|
624
|
+
}
|
|
625
|
+
|
|
599
626
|
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
600
627
|
static func prune(keep: [String]) -> Int {
|
|
601
628
|
lock.lock()
|
|
@@ -5416,6 +5416,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5416
5416
|
guard !isAppScript, navigationController?.topViewController === self,
|
|
5417
5417
|
let screen = payload["screen"] as? String, !screen.isEmpty,
|
|
5418
5418
|
let navigation = navigationController else { return }
|
|
5419
|
+
if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
|
|
5420
|
+
hybridEvents?.nativeScreen(self, replace: screen)
|
|
5421
|
+
return
|
|
5422
|
+
}
|
|
5419
5423
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
5420
5424
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
5421
5425
|
next.hybridEvents = hybridEvents
|
|
@@ -6067,7 +6071,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6067
6071
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
6068
6072
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6069
6073
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
6070
|
-
image.clipsToBounds = image.contentMode == .scaleAspectFill
|
|
6074
|
+
image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
|
|
6071
6075
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
6072
6076
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
6073
6077
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
@@ -6086,7 +6090,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6086
6090
|
if let current = background.image { background.image = imageForDisplay(current, view: background) }
|
|
6087
6091
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6088
6092
|
background.contentMode = imageContentMode(requestedResizeMode)
|
|
6089
|
-
background.clipsToBounds = background.contentMode == .scaleAspectFill
|
|
6093
|
+
background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
|
|
6090
6094
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
6091
6095
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
6092
6096
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
@@ -7006,6 +7010,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7006
7010
|
view.clipsToBounds = overflow == "hidden"
|
|
7007
7011
|
|| (view is UIScrollView && overflow != "visible")
|
|
7008
7012
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
7013
|
+
|| (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
|
|
7009
7014
|
view.layer.masksToBounds = view.clipsToBounds
|
|
7010
7015
|
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
7011
7016
|
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
package/dist/mobile-testing.js
CHANGED
|
@@ -149,6 +149,8 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
149
149
|
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
|
|
150
150
|
if (counts.get("/ios/slow.mp4") !== 1)
|
|
151
151
|
throw new Error("iOS duplicate fetch: keep did not coalesce");
|
|
152
|
+
if (counts.get("/ios/account/slow.mp4") !== 2)
|
|
153
|
+
throw new Error("iOS account switch did not start a fresh transfer");
|
|
152
154
|
print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
|
|
153
155
|
}
|
|
154
156
|
}
|
|
@@ -232,6 +234,8 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
232
234
|
await probe("recover");
|
|
233
235
|
if (counts.get("/android/slow.mp4") !== 1)
|
|
234
236
|
throw new Error("Android duplicate fetch: keep did not coalesce");
|
|
237
|
+
if (counts.get("/android/account/slow.mp4") !== 2)
|
|
238
|
+
throw new Error("Android account switch did not start a fresh transfer");
|
|
235
239
|
print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
|
|
236
240
|
}
|
|
237
241
|
}
|
|
@@ -75,10 +75,11 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
75
75
|
"interrupt" -> {
|
|
76
76
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
77
77
|
CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
|
|
78
|
-
val
|
|
78
|
+
val target = CraftNativeMedia.file(targetContext, restart)
|
|
79
|
+
fun partial() = target.parentFile!!.listFiles()?.any { it.name.startsWith(target.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
|
|
79
80
|
val until = System.currentTimeMillis() + 25000
|
|
80
|
-
while (
|
|
81
|
-
check(
|
|
81
|
+
while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
|
|
82
|
+
check(partial(), "download is in progress before process death")
|
|
82
83
|
android.os.Process.killProcess(android.os.Process.myPid())
|
|
83
84
|
}
|
|
84
85
|
"recover" -> {
|
|
@@ -111,6 +112,26 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
111
112
|
check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
|
|
112
113
|
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
|
|
113
114
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
115
|
+
val privateVideo = "$base/account/slow.mp4"
|
|
116
|
+
val previous = java.util.concurrent.CountDownLatch(1)
|
|
117
|
+
var cancelled = false
|
|
118
|
+
CraftNativeMedia.keep(targetContext, privateVideo, JSONObject(), true) { cancelled = it.code == "CANCELLED"; previous.countDown() }
|
|
119
|
+
val privateTarget = CraftNativeMedia.file(targetContext, privateVideo)
|
|
120
|
+
fun downloading() = root.listFiles()?.any { it.name.startsWith(privateTarget.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
|
|
121
|
+
val deadline = System.currentTimeMillis() + 25000
|
|
122
|
+
while (!downloading() && System.currentTimeMillis() < deadline) Thread.sleep(10)
|
|
123
|
+
check(downloading(), "the previous athlete's download is in progress before sign-out")
|
|
124
|
+
CraftNativeMedia.clear(targetContext)
|
|
125
|
+
check(previous.await(5, java.util.concurrent.TimeUnit.SECONDS) && cancelled, "sign-out cancels the previous athlete's download")
|
|
126
|
+
check(CraftNativeMedia.local(targetContext, privateVideo) == null, "signed-out video is not playable from cache")
|
|
127
|
+
check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
|
|
128
|
+
val media = android.media.MediaMetadataRetriever()
|
|
129
|
+
try {
|
|
130
|
+
media.setDataSource(CraftNativeMedia.local(targetContext, privateVideo)!!.absolutePath)
|
|
131
|
+
check(media.getFrameAtTime(1000000) != null, "the next athlete's local video decodes after the old download settles")
|
|
132
|
+
} finally { media.release() }
|
|
133
|
+
CraftNativeMedia.clear(targetContext)
|
|
134
|
+
check(CraftNativeMedia.local(targetContext, privateVideo) == null, "sign-out removes the next athlete's cached copy too")
|
|
114
135
|
}
|
|
115
136
|
}
|
|
116
137
|
result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
|
|
@@ -17,16 +17,16 @@ var latestTask: URLSessionTask?
|
|
|
17
17
|
var latestErrorCode: String?
|
|
18
18
|
func start(_ address: String, _ done: @escaping (Bool) -> Void) {
|
|
19
19
|
if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
|
|
20
|
-
|
|
20
|
+
guard let flight = CraftNativeMedia.begin(address, { result in
|
|
21
21
|
if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
|
|
22
22
|
else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
|
|
23
|
-
}) { return }
|
|
23
|
+
}) else { return }
|
|
24
24
|
let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
|
|
25
25
|
let result: Result<Any, CraftNativeActionError>
|
|
26
26
|
if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
|
|
27
|
-
else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response) }
|
|
27
|
+
else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
|
|
28
28
|
else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
|
|
29
|
-
CraftNativeMedia.finish(address, result)
|
|
29
|
+
CraftNativeMedia.finish(address, flight: flight, result)
|
|
30
30
|
}
|
|
31
31
|
latestTask = task
|
|
32
32
|
task.resume()
|
|
@@ -116,3 +116,19 @@ check(answers == [true, true], "concurrent downloads coalesce and survive prune"
|
|
|
116
116
|
let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
|
|
117
117
|
check(parts.isEmpty, "no abandoned staging files remain")
|
|
118
118
|
_ = CraftNativeMedia.prune(keep: [])
|
|
119
|
+
let privateVideo = base + "/account/slow.mp4"
|
|
120
|
+
var previousAnswer: Bool?
|
|
121
|
+
start(privateVideo) { previousAnswer = $0 }
|
|
122
|
+
let previousTask = latestTask!
|
|
123
|
+
wait { previousTask.countOfBytesReceived > 0 }
|
|
124
|
+
_ = try CraftNativeMedia.clear()
|
|
125
|
+
check(previousAnswer == false && latestErrorCode == "CANCELLED", "sign-out cancels the previous athlete's download")
|
|
126
|
+
check(CraftNativeMedia.local(privateVideo) == nil, "signed-out video is not playable from cache")
|
|
127
|
+
check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
|
|
128
|
+
wait { previousTask.state == .completed }
|
|
129
|
+
let cached = CraftNativeMedia.local(privateVideo)!
|
|
130
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: cached))
|
|
131
|
+
_ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
|
|
132
|
+
check(true, "the next athlete's local video decodes after the old download settles")
|
|
133
|
+
_ = try CraftNativeMedia.clear()
|
|
134
|
+
check(CraftNativeMedia.local(privateVideo) == nil, "sign-out removes the next athlete's cached copy too")
|