craft-native 0.0.254 → 0.0.272
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/README.md +22 -0
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +65 -4
- 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/CraftNativeMutation.kt.template +2 -0
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +343 -33
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/browser/lifecycle.d.ts +7 -0
- package/dist/browser.js +126 -24
- 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 +55 -7
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +50 -7
- package/dist/ios/templates/CraftNativeActions.swift +109 -22
- package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
- package/dist/ios/templates/CraftNativeMutation.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +466 -24
- package/dist/macos/templates/CraftNativeMutation.swift +3 -1
- package/dist/mobile.js +1 -1
- 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)"))
|
|
@@ -309,6 +309,8 @@ class CraftNativeMutationDocument {
|
|
|
309
309
|
"View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
|
|
310
310
|
"TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
|
|
311
311
|
"Fragment", "Svg", "G", "Map",
|
|
312
|
+
// A shape holds its `Animate`: SMIL keyframes.
|
|
313
|
+
"Path", "Line", "Rect", "Circle", "Ellipse", "Polyline", "Polygon",
|
|
312
314
|
)
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -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
|
|
|
@@ -907,6 +907,11 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
907
907
|
if (kotlin.math.abs(dx) > swipeSlop && kotlin.math.abs(dx) > kotlin.math.abs(dy)) {
|
|
908
908
|
swipeTracking = false
|
|
909
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)
|
|
910
915
|
return swipeIntercepted
|
|
911
916
|
}
|
|
912
917
|
}
|