craft-native 0.0.436 → 0.0.443

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 (38) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +44 -0
  4. package/dist/android/src/index.js +82 -3
  5. package/dist/android/templates/AndroidManifest.xml.template +2 -1
  6. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  7. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  8. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  9. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  10. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  11. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  12. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  13. package/dist/android/templates/MainActivity.kt.template +3 -0
  14. package/dist/android/templates/MainActivityNative.kt.template +132 -1
  15. package/dist/cli.js +289 -108
  16. package/dist/components/index.d.ts +25 -0
  17. package/dist/index.cjs +207 -27
  18. package/dist/index.d.cts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js +321 -140
  21. package/dist/installer-background.d.ts +2 -0
  22. package/dist/installer-layout.d.ts +1 -0
  23. package/dist/ios/src/index.d.ts +23 -1
  24. package/dist/ios/src/index.js +49 -9
  25. package/dist/ios/templates/CraftApp.swift +14 -0
  26. package/dist/ios/templates/CraftHealth.swift +2 -1
  27. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  28. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  29. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  30. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  31. package/dist/macos/src/index.js +1 -1
  32. package/dist/macos/src/toolchain.d.ts +1 -1
  33. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  34. package/dist/macos/templates/CraftMacRenderer.swift +111 -33
  35. package/dist/macos/templates/CraftMacRuntime.swift +270 -81
  36. package/dist/package.d.ts +40 -0
  37. package/dist/types.d.ts +26 -0
  38. package/package.json +1 -1
