craft-native 0.0.246 → 0.0.248

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 (46) hide show
  1. package/dist/android/src/index.d.ts +11 -0
  2. package/dist/android/src/index.js +33 -21
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +136 -1
  5. package/dist/android/templates/CraftHeartRate.kt.template +938 -0
  6. package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
  7. package/dist/android/templates/CraftNative.kt.template +17 -0
  8. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  11. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  12. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  13. package/dist/android/templates/CraftNativeServices.kt.template +59 -1
  14. package/dist/android/templates/CraftNativeShell.kt.template +54 -14
  15. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  16. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  17. package/dist/api/mobile.d.ts +39 -3
  18. package/dist/browser.js +9 -9
  19. package/dist/cli.js +90 -673
  20. package/dist/components/index.d.ts +50 -9
  21. package/dist/index.cjs +394 -438
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +446 -493
  25. package/dist/ios/src/index.d.ts +2 -0
  26. package/dist/ios/src/index.js +54 -53
  27. package/dist/ios/templates/CraftApp.swift +37 -2
  28. package/dist/ios/templates/CraftHealth.swift +217 -0
  29. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  30. package/dist/ios/templates/CraftHybrid.swift +18 -19
  31. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  32. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  33. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  34. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  35. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  36. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  37. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  38. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  39. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  40. package/dist/ios/templates/testing/HostApp.swift +9 -2
  41. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  42. package/dist/macos/templates/native_layout.zig +226 -2
  43. package/dist/mobile-testing.js +21 -3
  44. package/dist/mobile.js +101 -63
  45. package/dist/testing/media-cache/media-cache.kt +4 -2
  46. package/package.json +1 -1
