craft-native 0.0.435 → 0.0.442

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 (39) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +8 -0
  4. package/dist/android/src/index.js +17 -2
  5. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  6. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  7. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  9. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  10. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  11. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  12. package/dist/android/templates/MainActivityNative.kt.template +129 -1
  13. package/dist/cli.js +289 -108
  14. package/dist/components/index.d.ts +25 -0
  15. package/dist/index.cjs +207 -27
  16. package/dist/index.d.cts +2 -2
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +321 -140
  19. package/dist/installer-background.d.ts +2 -0
  20. package/dist/installer-layout.d.ts +1 -0
  21. package/dist/ios/src/index.d.ts +10 -0
  22. package/dist/ios/src/index.js +28 -2
  23. package/dist/ios/templates/CraftApp.swift +14 -0
  24. package/dist/ios/templates/CraftHealth.swift +2 -1
  25. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  26. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  27. package/dist/ios/templates/CraftNativeMutation.swift +18 -3
  28. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  29. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +24 -4
  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 +169 -37
  35. package/dist/macos/templates/CraftMacRuntime.swift +298 -77
  36. package/dist/macos/templates/CraftNativeMutation.swift +18 -3
  37. package/dist/package.d.ts +40 -0
  38. package/dist/types.d.ts +26 -0
  39. 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
@@ -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,
@@ -1013,10 +1017,14 @@ function renderAndroidPermissions(config) {
1013
1017
  }
1014
1018
  if (config.enablePushNotifications)
1015
1019
  permissions.add("android.permission.POST_NOTIFICATIONS");
1016
- if (config.enableKeepAwake)
1020
+ if (config.enableKeepAwake || config.enableBarometer && config.enableBackgroundLocation)
1017
1021
  permissions.add("android.permission.WAKE_LOCK");
1018
1022
  if (config.enableHealthConnect)
