@craft-native/android 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.436",
3
+ "version": "0.0.443",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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() }
@@ -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
+ }