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.
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +69 -5
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftHealthServices.kt.template +132 -0
- package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
- package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
- package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +73 -23
- package/dist/android/templates/MainActivityNative.kt.template +217 -28
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +36 -4
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +38 -8
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
- package/dist/ios/templates/CraftNativeScreen.swift +308 -28
- 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/mobile.js +1 -1
- package/dist/testing/media-cache/media-cache.kt +47 -6
- 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" ->
|
|
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" ->
|
|
186
|
-
|
|
187
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1443
|
+
answer(CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
|
|
1414
1444
|
return Outcome.DONE
|
|
1415
1445
|
}
|
|
1416
1446
|
}
|
|
1417
|
-
|
|
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
|
|
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
|
-
.
|
|
1455
|
-
|
|
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
|
-
//
|
|
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
|
|
1567
|
-
return
|
|
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
|
|