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
@@ -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)
@@ -0,0 +1,159 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Intent
7
+ import androidx.work.Worker
8
+ import androidx.work.WorkerParameters
9
+ import androidx.work.WorkManager
10
+ import androidx.work.OneTimeWorkRequestBuilder
11
+ import androidx.work.ExistingWorkPolicy
12
+ import android.content.pm.PackageManager
13
+ import android.os.SystemClock
14
+ import androidx.core.content.ContextCompat
15
+ import androidx.health.services.client.HealthServices
16
+ import androidx.health.services.client.PassiveListenerService
17
+ import androidx.health.services.client.data.DataPointContainer
18
+ import androidx.health.services.client.data.DataType
19
+ import androidx.health.services.client.data.PassiveListenerConfig
20
+ import org.json.JSONArray
21
+ import org.json.JSONObject
22
+ import java.time.Instant
23
+ import java.time.ZoneId
24
+ import java.util.concurrent.TimeUnit
25
+
26
+ /** Daily Health Services totals are not the raw counter since boot. */
27
+ internal class CraftPassiveStepStore(context: Context) {
28
+ private val prefs = context.getSharedPreferences("craft-passive-steps", Context.MODE_PRIVATE)
29
+ companion object { private val lock = Any() }
30
+ fun enabled(): Boolean = synchronized(lock) { prefs.getBoolean("enabled", false) }
31
+ fun setEnabled(value: Boolean): Boolean = synchronized(lock) { prefs.edit().putBoolean("enabled", value).commit() }
32
+ fun history(): JSONArray = synchronized(lock) { JSONArray(prefs.getString("days", "[]")) }
33
+ fun save(steps: Long, start: Long, end: Long, zone: String): Boolean = synchronized(lock) {
34
+ if (!enabled() || steps < 0 || start > end) return@synchronized false
35
+ // Attribute a completed interval ending at midnight to its start day.
36
+ val day = Instant.ofEpochMilli(start).atZone(ZoneId.of(zone)).toLocalDate().toString()
37
+ val rows = history()
38
+ val values = (0 until rows.length()).map { rows.getJSONObject(it) }.toMutableList()
39
+ val previous = values.firstOrNull { it.optString("day") == day && it.optString("timeZone") == zone }
40
+ if (previous != null && previous.optLong("endDate") > end) return@synchronized false
41
+ values.removeAll { it.optString("day") == day && it.optString("timeZone") == zone }
42
+ values.add(JSONObject().put("kind", "daily").put("source", "healthServices").put("day", day).put("timeZone", zone)
43
+ .put("startDate", start).put("endDate", end).put("steps", steps))
44
+ val retained = JSONArray(values.sortedBy { it.getLong("endDate") }.takeLast(8))
45
+ prefs.edit().putString("days", retained.toString()).commit()
46
+ }
47
+ }
48
+
49
+ /** Health Services binds this service even when no Craft screen is alive. */
50
+ class CraftPassiveStepsService : PassiveListenerService() {
51
+ override fun onNewDataPointsReceived(dataPoints: DataPointContainer) {
52
+ val storage = CraftPassiveStepStore(this)
53
+ val boot = Instant.ofEpochMilli(System.currentTimeMillis() - SystemClock.elapsedRealtime())
54
+ for (point in dataPoints.getData(DataType.STEPS_DAILY)) {
55
+ storage.save(point.value, point.getStartInstant(boot).toEpochMilli(), point.getEndInstant(boot).toEpochMilli(), ZoneId.systemDefault().id)
56
+ }
57
+ }
58
+ override fun onPermissionLost() { CraftPassiveStepStore(this).setEnabled(false) }
59
+ }
60
+
61
+ internal interface CraftPassiveStepProvider {
62
+ fun supported(): Boolean
63
+ fun start()
64
+ fun stop()
65
+ fun discardRegistration() { stop() }
66
+ }
67
+
68
+ private class CraftHealthPassiveProvider(context: Context) : CraftPassiveStepProvider {
69
+ private val client = HealthServices.getClient(context).passiveMonitoringClient
70
+ override fun supported(): Boolean = DataType.STEPS_DAILY in client.getCapabilitiesAsync().get(10, TimeUnit.SECONDS).supportedDataTypesPassiveMonitoring
71
+ override fun start() {
72
+ client.setPassiveListenerServiceAsync(CraftPassiveStepsService::class.java,
73
+ PassiveListenerConfig.builder().setDataTypes(setOf(DataType.STEPS_DAILY)).build()).get(10, TimeUnit.SECONDS)
74
+ }
75
+ override fun stop() { client.clearPassiveListenerServiceAsync().get(10, TimeUnit.SECONDS) }
76
+ override fun discardRegistration() { client.clearPassiveListenerServiceAsync() }
77
+ }
78
+
79
+ internal class CraftPassiveSteps(
80
+ private val context: Context,
81
+ private val provider: CraftPassiveStepProvider = CraftHealthPassiveProvider(context),
82
+ private val permission: () -> Boolean = { ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED },
83
+ ) {
84
+ private val storage = CraftPassiveStepStore(context)
85
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
86
+
87
+ fun perform(method: String, done: (CraftNativeCapabilityResult) -> Unit) {
88
+ // These bounded provider calls run on a worker: no main-thread binder wait.
89
+ executor.execute {
90
+ val result = try {
91
+ when (method) {
92
+ "history" -> CraftNativeCapabilityResult(data = storage.history())
93
+ "availability" -> CraftNativeCapabilityResult(data = JSONObject()
94
+ .put("supported", provider.supported())
95
+ .put("enabled", storage.enabled()).put("permissionGranted", permission()))
96
+ "start", "restore" -> {
97
+ if (method == "restore" && !storage.enabled()) CraftNativeCapabilityResult(data = false)
98
+ else if (!permission()) failure("PERMISSION_DENIED", "Activity recognition access is required")
99
+ else if (!provider.supported())
100
+ failure("UNAVAILABLE", "This device has no passive daily steps provider")
101
+ else {
102
+ // Persist intent before binding; the service may receive data immediately.
103
+ if (!storage.setEnabled(true)) failure("STORAGE_ERROR", "Passive registration intent could not be saved")
104
+ else {
105
+ try {
106
+ provider.start()
107
+ CraftNativeCapabilityResult(data = true)
108
+ } catch (error: Exception) {
109
+ if (method == "start") storage.setEnabled(false)
110
+ runCatching { provider.discardRegistration() }
111
+ throw error
112
+ }
113
+ }
114
+ }
115
+ }
116
+ "stop" -> {
117
+ if (!storage.setEnabled(false)) failure("STORAGE_ERROR", "Passive registration intent could not be cleared")
118
+ else {
119
+ provider.stop()
120
+ CraftNativeCapabilityResult(data = true)
121
+ }
122
+ }
123
+ else -> failure("UNKNOWN_ACTION", "Unknown passive pedometer action")
124
+ }
125
+ } catch (error: Exception) {
126
+ failure("UNAVAILABLE", error.cause?.message ?: error.message ?: "Health Services is unavailable")
127
+ }
128
+ ContextCompat.getMainExecutor(context).execute { done(result) }
129
+ }
130
+ }
131
+ companion object {
132
+ // Serialize app-wide start/stop commands, including commands from different routes.
133
+ private val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
134
+ }
135
+ }
136
+
137
+ /** Registrations do not survive a reboot. Restore only explicit saved opt-in. */
138
+ class CraftPassiveStepsBootReceiver : BroadcastReceiver() {
139
+ override fun onReceive(context: Context, intent: Intent) {
140
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED || !CraftPassiveStepStore(context).enabled()) return
141
+ WorkManager.getInstance(context).enqueueUniqueWork("craft-passive-steps-restore", ExistingWorkPolicy.KEEP,
142
+ OneTimeWorkRequestBuilder<CraftPassiveStepsRestoreWorker>().build())
143
+ }
144
+ }
145
+
146
+ class CraftPassiveStepsRestoreWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
147
+ override fun doWork(): Result {
148
+ if (!CraftPassiveStepStore(applicationContext).enabled()) return Result.success()
149
+ val finished = java.util.concurrent.CountDownLatch(1)
150
+ var answer: CraftNativeCapabilityResult? = null
151
+ CraftPassiveSteps(applicationContext).perform("restore") { answer = it; finished.countDown() }
152
+ if (!finished.await(25, TimeUnit.SECONDS)) return Result.retry()
153
+ return when (answer?.code) {
154
+ null -> Result.success()
155
+ "PERMISSION_DENIED" -> { CraftPassiveStepStore(applicationContext).setEnabled(false); Result.failure() }
156
+ else -> if (runAttemptCount < 3) Result.retry() else Result.failure()
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,156 @@
1
+ package com.craft.runtime
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 android.os.PowerManager
11
+ import org.json.JSONArray
12
+ import org.json.JSONObject
13
+ import java.io.File
14
+ import java.util.UUID
15
+
16
+ /** Native-only journal: JS suspension cannot drop pressure readings from a route. */
17
+ object CraftRecordingPressure {
18
+ private fun file(context: Context, id: String): File {
19
+ val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8))
20
+ return File(context.filesDir, "craft-pressure-${digest.joinToString("") { "%02x".format(it) }}.jsonl")
21
+ }
22
+ fun requireAvailable(context: Context) {
23
+ val config = context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
24
+ if (!config.optBoolean("enableBarometer") || !config.optBoolean("enableBackgroundLocation"))
25
+ throw CraftRecordingException("CAPABILITY_DISABLED", "Recording pressure requires enableBarometer and enableBackgroundLocation")
26
+ val manager = context.getSystemService(SensorManager::class.java)
27
+ if (manager?.getDefaultSensor(Sensor.TYPE_PRESSURE, true) == null && manager?.getDefaultSensor(Sensor.TYPE_PRESSURE) == null)
28
+ throw CraftRecordingException("UNAVAILABLE", "Barometer is not present")
29
+ }
30
+ // Callers also hold the recording-store lock to serialize owner/ACK changes.
31
+ fun append(context: Context, id: String, event: JSONObject) {
32
+ val bytes = (event.put("recordingId", id).toString() + "\n").toByteArray(Charsets.UTF_8)
33
+ java.io.FileOutputStream(file(context, id), true).use { it.write(bytes); it.fd.sync() }
34
+ }
35
+ fun read(context: Context, id: String): JSONArray {
36
+ val result = JSONArray()
37
+ val source = file(context, id)
38
+ if (id.isEmpty() || !source.exists()) return result
39
+ source.forEachLine { line ->
40
+ if (line.isNotBlank()) {
41
+ val event = JSONObject(line)
42
+ check(event.getString("recordingId") == id) { "Invalid pressure recording owner" }
43
+ result.put(event)
44
+ }
45
+ }
46
+ return result
47
+ }
48
+ fun delete(context: Context, id: String) {
49
+ val source = file(context, id)
50
+ if (source.exists() && !source.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged pressure recording")
51
+ }
52
+ }
53
+
54
+ internal interface CraftRecordingPressureSource {
55
+ fun open(sample: (Float, Long) -> Unit): AutoCloseable
56
+ }
57
+
58
+ private class AndroidRecordingPressureSource(private val context: Context) : CraftRecordingPressureSource {
59
+ override fun open(sample: (Float, Long) -> Unit): AutoCloseable {
60
+ CraftRecordingPressure.requireAvailable(context)
61
+ val manager = context.getSystemService(SensorManager::class.java)
62
+ ?: throw IllegalStateException("Sensor manager unavailable")
63
+ val sensor = manager.getDefaultSensor(Sensor.TYPE_PRESSURE, true)
64
+ ?: manager.getDefaultSensor(Sensor.TYPE_PRESSURE)
65
+ ?: throw IllegalStateException("Barometer unavailable")
66
+ // A non-wake-up sensor cannot deliver continuously during CPU suspend.
67
+ // Only the explicitly requested, unpaused foreground recording owns this lock.
68
+ val wake = if (!sensor.isWakeUpSensor) context.getSystemService(PowerManager::class.java)
69
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "craft:recording-pressure").apply { setReferenceCounted(false) } else null
70
+ val listener = object : SensorEventListener {
71
+ override fun onSensorChanged(event: SensorEvent) { sample(event.values.firstOrNull() ?: Float.NaN, event.timestamp) }
72
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
73
+ }
74
+ try {
75
+ wake?.acquire()
76
+ check(manager.registerListener(listener, sensor, 1_000_000, Handler(Looper.getMainLooper()))) { "Pressure registration rejected" }
77
+ } catch (error: Exception) {
78
+ manager.unregisterListener(listener)
79
+ if (wake?.isHeld == true) wake.release()
80
+ throw error
81
+ }
82
+ return AutoCloseable {
83
+ try { manager.unregisterListener(listener) }
84
+ finally { if (wake?.isHeld == true) wake.release() }
85
+ }
86
+ }
87
+ }
88
+
89
+ /** One service-owned segment, independent of native screen and WebView lifetimes. */
90
+ internal class CraftRecordingPressureSession(
91
+ private val context: Context,
92
+ val id: String,
93
+ private val source: CraftRecordingPressureSource = AndroidRecordingPressureSource(context),
94
+ ) {
95
+ val epoch = CraftLocationRecordingStore.pressureEpoch
96
+ private var registration: AutoCloseable? = null
97
+ private var closed = false
98
+ private var started = false
99
+ private var reference: Float? = null
100
+ private var lastTimestamp: Long? = null
101
+ private var segment = UUID.randomUUID().toString()
102
+
103
+ private fun ownsRecording() = epoch == CraftLocationRecordingStore.pressureEpoch && CraftLocationRecordingStore.recordingId(context) == id &&
104
+ CraftLocationRecordingStore.isActive(context) && !CraftLocationRecordingStore.isPaused(context) &&
105
+ CraftLocationRecordingStore.pressureEnabled(context)
106
+
107
+ fun start() = synchronized(CraftLocationRecordingStore) {
108
+ if (closed || started || !ownsRecording()) return@synchronized
109
+ started = true
110
+ try {
111
+ write(JSONObject().put("state", "segment").put("reason", "startedOrRecovered"))
112
+ val acquired = source.open(::sample)
113
+ if (closed) acquired.close() else registration = acquired
114
+ } catch (error: Exception) { fail(error) }
115
+ }
116
+
117
+ private fun write(event: JSONObject) {
118
+ CraftRecordingPressure.append(context, id, event.put("segmentId", segment).put("timestamp", System.currentTimeMillis()))
119
+ }
120
+
121
+ private fun sample(pressure: Float, timestamp: Long) = synchronized(CraftLocationRecordingStore) {
122
+ if (closed || !ownsRecording()) return@synchronized
123
+ try {
124
+ require(pressure.isFinite() && pressure > 0 && timestamp >= 0) { "Invalid pressure sample" }
125
+ val previous = lastTimestamp
126
+ if (previous != null && timestamp <= previous) return@synchronized
127
+ if (previous != null && timestamp - previous < 1_000_000_000L) return@synchronized
128
+ if (previous != null && timestamp - previous > 3_000_000_000L) {
129
+ segment = UUID.randomUUID().toString()
130
+ reference = null
131
+ write(JSONObject().put("state", "segment").put("reason", "sampleGap").put("timestampMs", timestamp / 1_000_000.0))
132
+ }
133
+ val baseline = reference ?: pressure.also { reference = it }
134
+ val altitude = SensorManager.getAltitude(baseline, pressure)
135
+ require(altitude.isFinite()) { "Invalid pressure altitude" }
136
+ write(JSONObject().put("state", "sample").put("pressureHpa", pressure)
137
+ .put("referencePressureHpa", baseline).put("relativeAltitudeMeters", altitude)
138
+ .put("timestampMs", timestamp / 1_000_000.0))
139
+ lastTimestamp = timestamp
140
+ } catch (error: Exception) { fail(error) }
141
+ }
142
+
143
+ private fun fail(error: Exception) {
144
+ close()
145
+ runCatching { write(JSONObject().put("state", "stopped").put("reason", "sensorOrStorageError")) }
146
+ CraftLocationRecordingStore.failPressure(context, id, error.message ?: "Pressure recording failed")
147
+ }
148
+
149
+ fun close() {
150
+ if (closed) return
151
+ closed = true
152
+ runCatching { registration?.close() }
153
+ .onFailure { android.util.Log.e("CraftLocation", "Unable to unregister pressure listener", it) }
154
+ registration = null
155
+ }
156
+ }