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.
Files changed (31) hide show
  1. package/README.md +22 -0
  2. package/dist/android/src/index.d.ts +4 -2
  3. package/dist/android/src/index.js +65 -4
  4. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  5. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  6. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  7. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  8. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  9. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  10. package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
  11. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  12. package/dist/android/templates/CraftNativeShell.kt.template +5 -0
  13. package/dist/android/templates/MainActivityNative.kt.template +343 -33
  14. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  15. package/dist/api/mobile.d.ts +9 -1
  16. package/dist/browser/lifecycle.d.ts +7 -0
  17. package/dist/browser.js +126 -24
  18. package/dist/cli.js +1 -1
  19. package/dist/components/index.d.ts +19 -10
  20. package/dist/index.cjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/ios/templates/CraftApp.swift +55 -7
  23. package/dist/ios/templates/CraftHealth.swift +38 -0
  24. package/dist/ios/templates/CraftHybrid.swift +50 -7
  25. package/dist/ios/templates/CraftNativeActions.swift +109 -22
  26. package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
  27. package/dist/ios/templates/CraftNativeMutation.swift +3 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +466 -24
  29. package/dist/macos/templates/CraftNativeMutation.swift +3 -1
  30. package/dist/mobile.js +1 -1
  31. 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" -> 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
 
@@ -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
  }