@craft-native/android 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.
- package/dist/index.d.ts +8 -0
- package/dist/index.js +18 -3
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/templates/CraftHealthServices.kt.template +2 -0
- package/templates/CraftNativeCapabilities.kt.template +42 -1
- package/templates/CraftNativeMotion.kt.template +269 -0
- package/templates/CraftNativeServices.kt.template +6 -1
- package/templates/CraftPassiveSteps.kt.template +159 -0
- package/templates/CraftRecordingPressure.kt.template +156 -0
- package/templates/LocationRecordingService.kt.template +77 -9
- package/templates/MainActivityNative.kt.template +129 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|