@@ -60,7 +60,18 @@ export interface CraftAndroidConfig {
60
60
  enableGeolocation?: boolean;
61
61
  enableBackgroundLocation?: boolean;
62
62
  enableBluetooth?: boolean;
63
+ /**
64
+ * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
65
+ * standard Heart Rate Service), through `craft.heartRate` on a page and the
66
+ * `HeartRate` module on a native screen (CraftHeartRate.kt): scan, connect,
67
+ * live readings as `craftHeartRate` events, and a recording kept natively,
68
+ * in the foreground (a connectedDevice service) while it runs. The iOS
69
+ * builder's flag of the same name.
70
+ */
71
+ enableHeartRate?: boolean;
63
72
  enableHealthConnect?: boolean;
73
+ /** Google Wallet save-link handoff for native screens. */
74
+ enableWallet?: boolean;
64
75
  /**
65
76
  * The Health Connect data the app reads and writes, by the shared type
66
77
  * names (`steps`, `sleep`, `workouts`...). The manifest declares only
@@ -18,6 +18,8 @@ import com.google.android.gms.maps.model.LatLng
18
18
  import com.google.android.gms.maps.model.LatLngBounds
19
19
  import com.google.android.gms.maps.model.Marker
20
20
  import com.google.android.gms.maps.model.MarkerOptions
21
+ import com.google.android.gms.maps.model.Polygon
22
+ import com.google.android.gms.maps.model.PolygonOptions
21
23
  import com.google.android.gms.maps.model.Polyline
22
24
  import com.google.android.gms.maps.model.PolylineOptions`,
23
25
  dependency: "com.google.android.gms:play-services-maps:18.2.0"
@@ -37,6 +39,7 @@ import org.maplibre.android.maps.OnMapReadyCallback
37
39
  import org.maplibre.android.maps.Style
38
40
  import org.maplibre.android.style.expressions.Expression
39
41
  import org.maplibre.android.style.layers.CircleLayer
42
+ import org.maplibre.android.style.layers.FillLayer
40
43
  import org.maplibre.android.style.layers.LineLayer
41
44
  import org.maplibre.android.style.layers.SymbolLayer
42
45
  import org.maplibre.android.style.layers.PropertyFactory.*
@@ -498,7 +501,9 @@ var DEFAULT_CONFIG = {
498
501
  enableGeolocation: false,
499
502
  enableBackgroundLocation: false,
500
503
  enableBluetooth: false,
504
+ enableHeartRate: false,
501
505
  enableHealthConnect: false,
506
+ enableWallet: false,
502
507
  enableKeepAwake: false,
503
508
  enableDeepLinks: false,
504
509
  urlSchemes: [],
@@ -790,7 +795,11 @@ function renderAndroidPermissions(config) {
790
795
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
791
796
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
792
797
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
793
- const bluetooth = config.enableBluetooth ? [
798
+ if (config.enableHeartRate) {
799
+ permissions.add("android.permission.FOREGROUND_SERVICE");
800
+ permissions.add("android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE");
801
+ }
802
+ const bluetooth = config.enableBluetooth || config.enableHeartRate ? [
794
803
  ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
795
804
  ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
796
805
  ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
@@ -1019,7 +1028,7 @@ async function init(options) {
1019
1028
  writeNativeShellSources(output, packagePath, finalPackageName, config);
1020
1029
  }
1021
1030
  const craftBridgeTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
1022
- const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
1031
+ const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{ENABLE_HEART_RATE\}\}/g, String(Boolean(config.enableHeartRate))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
1023
1032
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
1024
1033
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
1025
1034
  ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
@@ -1047,8 +1056,11 @@ async function init(options) {
1047
1056
  writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1048
1057
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
1049
1058
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1059
+ for (const [template, file] of [["CraftHeartRateCore.kt.template", "CraftHeartRateCore.kt"], ["CraftHeartRate.kt.template", "CraftHeartRate.kt"]]) {
1060
+ writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1061
+ }
1050
1062
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1051
- 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(/\{\{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>
1063
+ 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>
1052
1064
  <package android:name="com.google.android.apps.healthdata" />
1053
1065
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1054
1066
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1374,23 +1386,23 @@ async function run(options) {
1374
1386
  }
1375
1387
  }
1376
1388
  export {
1377
- syncNativeFonts,
1378
- syncAndroidWebAssets,
1379
- runKotlinFixture,
1380
- run,
1381
- resolveRuntimeDir,
1382
- renderAndroidPermissions,
1383
- renderAndroidDeepLinks,
1384
- open,
1385
- nativeRouteRuntime,
1386
- nativeRouteBundles,
1387
- nativeMessages,
1388
- nativeMapProviders,
1389
- installRuntime,
1390
- init,
1391
- healthConnectPermissions,
1392
- createAndroidEmulator,
1393
- build,
1389
+ ANDROID_MIN_SDK,
1394
1390
  HEALTH_CONNECT_PERMISSIONS,
1395
- ANDROID_MIN_SDK
1391
+ build,
1392
+ createAndroidEmulator,
1393
+ healthConnectPermissions,
1394
+ init,
1395
+ installRuntime,
1396
+ nativeMapProviders,
1397
+ nativeMessages,
1398
+ nativeRouteBundles,
1399
+ nativeRouteRuntime,
1400
+ open,
1401
+ renderAndroidDeepLinks,
1402
+ renderAndroidPermissions,
1403
+ resolveRuntimeDir,
1404
+ run,
1405
+ runKotlinFixture,
1406
+ syncAndroidWebAssets,
1407
+ syncNativeFonts
1396
1408
  };
@@ -55,6 +55,7 @@
55
55
  </activity>
56
56
 
57
57
  {{BACKGROUND_SERVICE}}
58
+ {{HEART_RATE_SERVICE}}
58
59
  {{NATIVE_NOTIFICATION_RECEIVER}}
59
60
  {{CRASH_REPORTER_PROVIDER}}
60
61
 
@@ -444,6 +444,9 @@ class CraftBridge(
444
444
  bluetooth: false,
445
445
  nfc: false,
446
446
  health: {{ENABLE_HEALTH_CONNECT}},
447
+ heartRate: {{ENABLE_HEART_RATE}},
448
+ heartRateRecordingOwnership: {{ENABLE_HEART_RATE}},
449
+ heartRateRecordingIntentIdentity: {{ENABLE_HEART_RATE}},
447
450
  screenCapture: false
448
451
  },
449
452
 
@@ -1808,6 +1811,85 @@ class CraftBridge(
1808
1811
  readRecording: function(options) { return recordingCall('readRecording', options); },
1809
1812
  acknowledgeRecording: function(options) { return recordingCall('acknowledgeRecording', options); }
1810
1813
  };
1814
+ // A Bluetooth heart-rate strap, or a watch broadcasting its
1815
+ // heart rate (CraftHeartRate.kt), in the iOS page's names.
1816
+ // Readings, devices found by scan() and connection changes
1817
+ // arrive as craftHeartRate events ({type: 'reading' | 'device'
1818
+ // | 'state'}); startRecording() keeps every reading natively,
1819
+ // screen locked or app closed, until stopRecording(). Calls
1820
+ // that wait on Android's Nearby devices prompt have no deadline.
1821
+ var heartRateSequence = 0;
1822
+ var heartRateRequests = Object.create(null);
1823
+ var heartRateClosed = false;
1824
+ window.__craftHeartRateResult = function(id, value) {
1825
+ var pending = heartRateRequests[id];
1826
+ if (!pending) return;
1827
+ delete heartRateRequests[id];
1828
+ pending.resolve(value);
1829
+ };
1830
+ window.__craftHeartRateError = function(id, message, code) {
1831
+ var pending = heartRateRequests[id];
1832
+ if (!pending) return;
1833
+ delete heartRateRequests[id];
1834
+ var error = new Error(message);
1835
+ error.code = code || 'CRAFT_ERROR';
1836
+ error.bridge = true;
1837
+ pending.reject(error);
1838
+ };
1839
+ window.__craftRejectHeartRateRequests = function(message) {
1840
+ heartRateClosed = true;
1841
+ Object.keys(heartRateRequests).forEach(function(id) {
1842
+ var pending = heartRateRequests[id];
1843
+ delete heartRateRequests[id];
1844
+ pending.reject(new Error(message || 'Android bridge closed'));
1845
+ });
1846
+ };
1847
+ function heartRate(method, options) {
1848
+ options = options || {};
1849
+ if (['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(method) !== -1) {
1850
+ if (Object.prototype.hasOwnProperty.call(options, 'recordingId') &&
1851
+ (typeof options.recordingId !== 'string' || !options.recordingId.trim())) {
1852
+ var invalidStart = new Error('recordingId must be a nonempty recording ID');
1853
+ invalidStart.code = 'INVALID_ARGUMENT';
1854
+ return Promise.reject(invalidStart);
1855
+ }
1856
+ if (Object.prototype.hasOwnProperty.call(options, 'expectedId') &&
1857
+ (typeof options.expectedId !== 'string' || !options.expectedId.trim())) {
1858
+ var invalid = new Error('expectedId must be a nonempty recording ID');
1859
+ invalid.code = 'INVALID_ARGUMENT';
1860
+ return Promise.reject(invalid);
1861
+ }
1862
+ }
1863
+ if (heartRateClosed) return Promise.reject(new Error('Android bridge is closed'));
1864
+ return new Promise(function(resolve, reject) {
1865
+ heartRateSequence += 1;
1866
+ var id = heartRateSequence;
1867
+ heartRateRequests[id] = {resolve: resolve, reject: reject};
1868
+ try {
1869
+ CraftAndroid.heartRate(method, JSON.stringify(options), id);
1870
+ } catch (error) {
1871
+ delete heartRateRequests[id];
1872
+ reject(error);
1873
+ }
1874
+ });
1875
+ }
1876
+ craft.heartRate = {
1877
+ getState: function() { return heartRate('getState'); },
1878
+ requestAuthorization: function() { return heartRate('requestAuthorization'); },
1879
+ scan: function(options) { return heartRate('scan', {timeout: options && typeof options.timeout === 'number' ? options.timeout : null}); },
1880
+ stopScan: function() { return heartRate('stopScan'); },
1881
+ getDevices: function() { return heartRate('getDevices'); },
1882
+ connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
1883
+ reconnect: function() { return heartRate('reconnect'); },
1884
+ disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
1885
+ startRecording: function(options) { return heartRate('startRecording', options); },
1886
+ pauseRecording: function(options) { return heartRate('pauseRecording', options); },
1887
+ resumeRecording: function(options) { return heartRate('resumeRecording', options); },
1888
+ stopRecording: function(options) { return heartRate('stopRecording', options); },
1889
+ getRecordingState: function(options) { return heartRate('getRecordingState', options); },
1890
+ acknowledgeRecording: function(options) { return heartRate('acknowledgeRecording', options); },
1891
+ readRecording: function(options) { return heartRate('readRecording', options); }
1892
+ };
1811
1893
  var shareApi = function(text, title) { return legacyShare(text, title); };
1812
1894
  shareApi.share = function(options) {
1813
1895
  options = options || {};
@@ -2537,6 +2619,7 @@ class CraftBridge(
2537
2619
  }
2538
2620
 
2539
2621
  fun onRequestPermissionsResult(requestCode: Int): Boolean {
2622
+ if (heartRateEnabled && CraftHeartRate.get(activity).onPermissions(requestCode)) return true
2540
2623
  if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END
2541
2624
  // startListening asked for the microphone and is waiting on this.
2542
2625
  if (requestCode == REQUEST_SPEECH) {
@@ -2820,6 +2903,54 @@ class CraftBridge(
2820
2903
  return legacyRecordingAction("readRecording")
2821
2904
  }
2822
2905
 
2906
+ // ==================== Heart rate (CraftHeartRate.kt) ====================
2907
+
2908
+ private val heartRateEnabled = {{ENABLE_HEART_RATE}}
2909
+
2910
+ /** The strap's events, to this page as `craftHeartRate`. */
2911
+ private val heartRateSink: (JSONObject) -> Unit = { detail ->
2912
+ evaluateJavascriptUnlessClosed("window.dispatchEvent(new CustomEvent('craftHeartRate', {detail: $detail}));")
2913
+ }
2914
+
2915
+ init {
2916
+ if (heartRateEnabled) CraftHeartRate.get(activity).sinks["page"] = heartRateSink
2917
+ }
2918
+
2919
+ /**
2920
+ * One `craft.heartRate` call, by method name, answered through
2921
+ * `__craftHeartRateResult` / `__craftHeartRateError` with its id. An
2922
+ * answer for a page that has since navigated away is dropped.
2923
+ */
2924
+ @JavascriptInterface
2925
+ fun heartRate(method: String, optionsJson: String, callbackId: Int) {
2926
+ activity.runOnUiThread {
2927
+ if (closed) return@runOnUiThread
2928
+ if (!heartRateEnabled) return@runOnUiThread rejectHeartRate(callbackId, navigationGeneration, "Heart rate is disabled", "CAPABILITY_DISABLED")
2929
+ val generation = navigationGeneration
2930
+ val options = runCatching { JSONObject(optionsJson) }.getOrNull()
2931
+ ?: return@runOnUiThread rejectHeartRate(callbackId, generation, "Invalid heart-rate options", "INVALID_ARGUMENT")
2932
+ CraftHeartRate.get(activity).perform(method, options, activity) { result ->
2933
+ result.fold(
2934
+ onSuccess = { value ->
2935
+ if (generation != navigationGeneration) return@fold
2936
+ val json = if (value is String) JSONObject.quote(value) else value.toString()
2937
+ evaluateJavascriptUnlessClosed("window.__craftHeartRateResult && window.__craftHeartRateResult($callbackId, $json)")
2938
+ },
2939
+ onFailure = { error ->
2940
+ rejectHeartRate(callbackId, generation, error.message ?: "Heart rate failed", (error as? CraftHeartRateException)?.code ?: "CRAFT_ERROR")
2941
+ },
2942
+ )
2943
+ }
2944
+ }
2945
+ }
2946
+
2947
+ private fun rejectHeartRate(callbackId: Int, generation: Int, message: String, code: String) {
2948
+ if (generation != navigationGeneration) return
2949
+ evaluateJavascriptUnlessClosed(
2950
+ "window.__craftHeartRateError && window.__craftHeartRateError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
2951
+ )
2952
+ }
2953
+
2823
2954
  // ==================== Clipboard ====================
2824
2955
 
2825
2956
  @JavascriptInterface
@@ -4473,13 +4604,17 @@ class CraftBridge(
4473
4604
  webView.evaluateJavascript(
4474
4605
  "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
4475
4606
  "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
4476
- "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
4607
+ "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed');" +
4608
+ "window.__craftRejectHeartRateRequests && window.__craftRejectHeartRateRequests('Android bridge closed')",
4477
4609
  null
4478
4610
  )
4479
4611
  }
4480
4612
  }
4481
4613
  CraftNative.close(activity)
4482
4614
  healthConnect.close()
4615
+ // The strap and its recording carry on (the service is the
4616
+ // process's); only this page stops hearing them.
4617
+ if (heartRateEnabled) CraftHeartRate.get(activity).sinks.remove("page", heartRateSink)
4483
4618
 
4484
4619
  if (shareReceiverRegistered) {
4485
4620
  runCatching { activity.unregisterReceiver(shareChosenReceiver) }