craft-native 0.0.246 → 0.0.247

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.
@@ -60,6 +60,15 @@ 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;
64
73
  /**
65
74
  * The Health Connect data the app reads and writes, by the shared type
@@ -498,6 +498,7 @@ var DEFAULT_CONFIG = {
498
498
  enableGeolocation: false,
499
499
  enableBackgroundLocation: false,
500
500
  enableBluetooth: false,
501
+ enableHeartRate: false,
501
502
  enableHealthConnect: false,
502
503
  enableKeepAwake: false,
503
504
  enableDeepLinks: false,
@@ -790,7 +791,11 @@ function renderAndroidPermissions(config) {
790
791
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
791
792
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
792
793
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
793
- const bluetooth = config.enableBluetooth ? [
794
+ if (config.enableHeartRate) {
795
+ permissions.add("android.permission.FOREGROUND_SERVICE");
796
+ permissions.add("android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE");
797
+ }
798
+ const bluetooth = config.enableBluetooth || config.enableHeartRate ? [
794
799
  ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
795
800
  ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
796
801
  ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
@@ -1019,7 +1024,7 @@ async function init(options) {
1019
1024
  writeNativeShellSources(output, packagePath, finalPackageName, config);
1020
1025
  }
1021
1026
  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 {
1027
+ 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
1028
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
1024
1029
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
1025
1030
  ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
@@ -1047,8 +1052,11 @@ async function init(options) {
1047
1052
  writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1048
1053
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
1049
1054
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1055
+ for (const [template, file] of [["CraftHeartRateCore.kt.template", "CraftHeartRateCore.kt"], ["CraftHeartRate.kt.template", "CraftHeartRate.kt"]]) {
1056
+ writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1057
+ }
1050
1058
  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>
1059
+ 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
1060
  <package android:name="com.google.android.apps.healthdata" />
1053
1061
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1054
1062
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -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) }