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.
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +69 -5
- 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/CraftNativeMapGoogle.kt.template +0 -1
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +73 -23
- package/dist/android/templates/MainActivityNative.kt.template +217 -28
- 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 +38 -8
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
- package/dist/ios/templates/CraftNativeScreen.swift +308 -28
- package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
- package/dist/mobile-media-testing.d.ts +2 -0
- package/dist/mobile-testing.js +19 -10
- package/dist/mobile.js +1 -1
- package/dist/testing/media-cache/media-cache.kt +47 -6
- 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;
|
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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("
|
|
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(/\{\{
|
|
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);
|
|
@@ -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
|
+
}
|