1019
1023
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
1024
+ if (config.enablePedometer)
1025
+ permissions.add("android.permission.ACTIVITY_RECOGNITION");
1026
+ if (config.enablePedometer && config.enableHealthServices)
1027
+ permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
1020
1028
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
1021
1029
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
1022
1030
  if (config.enableHeartRate) {
@@ -1056,13 +1064,16 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
1056
1064
  write("CraftSnapshotFile.kt.template", "CraftSnapshotFile.kt");
1057
1065
  write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
1058
1066
  write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
1067
+ write("CraftNativeMotion.kt.template", "CraftNativeMotion.kt");
1059
1068
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
1060
1069
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
1061
1070
  write(config.enableHealthServices ? "CraftHealthServices.kt.template" : "CraftHealthServicesStub.kt.template", "CraftHealthServices.kt");
1062
1071
  if (config.enableHealthServices) {
1063
1072
  write("CraftHealthServicesService.kt.template", "CraftHealthServicesService.kt");
1073
+ write("CraftPassiveSteps.kt.template", "CraftPassiveSteps.kt");
1064
1074
  } else {
1065
1075
  rmSync2(join3(javaDir, "CraftHealthServicesService.kt"), { force: true });
1076
+ rmSync2(join3(javaDir, "CraftPassiveSteps.kt"), { force: true });
1066
1077
  }
1067
1078
  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
1079
  import com.google.android.gms.pay.PayApiAvailabilityStatus
@@ -1348,6 +1359,7 @@ async function init(options) {
1348
1359
  const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
1349
1360
  mkdirSync2(nativeDir, { recursive: true });
1350
1361
  writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
1362
+ writeFileSync(join3(nativeDir, "CraftRecordingPressure.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftRecordingPressure.kt.template"), "utf-8"));
1351
1363
  const serviceTemplate = readFileSync2(join3(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
1352
1364
  writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1353
1365
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
@@ -1356,7 +1368,9 @@ async function init(options) {
1356
1368
  writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1357
1369
  }
1358
1370
  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>
1371
+ 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 ? `
1372
+ <service android:name=".CraftPassiveStepsService" android:exported="true" android:permission="com.google.android.wearable.healthservices.permission.PASSIVE_DATA_BINDING" />
1373
+ <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
1374
  <package android:name="com.google.android.apps.healthdata" />
1361
1375
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
1362
1376
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -1704,6 +1718,7 @@ export {
1704
1718
  nativeMessages,
1705
1719
  nativeRouteBundles,
1706
1720
  nativeRouteRuntime,
1721
+ nativeSensorCapabilities,
1707
1722
  open,
1708
1723
  renderAndroidDeepLinks,
1709
1724
  renderAndroidPermissions,
@@ -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() }
@@ -0,0 +1,269 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.hardware.Sensor
5
+ import android.hardware.SensorEvent
6
+ import android.hardware.SensorEventListener
7
+ import android.hardware.SensorManager
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import org.json.JSONArray
11
+ import org.json.JSONObject
12
+
13
+ /** Registration owns its listener. Closing an old registration cannot stop a replacement. */
14
+ internal interface CraftMotionRegistration { fun close() }
15
+ internal interface CraftMotionSource {
16
+ fun available(type: Int): Boolean
17
+ fun register(type: Int, periodUs: Int, sample: (FloatArray, Long, Int) -> Unit): CraftMotionRegistration?
18
+ }
19
+
20
+ internal class CraftAndroidMotionSource(context: Context) : CraftMotionSource {
21
+ private val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
22
+ override fun available(type: Int): Boolean = manager?.getDefaultSensor(type) != null
23
+ override fun register(type: Int, periodUs: Int, sample: (FloatArray, Long, Int) -> Unit): CraftMotionRegistration? {
24
+ val manager = manager ?: return null
25
+ val sensor = manager.getDefaultSensor(type) ?: return null
26
+ val listener = object : SensorEventListener {
27
+ override fun onSensorChanged(event: SensorEvent) {
28
+ sample(event.values.clone(), event.timestamp, event.accuracy)
29
+ }
30
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
31
+ }
32
+ try {
33
+ if (!manager.registerListener(listener, sensor, periodUs, Handler(Looper.getMainLooper()))) {
34
+ manager.unregisterListener(listener)
35
+ return null
36
+ }
37
+ } catch (error: Exception) {
38
+ manager.unregisterListener(listener)
39
+ throw error
40
+ }
41
+ return object : CraftMotionRegistration {
42
+ override fun close() { manager.unregisterListener(listener) }
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Foreground, route-owned raw readings; deliberately distinct from iOS CMDeviceMotion. */
48
+ internal class CraftNativeMotion(private val source: CraftMotionSource) {
49
+ private data class Kind(val type: Int, val unit: String)
50
+ private val kinds = linkedMapOf(
51
+ "accelerometer" to Kind(Sensor.TYPE_ACCELEROMETER, "m/s²"),
52
+ "gyroscope" to Kind(Sensor.TYPE_GYROSCOPE, "rad/s"),
53
+ "magnetometer" to Kind(Sensor.TYPE_MAGNETIC_FIELD, "µT"),
54
+ "rotationVector" to Kind(Sensor.TYPE_ROTATION_VECTOR, "quaternion"),
55
+ )
56
+ private class Stream { var registration: CraftMotionRegistration? = null }
57
+ private val streams = mutableMapOf<String, Stream>()
58
+ private var closed = false
59
+ var emit: (JSONObject) -> Unit = {}
60
+
61
+ fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
62
+ if (closed) return failure("HOST_CLOSED", "Motion host is closed")
63
+ return when (method) {
64
+ "getAvailability" -> CraftNativeCapabilityResult(data = JSONObject().apply {
65
+ kinds.forEach { (name, kind) -> put(name, source.available(kind.type)) }
66
+ })
67
+ "start" -> start(args.optJSONObject(0))
68
+ "stop" -> {
69
+ val name = args.opt(0) as? String
70
+ if (name !in kinds) failure("INVALID_ARGUMENT", "Unknown motion sensor")
71
+ else CraftNativeCapabilityResult(data = stop(name!!))
72
+ }
73
+ "stopAll" -> { stopAll(); CraftNativeCapabilityResult(data = true) }
74
+ else -> failure("UNKNOWN_ACTION", "Unsupported MotionSensors method")
75
+ }
76
+ }
77
+
78
+ private fun start(options: JSONObject?): CraftNativeCapabilityResult {
79
+ val name = options?.opt("sensor") as? String
80
+ val kind = kinds[name] ?: return failure("INVALID_ARGUMENT", "Unknown motion sensor")
81
+ val rawInterval = if (options?.has("intervalMs") == true) options.opt("intervalMs") else 100
82
+ val interval = (rawInterval as? Number)?.toDouble() ?: Double.NaN
83
+ // Do not silently opt an app into Android's high sampling-rate permission.
84
+ if (!interval.isFinite() || interval < 5 || interval > 60_000 || interval % 1 != 0.0) {
85
+ return failure("INVALID_ARGUMENT", "intervalMs must be an integer from 5 through 60000")
86
+ }
87
+ if (!source.available(kind.type)) return failure("UNAVAILABLE", "Sensor is not present: $name")
88
+ val stream = Stream()
89
+ val registration = try {
90
+ source.register(kind.type, (interval * 1000).toInt()) { values, timestamp, accuracy ->
91
+ // Android can still deliver a queued sample after unregisterListener.
92
+ if (!closed && streams[name] === stream && values.size >= 3) {
93
+ val reading = if (kind.type == Sensor.TYPE_ROTATION_VECTOR) {
94
+ val quaternion = FloatArray(4)
95
+ SensorManager.getQuaternionFromVector(quaternion, values)
96
+ JSONObject().put("w", quaternion[0]).put("x", quaternion[1])
97
+ .put("y", quaternion[2]).put("z", quaternion[3])
98
+ } else JSONObject().put("x", values[0]).put("y", values[1]).put("z", values[2])
99
+ emit(JSONObject().put("sensor", name).put("values", reading).put("unit", kind.unit)
100
+ .put("timestampMs", timestamp / 1_000_000.0).put("accuracy", accuracy))
101
+ }
102
+ }
103
+ } catch (error: Exception) {
104
+ return failure("SENSOR_ERROR", error.message ?: "Sensor registration failed")
105
+ } ?: return failure("UNAVAILABLE", "Sensor registration was rejected: $name")
106
+ stream.registration = registration
107
+ // Validate and acquire the replacement before retiring a working stream.
108
+ val previous = streams.put(name!!, stream)
109
+ previous?.registration?.close()
110
+ return CraftNativeCapabilityResult(data = true)
111
+ }
112
+
113
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
114
+ private fun stop(name: String): Boolean {
115
+ val stream = streams.remove(name) ?: return false
116
+ stream.registration?.close()
117
+ return true
118
+ }
119
+ fun stopAll() { streams.keys.toList().forEach(::stop) }
120
+ fun close() { closed = true; stopAll(); emit = {} }
121
+ }
122
+
123
+ /** A pressure-only stream with a new reference on each successful start. */
124
+ internal class CraftNativeBarometer(private val source: CraftMotionSource) {
125
+ private class Stream {
126
+ var registration: CraftMotionRegistration? = null
127
+ var reference: Float? = null
128
+ }
129
+ private var stream: Stream? = null
130
+ private var closed = false
131
+ var emit: (JSONObject) -> Unit = {}
132
+
133
+ fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
134
+ if (closed) return failure("HOST_CLOSED", "Barometer host is closed")
135
+ return when (method) {
136
+ "getAvailability" -> CraftNativeCapabilityResult(data = JSONObject()
137
+ .put("relativeAltitude", source.available(Sensor.TYPE_PRESSURE))
138
+ .put("absoluteAltitude", false).put("background", false))
139
+ "start" -> start(args.optJSONObject(0), args.length() != 0)
140
+ "stop" -> CraftNativeCapabilityResult(data = stop("requested"))
141
+ else -> failure("UNKNOWN_ACTION", "Unsupported Barometer method")
142
+ }
143
+ }
144
+
145
+ private fun start(options: JSONObject?, supplied: Boolean): CraftNativeCapabilityResult {
146
+ if (supplied && options == null) return failure("INVALID_ARGUMENT", "Barometer.start needs an options object")
147
+ if (options?.has("background") == true && options.opt("background") !is Boolean)
148
+ return failure("INVALID_ARGUMENT", "background must be boolean")
149
+ if (options?.optBoolean("background", false) == true)
150
+ return failure("UNSUPPORTED", "Background barometer recording is not supported")
151
+ val raw = if (options?.has("intervalMs") == true) options.opt("intervalMs") else 100
152
+ val interval = (raw as? Number)?.toDouble() ?: Double.NaN
153
+ if (!interval.isFinite() || interval < 5 || interval > 60_000 || interval % 1 != 0.0)
154
+ return failure("INVALID_ARGUMENT", "intervalMs must be an integer from 5 through 60000")
155
+ if (!source.available(Sensor.TYPE_PRESSURE)) return failure("UNAVAILABLE", "Barometer is not present")
156
+ val next = Stream()
157
+ val registration = try {
158
+ source.register(Sensor.TYPE_PRESSURE, (interval * 1000).toInt()) { values, timestamp, _ ->
159
+ if (!closed && stream === next) {
160
+ val pressure = values.firstOrNull()
161
+ if (pressure == null || !pressure.isFinite() || pressure <= 0) {
162
+ stop("sensorError")
163
+ } else {
164
+ val reference = next.reference ?: pressure.also { next.reference = it }
165
+ val altitude = SensorManager.getAltitude(reference, pressure)
166
+ if (!altitude.isFinite()) stop("sensorError")
167
+ else emit(JSONObject().put("state", "sample").put("pressureHpa", pressure)
168
+ .put("relativeAltitudeMeters", altitude).put("referencePressureHpa", reference)
169
+ .put("timestampMs", timestamp / 1_000_000.0))
170
+ }
171
+ }
172
+ }
173
+ } catch (error: Exception) {
174
+ return failure("SENSOR_ERROR", error.message ?: "Barometer registration failed")
175
+ } ?: return failure("UNAVAILABLE", "Barometer registration was rejected")
176
+ next.registration = registration
177
+ val previous = stream
178
+ stream = next
179
+ previous?.registration?.close()
180
+ return CraftNativeCapabilityResult(data = true)
181
+ }
182
+
183
+ fun stop(reason: String): Boolean {
184
+ val previous = stream ?: return false
185
+ stream = null
186
+ previous.registration?.close()
187
+ emit(JSONObject().put("state", "stopped").put("reason", reason))
188
+ return true
189
+ }
190
+ fun close() { closed = true; stop("disposed"); emit = {} }
191
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
192
+ }
193
+
194
+ /** Android counter and detector semantics stay distinct from interval totals. */
195
+ internal class CraftNativePedometer(private val source: CraftMotionSource, private val hasPermission: () -> Boolean) {
196
+ private class Stream { var registration: CraftMotionRegistration? = null }
197
+ private var stream: Stream? = null
198
+ private var closed = false
199
+ var emit: (JSONObject) -> Unit = {}
200
+
201
+ fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
202
+ if (closed) return failure("HOST_CLOSED", "Pedometer host is closed")
203
+ return when (method) {
204
+ "getAvailability" -> CraftNativeCapabilityResult(data = JSONObject()
205
+ .put("counter", source.available(Sensor.TYPE_STEP_COUNTER))
206
+ .put("detector", source.available(Sensor.TYPE_STEP_DETECTOR))
207
+ .put("history", false).put("background", false).put("cadence", false).put("floors", false))
208
+ "getPermissionStatus" -> CraftNativeCapabilityResult(data = if (hasPermission()) "granted" else "denied")
209
+ "start" -> start(args.optJSONObject(0), args.length() != 0)
210
+ "stop" -> CraftNativeCapabilityResult(data = stop("requested"))
211
+ "query" -> failure("UNSUPPORTED", "Android's step sensor does not provide interval history")
212
+ else -> failure("UNKNOWN_ACTION", "Unsupported Pedometer method")
213
+ }
214
+ }
215
+
216
+ private fun start(options: JSONObject?, supplied: Boolean): CraftNativeCapabilityResult {
217
+ if (supplied && options == null) return failure("INVALID_ARGUMENT", "Pedometer.start needs an options object")
218
+ if (options?.has("background") == true && options.opt("background") !is Boolean)
219
+ return failure("INVALID_ARGUMENT", "background must be boolean")
220
+ if (options?.optBoolean("background", false) == true)
221
+ return failure("UNSUPPORTED", "Background step streaming is not supported by this sensor adapter")
222
+ val name = if (options?.has("sensor") == true) options.opt("sensor") as? String else "counter"
223
+ val type = when (name) {
224
+ "counter" -> Sensor.TYPE_STEP_COUNTER
225
+ "detector" -> Sensor.TYPE_STEP_DETECTOR
226
+ else -> return failure("INVALID_ARGUMENT", "sensor must be counter or detector")
227
+ }
228
+ if (options?.has("startDate") == true || options?.has("endDate") == true)
229
+ return failure("UNSUPPORTED", "Android steps are not counts for a requested interval")
230
+ if (!source.available(type)) return failure("UNAVAILABLE", "Requested step sensor is not present")
231
+ if (!hasPermission()) return failure("PERMISSION_DENIED", "Activity recognition permission is required")
232
+ val next = Stream()
233
+ val registration = try {
234
+ source.register(type, 200_000) { values, timestamp, _ ->
235
+ if (!closed && stream === next) {
236
+ val value = values.firstOrNull()
237
+ if (!hasPermission()) stop("permissionDenied")
238
+ else if (value == null || !value.isFinite() || value < 0 || value % 1f != 0f || (name == "detector" && value != 1f)) stop("sensorError")
239
+ else {
240
+ val payload = JSONObject().put("state", "sample").put("kind", if (name == "counter") "counter" else "step")
241
+ .put("timestampMs", timestamp / 1_000_000.0)
242
+ if (name == "counter") payload.put("stepsSinceBoot", value.toDouble())
243
+ else payload.put("count", 1)
244
+ emit(payload)
245
+ }
246
+ }
247
+ }
248
+ } catch (error: SecurityException) {
249
+ return failure("PERMISSION_DENIED", "Activity recognition permission is required")
250
+ } catch (error: Exception) {
251
+ return failure("SENSOR_ERROR", error.message ?: "Step sensor registration failed")
252
+ } ?: return failure("UNAVAILABLE", "Step sensor registration was rejected")
253
+ next.registration = registration
254
+ val previous = stream
255
+ stream = next
256
+ previous?.registration?.close()
257
+ return CraftNativeCapabilityResult(data = true)
258
+ }
259
+
260
+ fun stop(reason: String): Boolean {
261
+ val previous = stream ?: return false
262
+ stream = null
263
+ previous.registration?.close()
264
+ emit(JSONObject().put("state", "stopped").put("reason", reason))
265
+ return true
266
+ }
267
+ fun close() { closed = true; stop("disposed"); emit = {} }
268
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
269
+ }
@@ -129,10 +129,12 @@ internal class CraftNativeServices(
129
129
  add("healthAvailability")
130
130
  }
131
131
  if (healthServices != null) add("healthServices")
132
+ if (healthServices != null && config.optBoolean("enablePedometer")) add("pedometerPassive")
132
133
  if (config.optBoolean("enableWallet")) add("wallet")
133
134
  if (config.optBoolean("enableKeepAwake")) add("keepAwake")
134
135
  if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
135
136
  add("geolocation")
137
+ if (config.optBoolean("enableBarometer") && config.optBoolean("enableBackgroundLocation")) add("locationRecordingBarometer")
136
138
  if (CraftNative.supportsRecordingOwnership()) add("locationRecordingOwnership")
137
139
  if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
138
140
  if (CraftNative.supportsRecordingPowerMode()) add("locationRecordingPowerMode")
@@ -146,7 +148,7 @@ internal class CraftNativeServices(
146
148
  }
147
149
 
148
150
  fun handles(module: String): Boolean = module in setOf(
149
- "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
151
+ "Dialog", "Browser", "Linking", "Share", "Push", "Health", "PedometerPassive", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
150
152
  "Location", "Media", "CrashReporter", "HeartRate", "AudioRecording",
151
153
  "Wallet",
152
154
  )
@@ -165,6 +167,9 @@ internal class CraftNativeServices(
165
167
  "Health" -> if (healthServices != null && method in setOf("startWorkout", "pauseWorkout", "resumeWorkout", "endWorkout")) {
166
168
  healthServices.perform(method, args, done)
167
169
  } else health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health is not enabled for this app"))
170
+ "PedometerPassive" -> if (config.optBoolean("enablePedometer") && healthServices != null) {
171
+ healthServices.perform("passive:$method", args, done)
172
+ } else done(failure("CAPABILITY_DISABLED", "Passive steps require pedometer and Health Services"))
168
173
  "Wallet" -> wallet(method, args, done)
169
174
  "KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
170
175
  "Brightness" -> brightness(method, args, done)