craft-native 0.0.251 → 0.0.262

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/android/src/index.d.ts +4 -2
  2. package/dist/android/src/index.js +69 -5
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  5. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  6. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  7. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  8. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
  10. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  11. package/dist/android/templates/CraftNativeShell.kt.template +73 -23
  12. package/dist/android/templates/MainActivityNative.kt.template +217 -28
  13. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  14. package/dist/api/mobile.d.ts +9 -1
  15. package/dist/cli.js +1 -1
  16. package/dist/components/index.d.ts +19 -10
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/templates/CraftApp.swift +36 -4
  20. package/dist/ios/templates/CraftHealth.swift +38 -0
  21. package/dist/ios/templates/CraftHybrid.swift +38 -8
  22. package/dist/ios/templates/CraftNativeActions.swift +1 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
  24. package/dist/ios/templates/CraftNativeScreen.swift +308 -28
  25. package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
  26. package/dist/mobile-media-testing.d.ts +2 -0
  27. package/dist/mobile-testing.js +19 -10
  28. package/dist/mobile.js +1 -1
  29. package/dist/testing/media-cache/media-cache.kt +47 -6
  30. package/package.json +1 -1
@@ -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;
@@ -194,6 +194,7 @@ async function createAndroidEmulator(options) {
194
194
  if (options.acceleration === "software" && process.arch !== "x64")
195
195
  throw new Error("Android software emulation requires an x86_64 host and system image");
196
196
  const bootTimeoutMs = options.bootTimeoutMs ?? 600000;
197
+ const requiresPlayStore = options.image.split(";")[2] === "google_apis_playstore";
197
198
  const maximumBootTimeoutMs = options.acceleration === "software" ? 1200000 : 600000;
198
199
  if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
199
200
  throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`);
@@ -332,11 +333,13 @@ async function createAndroidEmulator(options) {
332
333
  if (completion.code === 0 && completion.stdout.trim() === "1") {
333
334
  const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
334
335
  const packages = await run([adb, "-s", serial, "shell", "pm", "path", "android"], undefined, probeTimeout(), true);
336
+ const playStore = requiresPlayStore ? await run([adb, "-s", serial, "shell", "pm", "path", "com.android.vending"], undefined, probeTimeout(), true) : undefined;
335
337
  const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
336
338
  const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-t", "1"], undefined, probeTimeout(), true);
337
339
  const home = await run([adb, "-s", serial, "shell", "cmd", "package", "resolve-activity", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.HOME"], undefined, probeTimeout(), true);
338
340
  const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? "";
339
- booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !homeActivity.startsWith("com.android.sdksetup/");
341
+ const temporaryHome = homeActivity.startsWith("com.android.sdksetup/") || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity);
342
+ booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && (!requiresPlayStore || playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome;
340
343
  }
341
344
  }
342
345
  const checkedAt = Date.now();
@@ -451,6 +454,7 @@ var KOTLIN_KEYWORDS = new Set([
451
454
  "while"
452
455
  ]);
453
456
  var ANDROID_MIN_SDK = 26;
457
+ var ANDROID_HEALTH_SERVICES_MIN_SDK = 30;
454
458
  var HEALTH_CONNECT_PERMISSIONS = {
455
459
  steps: { read: ["READ_STEPS"], write: [] },
456
460
  heartRate: { read: ["READ_HEART_RATE"], write: [] },
@@ -511,6 +515,7 @@ var DEFAULT_CONFIG = {
511
515
  enableBluetooth: false,
512
516
  enableHeartRate: false,
513
517
  enableHealthConnect: false,
518
+ enableHealthServices: false,
514
519
  enableWallet: false,
515
520
  enableKeepAwake: false,
516
521
  enableDeepLinks: false,
@@ -645,6 +650,9 @@ function validateAndroidConfig(config) {
645
650
  if (Number(config.minSdk) < ANDROID_MIN_SDK) {
646
651
  throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`);
647
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
+ }
648
656
  if (Number(config.minSdk) > Number(config.targetSdk)) {
649
657
  throw new Error("Android minSdk must not exceed targetSdk");
650
658
  }
@@ -807,12 +815,23 @@ function renderAndroidPermissions(config) {
807
815
  permissions.add("android.permission.FOREGROUND_SERVICE");
808
816
  permissions.add("android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE");
809
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
+ }
810
829
  const bluetooth = config.enableBluetooth || config.enableHeartRate ? [
811
830
  ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
812
831
  ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
813
832
  ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
814
833
  ' <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />',
815
- ...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" />']
816
835
  ].join(`
817
836
  `) : "";
818
837
  return [bluetooth, ...[...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`)].filter(Boolean).join(`
@@ -830,7 +849,49 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
830
849
  write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
831
850
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
832
851
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
833
- 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 {
834
895
  FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
835
896
  val token = if (task.isSuccessful) task.result else null
836
897
  if (token.isNullOrBlank()) done(failure("PUSH_UNAVAILABLE", task.exception?.message ?: "Firebase Cloud Messaging gave no token"))
@@ -960,6 +1021,8 @@ async function init(options) {
960
1021
  appName: name,
961
1022
  packageName: finalPackageName
962
1023
  };
1024
+ if (config.enableHealthServices && given.minSdk === undefined)
1025
+ config.minSdk = ANDROID_HEALTH_SERVICES_MIN_SDK;
963
1026
  if (config.enableBackgroundLocation)
964
1027
  config.enableGeolocation = true;
965
1028
  if (config.enableHealthConnect) {
@@ -1068,7 +1131,7 @@ async function init(options) {
1068
1131
  writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1069
1132
  }
1070
1133
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1071
- 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>
1072
1135
  <package android:name="com.google.android.apps.healthdata" />
1073
1136
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1074
1137
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1083,7 +1146,8 @@ async function init(options) {
1083
1146
  const appGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
1084
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
1085
1148
  implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1086
- 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")
1087
1151
  // Native background refresh (WorkManager).
1088
1152
  implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
1089
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
+ }