package/LICENSE.md CHANGED
@@ -19,3 +19,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ## Installer layout notices
24
+
25
+ The Finder store and Carbon Alias format implementation follows the MIT-licensed
26
+ references [gwend/dsstore](https://github.com/gwend/dsstore),
27
+ [rigsmith/rigsmith](https://github.com/rigsmith/rigsmith/tree/main/core/dsstore), and
28
+ [al45tair/mac_alias](https://github.com/al45tair/mac_alias).
29
+
30
+ Copyright (c) 2020 Kenty Gwend
31
+ Copyright (c) 2026 John Campion Jr
32
+ Copyright (c) 2014 Alastair Houghton
33
+ Copyright (c) 2022 Russell Keith-Magee
34
+
35
+ Permission is hereby granted, free of charge, to any person obtaining a copy
36
+ of this software and associated documentation files (the "Software"), to deal
37
+ in the Software without restriction, including without limitation the rights
38
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
39
+ copies of the Software, and to permit persons to whom the Software is
40
+ furnished to do so, subject to the following conditions:
41
+
42
+ The above copyright notice and this permission notice shall be included in all
43
+ copies or substantial portions of the Software.
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
48
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
49
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
50
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
51
+ SOFTWARE.
package/README.md CHANGED
@@ -740,3 +740,38 @@ Craft's bundled-backend host reads `backend: { executable: 'stx-backend' }` from
740
740
  the helper with `--serve --managed`, and consumes its authenticated loopback URL.
741
741
  The helper's standard input is a lifetime lease: it must stop when that pipe
742
742
  closes. `craft --capabilities` advertises `bundleBackend` on macOS.
743
+
744
+ ## Drag-to-Applications disk images
745
+
746
+ `createDiskImage` packages an existing macOS app bundle with an Applications
747
+ shortcut, installation artwork and a Finder icon layout. The layout is written
748
+ without launching Finder or requesting Automation permission.
749
+
750
+ ```ts
751
+ import { createDiskImage } from 'craft-native'
752
+
753
+ const image = await createDiskImage({
754
+ appBundlePath: './dist/Example.app',
755
+ outputPath: './dist/Example.dmg',
756
+ volumeName: 'Example',
757
+ })
758
+ if (!image.success) throw new Error(image.error)
759
+ ```
760
+
761
+ Signing and notarization belong to the publishing pipeline. Stacks applications
762
+ use Buddy and Pantry's native workflow to sign, notarize and staple the image
763
+ before attaching its ZIP archive. Craft's updater accepts both app ZIPs and ZIPs
764
+ containing a single DMG, and verifies the extracted app before installation.
765
+
766
+ The shipped LICENSE file includes the installer layout's third-party notices.
767
+
768
+ ## Framework bridge types
769
+
770
+ Core owns the optional `Window.craft` declaration. React, Svelte and Vue bindings
771
+ depend on core for types, so importing a binding alongside `craft-native` does
772
+ not redeclare that global with an incompatible shape.
773
+
774
+ `CraftBindingsAPI` describes the bindings' existing flat host API (`getPlatform`,
775
+ `invoke`, and related methods). Their hooks and stores retain that view and their
776
+ runtime behavior. This type does not install a bridge, add flat methods to a
777
+ desktop host, or replace the structured `CraftBridgeAPI` and typed event APIs.
@@ -7,6 +7,8 @@ export type { AndroidMapProvider } from './native-map.js';
7
7
  * well as the one-file bundle, and the app runs each screen from its own
8
8
  * route's file. Stacks asks for route bundles only when this is true.
9
9
  */
10
+ /** Build-time proof for Stacks capability mappings, including unreleased source builds. */
11
+ export declare const nativeSensorCapabilities: readonly ['motionSensors', 'barometer', 'pedometer'];
10
12
  export declare const nativeRouteBundles = true;
11
13
  /**
12
14
  * The app evaluates stx's shared runtime (`__runtime.js`, stx's
@@ -65,6 +67,12 @@ export interface CraftAndroidConfig {
65
67
  enableSecureStorage?: boolean;
66
68
  enableGeolocation?: boolean;
67
69
  enableBackgroundLocation?: boolean;
70
+ /** Foreground raw Android sensors on native screens; separate from iOS fused motion. */
71
+ enableMotionSensors?: boolean;
72
+ /** Foreground pressure and session-relative altitude; does not enable background recording. */
73
+ enableBarometer?: boolean;
74
+ /** Foreground hardware step counters/detectors; requires activity recognition on Android 10+. */
75
+ enablePedometer?: boolean;
68
76
  enableBluetooth?: boolean;
69
77
  /**
70
78
  * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
@@ -114,6 +122,18 @@ export interface CraftAndroidConfig {
114
122
  }>;
115
123
  trustedOrigins?: string[];
116
124
  appIconPath?: string;
125
+ /**
126
+ * The launch splash: a directory laid out as Android resources, holding
127
+ * `drawable-<density>/splash_icon.png` (Android 12's splash icon, 288dp
128
+ * with its content inside the 192dp circle) and `splash_logo.png` (the
129
+ * logo the window background centres before Android 12), with Dark Mode
130
+ * ones in `drawable-night-<density>`. ts-images' `generateSplashScreens`
131
+ * writes exactly this (`android.resources`). Both sit on `backgroundColor`
132
+ * (`backgroundColorDark` at night), the colour the first frame continues.
133
+ * Without it, Android 12 shows the launcher icon and older versions the
134
+ * background colour.
135
+ */
136
+ splashResources?: string;
117
137
  /**
118
138
  * The small icon in the status bar and on every notification the app
119
139
  * shows, push or local: an Android vector drawable (`.xml`) or a `.png`,
@@ -260,6 +280,30 @@ export interface RunOptions {
260
280
  device?: string;
261
281
  output: string;
262
282
  }
283
+ /** A splash file, by its place under `res/` (`drawable-night-xxhdpi/splash_icon.png`). */
284
+ export interface AndroidSplashFile {
285
+ folder: string;
286
+ name: 'splash_icon.png' | 'splash_logo.png';
287
+ path: string;
288
+ }
289
+ /**
290
+ * The splash files in a resources directory: `splash_icon.png` and
291
+ * `splash_logo.png` in each `drawable[-night][-<density>]` folder. A directory
292
+ * with neither is a mistake, not an app without a splash.
293
+ */
294
+ export declare function androidSplashFiles(resources: string): AndroidSplashFile[];
295
+ /**
296
+ * The launch theme the activity starts in, and the theme it swaps to before
297
+ * its first frame. Android 12 and later draw their splash from
298
+ * `windowSplashScreen*`; earlier versions draw the window background, here
299
+ * the logo centred on the background colour. Either way the colour is the
300
+ * app's own `background`, so nothing flashes when the first frame replaces it.
301
+ */
302
+ export declare function androidLaunchThemes(splash: Pick<AndroidSplashFile, 'name'>[]): {
303
+ base: string;
304
+ v31: string;
305
+ background?: string;
306
+ };
263
307
  export declare function syncAndroidWebAssets(source: string, output: string): void;
264
308
  export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
265
309
  export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
@@ -549,6 +549,7 @@ function compressNativeSources(target) {
549
549
  if (packed.length < bytes.length)
550
550
  writeFileSync(target, packed);
551
551
  }
552
+ var nativeSensorCapabilities = ["motionSensors", "barometer", "pedometer"];
552
553
  var nativeRouteBundles = true;
553
554
  var nativeRouteRuntime = true;
554
555
  var nativeMessages = true;
@@ -715,6 +716,9 @@ var DEFAULT_CONFIG = {
715
716
  enableSecureStorage: false,
716
717
  enableGeolocation: false,
717
718
  enableBackgroundLocation: false,
719
+ enableMotionSensors: false,
720
+ enableBarometer: false,
721
+ enablePedometer: false,
718
722
  enableBluetooth: false,
719
723
  enableHeartRate: false,
720
724
  enableHealthConnect: false,
@@ -753,6 +757,49 @@ function requireRegularFile(path, label) {
753
757
  if (!statSync(path).isFile())
754
758
  throw new Error(`${label} must be a file: ${path}`);
755
759
  }
760
+ function androidSplashFiles(resources) {
761
+ if (!existsSync(resources) || !statSync(resources).isDirectory())
762
+ throw new Error(`Splash resources must be a directory: ${resources}`);
763
+ const files = [];
764
+ for (const folder of readdirSync(resources).sort()) {
765
+ if (!/^drawable(?:-night)?(?:-(?:l|m|tv|h|xh|xxh|xxxh)dpi)?$/.test(folder))
766
+ continue;
767
+ for (const name of ["splash_icon.png", "splash_logo.png"]) {
768
+ const path = join3(resources, folder, name);
769
+ if (existsSync(path) && statSync(path).isFile())
770
+ files.push({ folder, name, path });
771
+ }
772
+ }
773
+ if (!files.length)
774
+ throw new Error(`Splash resources hold no drawable*/splash_icon.png or splash_logo.png: ${resources}`);
775
+ return files;
776
+ }
777
+ function androidLaunchThemes(splash) {
778
+ const icon = splash.some((file) => file.name === "splash_icon.png");
779
+ const logo = splash.some((file) => file.name === "splash_logo.png");
780
+ const base = logo ? ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
781
+ <item name="android:windowBackground">@drawable/craft_launch_background</item>
782
+ </style>
783
+ ` : ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp" />
784
+ `;
785
+ const v31 = `<?xml version="1.0" encoding="utf-8"?>
786
+ <resources>
787
+ <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
788
+ <item name="android:windowSplashScreenBackground">@color/background</item>${icon ? `
789
+ <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>` : ""}
790
+ </style>
791
+ </resources>
792
+ `;
793
+ const background = logo ? `<?xml version="1.0" encoding="utf-8"?>
794
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
795
+ <item android:drawable="@color/background" />
796
+ <item>
797
+ <bitmap android:gravity="center" android:src="@drawable/splash_logo" />
798
+ </item>
799
+ </layer-list>
800
+ ` : undefined;
801
+ return { base, v31, background };
802
+ }
756
803
  function containsPath(parent, candidate) {
757
804
  const relativePath = relative(parent, candidate);
758
805
  return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute2(relativePath);
@@ -960,6 +1007,7 @@ function writeAndroidConfig(output, config) {
960
1007
  writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
961
1008
  const runtimeConfig = { ...config };
962
1009
  delete runtimeConfig.appIconPath;
1010
+ delete runtimeConfig.splashResources;
963
1011
  delete runtimeConfig.notificationIconPath;
964
1012
  delete runtimeConfig.googleServicesFile;
965
1013
  writeFileSync(join3(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
@@ -1013,10 +1061,14 @@ function renderAndroidPermissions(config) {
1013
1061
  }
1014
1062
  if (config.enablePushNotifications)
1015
1063
  permissions.add("android.permission.POST_NOTIFICATIONS");
1016
- if (config.enableKeepAwake)
1064
+ if (config.enableKeepAwake || config.enableBarometer && config.enableBackgroundLocation)
1017
1065
  permissions.add("android.permission.WAKE_LOCK");
1018
1066
  if (config.enableHealthConnect)
1019
1067
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
1068
+ if (config.enablePedometer)
1069
+ permissions.add("android.permission.ACTIVITY_RECOGNITION");
1070
+ if (config.enablePedometer && config.enableHealthServices)
1071
+ permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
1020
1072
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
1021
1073
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
1022
1074
  if (config.enableHeartRate) {
@@ -1056,13 +1108,16 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
1056
1108
  write("CraftSnapshotFile.kt.template", "CraftSnapshotFile.kt");
1057
1109
  write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
1058
1110
  write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
1111
+ write("CraftNativeMotion.kt.template", "CraftNativeMotion.kt");
1059
1112
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
1060
1113
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
1061
1114
  write(config.enableHealthServices ? "CraftHealthServices.kt.template" : "CraftHealthServicesStub.kt.template", "CraftHealthServices.kt");
1062
1115
  if (config.enableHealthServices) {
1063
1116
  write("CraftHealthServicesService.kt.template", "CraftHealthServicesService.kt");
1117
+ write("CraftPassiveSteps.kt.template", "CraftPassiveSteps.kt");
1064
1118
  } else {
1065
1119
  rmSync2(join3(javaDir, "CraftHealthServicesService.kt"), { force: true });
1120
+ rmSync2(join3(javaDir, "CraftPassiveSteps.kt"), { force: true });
1066
1121
  }
1067
1122
  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
1068
1123
  import com.google.android.gms.pay.PayApiAvailabilityStatus
@@ -1255,6 +1310,7 @@ async function init(options) {
1255
1310
  if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
1256
1311
  throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
1257
1312
  }
1313
+ const splash = config.splashResources ? androidSplashFiles(config.splashResources) : [];
1258
1314
  if (config.notificationIconPath)
1259
1315
  requireRegularFile(config.notificationIconPath, "Notification icon");
1260
1316
  const notificationIconExtension = config.notificationIconPath ? extname(config.notificationIconPath).toLowerCase() : undefined;
@@ -1348,6 +1404,7 @@ async function init(options) {
1348
1404
  const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
1349
1405
  mkdirSync2(nativeDir, { recursive: true });
1350
1406
  writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
1407
+ writeFileSync(join3(nativeDir, "CraftRecordingPressure.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftRecordingPressure.kt.template"), "utf-8"));
1351
1408
  const serviceTemplate = readFileSync2(join3(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
1352
1409
  writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1353
1410
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
@@ -1356,7 +1413,9 @@ async function init(options) {
1356
1413
  writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1357
1414
  }
1358
1415
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1359
- 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>
1416
+ 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" />' + (config.enablePedometer ? `
1417
+ <service android:name=".CraftPassiveStepsService" android:exported="true" android:permission="com.google.android.wearable.healthservices.permission.PASSIVE_DATA_BINDING" />
1418
+ <receiver android:name=".CraftPassiveStepsBootReceiver" android:exported="true"><intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter></receiver>` : "") : "").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>
1360
1419
  <package android:name="com.google.android.apps.healthdata" />
1361
1420
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1362
1421
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1461,6 +1520,17 @@ zipStorePath=wrapper/dists
1461
1520
  </vector>
1462
1521
  `);
1463
1522
  }
1523
+ const resDir = join3(output, "app/src/main/res");
1524
+ for (const folder of existsSync(resDir) ? readdirSync(resDir) : []) {
1525
+ if (folder.startsWith("drawable"))
1526
+ for (const name of ["splash_icon.png", "splash_logo.png"])
1527
+ rmSync2(join3(resDir, folder, name), { force: true });
1528
+ }
1529
+ for (const file of splash) {
1530
+ mkdirSync2(join3(resDir, file.folder), { recursive: true });
1531
+ cpSync(file.path, join3(resDir, file.folder, file.name));
1532
+ }
1533
+ const launch = androidLaunchThemes(splash);
1464
1534
  const themesXml = `<?xml version="1.0" encoding="utf-8"?>
1465
1535
  <resources>
1466
1536
  <style name="Theme.CraftApp" parent="Theme.Material3.DayNight.NoActionBar">
@@ -1468,9 +1538,15 @@ zipStorePath=wrapper/dists
1468
1538
  <item name="android:navigationBarColor">@color/primary</item>
1469
1539
  <item name="android:windowBackground">@color/background</item>
1470
1540
  </style>
1471
- </resources>
1541
+ ${launch.base}</resources>
1472
1542
  `;
1473
1543
  writeFileSync(join3(output, "app/src/main/res/values/themes.xml"), themesXml);
1544
+ mkdirSync2(join3(output, "app/src/main/res/values-v31"), { recursive: true });
1545
+ writeFileSync(join3(output, "app/src/main/res/values-v31/themes.xml"), launch.v31);
1546
+ if (launch.background)
1547
+ writeFileSync(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), launch.background);
1548
+ else
1549
+ rmSync2(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), { force: true });
1474
1550
  const activityMainXml = config.renderer === "native" ? `<?xml version="1.0" encoding="utf-8"?>
1475
1551
  <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
1476
1552
  android:id="@+id/native_root"
@@ -1694,6 +1770,8 @@ async function run(options) {
1694
1770
  export {
1695
1771
  ANDROID_MIN_SDK,
1696
1772
  HEALTH_CONNECT_PERMISSIONS,
1773
+ androidLaunchThemes,
1774
+ androidSplashFiles,
1697
1775
  build,
1698
1776
  compressNativeSources,
1699
1777
  createAndroidEmulator,
@@ -1704,6 +1782,7 @@ export {
1704
1782
  nativeMessages,
1705
1783
  nativeRouteBundles,
1706
1784
  nativeRouteRuntime,
1785
+ nativeSensorCapabilities,
1707
1786
  open,
1708
1787
  renderAndroidDeepLinks,
1709
1788
  renderAndroidPermissions,
@@ -45,7 +45,8 @@
45
45
  android:windowSoftInputMode="adjustResize"
46
46
  android:supportsPictureInPicture="true"
47
47
  android:resizeableActivity="true"
48
- android:launchMode="singleTask">
48
+ android:launchMode="singleTask"
49
+ android:theme="@style/Theme.CraftApp.Launch">
49
50
  <intent-filter>
50
51
  <action android:name="android.intent.action.MAIN" />
51
52
  <category android:name="android.intent.category.LAUNCHER" />
@@ -20,9 +20,11 @@ internal class CraftHealthServices(
20
20
  private val activityProvider: () -> Activity?,
21
21
  private val fallback: ((String, JSONArray, (CraftNativeCapabilityResult) -> Unit) -> Unit)? = null,
22
22
  ) {
23
+ private val passiveSteps by lazy { CraftPassiveSteps(context) }
23
24
  private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
24
25
 
25
26
  fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
27
+ if (method.startsWith("passive:")) { passiveSteps.perform(method.removePrefix("passive:"), done); return }
26
28
  when (method) {
27
29
  "startWorkout" -> start(args.optJSONObject(0) ?: JSONObject(), done)
28
30
  "pauseWorkout" -> command(CraftHealthServicesService.ACTION_PAUSE, done)
@@ -22,6 +22,7 @@ import android.net.NetworkCapabilities
22
22
  import android.provider.MediaStore
23
23
  import android.util.Base64
24
24
  import androidx.core.content.ContextCompat
25
+ import androidx.core.app.ActivityCompat
25
26
  import androidx.biometric.BiometricManager
26
27
  import androidx.biometric.BiometricPrompt
27
28
  import androidx.fragment.app.FragmentActivity
@@ -50,6 +51,7 @@ private const val CRAFT_NATIVE_FILE_PICK_FIRST = 0x4000
50
51
  private const val CRAFT_NATIVE_FILE_PICK_LAST = 0xffff
51
52
  private const val CRAFT_NATIVE_FILE_PICK_STATE = "craft.filePicker.nextCode"
52
53
  private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
54
+ private const val CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST = 4358
53
55
  private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
54
56
  private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
55
57
  private const val CRAFT_NATIVE_FILE_DEFAULT_QUALITY = 0.85
@@ -88,6 +90,8 @@ internal class CraftNativeCapabilities(
88
90
  override fun dismiss(requestCode: Int) { host.finishActivity(requestCode) }
89
91
  }
90
92
  },
93
+ private val requestPedometerPermission: ((Int) -> Unit)? = null,
94
+ private val checkPedometerPermission: (() -> Boolean)? = null,
91
95
  ) {
92
96
  companion object {
93
97
  private val pickerCodes = CraftNativePickerCodes()
@@ -127,6 +131,8 @@ internal class CraftNativeCapabilities(
127
131
  private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
128
132
  private var pendingFilePick: PendingFilePick? = null
129
133
  private var pendingNotificationPermission: ((Boolean) -> Unit)? = null
134
+ private var pendingPedometerPermission: Pair<String, (CraftNativeCapabilityResult) -> Unit>? = null
135
+ private var pedometerPermissionInFlight = false
130
136
  private var database: SQLiteDatabase? = null
131
137
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
132
138
 
@@ -214,6 +220,29 @@ internal class CraftNativeCapabilities(
214
220
  if (method == "randomUUID") complete(requestToken, success(UUID.randomUUID().toString()), completion)
215
221
  else complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Crypto method"), completion)
216
222
  }
223
+ "Pedometer" -> {
224
+ if (!config.optBoolean("enablePedometer")) complete(requestToken, failure("CAPABILITY_DISABLED", "Pedometer is disabled"), completion)
225
+ else if (method != "requestPermission") complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Pedometer permission method"), completion)
226
+ else onMain {
227
+ if (cancelled.remove(requestToken) || closed.get()) return@onMain
228
+ val host = activity
229
+ if (host == null && requestPedometerPermission == null) complete(requestToken, failure("NEEDS_ACTIVITY", "Pedometer permission needs a screen"), completion)
230
+ else if (pedometerPermissionGranted()) complete(requestToken, success("granted"), completion)
231
+ else if (pedometerPermissionInFlight) complete(requestToken, failure("PERMISSION_BUSY", "An activity recognition request is already open"), completion)
232
+ else {
233
+ pendingPedometerPermission = requestToken to completion
234
+ pedometerPermissionInFlight = true
235
+ try {
236
+ if (requestPedometerPermission != null) requestPedometerPermission.invoke(CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST)
237
+ else ActivityCompat.requestPermissions(requireNotNull(host), arrayOf(Manifest.permission.ACTIVITY_RECOGNITION), CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST)
238
+ } catch (error: Exception) {
239
+ pendingPedometerPermission = null
240
+ pedometerPermissionInFlight = false
241
+ complete(requestToken, failure("PERMISSION_ERROR", error.message ?: "Permission could not be requested"), completion)
242
+ }
243
+ }
244
+ }
245
+ }
217
246
  "Bluetooth" -> {
218
247
  if (!config.optBoolean("enableBluetooth")) complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
219
248
  else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
@@ -268,6 +297,7 @@ internal class CraftNativeCapabilities(
268
297
  cancelled.add(requestToken)
269
298
  fetchConnections.remove(requestToken)?.disconnect()
270
299
  onMain {
300
+ if (pendingPedometerPermission?.first == requestToken) pendingPedometerPermission = null
271
301
  if (pendingFilePick?.requestToken == requestToken) {
272
302
  dismissFilePick()
273
303
  cancelled.remove(requestToken)
@@ -285,13 +315,24 @@ internal class CraftNativeCapabilities(
285
315
  networkExecutor.shutdownNow()
286
316
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
287
317
  fetchConnections.clear()
288
- onMain { dismissFilePick() }
318
+ onMain { dismissFilePick(); pendingPedometerPermission = null; pedometerPermissionInFlight = false }
289
319
  database?.close()
290
320
  database = null
291
321
  biometricPrompts.values.forEach { it.cancelAuthentication() }
292
322
  biometricPrompts.clear()
293
323
  }
294
324
 
325
+ fun pedometerPermissionGranted(): Boolean = checkPedometerPermission?.invoke() ?: (Build.VERSION.SDK_INT < 29 ||
326
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED)
327
+
328
+ fun onPedometerPermissions(requestCode: Int) {
329
+ if (requestCode != CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST) return
330
+ pedometerPermissionInFlight = false
331
+ val pending = pendingPedometerPermission
332
+ pendingPedometerPermission = null
333
+ pending?.let { (token, completion) -> complete(token, success(if (pedometerPermissionGranted()) "granted" else "denied"), completion) }
334
+ }
335
+
295
336
  private fun onMain(work: () -> Unit) {
296
337
  if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) work()
297
338
  else mainHandler.post { work() }