craft-native 0.0.254 → 0.0.262
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +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/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +212 -23
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +36 -4
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +30 -7
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +32 -3
- package/dist/ios/templates/CraftNativeScreen.swift +288 -10
- package/dist/mobile.js +1 -1
- package/package.json +1 -1
|
@@ -70,7 +70,9 @@ export interface CraftAndroidConfig {
|
|
|
70
70
|
*/
|
|
71
71
|
enableHeartRate?: boolean;
|
|
72
72
|
enableHealthConnect?: boolean;
|
|
73
|
-
/**
|
|
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
|
-
...
|
|
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("
|
|
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(/\{\{
|
|
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);
|
|
@@ -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
|
+
}
|