craft-native 0.0.251 → 0.0.262

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.
Files changed (30) hide show
  1. package/dist/android/src/index.d.ts +4 -2
  2. package/dist/android/src/index.js +69 -5
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  5. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  6. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  7. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  8. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
  10. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  11. package/dist/android/templates/CraftNativeShell.kt.template +73 -23
  12. package/dist/android/templates/MainActivityNative.kt.template +217 -28
  13. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  14. package/dist/api/mobile.d.ts +9 -1
  15. package/dist/cli.js +1 -1
  16. package/dist/components/index.d.ts +19 -10
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/templates/CraftApp.swift +36 -4
  20. package/dist/ios/templates/CraftHealth.swift +38 -0
  21. package/dist/ios/templates/CraftHybrid.swift +38 -8
  22. package/dist/ios/templates/CraftNativeActions.swift +1 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
  24. package/dist/ios/templates/CraftNativeScreen.swift +308 -28
  25. package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
  26. package/dist/mobile-media-testing.d.ts +2 -0
  27. package/dist/mobile-testing.js +19 -10
  28. package/dist/mobile.js +1 -1
  29. package/dist/testing/media-cache/media-cache.kt +47 -6
  30. package/package.json +1 -1
@@ -56,6 +56,7 @@ import java.time.LocalDateTime
56
56
  import java.time.Period
57
57
  import java.time.ZoneId
58
58
  import java.util.TreeMap
59
+ import java.util.UUID
59
60
  import java.util.concurrent.ConcurrentHashMap
60
61
  import java.util.concurrent.atomic.AtomicBoolean
61
62
  import java.util.concurrent.atomic.AtomicReference
