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
package/README.md CHANGED
@@ -646,3 +646,25 @@ An explicit discard may ACK the captured old ID only after a fresh committed
646
646
  native read proves that its previously registered intent was deleted or
647
647
  replaced. A cached missing draft or an unsuccessful initial registration is
648
648
  not evidence of deletion.
649
+
650
+ ### Browser lifecycle
651
+
652
+ `createBrowserPlatform()` from `craft-native/browser` supplies
653
+ `craft.lifecycle.getState()` and `onStateChange(callback)` (also `onChange`).
654
+ States are `active` and `background`. The host emits the native-compatible
655
+ `APP_STATE { state }` event on visibility changes and page cache navigation,
656
+ coalesces duplicate transitions, and releases listeners in `dispose()`.
657
+ This lets browser simulations checkpoint on backgrounding with the same contract
658
+ as a native shell. Storage failures must still be handled by the caller.
659
+
660
+ Await `platform.call('Storage', 'set', [key, value], signal)` before reporting
661
+ a saved checkpoint. `craft.storage.setSync` updates the immediate UI cache and
662
+ queues its write when Web Locks are available; it cannot acknowledge durability
663
+ or propagate a later quota failure. Background events are an opportunity to
664
+ checkpoint, but browsers can terminate a page before queued work finishes, so
665
+ also save periodically during play.
666
+
667
+ Browser audio assigns one owner per sound ID. A newer play or stop invalidates
668
+ pending media resolution for that ID; stopping all sounds, changing accounts,
669
+ aborting a call or disposing the host also releases active audio. Superseded
670
+ play calls reject with `AbortError`.
@@ -70,7 +70,9 @@ export interface CraftAndroidConfig {
70
70
  */
71
71
  enableHeartRate?: boolean;
72
72
  enableHealthConnect?: boolean;
73
- /** Google Wallet save-link handoff for native screens. */
73
+ /** Android Health Services live exercise tracking for native screens. Requires minSdk 30. */
74
+ enableHealthServices?: boolean;
75
+ /** Google Wallet save-link or native SDK handoff for native screens. */
74
76
  enableWallet?: boolean;
75
77
  /**
76
78
  * The Health Connect data the app reads and writes, by the shared type
@@ -123,7 +125,7 @@ export interface CraftAndroidConfig {
123
125
  mapsApiKey?: string;
124
126
  devServerURL?: string;
125
127
  hasBundledFallback?: boolean;
126
- /** Android API level, at least ANDROID_MIN_SDK (26). */
128
+ /** Android API level, at least ANDROID_MIN_SDK (26), or 30 with Health Services. */
127
129
  minSdk?: number;
128
130
  compileSdk?: number;
129
131
  targetSdk?: number;
@@ -454,6 +454,7 @@ var KOTLIN_KEYWORDS = new Set([
454
454
  "while"
455
455
  ]);
456
456
  var ANDROID_MIN_SDK = 26;
457
+ var ANDROID_HEALTH_SERVICES_MIN_SDK = 30;
457
458
  var HEALTH_CONNECT_PERMISSIONS = {
458
459
  steps: { read: ["READ_STEPS"], write: [] },
459
460
  heartRate: { read: ["READ_HEART_RATE"], write: [] },
@@ -514,6 +515,7 @@ var DEFAULT_CONFIG = {
514
515
  enableBluetooth: false,
515
516
  enableHeartRate: false,
516
517
  enableHealthConnect: false,
518
+ enableHealthServices: false,
517
519
  enableWallet: false,
518
520
  enableKeepAwake: false,
519
521
  enableDeepLinks: false,
@@ -648,6 +650,9 @@ function validateAndroidConfig(config) {
648
650
  if (Number(config.minSdk) < ANDROID_MIN_SDK) {
649
651
  throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`);
650
652
  }
653
+ if (config.enableHealthServices && Number(config.minSdk) < ANDROID_HEALTH_SERVICES_MIN_SDK) {
654
+ throw new Error(`Android Health Services requires minSdk at least ${ANDROID_HEALTH_SERVICES_MIN_SDK} (Android 11)`);
655
+ }
651
656
  if (Number(config.minSdk) > Number(config.targetSdk)) {
652
657
  throw new Error("Android minSdk must not exceed targetSdk");
653
658
  }
@@ -810,12 +815,23 @@ function renderAndroidPermissions(config) {
810
815
  permissions.add("android.permission.FOREGROUND_SERVICE");
811
816
  permissions.add("android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE");
812
817
  }
818
+ if (config.enableHealthServices) {
819
+ permissions.add("android.permission.BODY_SENSORS");
820
+ permissions.add("android.permission.BODY_SENSORS_BACKGROUND");
821
+ permissions.add("android.permission.health.READ_HEART_RATE");
822
+ permissions.add("android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND");
823
+ permissions.add("android.permission.ACTIVITY_RECOGNITION");
824
+ permissions.add("android.permission.ACCESS_FINE_LOCATION");
825
+ permissions.add("android.permission.ACCESS_COARSE_LOCATION");
826
+ permissions.add("android.permission.FOREGROUND_SERVICE");
827
+ permissions.add("android.permission.FOREGROUND_SERVICE_HEALTH");
828
+ }
813
829
  const bluetooth = config.enableBluetooth || config.enableHeartRate ? [
814
830
  ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
815
831
  ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
816
832
  ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
817
833
  ' <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />',
818
- ...config.enableGeolocation ? [] : [' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />']
834
+ ...permissions.has("android.permission.ACCESS_FINE_LOCATION") ? [] : [' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />']
819
835
  ].join(`
820
836
  `) : "";
821
837
  return [bluetooth, ...[...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`)].filter(Boolean).join(`
@@ -833,7 +849,49 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
833
849
  write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
834
850
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
835
851
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
836
- write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
852
+ write(config.enableHealthServices ? "CraftHealthServices.kt.template" : "CraftHealthServicesStub.kt.template", "CraftHealthServices.kt");
853
+ if (config.enableHealthServices) {
854
+ write("CraftHealthServicesService.kt.template", "CraftHealthServicesService.kt");
855
+ } else {
856
+ rmSync2(join3(javaDir, "CraftHealthServicesService.kt"), { force: true });
857
+ }
858
+ write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_WALLET_IMPORTS}}", config.enableWallet ? `import com.google.android.gms.pay.Pay
859
+ import com.google.android.gms.pay.PayApiAvailabilityStatus
860
+ import com.google.android.gms.pay.PayClient` : "").replace("{{NATIVE_WALLET_FIELDS}}", config.enableWallet ? ` private var pendingWallet: ((CraftNativeCapabilityResult) -> Unit)? = null
861
+ private val walletRequestCode = 9517` : "").replace("{{NATIVE_WALLET_AVAILABILITY}}", config.enableWallet ? ` val host = activity()
862
+ if (host == null) {
863
+ done(success(false))
864
+ } else {
865
+ Pay.getClient(host).getPayApiAvailabilityStatus(PayClient.RequestType.SAVE_PASSES_JWT)
866
+ .addOnSuccessListener { status -> done(success(status == PayApiAvailabilityStatus.AVAILABLE)) }
867
+ .addOnFailureListener { done(success(false)) }
868
+ }` : ` done(success(runCatching {
869
+ context.packageManager.getLaunchIntentForPackage("com.google.android.apps.walletnfcrel") != null
870
+ }.getOrDefault(false)))`).replace("{{NATIVE_WALLET_JWT_ADD}}", config.enableWallet ? " saveWalletJwt(jwt, host, done)" : ' done(failure("UNSUPPORTED", "Android Wallet JWT saves are not enabled"))').replace("{{NATIVE_WALLET_HELPER}}", config.enableWallet ? ` private fun saveWalletJwt(jwt: String, host: Activity, done: (CraftNativeCapabilityResult) -> Unit) {
871
+ if (pendingWallet != null) return done(failure("BUSY", "A Google Wallet save is already in progress"))
872
+ pendingWallet = done
873
+ try {
874
+ Pay.getClient(host).savePassesJwt(jwt, host, walletRequestCode)
875
+ } catch (error: Exception) {
876
+ pendingWallet = null
877
+ done(failure("PRESENTATION_FAILED", error.message ?: "Google Wallet could not open"))
878
+ }
879
+ }
880
+ ` : "").replace("{{NATIVE_WALLET_RESULT}}", config.enableWallet ? ` if (requestCode == walletRequestCode) {
881
+ val completion = pendingWallet
882
+ pendingWallet = null
883
+ if (completion == null) return true
884
+ when (resultCode) {
885
+ Activity.RESULT_OK -> completion(success(true))
886
+ Activity.RESULT_CANCELED -> completion(success(false))
887
+ PayClient.SavePassesResult.API_UNAVAILABLE -> completion(failure("UNAVAILABLE", "Google Wallet save is unavailable"))
888
+ PayClient.SavePassesResult.INTERNAL_ERROR -> completion(failure("WALLET_ERROR", "Google Wallet returned an internal error"))
889
+ PayClient.SavePassesResult.SAVE_ERROR -> completion(failure("SAVE_ERROR", data?.getStringExtra(PayClient.EXTRA_API_ERROR_MESSAGE) ?: "Google Wallet could not save the pass"))
890
+ else -> completion(failure("WALLET_ERROR", "Google Wallet returned an unknown result"))
891
+ }
892
+ return true
893
+ }
894
+ ` : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
837
895
  FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
838
896
  val token = if (task.isSuccessful) task.result else null
839
897
  if (token.isNullOrBlank()) done(failure("PUSH_UNAVAILABLE", task.exception?.message ?: "Firebase Cloud Messaging gave no token"))
@@ -963,6 +1021,8 @@ async function init(options) {
963
1021
  appName: name,
964
1022
  packageName: finalPackageName
965
1023
  };
1024
+ if (config.enableHealthServices && given.minSdk === undefined)
1025
+ config.minSdk = ANDROID_HEALTH_SERVICES_MIN_SDK;
966
1026
  if (config.enableBackgroundLocation)
967
1027
  config.enableGeolocation = true;
968
1028
  if (config.enableHealthConnect) {
@@ -1071,7 +1131,7 @@ async function init(options) {
1071
1131
  writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1072
1132
  }
1073
1133
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1074
- const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`).replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== "maplibre" && config.mapsApiKey ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
1134
+ const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />' : "").replace(/\{\{HEALTH_SERVICES_SERVICE\}\}/g, config.enableHealthServices ? ' <service android:name=".CraftHealthServicesService" android:exported="false" android:foregroundServiceType="health" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`).replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== "maplibre" && config.mapsApiKey ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
1075
1135
  <package android:name="com.google.android.apps.healthdata" />
1076
1136
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1077
1137
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1086,7 +1146,8 @@ async function init(options) {
1086
1146
  const appGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
1087
1147
  const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{NATIVE_MAP_DEPENDENCY\}\}/g, config.renderer === "native" ? ` // Native STX Map overlays and trail routes
1088
1148
  implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1089
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ` implementation("androidx.javascriptengine:javascriptengine:1.1.0")
1149
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{HEALTH_SERVICES_DEPENDENCIES\}\}/g, config.enableHealthServices ? ' implementation("androidx.health:health-services-client:1.1.0")' : "").replace(/\{\{WALLET_DEPENDENCY\}\}/g, config.enableWallet ? ` // Native Google Wallet save flow for signed pass JWTs
1150
+ implementation("com.google.android.gms:play-services-pay:16.5.0")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ` implementation("androidx.javascriptengine:javascriptengine:1.1.0")
1090
1151
  // Native background refresh (WorkManager).
1091
1152
  implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
1092
1153
  writeFileSync(join3(output, "app/build.gradle.kts"), appGradle);
@@ -56,6 +56,7 @@
56
56
 
57
57
  {{BACKGROUND_SERVICE}}
58
58
  {{HEART_RATE_SERVICE}}
59
+ {{HEALTH_SERVICES_SERVICE}}
59
60
  {{NATIVE_NOTIFICATION_RECEIVER}}
60
61
  {{CRASH_REPORTER_PROVIDER}}
61
62
 
@@ -0,0 +1,132 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.os.Build
7
+ import androidx.core.content.ContextCompat
8
+ import androidx.health.services.client.HealthServices
9
+ import androidx.health.services.client.data.DataType
10
+ import androidx.health.services.client.data.ExerciseType
11
+ import org.json.JSONArray
12
+ import org.json.JSONObject
13
+ import java.util.UUID
14
+ import java.util.concurrent.atomic.AtomicReference
15
+ import java.util.concurrent.ConcurrentHashMap
16
+
17
+ /** Native Health Services entry point; the ExerciseClient lives in the retained service. */
18
+ internal class CraftHealthServices(
19
+ private val context: Context,
20
+ private val activityProvider: () -> Activity?,
21
+ private val fallback: ((String, JSONArray, (CraftNativeCapabilityResult) -> Unit) -> Unit)? = null,
22
+ ) {
23
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
24
+
25
+ fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
26
+ when (method) {
27
+ "startWorkout" -> start(args.optJSONObject(0) ?: JSONObject(), done)
28
+ "pauseWorkout" -> command(CraftHealthServicesService.ACTION_PAUSE, done)
29
+ "resumeWorkout" -> command(CraftHealthServicesService.ACTION_RESUME, done)
30
+ "endWorkout" -> command(CraftHealthServicesService.ACTION_END, done)
31
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Health Services method"))
32
+ }
33
+ }
34
+
35
+ private fun start(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
36
+ if (activityProvider() == null) return done(failure("NEEDS_ACTIVITY", "Live workouts require a foreground activity"))
37
+ val type = options.optString("type").takeIf { it.isNotBlank() }
38
+ ?: return done(failure("INVALID_ARGUMENT", "startWorkout needs a workout type"))
39
+ if (type !in SUPPORTED_TYPES) return done(failure("INVALID_ARGUMENT", "Unsupported workout type: $type"))
40
+ if (!activeId.compareAndSet(null, "pending")) return done(failure("BUSY", "A live workout is already running"))
41
+ val id = "live-${UUID.randomUUID()}"
42
+ val exerciseType = when (type) {
43
+ "running" -> ExerciseType.RUNNING
44
+ "walking" -> ExerciseType.WALKING
45
+ "hiking" -> ExerciseType.HIKING
46
+ "cycling" -> ExerciseType.BIKING
47
+ else -> null
48
+ } ?: return unsupported(options, done, "Unsupported workout type: $type")
49
+ val future = HealthServices.getClient(context).exerciseClient.getCapabilitiesAsync()
50
+ future.addListener({
51
+ runCatching {
52
+ val capabilities = future.get()
53
+ val requested = setOf(DataType.HEART_RATE_BPM, DataType.CALORIES_TOTAL, DataType.DISTANCE_TOTAL)
54
+ if (exerciseType !in capabilities.supportedExerciseTypes) {
55
+ return@runCatching unsupported(options, done, "Health Services does not support this workout on this device")
56
+ }
57
+ val typeCapabilities = capabilities.getExerciseTypeCapabilities(exerciseType)
58
+ if (requested.none { it in typeCapabilities.supportedDataTypes }) {
59
+ return@runCatching unsupported(options, done, "Health Services exposes none of the requested workout metrics")
60
+ }
61
+ startService(id, type, done)
62
+ }.onFailure { unsupported(options, done, it.message ?: "Health Services is unavailable") }
63
+ }, ContextCompat.getMainExecutor(context))
64
+ }
65
+
66
+ private fun startService(id: String, type: String, done: (CraftNativeCapabilityResult) -> Unit) {
67
+ activeId.set(id)
68
+ val intent = Intent(context, CraftHealthServicesService::class.java)
69
+ .setAction(CraftHealthServicesService.ACTION_START)
70
+ .putExtra(CraftHealthServicesService.EXTRA_ID, id)
71
+ .putExtra(CraftHealthServicesService.EXTRA_TYPE, type)
72
+ try {
73
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
74
+ done(CraftNativeCapabilityResult(data = JSONObject().put("id", id).put("type", type).put("startDate", System.currentTimeMillis())))
75
+ } catch (error: Exception) {
76
+ activeId.compareAndSet(id, null)
77
+ done(failure("UNAVAILABLE", error.message ?: "Health Services could not start"))
78
+ }
79
+ }
80
+
81
+ private fun unsupported(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit, message: String) {
82
+ activeId.set(null)
83
+ fallback?.invoke("startWorkout", JSONArray().put(options), done)
84
+ ?: done(failure("UNAVAILABLE", message))
85
+ }
86
+
87
+ private fun command(action: String, done: (CraftNativeCapabilityResult) -> Unit) {
88
+ val id = activeId.get()?.takeIf { it != "pending" }
89
+ ?: return done(failure("NO_WORKOUT", "No live workout is running"))
90
+ try {
91
+ context.startService(Intent(context, CraftHealthServicesService::class.java).setAction(action).putExtra(CraftHealthServicesService.EXTRA_ID, id))
92
+ if (action == CraftHealthServicesService.ACTION_END) endCompletions[id] = { result -> finishEnd(result, done) }
93
+ else done(CraftNativeCapabilityResult(data = true))
94
+ } catch (error: Exception) {
95
+ if (action == CraftHealthServicesService.ACTION_END) endCompletions.remove(id)
96
+ done(failure("UNAVAILABLE", error.message ?: "Health Services command failed"))
97
+ }
98
+ }
99
+
100
+ private fun finishEnd(result: CraftNativeCapabilityResult, done: (CraftNativeCapabilityResult) -> Unit) {
101
+ val payload = result.data as? JSONObject
102
+ if (result.code != null || payload == null || fallback == null) return done(result)
103
+ fallback.invoke("saveWorkout", JSONArray().put(payload)) { saved ->
104
+ if (saved.code != null) return@invoke done(saved)
105
+ val workoutId = (saved.data as? JSONObject)?.optString("id")?.takeIf { it.isNotBlank() }
106
+ ?: payload.optString("id")
107
+ done(CraftNativeCapabilityResult(data = JSONObject()
108
+ .put("id", payload.optString("id"))
109
+ .put("workoutId", workoutId)
110
+ .put("endDate", payload.optLong("endDate"))
111
+ .put("durationSeconds", payload.optDouble("durationSeconds"))))
112
+ }
113
+ }
114
+
115
+ companion object {
116
+ private val activeId = AtomicReference<String?>(null)
117
+ private val endCompletions = ConcurrentHashMap<String, (CraftNativeCapabilityResult) -> Unit>()
118
+ private val SUPPORTED_TYPES = setOf("running", "walking", "hiking", "cycling")
119
+
120
+ internal fun completeEnd(id: String, result: CraftNativeCapabilityResult) {
121
+ endCompletions.remove(id)?.invoke(result)
122
+ }
123
+
124
+ internal fun failEnd(id: String, message: String) {
125
+ completeEnd(id, CraftNativeCapabilityResult(code = "UNAVAILABLE", message = message))
126
+ }
127
+
128
+ internal fun clearActive() {
129
+ activeId.set(null)
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,217 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.Service
7
+ import android.content.Intent
8
+ import android.os.Build
9
+ import android.os.IBinder
10
+ import androidx.core.app.NotificationCompat
11
+ import androidx.health.services.client.ExerciseClient
12
+ import androidx.health.services.client.ExerciseUpdateCallback
13
+ import androidx.health.services.client.HealthServices
14
+ import androidx.health.services.client.data.Availability
15
+ import androidx.health.services.client.data.DataType
16
+ import androidx.health.services.client.data.ExerciseConfig
17
+ import androidx.health.services.client.data.ExerciseLapSummary
18
+ import androidx.health.services.client.data.ExerciseState
19
+ import androidx.health.services.client.data.ExerciseType
20
+ import androidx.health.services.client.data.ExerciseUpdate
21
+ import org.json.JSONObject
22
+ import java.util.concurrent.Executor
23
+
24
+ /** Retained foreground owner for one Android Health Services exercise. */
25
+ internal class CraftHealthServicesService : Service() {
26
+ private lateinit var exerciseClient: ExerciseClient
27
+ private lateinit var callback: ExerciseUpdateCallback
28
+ private val executor: Executor by lazy { mainExecutor }
29
+ private var workoutId = ""
30
+ private var workoutType = ""
31
+ private var workoutStartedAt = 0L
32
+ private var pausedAt = 0L
33
+ private var pausedMillis = 0L
34
+ private var lastDurationSeconds = 0.0
35
+ private var lastEnergyCalories: Double? = null
36
+ private var lastDistanceMeters: Double? = null
37
+
38
+ override fun onCreate() {
39
+ super.onCreate()
40
+ createNotificationChannel()
41
+ exerciseClient = HealthServices.getClient(this).exerciseClient
42
+ callback = object : ExerciseUpdateCallback {
43
+ override fun onRegistered() = Unit
44
+ override fun onRegistrationFailed(throwable: Throwable) {
45
+ emitError(throwable.message ?: "Health Services callback registration failed")
46
+ }
47
+ override fun onExerciseUpdateReceived(update: ExerciseUpdate) = emit(update)
48
+ override fun onAvailabilityChanged(dataType: DataType<*, *>, availability: Availability) {
49
+ emitError("${dataType.name} is unavailable ($availability)")
50
+ }
51
+ override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) = Unit
52
+ }
53
+ exerciseClient.setUpdateCallback(executor, callback)
54
+ }
55
+
56
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
57
+ when (intent?.action) {
58
+ ACTION_START -> start(intent)
59
+ ACTION_PAUSE -> {
60
+ if (pausedAt == 0L) pausedAt = System.currentTimeMillis()
61
+ exerciseClient.pauseExerciseAsync()
62
+ }
63
+ ACTION_RESUME -> {
64
+ if (pausedAt != 0L) {
65
+ pausedMillis += (System.currentTimeMillis() - pausedAt).coerceAtLeast(0)
66
+ pausedAt = 0L
67
+ }
68
+ exerciseClient.resumeExerciseAsync()
69
+ }
70
+ ACTION_END -> end()
71
+ }
72
+ return START_STICKY
73
+ }
74
+
75
+ private fun start(intent: Intent) {
76
+ workoutId = intent.getStringExtra(EXTRA_ID).orEmpty()
77
+ workoutType = intent.getStringExtra(EXTRA_TYPE).orEmpty()
78
+ workoutStartedAt = System.currentTimeMillis()
79
+ pausedAt = 0L
80
+ pausedMillis = 0L
81
+ lastDurationSeconds = 0.0
82
+ lastEnergyCalories = null
83
+ lastDistanceMeters = null
84
+ startForeground(NOTIFICATION_ID, notification())
85
+ val type = exerciseType(workoutType) ?: return emitError("Unsupported Health Services workout type: $workoutType")
86
+ val capabilitiesFuture = exerciseClient.getCapabilitiesAsync()
87
+ capabilitiesFuture.addListener({
88
+ runCatching {
89
+ val capabilities = capabilitiesFuture.get()
90
+ if (type !in capabilities.supportedExerciseTypes) return@runCatching emitError("Health Services does not support $workoutType on this device")
91
+ val typeCapabilities = capabilities.getExerciseTypeCapabilities(type)
92
+ val requested = setOf(DataType.HEART_RATE_BPM, DataType.CALORIES_TOTAL, DataType.DISTANCE_TOTAL)
93
+ .filter { it in typeCapabilities.supportedDataTypes }.toSet()
94
+ if (requested.isEmpty()) return@runCatching emitError("Health Services exposes none of the requested workout metrics")
95
+ val config = ExerciseConfig.builder(type).setDataTypes(requested).setIsGpsEnabled(DataType.DISTANCE_TOTAL in requested).build()
96
+ exerciseClient.startExerciseAsync(config)
97
+ }.onFailure { emitError(it.message ?: "Health Services could not start") }
98
+ }, executor)
99
+ }
100
+
101
+ private fun end() {
102
+ val id = workoutId
103
+ if (pausedAt != 0L) {
104
+ pausedMillis += (System.currentTimeMillis() - pausedAt).coerceAtLeast(0)
105
+ pausedAt = 0L
106
+ }
107
+ try {
108
+ val future = exerciseClient.endExerciseAsync()
109
+ future.addListener({
110
+ runCatching {
111
+ future.get()
112
+ val duration = if (lastDurationSeconds > 0) lastDurationSeconds
113
+ else ((System.currentTimeMillis() - workoutStartedAt - pausedMillis).coerceAtLeast(0) / 1000.0)
114
+ val result = JSONObject()
115
+ .put("id", id)
116
+ .put("activityId", id)
117
+ .put("type", workoutType)
118
+ .put("startDate", workoutStartedAt)
119
+ .put("workoutId", id)
120
+ .put("endDate", System.currentTimeMillis())
121
+ .put("durationSeconds", duration)
122
+ lastEnergyCalories?.let { result.put("activeEnergyCalories", it) }
123
+ lastDistanceMeters?.let { result.put("distanceMeters", it) }
124
+ CraftHealthServices.completeEnd(id, CraftNativeCapabilityResult(data = result))
125
+ CraftHealthServices.clearActive()
126
+ stopSelf()
127
+ }.onFailure {
128
+ CraftHealthServices.failEnd(id, it.message ?: "Health Services workout could not end")
129
+ CraftHealthServices.clearActive()
130
+ emitError(it.message ?: "Health Services workout could not end")
131
+ }
132
+ }, executor)
133
+ } catch (error: Exception) {
134
+ CraftHealthServices.failEnd(id, error.message ?: "Health Services workout could not end")
135
+ CraftHealthServices.clearActive()
136
+ emitError(error.message ?: "Health Services workout could not end")
137
+ }
138
+ }
139
+
140
+ private fun emit(update: ExerciseUpdate) {
141
+ val state = update.exerciseStateInfo.state
142
+ val detail = JSONObject().put("id", workoutId).put("event", if (state.isEnded) "state" else "metrics")
143
+ .put("state", stateName(state)).put("date", System.currentTimeMillis())
144
+ update.latestMetrics.getData(DataType.HEART_RATE_BPM).lastOrNull()?.value?.let { detail.put("heartRateBpm", it) }
145
+ update.latestMetrics.getData(DataType.CALORIES_TOTAL)?.total?.let {
146
+ lastEnergyCalories = it
147
+ detail.put("activeEnergyCalories", it)
148
+ }
149
+ update.latestMetrics.getData(DataType.DISTANCE_TOTAL)?.total?.let {
150
+ lastDistanceMeters = it
151
+ detail.put("distanceMeters", it)
152
+ }
153
+ update.activeDurationCheckpoint?.let { detail.put("durationSeconds", it.activeDuration.toMillis() / 1000.0) }
154
+ detail.optDouble("durationSeconds", -1.0).takeIf { it >= 0 }?.let { lastDurationSeconds = it }
155
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHealthWorkout").put("detail", detail))
156
+ if (state.isEnded) {
157
+ CraftHealthServices.clearActive()
158
+ stopSelf()
159
+ }
160
+ }
161
+
162
+ private fun emitError(message: String) {
163
+ CraftHealthServices.clearActive()
164
+ CraftHealthServices.failEnd(workoutId, message)
165
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHealthWorkout").put("detail", JSONObject()
166
+ .put("id", workoutId).put("event", "error").put("message", message)))
167
+ stopSelf()
168
+ }
169
+
170
+ private fun exerciseType(value: String): ExerciseType? = when (value) {
171
+ "running" -> ExerciseType.RUNNING
172
+ "walking" -> ExerciseType.WALKING
173
+ "hiking" -> ExerciseType.HIKING
174
+ "cycling" -> ExerciseType.BIKING
175
+ else -> null
176
+ }
177
+
178
+ private fun stateName(state: ExerciseState): String = when {
179
+ state == ExerciseState.ACTIVE -> "running"
180
+ state.isPaused -> "paused"
181
+ state.isEnded -> "ended"
182
+ else -> "unknown"
183
+ }
184
+
185
+ private fun notification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID)
186
+ .setSmallIcon(android.R.drawable.ic_media_play)
187
+ .setContentTitle("Workout in progress")
188
+ .setContentText("Craft is tracking your workout")
189
+ .setOngoing(true)
190
+ .build()
191
+
192
+ private fun createNotificationChannel() {
193
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
194
+ getSystemService(NotificationManager::class.java).createNotificationChannel(
195
+ NotificationChannel(CHANNEL_ID, "Workouts", NotificationManager.IMPORTANCE_LOW),
196
+ )
197
+ }
198
+ }
199
+
200
+ override fun onDestroy() {
201
+ runCatching { exerciseClient.clearUpdateCallbackAsync(callback) }
202
+ super.onDestroy()
203
+ }
204
+
205
+ override fun onBind(intent: Intent?): IBinder? = null
206
+
207
+ companion object {
208
+ const val ACTION_START = "com.craft.health.START"
209
+ const val ACTION_PAUSE = "com.craft.health.PAUSE"
210
+ const val ACTION_RESUME = "com.craft.health.RESUME"
211
+ const val ACTION_END = "com.craft.health.END"
212
+ const val EXTRA_ID = "craft.health.id"
213
+ const val EXTRA_TYPE = "craft.health.type"
214
+ private const val CHANNEL_ID = "craft-health-workout"
215
+ private const val NOTIFICATION_ID = 8412
216
+ }
217
+ }
@@ -0,0 +1,16 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.app.Activity
5
+ import org.json.JSONArray
6
+
7
+ /** Compile-time placeholder when Android Health Services is not enabled. */
8
+ internal class CraftHealthServices(
9
+ private val context: Context,
10
+ private val activityProvider: () -> Activity?,
11
+ private val fallback: ((String, JSONArray, (CraftNativeCapabilityResult) -> Unit) -> Unit)? = null,
12
+ ) {
13
+ fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
14
+ done(CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Health Services is not enabled for this app"))
15
+ }
16
+ }