craft-native 0.0.245 → 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.
@@ -11,6 +11,20 @@ export type { AndroidMapProvider } from './native-map.js';
11
11
  * route's file. Stacks asks for route bundles only when this is true.
12
12
  */
13
13
  export declare const nativeRouteBundles = true;
14
+ /**
15
+ * The app evaluates stx's shared runtime (`__runtime.js`, stx's
16
+ * `sharedRuntime`) in each screen's context before the route's own file.
17
+ * Stacks asks stx for it only when this is true: a route file built so
18
+ * refuses to run without it.
19
+ */
20
+ export declare const nativeRouteRuntime = true;
21
+ /**
22
+ * The app gives a screen its messages a language at a time (stx's
23
+ * `messages`, `native-screens/__messages/`): the areas its route uses,
24
+ * evaluated ahead of it (an isolate cannot read a file when asked). Stacks leaves the languages
25
+ * out of the screens' code only when this is true.
26
+ */
27
+ export declare const nativeMessages = true;
14
28
  /** Providers this published builder can generate as real Android map views. */
15
29
  export declare const nativeMapProviders: readonly ['google', 'maplibre'];
16
30
  export { runKotlinFixture } from './testing.js';
@@ -46,6 +60,15 @@ export interface CraftAndroidConfig {
46
60
  enableGeolocation?: boolean;
47
61
  enableBackgroundLocation?: boolean;
48
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;
49
72
  enableHealthConnect?: boolean;
50
73
  /**
51
74
  * The Health Connect data the app reads and writes, by the shared type
@@ -360,6 +360,8 @@ async function createAndroidEmulator(options) {
360
360
 
361
361
  // ../android/src/index.ts
362
362
  var nativeRouteBundles = true;
363
+ var nativeRouteRuntime = true;
364
+ var nativeMessages = true;
363
365
  var nativeMapProviders = ["google", "maplibre"];
364
366
  function syncNativeFonts(fonts, output) {
365
367
  const destination = join3(output, "app/src/main/assets/NativeFonts");
@@ -496,6 +498,7 @@ var DEFAULT_CONFIG = {
496
498
  enableGeolocation: false,
497
499
  enableBackgroundLocation: false,
498
500
  enableBluetooth: false,
501
+ enableHeartRate: false,
499
502
  enableHealthConnect: false,
500
503
  enableKeepAwake: false,
501
504
  enableDeepLinks: false,
@@ -788,7 +791,11 @@ function renderAndroidPermissions(config) {
788
791
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
789
792
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
790
793
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
791
- 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 ? [
792
799
  ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
793
800
  ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
794
801
  ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
@@ -1017,7 +1024,7 @@ async function init(options) {
1017
1024
  writeNativeShellSources(output, packagePath, finalPackageName, config);
1018
1025
  }
1019
1026
  const craftBridgeTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
1020
- 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 {
1021
1028
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
1022
1029
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
1023
1030
  ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
@@ -1045,8 +1052,11 @@ async function init(options) {
1045
1052
  writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1046
1053
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
1047
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
+ }
1048
1058
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1049
- 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>
1050
1060
  <package android:name="com.google.android.apps.healthdata" />
1051
1061
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1052
1062
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1380,7 +1390,9 @@ export {
1380
1390
  renderAndroidPermissions,
1381
1391
  renderAndroidDeepLinks,
1382
1392
  open,
1393
+ nativeRouteRuntime,
1383
1394
  nativeRouteBundles,
1395
+ nativeMessages,
1384
1396
  nativeMapProviders,
1385
1397
  installRuntime,
1386
1398
  init,
@@ -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) }