@@ -89,10 +90,22 @@ internal class CraftNativeHealth(
89
90
  /** Ask for READ_HEALTH_DATA_IN_BACKGROUND / READ_HEALTH_DATA_HISTORY with the read types when the feature is available (the manifest declares them only when these are true). */
90
91
  private val backgroundReads: Boolean,
91
92
  private val historyReads: Boolean,
93
+ /** Lifecycle events are broadcast to native screens and the web host. */
94
+ private val workoutSink: ((JSONObject) -> Unit)? = null,
92
95
  ) {
93
96
  /** A read or write that did not happen: the bridge's error code and the message a page sees. */
94
97
  private class HealthFailure(val code: String, message: String) : Exception(message)
95
98
 
99
+ private data class LiveWorkout(
100
+ val id: String,
101
+ val type: String,
102
+ val start: Instant,
103
+ val location: String?,
104
+ var pausedAt: Instant? = null,
105
+ var pausedMillis: Long = 0,
106
+ var state: String = "running",
107
+ )
108
+
96
109
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
97
110
  private val mainHandler = Handler(Looper.getMainLooper())
98
111
  private val closed = AtomicBoolean(false)
@@ -108,6 +121,8 @@ internal class CraftNativeHealth(
108
121
  private var cachedClient: HealthConnectClient? = null
109
122
 
110
123
  private val ownOrigin = DataOrigin(context.packageName)
124
+ private val workoutLock = Any()
125
+ private val liveWorkout = AtomicReference<LiveWorkout?>(null)
111
126
 
112
127
  /** A native screen's call (`Health` in CraftNativeActions), by method and arguments as the bridge sends them. */
113
128
  fun perform(method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
@@ -150,6 +165,10 @@ internal class CraftNativeHealth(
150
165
  "getSleepIntervals" -> answer(completion) { getSleepIntervals(options(0)) }
151
166
  "saveMindfulSession" -> answer(completion) { saveMindfulSession(options(0)) }
152
167
  "getMindfulSessions" -> answer(completion) { getMindfulSessions(options(0)) }
168
+ "startWorkout" -> answer(completion) { startWorkout(options(0)) }
169
+ "pauseWorkout" -> answer(completion) { pauseWorkout() }
170
+ "resumeWorkout" -> answer(completion) { resumeWorkout() }
171
+ "endWorkout" -> answer(completion) { endWorkout() }
153
172
  else -> deliver(completion, CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Health method: $method"))
154
173
  }
155
174
  }
@@ -165,6 +184,7 @@ internal class CraftNativeHealth(
165
184
 
166
185
  fun close() {
167
186
  closed.set(true)
187
+ liveWorkout.getAndSet(null)?.let { emitWorkoutState(it, "stopped") }
168
188
  pendingPermission.getAndSet(null)?.cancel()
169
189
  scope.cancel()
170
190
  }
@@ -213,6 +233,101 @@ internal class CraftNativeHealth(
213
233
 
214
234
  private fun invalid(message: String) = HealthFailure("INVALID_ARGUMENT", message)
215
235
 
236
+ // MARK: - Live workout lifecycle
237
+
238
+ /**
239
+ * Android Health Connect stores the finished ExerciseSessionRecord. The
240
+ * lifecycle is kept in the foreground host until the Health Services
241
+ * foreground-session adapter is available; no post-hoc save is implied by
242
+ * this API, and every transition is still observable by the page.
243
+ */
244
+ private suspend fun startWorkout(body: JSONObject): Any {
245
+ val type = body.optString("type").takeIf { it.isNotBlank() }
246
+ ?: throw invalid("startWorkout needs a workout type")
247
+ if (EXERCISE_TYPES[type] == null) throw invalid("Unsupported workout type: $type")
248
+ if (activityProvider() == null) throw HealthFailure("NEEDS_ACTIVITY", "Live workouts require a foreground activity")
249
+ val now = Instant.now()
250
+ val workout = synchronized(workoutLock) {
251
+ if (liveWorkout.get() != null) throw HealthFailure("BUSY", "A live workout is already running")
252
+ LiveWorkout("live-${UUID.randomUUID()}", type, now, body.optString("location").takeIf { it.isNotBlank() }).also(liveWorkout::set)
253
+ }
254
+ emitWorkoutState(workout, "running", now)
255
+ return JSONObject()
256
+ .put("id", workout.id)
257
+ .put("type", workout.type)
258
+ .put("startDate", workout.start.ms())
259
+ }
260
+
261
+ private suspend fun pauseWorkout(): Any {
262
+ val now = Instant.now()
263
+ val workout = synchronized(workoutLock) {
264
+ val current = liveWorkout.get() ?: throw HealthFailure("NO_WORKOUT", "No live workout is running")
265
+ if (current.state != "running") throw HealthFailure("INVALID_STATE", "The live workout is not running")
266
+ current.pausedAt = now
267
+ current.state = "paused"
268
+ current
269
+ }
270
+ emitWorkoutState(workout, "paused", now)
271
+ return true
272
+ }
273
+
274
+ private suspend fun resumeWorkout(): Any {
275
+ val now = Instant.now()
276
+ val workout = synchronized(workoutLock) {
277
+ val current = liveWorkout.get() ?: throw HealthFailure("NO_WORKOUT", "No live workout is running")
278
+ val pausedAt = current.pausedAt ?: throw HealthFailure("INVALID_STATE", "The live workout is not paused")
279
+ current.pausedMillis += Duration.between(pausedAt, now).toMillis().coerceAtLeast(0)
280
+ current.pausedAt = null
281
+ current.state = "running"
282
+ current
283
+ }
284
+ emitWorkoutState(workout, "running", now)
285
+ return true
286
+ }
287
+
288
+ private suspend fun endWorkout(): Any {
289
+ val now = Instant.now()
290
+ val workout = synchronized(workoutLock) {
291
+ val current = liveWorkout.getAndSet(null) ?: throw HealthFailure("NO_WORKOUT", "No live workout is running")
292
+ current.pausedAt?.let { current.pausedMillis += Duration.between(it, now).toMillis().coerceAtLeast(0) }
293
+ current.pausedAt = null
294
+ current.state = "ended"
295
+ current
296
+ }
297
+ val body = JSONObject()
298
+ .put("activityId", workout.id)
299
+ .put("type", workout.type)
300
+ .put("startDate", workout.start.ms())
301
+ .put("endDate", now.ms())
302
+ val saved = try {
303
+ saveWorkout(body) as JSONObject
304
+ } catch (error: CancellationException) {
305
+ throw error
306
+ } catch (error: Throwable) {
307
+ emitWorkoutError(workout, error.message ?: "Live workout could not be saved")
308
+ throw error
309
+ }
310
+ val duration = (Duration.between(workout.start, now).toMillis() - workout.pausedMillis).coerceAtLeast(0) / 1000.0
311
+ emitWorkoutState(workout, "ended", now)
312
+ return JSONObject()
313
+ .put("id", workout.id)
314
+ .put("workoutId", saved.optString("id", workout.id))
315
+ .put("endDate", now.ms())
316
+ .put("durationSeconds", duration)
317
+ }
318
+
319
+ private fun emitWorkoutState(workout: LiveWorkout, state: String, date: Instant = Instant.now()) {
320
+ mainHandler.post {
321
+ workoutSink?.invoke(JSONObject().put("id", workout.id).put("event", "state").put("state", state).put("date", date.ms()))
322
+ }
323
+ }
324
+
325
+ private fun emitWorkoutError(workout: LiveWorkout, message: String) {
326
+ mainHandler.post {
327
+ workoutSink?.invoke(JSONObject().put("id", workout.id).put("event", "error").put("message", message))
328
+ }
329
+ }
330
+
216
331
  // MARK: - Health Connect itself
217
332
 
218
333
  private fun sdkStatus(): Int = HealthConnectClient.getSdkStatus(context, PROVIDER_PACKAGE)
@@ -15,6 +15,7 @@ internal class CraftNativeHealth(
15
15
  @Suppress("UNUSED_PARAMETER") activityProvider: () -> Activity?,
16
16
  @Suppress("UNUSED_PARAMETER") backgroundReads: Boolean,
17
17
  @Suppress("UNUSED_PARAMETER") historyReads: Boolean,
18
+ @Suppress("UNUSED_PARAMETER") workoutSink: ((org.json.JSONObject) -> Unit)? = null,
18
19
  ) {
19
20
  fun perform(method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
20
21
  completion(CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Health Connect is not enabled for this app ($method, ${args.length()} arguments)"))
@@ -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) {
@@ -30,6 +30,7 @@ import java.io.File
30
30
  import java.util.Locale
31
31
  import java.util.UUID
32
32
  {{NATIVE_PUSH_IMPORTS}}
33
+ {{NATIVE_WALLET_IMPORTS}}
33
34
 
34
35
  private fun craftLocationFix(location: android.location.Location): JSONObject = JSONObject()
35
36
  .put("latitude", location.latitude)
@@ -61,7 +62,16 @@ internal class CraftNativeServices(
61
62
  private val main = Handler(Looper.getMainLooper())
62
63
  private val heartRateEnabled = config.optBoolean("enableHeartRate")
63
64
  private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
64
- CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
65
+ CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory")) { detail ->
66
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHealthWorkout").put("detail", detail))
67
+ }
68
+ } else null
69
+ private val healthServices: CraftHealthServices? = if (config.optBoolean("enableHealthServices")) {
70
+ CraftHealthServices(context, activity, health?.let { healthApi ->
71
+ { method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit ->
72
+ healthApi.perform(method, args, completion)
73
+ }
74
+ })
65
75
  } else null
66
76
  private var pendingPushPermission: ((CraftNativeCapabilityResult) -> Unit)? = null
67
77
  private var pendingBrowser: Pair<String?, (CraftNativeCapabilityResult) -> Unit>? = null
@@ -70,6 +80,7 @@ internal class CraftNativeServices(
70
80
  private var speechReady = false
71
81
  private val speechQueue = mutableListOf<() -> Unit>()
72
82
  private val speeches = mutableMapOf<String, (CraftNativeCapabilityResult) -> Unit>()
83
+ {{NATIVE_WALLET_FIELDS}}
73
84
 
74
85
  init {
75
86
  if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
@@ -106,13 +117,14 @@ internal class CraftNativeServices(
106
117
  add("media")
107
118
  if (config.optBoolean("enableShare")) add("share")
108
119
  if (config.optBoolean("enablePushNotifications")) add("push")
109
- if (health != null) {
120
+ if (health != null || healthServices != null) {
110
121
  add("health")
111
122
  // Health Connect may need installing or updating, and its grants
112
123
  // are managed in its own screen: `health.availability()`,
113
124
  // `health.grantedTypes()` and `health.openSettings()`.
114
125
  add("healthAvailability")
115
126
  }
127
+ if (healthServices != null) add("healthServices")
116
128
  if (config.optBoolean("enableWallet")) add("wallet")
117
129
  if (config.optBoolean("enableKeepAwake")) add("keepAwake")
118
130
  if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
@@ -145,7 +157,9 @@ internal class CraftNativeServices(
145
157
  "Linking" -> linking(method, args.optString(0), done)
146
158
  "Share" -> share(args.optJSONObject(0) ?: JSONObject(), done)
147
159
  "Push" -> push(method, done)
148
- "Health" -> health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health Connect is not enabled for this app"))
160
+ "Health" -> if (healthServices != null && method in setOf("startWorkout", "pauseWorkout", "resumeWorkout", "endWorkout")) {
161
+ healthServices.perform(method, args, done)
162
+ } else health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health is not enabled for this app"))
149
163
  "Wallet" -> wallet(method, args, done)
150
164
  "KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
151
165
  "Brightness" -> brightness(method, args, done)
@@ -182,11 +196,17 @@ internal class CraftNativeServices(
182
196
  private fun wallet(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
183
197
  if (!config.optBoolean("enableWallet")) return done(failure("CAPABILITY_DISABLED", "Wallet is disabled"))
184
198
  when (method) {
185
- "isAvailable" -> done(success(runCatching {
186
- context.packageManager.getLaunchIntentForPackage("com.google.android.apps.walletnfcrel") != null
187
- }.getOrDefault(false)))
199
+ "isAvailable" -> {
200
+ {{NATIVE_WALLET_AVAILABILITY}}
201
+ }
188
202
  "add" -> {
189
203
  val source = args.optJSONObject(0) ?: return done(failure("INVALID_ARGUMENT", "Wallet.add needs a source object"))
204
+ val jwt = source.optString("jwt").trim()
205
+ if (jwt.isNotEmpty()) {
206
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to open Wallet"))
207
+ {{NATIVE_WALLET_JWT_ADD}}
208
+ return
209
+ }
190
210
  val address = source.optString("url").trim()
191
211
  if (!address.startsWith("https://")) return done(failure("INVALID_ARGUMENT", "Android Wallet.add needs an HTTPS save URL"))
192
212
  val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to open Wallet"))
@@ -200,6 +220,7 @@ internal class CraftNativeServices(
200
220
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Wallet method"))
201
221
  }
202
222
  }
223
+ {{NATIVE_WALLET_HELPER}}
203
224
 
204
225
  // MARK: Dialogs
205
226
 
@@ -542,8 +563,10 @@ internal class CraftNativeServices(
542
563
 
543
564
  // MARK: Health Connect
544
565
 
545
- fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =
546
- health?.onActivityResult(requestCode, resultCode, data) == true
566
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
567
+ {{NATIVE_WALLET_RESULT}}
568
+ return health?.onActivityResult(requestCode, resultCode, data) == true
569
+ }
547
570
 
548
571
  // MARK: The screen
549
572
 
@@ -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"
@@ -893,6 +907,11 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
893
907
  if (kotlin.math.abs(dx) > swipeSlop && kotlin.math.abs(dx) > kotlin.math.abs(dy)) {
894
908
  swipeTracking = false
895
909
  swipeIntercepted = onTabSwipeStart?.invoke(dx, event.x, event.y) == true
910
+ // Android cancels the child's touch target for this move;
911
+ // it does not replay the move through our onTouchEvent.
912
+ // Apply it now so a paused or coalesced drag still previews
913
+ // the incoming tab before another input event arrives.
914
+ if (swipeIntercepted) onTabSwipeMove?.invoke(dx)
896
915
  return swipeIntercepted
897
916
  }
898
917
  }
@@ -1234,6 +1253,7 @@ internal object CraftNativeSymbols {
1234
1253
  * transfer finishes on its own; the next keep answers from the copy.
1235
1254
  */
1236
1255
  internal object CraftNativeMedia {
1256
+ const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
1237
1257
  private const val TAG = "craft-media"
1238
1258
  private const val PREFS = "craft.media"
1239
1259
  private val lock = Any()
@@ -1301,7 +1321,7 @@ internal object CraftNativeMedia {
1301
1321
  try {
1302
1322
  if (generation(app) == started) {
1303
1323
  val pending = work.getWorkInfosForUniqueWork(name).get().any {
1304
- "$TAG:generation:$started" in it.tags &&
1324
+ "$TAG:generation:$started" in it.tags && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
1305
1325
  (it.state == androidx.work.WorkInfo.State.RUNNING ||
1306
1326
  (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
1307
1327
  }
@@ -1349,12 +1369,22 @@ internal object CraftNativeMedia {
1349
1369
  * file where it stopped, moves it into place whole, and answers whoever
1350
1370
  * waits. RETRY leaves the part file for the next attempt.
1351
1371
  */
1352
- fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
1372
+ fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
1373
+ fun answer(result: CraftNativeCapabilityResult) {
1374
+ // The callback can immediately call keep again. Publish completion
1375
+ // before that callback, while WorkManager may still say RUNNING.
1376
+ synchronized(lock) {
1377
+ // A keep registered after this marker belongs to the next
1378
+ // attempt; it must not enter the old callback batch.
1379
+ beforeAnswer()
1380
+ deliver(context, address, started, result)
1381
+ }
1382
+ }
1353
1383
  // clear() already answered the old generation's waiters. Never answer
1354
1384
  // a new athlete's waiters from an obsolete worker for the same URL.
1355
1385
  if (generation(context) != started) return Outcome.DONE
1356
1386
  local(context, address)?.let {
1357
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1387
+ answer(CraftNativeCapabilityResult(data = describe(address, it)))
1358
1388
  return Outcome.DONE
1359
1389
  }
1360
1390
  val writer = writers.getOrPut(address) { Any() }
@@ -1364,7 +1394,7 @@ internal object CraftNativeMedia {
1364
1394
  if (generation(context) != started) return Outcome.DONE
1365
1395
  if (stopped()) return Outcome.RETRY
1366
1396
  local(context, address)?.let {
1367
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1397
+ answer(CraftNativeCapabilityResult(data = describe(address, it)))
1368
1398
  return Outcome.DONE
1369
1399
  }
1370
1400
  val target = file(context, address)
@@ -1383,22 +1413,22 @@ internal object CraftNativeMedia {
1383
1413
  }
1384
1414
  if (full) {
1385
1415
  staging.delete()
1386
- deliver(context, address, started, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1416
+ answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1387
1417
  return Outcome.DONE
1388
1418
  }
1389
1419
  if (stopped()) return Outcome.RETRY
1390
1420
  // Lost the connection part way: the part waits for the next attempt.
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"))
1421
+ 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
1422
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1393
1423
  }
1394
1424
  when (fetched) {
1395
1425
  is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
1396
1426
  is CraftResumableDownload.Fetched.Refused -> {
1397
- deliver(context, address, started, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1427
+ answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1398
1428
  return Outcome.DONE
1399
1429
  }
1400
1430
  is CraftResumableDownload.Fetched.Incomplete -> {
1401
- deliver(context, address, started, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1431
+ answer(CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1402
1432
  return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
1403
1433
  }
1404
1434
  is CraftResumableDownload.Fetched.Complete -> Unit
@@ -1410,11 +1440,11 @@ internal object CraftNativeMedia {
1410
1440
  }
1411
1441
  target.delete()
1412
1442
  if (!staging.renameTo(target)) {
1413
- deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1443
+ answer(CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1414
1444
  return Outcome.DONE
1415
1445
  }
1416
1446
  }
1417
- deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
1447
+ answer(CraftNativeCapabilityResult(data = describe(address, target)))
1418
1448
  return Outcome.DONE
1419
1449
  }
1420
1450
  }
@@ -1432,13 +1462,13 @@ internal object CraftNativeMedia {
1432
1462
  capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
1433
1463
  }.getOrDefault(true)
1434
1464
 
1435
- /** Removes every copy whose address is not in `keep`, and stops their transfers; answers how many files went. */
1465
+ /** Removes idle copies outside `keep` and cancels their jobs; pending keeps retain ownership. */
1436
1466
  fun prune(context: Context, keep: List<String>): Int {
1437
1467
  val wanted = keep.map { digest(it) }.toSet()
1438
1468
  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
1469
  synchronized(lock) {
1470
+ // A keep still waiting owns its files until it is answered.
1471
+ val active = waiting.keys.map { digest(it) }.toSet()
1442
1472
  root(context).listFiles()?.forEach { file ->
1443
1473
  val owner = file.name.substringBefore('.')
1444
1474
  // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
@@ -1451,8 +1481,16 @@ internal object CraftNativeMedia {
1451
1481
  runCatching {
1452
1482
  androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
1453
1483
  .filter { !it.state.isFinished }
1454
- .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
1455
- .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
1484
+ .forEach { info ->
1485
+ synchronized(lock) {
1486
+ // The query runs asynchronously: a new keep may now
1487
+ // own an address that was idle when prune was called.
1488
+ val active = waiting.keys.map { digest(it) }.toSet()
1489
+ if (info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted + active }) {
1490
+ androidx.work.WorkManager.getInstance(app).cancelWorkById(info.id)
1491
+ }
1492
+ }
1493
+ }
1456
1494
  }
1457
1495
  }
1458
1496
  return removed
@@ -1470,9 +1508,10 @@ internal object CraftNativeMedia {
1470
1508
  root(context).listFiles()?.forEach { file ->
1471
1509
  if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1472
1510
  }
1511
+ // Schedule cancellation before a new generation can enqueue its work.
1512
+ androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1473
1513
  callbacks
1474
1514
  }
1475
- androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
1476
1515
  main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1477
1516
  failure?.let { throw it }
1478
1517
  return removed
@@ -1511,12 +1550,10 @@ internal class CraftResumableDownload(
1511
1550
  if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
1512
1551
  try {
1513
1552
  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
1553
  val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
1517
1554
  if (status == 416 || (status == 206 && !resumed)) {
1518
1555
  part.delete()
1519
- return Fetched.Restart
1556
+ return if (have > 0) Fetched.Restart else Fetched.Refused(status)
1520
1557
  }
1521
1558
  if (status != 200 && !resumed) {
1522
1559
  part.delete()
@@ -1539,8 +1576,8 @@ internal class CraftResumableDownload(
1539
1576
  // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
1540
1577
  val encoded = connection.getHeaderField("Content-Encoding") != null
1541
1578
  if (size <= 0 || (!encoded && total >= 0 && size != total)) {
1542
- // Longer than the file says it is: nothing to resume from.
1543
- if (total in 0 until size) part.delete()
1579
+ // Empty or longer than the file says: nothing useful to resume.
1580
+ if (size <= 0 || total in 0 until size) part.delete()
1544
1581
  return Fetched.Incomplete
1545
1582
  }
1546
1583
  return Fetched.Complete(size)
@@ -1563,8 +1600,21 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
1563
1600
  override fun doWork(): Result {
1564
1601
  val address = inputData.getString("address") ?: return Result.failure()
1565
1602
  val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
1566
- val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, inputData.getInt("generation", 0), runAttemptCount) { isStopped }
1567
- return if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1603
+ val started = inputData.getInt("generation", 0)
1604
+ return try {
1605
+ setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
1606
+ val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
1607
+ beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
1608
+ ) { isStopped }
1609
+ if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1610
+ } catch (error: Exception) {
1611
+ if (error is InterruptedException) Thread.currentThread().interrupt()
1612
+ // A replacement owns the address's new waiters. A real progress
1613
+ // storage failure still needs an answer instead of a lost callback.
1614
+ if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
1615
+ CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"))
1616
+ if (isStopped) Result.retry() else Result.failure()
1617
+ }
1568
1618
  }
1569
1619
  }
1570
1620