craft-native 0.0.245 → 0.0.247

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.
@@ -0,0 +1,938 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.app.Activity
6
+ import android.app.NotificationChannel
7
+ import android.app.NotificationManager
8
+ import android.app.Service
9
+ import android.bluetooth.BluetoothAdapter
10
+ import android.bluetooth.BluetoothDevice
11
+ import android.bluetooth.BluetoothGatt
12
+ import android.bluetooth.BluetoothGattCallback
13
+ import android.bluetooth.BluetoothGattCharacteristic
14
+ import android.bluetooth.BluetoothGattDescriptor
15
+ import android.bluetooth.BluetoothManager
16
+ import android.bluetooth.BluetoothProfile
17
+ import android.bluetooth.BluetoothStatusCodes
18
+ import android.bluetooth.le.ScanCallback
19
+ import android.bluetooth.le.ScanFilter
20
+ import android.bluetooth.le.ScanResult
21
+ import android.bluetooth.le.ScanSettings
22
+ import android.content.BroadcastReceiver
23
+ import android.content.Context
24
+ import android.content.Intent
25
+ import android.content.IntentFilter
26
+ import android.content.pm.PackageManager
27
+ import android.content.pm.ServiceInfo
28
+ import android.os.Build
29
+ import android.os.Handler
30
+ import android.os.IBinder
31
+ import android.os.Looper
32
+ import android.os.ParcelUuid
33
+ import android.util.Log
34
+ import androidx.core.app.ActivityCompat
35
+ import androidx.core.app.NotificationCompat
36
+ import androidx.core.app.ServiceCompat
37
+ import androidx.core.content.ContextCompat
38
+ import org.json.JSONArray
39
+ import org.json.JSONObject
40
+ import org.json.JSONTokener
41
+ import java.io.File
42
+ import java.util.UUID
43
+ import kotlin.math.roundToInt
44
+ import kotlin.math.roundToLong
45
+ import kotlin.math.sin
46
+
47
+ /** Maps and lists as org.json values, null as JSONObject.NULL, for the bridge and the disk. */
48
+ internal fun craftHeartRateToJson(value: Any?): Any = when (value) {
49
+ null -> JSONObject.NULL
50
+ is JSONObject, is JSONArray -> value
51
+ is Map<*, *> -> JSONObject().also { json -> value.forEach { (key, item) -> json.put(key.toString(), craftHeartRateToJson(item)) } }
52
+ is List<*> -> JSONArray().also { json -> value.forEach { json.put(craftHeartRateToJson(it)) } }
53
+ else -> value
54
+ }
55
+
56
+ /** org.json values back as maps and lists, JSONObject.NULL as null. */
57
+ internal fun craftHeartRateFromJson(value: Any?): Any? = when {
58
+ value == null || value === JSONObject.NULL -> null
59
+ value is JSONObject -> value.keys().asSequence().associateWith { craftHeartRateFromJson(value.opt(it)) }
60
+ value is JSONArray -> (0 until value.length()).map { craftHeartRateFromJson(value.opt(it)) }
61
+ else -> value
62
+ }
63
+
64
+ internal object CraftHeartRateOrgJson : CraftHeartRateJson {
65
+ override fun encode(value: Any?): String = craftHeartRateToJson(value).toString()
66
+ override fun decode(text: String): Any? = craftHeartRateFromJson(JSONTokener(text).nextValue())
67
+ }
68
+
69
+ /**
70
+ * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate, read
71
+ * during a workout: the page's `craft.heartRate` (CraftBridge.kt) and a
72
+ * native screen's `HeartRate` module (CraftNativeServices.kt), one service
73
+ * for the whole process. The Android half of CraftHeartRate.swift, with the
74
+ * same methods, answers and `craftHeartRate` events.
75
+ *
76
+ * A GATT central for the standard Heart Rate Service (0x180D):
77
+ * - `scan` finds what advertises the service nearby, each one a
78
+ * `{type: 'device'}` event with its name and signal strength.
79
+ * - `connect` subscribes to Heart Rate Measurement (0x2A37), writing its
80
+ * client configuration descriptor; every reading is a `{type: 'reading'}`
81
+ * event with bpm and, when the strap sends them, RR intervals. The device
82
+ * (its address) is remembered, and `reconnect` asks for it again next time
83
+ * without a scan.
84
+ * - A strap that drops out (out of range, the battery, a sweaty contact) is
85
+ * asked for again until `disconnect`: the old GATT client is closed and an
86
+ * automatic connection, which never times out, waits for the strap, spaced
87
+ * out by craftHeartRateReconnectDelayMs when the stack itself fails. A strap
88
+ * this process has not seen in a scan is also looked for with a short
89
+ * filtered scan, since Android needs to have seen a random address first.
90
+ * - `startRecording` keeps every reading on disk until `stopRecording`
91
+ * (CraftHeartRateRecording, CraftHeartRateCore.kt). While it runs,
92
+ * CraftHeartRateService keeps the process in the foreground (a
93
+ * connectedDevice service), so readings keep coming with the screen locked,
94
+ * and the samples survive the app being closed.
95
+ *
96
+ * Connection changes are `{type: 'state'}` events. Every event goes to each
97
+ * sink: the page's bridge (`page`) and the native screens' hub (`native`).
98
+ *
99
+ * Emulators: with a file named `CraftFakeHeartRateMonitor` in the app's files
100
+ * directory (`adb shell run-as <package> touch files/CraftFakeHeartRateMonitor`)
101
+ * a made-up strap stands in, whose readings are encoded as 0x2A37 payloads
102
+ * and decoded like a real one's, its RR intervals varying with the breath.
103
+ * Never on a device.
104
+ *
105
+ * Everything runs on the main thread: GATT and scan callbacks hop there first.
106
+ */
107
+ @SuppressLint("MissingPermission")
108
+ internal class CraftHeartRate private constructor(private val context: Context) {
109
+ companion object {
110
+ val SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
111
+ val MEASUREMENT: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
112
+ /** Client Characteristic Configuration: notifications are switched on here. */
113
+ val CLIENT_CONFIGURATION: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
114
+ /** How long `connect` waits before answering TIMEOUT. The strap is still asked for after that. */
115
+ const val CONNECT_TIMEOUT_MS = 15_000L
116
+ /** A reading older than this is no longer "now". */
117
+ const val STALE_AFTER_MS = 10_000L
118
+ /** How long a strap not yet seen is looked for in a scan, while its connection is pending. */
119
+ const val SEEK_MS = 30_000L
120
+ const val PERMISSION_REQUEST = 4319
121
+ /** The stand-in strap's id: the iOS simulator's, so a test names it the same way on both. */
122
+ const val SIMULATED_ID = "00000000-0000-4000-8000-00000000180D"
123
+ private const val PREFERENCES = "craft-heart-rate"
124
+ private const val TAG = "CraftHeartRate"
125
+
126
+ @Volatile private var instance: CraftHeartRate? = null
127
+
128
+ fun get(context: Context): CraftHeartRate = instance ?: synchronized(this) {
129
+ instance ?: CraftHeartRate(context.applicationContext).also { instance = it }
130
+ }
131
+
132
+ /** Whether this is an emulator: a hint for the stand-in strap, never a security boundary. */
133
+ fun isEmulator(): Boolean {
134
+ val hardware = Build.HARDWARE.lowercase()
135
+ if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true
136
+ val fingerprint = Build.FINGERPRINT.lowercase()
137
+ if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true
138
+ if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true
139
+ val product = Build.PRODUCT.lowercase()
140
+ if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true
141
+ val model = Build.MODEL.lowercase()
142
+ return model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for")
143
+ }
144
+ }
145
+
146
+ private class Found(val device: BluetoothDevice?, val name: String, val rssi: Int)
147
+
148
+ private val main = Handler(Looper.getMainLooper())
149
+ private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
150
+ private val manager get() = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
151
+ private val adapter: BluetoothAdapter? get() = manager?.adapter
152
+ private val clock: () -> Long = System::currentTimeMillis
153
+
154
+ /** Where each event goes, by who listens: `page` (CraftBridge) and `native` (the screens' hub). */
155
+ val sinks = java.util.concurrent.ConcurrentHashMap<String, (JSONObject) -> Unit>()
156
+
157
+ val recording = CraftHeartRateRecording(context.noBackupFilesDir, CraftHeartRateOrgJson, clock)
158
+
159
+ private val discovered = linkedMapOf<String, Found>()
160
+ private var scanning = false
161
+ private var scannerRunning = false
162
+ private var scanStop: Runnable? = null
163
+ /** The remembered strap's address while it is looked for in a scan. */
164
+ private var seeking: String? = null
165
+ private var seekStop: Runnable? = null
166
+ private var gatt: BluetoothGatt? = null
167
+ private var device: BluetoothDevice? = null
168
+ private var deviceId: String? = null
169
+ private var deviceName: String? = null
170
+ private var connection = "disconnected"
171
+ /** Between connect and disconnect: a lost strap is asked for again. */
172
+ private var wantsConnection = false
173
+ private var linkConnected = false
174
+ private val connectWaiters = mutableListOf<(Result<Any?>) -> Unit>()
175
+ private var connectDeadline: Runnable? = null
176
+ private var latest: CraftHeartRateMeasurement? = null
177
+ private var latestAt: Long? = null
178
+ // Packet freshness is separate from a valid BPM: a monitor reporting no
179
+ // skin contact is still delivering notifications and needs no reconnect.
180
+ private var packetAt: Long? = null
181
+ private var subscriptionAt: Long? = null
182
+ private var recoveryTimer: Runnable? = null
183
+ private var reconnectTimer: Runnable? = null
184
+ private var reconnectAttempt = 0
185
+ private var hasSubscribed = false
186
+ private var radioWatched = false
187
+ private val permissionWaiters = mutableListOf<(Boolean) -> Unit>()
188
+
189
+ // MARK: The emulator's stand-in
190
+
191
+ private val simulated: Boolean by lazy { isEmulator() && File(context.filesDir, "CraftFakeHeartRateMonitor").exists() }
192
+ private var simulatedTimer: Runnable? = null
193
+ private var simulatedBeat = 0
194
+ /** Milliseconds since the stand-in connected, and how far its beats reach. */
195
+ private var simulatedElapsed = 0.0
196
+ private var simulatedBeatsUntil = 0.0
197
+
198
+ // MARK: Entry
199
+
200
+ /**
201
+ * One call from a page or a native screen, by method name. `activity` is
202
+ * the screen that can show Android's permission prompt; null in a
203
+ * background worker, where nothing can ask.
204
+ */
205
+ fun perform(method: String, options: JSONObject, activity: Activity?, done: (Result<Any?>) -> Unit) {
206
+ @Suppress("UNCHECKED_CAST")
207
+ val values = craftHeartRateFromJson(options) as? Map<String, Any?> ?: emptyMap()
208
+ val answer: (Result<Any?>) -> Unit = { result -> done(result.map { craftHeartRateToJson(it) }) }
209
+ main.post {
210
+ watchRadio()
211
+ try {
212
+ if (method in CraftHeartRateRecording.METHODS) {
213
+ val value = recording.perform(method, values)
214
+ keepRecordingAlive()
215
+ return@post answer(Result.success(value))
216
+ }
217
+ recording.restore()
218
+ when (method) {
219
+ "getState" -> {
220
+ recoverQuietBroadcast()
221
+ answer(Result.success(state()))
222
+ }
223
+ "requestAuthorization" -> requestAuthorization(activity, answer)
224
+ "scan" -> scan((values["timeout"] as? Number)?.toDouble(), activity, answer)
225
+ "stopScan" -> {
226
+ stopScan()
227
+ answer(Result.success(state()))
228
+ }
229
+ "getDevices" -> answer(Result.success(devices()))
230
+ "connect" -> connect(values["id"] as? String, wait = true, activity = activity, done = answer)
231
+ "reconnect" -> connect(null, wait = false, activity = activity, done = answer)
232
+ "disconnect" -> disconnect(values["forget"] == true, answer)
233
+ else -> throw CraftHeartRateException("UNKNOWN_ACTION", "Unsupported HeartRate method")
234
+ }
235
+ } catch (error: CraftHeartRateException) {
236
+ answer(Result.failure(error))
237
+ } catch (error: SecurityException) {
238
+ answer(Result.failure(CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed")))
239
+ } catch (error: Exception) {
240
+ Log.w(TAG, "HeartRate.$method failed", error)
241
+ answer(Result.failure(CraftHeartRateException("CRAFT_ERROR", error.message ?: "Heart rate failed")))
242
+ }
243
+ }
244
+ }
245
+
246
+ /** CraftHeartRateService, restarted by Android: the recording carries on, and so does its strap. */
247
+ fun resumeAfterRestart() {
248
+ main.post {
249
+ watchRadio()
250
+ runCatching { recording.restore() }.onFailure { Log.w(TAG, "Unable to restore the heart-rate recording", it) }
251
+ keepRecordingAlive()
252
+ if (recording.active && !wantsConnection && permitted()) connect(null, wait = false, activity = null) {}
253
+ }
254
+ }
255
+
256
+ // MARK: State
257
+
258
+ private fun permissions(): Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
259
+ arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
260
+ } else {
261
+ // Before Android 12 a BLE scan needs location; BLUETOOTH and
262
+ // BLUETOOTH_ADMIN are granted at install.
263
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
264
+ }
265
+
266
+ private fun permitted(): Boolean = simulated || permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
267
+
268
+ private val authorization: String
269
+ get() = if (simulated) "authorized" else craftHeartRateAuthorizationName(permitted(), preferences.getBoolean("asked", false))
270
+
271
+ private val bluetooth: String
272
+ get() {
273
+ if (simulated) return "poweredOn"
274
+ val radio = adapter
275
+ val radioState = try { radio?.state } catch (_: SecurityException) { null }
276
+ return craftHeartRateBluetoothName(radio != null, radioState, authorization)
277
+ }
278
+
279
+ private fun remembered(): Map<String, Any?>? {
280
+ val id = preferences.getString("lastDeviceId", null) ?: return null
281
+ return mapOf("id" to id, "name" to (preferences.getString("lastDeviceName", null) ?: "Heart rate monitor"))
282
+ }
283
+
284
+ private fun remember(id: String, name: String) {
285
+ preferences.edit().putString("lastDeviceId", id).putString("lastDeviceName", name).apply()
286
+ }
287
+
288
+ fun state(): Map<String, Any?> {
289
+ val fresh = latestAt?.let { clock() - it <= STALE_AFTER_MS } ?: false
290
+ return mapOf(
291
+ "available" to (bluetooth != "unsupported"),
292
+ "bluetooth" to bluetooth,
293
+ "authorization" to authorization,
294
+ "scanning" to scanning,
295
+ "connection" to connection,
296
+ "device" to deviceId?.let { mapOf("id" to it, "name" to (deviceName ?: "Heart rate monitor")) },
297
+ "lastDevice" to remembered(),
298
+ "bpm" to (if (fresh) latest?.bpm else null),
299
+ "lastReadingAt" to latestAt,
300
+ "recording" to recording.summary(false),
301
+ )
302
+ }
303
+
304
+ private fun emit(detail: Map<String, Any?>) {
305
+ val json = craftHeartRateToJson(detail) as JSONObject
306
+ sinks.values.forEach { sink -> runCatching { sink(json) }.onFailure { Log.w(TAG, "A heart-rate listener failed", it) } }
307
+ }
308
+
309
+ private fun emitState() = emit(state() + ("type" to "state"))
310
+
311
+ // MARK: Bluetooth
312
+
313
+ /**
314
+ * Android's Nearby devices prompt (location before Android 12), asked for
315
+ * from a screen. Without one, the answer is what was already allowed.
316
+ */
317
+ private fun withPermission(activity: Activity?, then: (Boolean) -> Unit) {
318
+ if (permitted()) return then(true)
319
+ if (activity == null) return then(false)
320
+ permissionWaiters.add(then)
321
+ if (permissionWaiters.size > 1) return
322
+ preferences.edit().putBoolean("asked", true).apply()
323
+ ActivityCompat.requestPermissions(activity, permissions(), PERMISSION_REQUEST)
324
+ }
325
+
326
+ /** The screen's permission answer (MainActivity routes PERMISSION_REQUEST here). */
327
+ fun onPermissions(requestCode: Int): Boolean {
328
+ if (requestCode != PERMISSION_REQUEST) return false
329
+ main.post {
330
+ val granted = permitted()
331
+ val waiting = permissionWaiters.toList()
332
+ permissionWaiters.clear()
333
+ waiting.forEach { it(granted) }
334
+ emitState()
335
+ }
336
+ return true
337
+ }
338
+
339
+ /** A reason the radio cannot be used now, or null when it can. */
340
+ private fun unusable(): CraftHeartRateException? {
341
+ if (!permitted()) return CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed")
342
+ return when (bluetooth) {
343
+ "poweredOn" -> null
344
+ "poweredOff" -> CraftHeartRateException("BLUETOOTH_OFF", "Bluetooth is off")
345
+ "unauthorized" -> CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed")
346
+ "unsupported" -> CraftHeartRateException("UNSUPPORTED", "This device has no Bluetooth")
347
+ else -> CraftHeartRateException("BLUETOOTH_UNAVAILABLE", "Bluetooth is not ready")
348
+ }
349
+ }
350
+
351
+ /** Asks when it has to, then runs `run` with a radio that can be used, or answers why not. */
352
+ private fun whenReady(activity: Activity?, done: (Result<Any?>) -> Unit, run: () -> Unit) {
353
+ withPermission(activity) {
354
+ val reason = unusable()
355
+ if (reason != null) done(Result.failure(reason))
356
+ else try { run() } catch (_: SecurityException) { done(Result.failure(CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed"))) }
357
+ }
358
+ }
359
+
360
+ private fun requestAuthorization(activity: Activity?, done: (Result<Any?>) -> Unit) {
361
+ withPermission(activity) { done(Result.success(authorization)) }
362
+ }
363
+
364
+ /** Bluetooth turned off and on again: the strap is asked for again, a scan stops. */
365
+ private val radioReceiver = object : BroadcastReceiver() {
366
+ override fun onReceive(context: Context, intent: Intent) {
367
+ if (intent.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
368
+ val radioState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
369
+ main.post {
370
+ if (radioState == BluetoothAdapter.STATE_ON) {
371
+ if (wantsConnection && gatt == null && !simulated) {
372
+ connection = "reconnecting"
373
+ openGatt(automatic = true)
374
+ }
375
+ updateScanner()
376
+ } else if (radioState == BluetoothAdapter.STATE_OFF || radioState == BluetoothAdapter.STATE_TURNING_OFF) {
377
+ scanning = false
378
+ scannerRunning = false
379
+ scanStop?.let(main::removeCallbacks)
380
+ scanStop = null
381
+ if (!simulated) {
382
+ closeGatt()
383
+ if (wantsConnection && connection != "disconnected") connection = "reconnecting"
384
+ }
385
+ }
386
+ emitState()
387
+ }
388
+ }
389
+ }
390
+
391
+ private fun watchRadio() {
392
+ if (radioWatched) return
393
+ radioWatched = true
394
+ // Exported: a protected broadcast only the system sends. Not-exported
395
+ // would, before Android 13, filter out the Bluetooth process sending it.
396
+ ContextCompat.registerReceiver(context, radioReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED)
397
+ }
398
+
399
+ // MARK: Scanning
400
+
401
+ private fun devices(): List<Map<String, Any?>> = discovered
402
+ .map { (id, found) -> mapOf<String, Any?>("id" to id, "name" to found.name, "rssi" to found.rssi) }
403
+ .sortedByDescending { it["rssi"] as Int }
404
+
405
+ private fun found(id: String, device: BluetoothDevice?, name: String, rssi: Int) {
406
+ val known = discovered.containsKey(id)
407
+ discovered[id] = Found(device, name, rssi)
408
+ if (!known && scanning) emit(mapOf("type" to "device", "device" to mapOf("id" to id, "name" to name, "rssi" to rssi)))
409
+ }
410
+
411
+ private fun scan(seconds: Double?, activity: Activity?, done: (Result<Any?>) -> Unit) {
412
+ whenReady(activity, done) {
413
+ discovered.clear()
414
+ scanning = true
415
+ if (simulated) found(SIMULATED_ID, null, "Simulated HRM", -48)
416
+ else updateScanner()
417
+ scanStop?.let(main::removeCallbacks)
418
+ scanStop = Runnable { stopScan() }.also { main.postDelayed(it, ((seconds ?: 15.0).coerceIn(1.0, 60.0) * 1000).roundToLong()) }
419
+ emitState()
420
+ done(Result.success(state()))
421
+ }
422
+ }
423
+
424
+ private fun stopScan() {
425
+ scanStop?.let(main::removeCallbacks)
426
+ scanStop = null
427
+ if (!scanning) return
428
+ scanning = false
429
+ updateScanner()
430
+ emitState()
431
+ }
432
+
433
+ /** One LE scan, filtered on 0x180D, while a person scans or a remembered strap is looked for. */
434
+ private fun updateScanner() {
435
+ if (simulated) return
436
+ val wanted = (scanning || seeking != null) && permitted() && adapter?.isEnabled == true
437
+ if (wanted == scannerRunning) return
438
+ val scanner = adapter?.bluetoothLeScanner
439
+ if (wanted) {
440
+ if (scanner == null) return
441
+ val filters = listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build())
442
+ val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
443
+ scannerRunning = runCatching { scanner.startScan(filters, settings, scanCallback) }
444
+ .onFailure { Log.w(TAG, "Heart-rate scan could not start", it) }
445
+ .isSuccess
446
+ } else {
447
+ runCatching { scanner?.stopScan(scanCallback) }
448
+ scannerRunning = false
449
+ }
450
+ }
451
+
452
+ private val scanCallback = object : ScanCallback() {
453
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
454
+ val address = result.device.address
455
+ val advertised = result.scanRecord?.deviceName
456
+ val rssi = result.rssi
457
+ main.post {
458
+ val name = advertised ?: runCatching { result.device.name }.getOrNull() ?: "Heart rate monitor"
459
+ // 127 is "no reading".
460
+ found(address, result.device, name, if (rssi == 127) -100 else rssi)
461
+ if (seeking == address) {
462
+ stopSeeking()
463
+ if (wantsConnection && deviceId == address && !linkConnected) {
464
+ device = result.device
465
+ openGatt(automatic = false)
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ override fun onScanFailed(errorCode: Int) {
472
+ main.post {
473
+ Log.w(TAG, "Heart-rate scan failed ($errorCode)")
474
+ scannerRunning = false
475
+ if (scanning) {
476
+ scanning = false
477
+ emitState()
478
+ }
479
+ }
480
+ }
481
+ }
482
+
483
+ private fun seek(address: String) {
484
+ if (simulated) return
485
+ seeking = address
486
+ seekStop?.let(main::removeCallbacks)
487
+ seekStop = Runnable { stopSeeking() }.also { main.postDelayed(it, SEEK_MS) }
488
+ updateScanner()
489
+ }
490
+
491
+ private fun stopSeeking() {
492
+ seekStop?.let(main::removeCallbacks)
493
+ seekStop = null
494
+ if (seeking == null) return
495
+ seeking = null
496
+ updateScanner()
497
+ }
498
+
499
+ // MARK: Connecting
500
+
501
+ private fun connect(id: String?, wait: Boolean, activity: Activity?, done: (Result<Any?>) -> Unit) {
502
+ val target = id ?: remembered()?.get("id") as? String
503
+ if (target == null) {
504
+ if (wait) done(Result.failure(CraftHeartRateException("INVALID_ARGUMENT", "connect needs the id of a device from scan")))
505
+ else done(Result.success(state()))
506
+ return
507
+ }
508
+ whenReady(activity, done) {
509
+ // A connected link can lose its notification subscription when a
510
+ // broadcasting watch restarts. Keep healthy links, repair quiet ones.
511
+ if (deviceId == target && wantsConnection) {
512
+ recoverQuietBroadcast()
513
+ if (connection == "connected" || !wait) return@whenReady done(Result.success(state()))
514
+ connectWaiters.add(done)
515
+ armConnectDeadline()
516
+ return@whenReady
517
+ }
518
+ release()
519
+ val saved = remembered()
520
+ val savedName = if (saved?.get("id") == target) saved["name"] as? String else null
521
+ val name = discovered[target]?.name ?: savedName ?: "Heart rate monitor"
522
+ if (simulated) {
523
+ if (target != SIMULATED_ID) return@whenReady done(Result.failure(CraftHeartRateException("NOT_FOUND", "No heart-rate monitor with that id is nearby")))
524
+ } else {
525
+ val next = discovered[target]?.device
526
+ ?: target.takeIf(BluetoothAdapter::checkBluetoothAddress)?.let { adapter?.getRemoteDevice(it) }
527
+ ?: return@whenReady done(Result.failure(CraftHeartRateException("NOT_FOUND", "No heart-rate monitor with that id is nearby")))
528
+ device = next
529
+ }
530
+ deviceId = target
531
+ deviceName = name
532
+ wantsConnection = true
533
+ connection = "connecting"
534
+ subscriptionAt = clock()
535
+ reconnectAttempt = 0
536
+ startRecoveryWatch()
537
+ remember(target, name)
538
+ if (wait) {
539
+ connectWaiters.add(done)
540
+ armConnectDeadline()
541
+ }
542
+ if (simulated) main.postDelayed({ simulatedConnected() }, 300)
543
+ else {
544
+ openGatt(automatic = false)
545
+ // Android connects to an address it has seen; one it has not
546
+ // (a random address, after a restart) is found by a scan.
547
+ if (!discovered.containsKey(target)) seek(target)
548
+ }
549
+ emitState()
550
+ if (!wait) done(Result.success(state()))
551
+ }
552
+ }
553
+
554
+ /** `connect` answers TIMEOUT after a while; the strap is still asked for, since it may wake up once it is worn. */
555
+ private fun armConnectDeadline() {
556
+ if (connectDeadline != null) return
557
+ connectDeadline = Runnable {
558
+ connectDeadline = null
559
+ if (connection != "connected") settleConnect(Result.failure(CraftHeartRateException("TIMEOUT", "The heart-rate monitor did not answer. Wear it to wake it up.")))
560
+ }.also { main.postDelayed(it, CONNECT_TIMEOUT_MS) }
561
+ }
562
+
563
+ private fun settleConnect(result: Result<Any?>) {
564
+ connectDeadline?.let(main::removeCallbacks)
565
+ connectDeadline = null
566
+ val waiting = connectWaiters.toList()
567
+ connectWaiters.clear()
568
+ waiting.forEach { it(result) }
569
+ }
570
+
571
+ /** A GATT client for the chosen strap; `automatic` waits for it as long as it takes. */
572
+ private fun openGatt(automatic: Boolean) {
573
+ val target = device ?: return
574
+ closeGatt()
575
+ reconnectTimer?.let(main::removeCallbacks)
576
+ reconnectTimer = null
577
+ gatt = target.connectGatt(context, automatic, gattCallback, BluetoothDevice.TRANSPORT_LE)
578
+ }
579
+
580
+ /** Lets go of the GATT client: disconnected and closed, so Android frees it. */
581
+ private fun closeGatt() {
582
+ val old = gatt ?: return
583
+ gatt = null
584
+ linkConnected = false
585
+ runCatching { old.disconnect() }
586
+ runCatching { old.close() }
587
+ }
588
+
589
+ /** After a dropped link: ask again, with an automatic connection, once the delay for this attempt has passed. */
590
+ private fun scheduleReconnect() {
591
+ reconnectTimer?.let(main::removeCallbacks)
592
+ reconnectTimer = Runnable {
593
+ reconnectTimer = null
594
+ if (!wantsConnection || gatt != null || simulated) return@Runnable
595
+ if (!permitted() || adapter?.isEnabled != true) return@Runnable
596
+ openGatt(automatic = true)
597
+ deviceId?.takeUnless(discovered::containsKey)?.let { seek(it) }
598
+ }.also { main.postDelayed(it, craftHeartRateReconnectDelayMs(reconnectAttempt++)) }
599
+ }
600
+
601
+ /** Lets go of the current strap without telling anyone. */
602
+ private fun release() {
603
+ wantsConnection = false
604
+ hasSubscribed = false
605
+ recoveryTimer?.let(main::removeCallbacks)
606
+ recoveryTimer = null
607
+ reconnectTimer?.let(main::removeCallbacks)
608
+ reconnectTimer = null
609
+ packetAt = null
610
+ subscriptionAt = null
611
+ simulatedTimer?.let(main::removeCallbacks)
612
+ simulatedTimer = null
613
+ stopSeeking()
614
+ closeGatt()
615
+ device = null
616
+ deviceId = null
617
+ deviceName = null
618
+ latest = null
619
+ latestAt = null
620
+ connection = "disconnected"
621
+ settleConnect(Result.failure(CraftHeartRateException("CANCELLED", "Another heart-rate monitor was chosen")))
622
+ }
623
+
624
+ private fun disconnect(forget: Boolean, done: (Result<Any?>) -> Unit) {
625
+ release()
626
+ if (forget) preferences.edit().remove("lastDeviceId").remove("lastDeviceName").apply()
627
+ emitState()
628
+ done(Result.success(state()))
629
+ }
630
+
631
+ private val gattCallback = object : BluetoothGattCallback() {
632
+ override fun onConnectionStateChange(peer: BluetoothGatt, status: Int, newState: Int) {
633
+ main.post {
634
+ if (peer !== gatt) {
635
+ // A client this service already let go of.
636
+ if (newState == BluetoothProfile.STATE_DISCONNECTED) runCatching { peer.close() }
637
+ return@post
638
+ }
639
+ if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
640
+ linkConnected = true
641
+ stopSeeking()
642
+ if (!wantsConnection) return@post closeGatt()
643
+ packetAt = null
644
+ subscriptionAt = clock()
645
+ if (!peer.discoverServices()) restartBroadcast()
646
+ return@post
647
+ }
648
+ // Dropped, or never made: close this client and ask again.
649
+ latest = null
650
+ latestAt = null
651
+ packetAt = null
652
+ subscriptionAt = clock()
653
+ closeGatt()
654
+ if (wantsConnection) {
655
+ connection = "reconnecting"
656
+ scheduleReconnect()
657
+ } else {
658
+ connection = "disconnected"
659
+ }
660
+ emitState()
661
+ }
662
+ }
663
+
664
+ override fun onServicesDiscovered(peer: BluetoothGatt, status: Int) {
665
+ main.post {
666
+ if (peer !== gatt || !wantsConnection) return@post
667
+ if (status != BluetoothGatt.GATT_SUCCESS) return@post restartBroadcast()
668
+ val measurement = peer.getService(SERVICE)?.getCharacteristic(MEASUREMENT)
669
+ val descriptor = measurement?.getDescriptor(CLIENT_CONFIGURATION)
670
+ if (measurement == null || descriptor == null) {
671
+ if (hasSubscribed) restartBroadcast() else notAHeartRateMonitor()
672
+ return@post
673
+ }
674
+ if (!peer.setCharacteristicNotification(measurement, true) || !writeNotify(peer, descriptor)) restartBroadcast()
675
+ }
676
+ }
677
+
678
+ override fun onDescriptorWrite(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
679
+ main.post {
680
+ if (peer !== gatt || !wantsConnection) return@post
681
+ if (descriptor.uuid != CLIENT_CONFIGURATION || descriptor.characteristic.uuid != MEASUREMENT) return@post
682
+ if (status != BluetoothGatt.GATT_SUCCESS) return@post restartBroadcast()
683
+ hasSubscribed = true
684
+ subscriptionAt = clock()
685
+ reconnectAttempt = 0
686
+ connection = "connected"
687
+ keepRecordingAlive()
688
+ emitState()
689
+ settleConnect(Result.success(state()))
690
+ }
691
+ }
692
+
693
+ // Garmin may invalidate its service when the heart-rate broadcast is
694
+ // toggled without dropping the link (Android 12+ tells us). Discover
695
+ // and subscribe to the replacement.
696
+ override fun onServiceChanged(peer: BluetoothGatt) {
697
+ main.post {
698
+ if (peer !== gatt || !wantsConnection) return@post
699
+ connection = "reconnecting"
700
+ latest = null
701
+ latestAt = null
702
+ packetAt = null
703
+ subscriptionAt = clock()
704
+ if (!peer.discoverServices()) restartBroadcast()
705
+ emitState()
706
+ }
707
+ }
708
+
709
+ // Android 13 and later hand over the value with the notification.
710
+ override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
711
+ notified(peer, characteristic, value.copyOf())
712
+ }
713
+
714
+ @Deprecated("Android 12 and earlier")
715
+ @Suppress("DEPRECATION")
716
+ override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
717
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
718
+ notified(peer, characteristic, characteristic.value?.copyOf() ?: return)
719
+ }
720
+ }
721
+
722
+ private fun notified(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
723
+ if (characteristic.uuid != MEASUREMENT) return
724
+ main.post {
725
+ if (peer !== gatt || !wantsConnection) return@post
726
+ received(value)
727
+ }
728
+ }
729
+
730
+ /** Switches notifications on in the client configuration descriptor. */
731
+ @Suppress("DEPRECATION")
732
+ private fun writeNotify(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor): Boolean {
733
+ val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
734
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
735
+ peer.writeDescriptor(descriptor, enable) == BluetoothStatusCodes.SUCCESS
736
+ } else {
737
+ descriptor.value = enable
738
+ peer.writeDescriptor(descriptor)
739
+ }
740
+ }
741
+
742
+ private fun notAHeartRateMonitor() {
743
+ wantsConnection = false
744
+ hasSubscribed = false
745
+ recoveryTimer?.let(main::removeCallbacks)
746
+ recoveryTimer = null
747
+ closeGatt()
748
+ connection = "disconnected"
749
+ settleConnect(Result.failure(CraftHeartRateException("NOT_HEART_RATE_MONITOR", "That device does not send a heart rate")))
750
+ emitState()
751
+ }
752
+
753
+ /**
754
+ * Run independently of any player or big-timer screen. A pending radio
755
+ * connection stays pending until the strap returns; only a link that went
756
+ * quiet is restarted.
757
+ */
758
+ private fun startRecoveryWatch() {
759
+ recoveryTimer?.let(main::removeCallbacks)
760
+ recoveryTimer = object : Runnable {
761
+ override fun run() {
762
+ recoverQuietBroadcast()
763
+ if (recoveryTimer === this) main.postDelayed(this, 5_000)
764
+ }
765
+ }.also { main.postDelayed(it, 5_000) }
766
+ }
767
+
768
+ private fun recoverQuietBroadcast() {
769
+ if (!wantsConnection) return
770
+ val since = packetAt ?: subscriptionAt ?: return
771
+ val link = if (simulated) connection == "connected" else linkConnected
772
+ if (craftHeartRateShouldRestart(connection, link, clock() - since)) restartBroadcast()
773
+ }
774
+
775
+ /**
776
+ * Keep the recording and the device choice while replacing the
777
+ * subscription: a connected link is dropped (its disconnect asks again),
778
+ * anything else is asked for again straight away.
779
+ */
780
+ private fun restartBroadcast() {
781
+ if (!wantsConnection) return
782
+ connection = "reconnecting"
783
+ latest = null
784
+ latestAt = null
785
+ packetAt = null
786
+ subscriptionAt = clock()
787
+ emitState()
788
+ if (simulated) {
789
+ simulatedTimer?.let(main::removeCallbacks)
790
+ simulatedTimer = null
791
+ main.postDelayed({ simulatedConnected() }, 300)
792
+ return
793
+ }
794
+ if (adapter?.isEnabled != true) return
795
+ val current = gatt
796
+ if (current != null && linkConnected) {
797
+ runCatching { current.disconnect() }
798
+ // Should the stack never report the drop, let go of it anyway.
799
+ main.postDelayed({
800
+ if (gatt === current && wantsConnection) {
801
+ closeGatt()
802
+ scheduleReconnect()
803
+ }
804
+ }, 3_000)
805
+ } else {
806
+ closeGatt()
807
+ scheduleReconnect()
808
+ }
809
+ }
810
+
811
+ // MARK: Readings
812
+
813
+ private fun received(bytes: ByteArray) {
814
+ // A strap reads 0 between losing the skin and giving up; that is no
815
+ // heart rate, and neither is anything a heart could not do.
816
+ packetAt = clock()
817
+ val reading = CraftHeartRateMeasurement.parse(bytes)
818
+ if (reading == null || reading.sensorContact == false || reading.bpm < 20 || reading.bpm > 250) {
819
+ latest = null
820
+ latestAt = null
821
+ emitState()
822
+ return
823
+ }
824
+ val now = clock()
825
+ latest = reading
826
+ latestAt = now
827
+ val intervals = reading.rrIntervals.map { (it * 10).roundToLong() / 10.0 }
828
+ val detail = mutableMapOf<String, Any?>(
829
+ "type" to "reading",
830
+ "bpm" to reading.bpm,
831
+ "rrIntervals" to intervals,
832
+ "contact" to reading.sensorContact,
833
+ "energyExpended" to reading.energyExpended,
834
+ "timestamp" to now,
835
+ )
836
+ deviceId?.let { detail["deviceId"] = it }
837
+ recording.append(mapOf("timestamp" to now, "bpm" to reading.bpm, "rr" to intervals))
838
+ emit(detail)
839
+ }
840
+
841
+ private fun simulatedConnected() {
842
+ if (!simulated || !wantsConnection || deviceId != SIMULATED_ID) return
843
+ connection = "connected"
844
+ subscriptionAt = clock()
845
+ emitState()
846
+ settleConnect(Result.success(state()))
847
+ simulatedTimer?.let(main::removeCallbacks)
848
+ simulatedElapsed = 0.0
849
+ simulatedBeatsUntil = 0.0
850
+ simulatedTimer = object : Runnable {
851
+ override fun run() {
852
+ if (simulatedTimer !== this) return
853
+ main.postDelayed(this, 1_000)
854
+ simulatedBeat += 1
855
+ simulatedElapsed += 1000
856
+ // A steady run with a slow swell, the way a real one drifts.
857
+ val rate = 142 + sin(simulatedBeat / 20.0) * 12
858
+ val (intervals, until) = craftHeartRateSimulatedIntervals(rate, simulatedBeatsUntil, simulatedElapsed)
859
+ simulatedBeatsUntil = until
860
+ if (intervals.isEmpty()) return
861
+ // The rate a strap reports is the one its intervals give.
862
+ val bpm = (60_000 / intervals.average()).roundToInt()
863
+ received(CraftHeartRateMeasurement.encode(bpm, sensorContact = true, rrIntervals = intervals))
864
+ }
865
+ }.also { main.postDelayed(it, 1_000) }
866
+ }
867
+
868
+ // MARK: The recording in the foreground
869
+
870
+ /** Whether CraftHeartRateService was asked to keep this recording. */
871
+ private var serviceStarted = false
872
+
873
+ /** A running recording keeps CraftHeartRateService up; a finished one lets it go. */
874
+ private fun keepRecordingAlive() {
875
+ val intent = Intent(context, CraftHeartRateService::class.java)
876
+ if (recording.active) {
877
+ // Android 14 starts a connectedDevice service only with Bluetooth
878
+ // allowed; asked again once it is (a strap subscribed).
879
+ if (serviceStarted || (!permitted() && !simulated)) return
880
+ serviceStarted = runCatching { ContextCompat.startForegroundService(context, intent) }
881
+ .onFailure { Log.w(TAG, "The heart-rate recording could not move to the foreground", it) }
882
+ .isSuccess
883
+ } else {
884
+ // Unconditionally: a relaunched process did not start the one running.
885
+ serviceStarted = false
886
+ runCatching { context.stopService(intent) }
887
+ }
888
+ }
889
+
890
+ /** Whether the service still has a recording to keep. */
891
+ fun keepsRecording(): Boolean = recording.active
892
+ }
893
+
894
+ /**
895
+ * Keeps the process in the foreground while a heart-rate recording runs, as
896
+ * iOS's `bluetooth-central` background mode does: readings keep coming with
897
+ * the screen locked. Declared in the manifest only for an app built with
898
+ * `enableHeartRate`; restarted by Android, it brings the recording and its
899
+ * strap back (CraftHeartRate.resumeAfterRestart).
900
+ */
901
+ class CraftHeartRateService : Service() {
902
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
903
+ val manager = getSystemService(NotificationManager::class.java)
904
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
905
+ manager.createNotificationChannel(NotificationChannel(CHANNEL, "Heart rate", NotificationManager.IMPORTANCE_LOW))
906
+ }
907
+ val notification = NotificationCompat.Builder(this, CHANNEL)
908
+ .setSmallIcon(R.drawable.craft_notification_icon)
909
+ .setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
910
+ .setContentTitle("Recording your heart rate")
911
+ .setContentText("${applicationInfo.loadLabel(packageManager)} is reading your heart-rate monitor")
912
+ .setOngoing(true)
913
+ .setSilent(true)
914
+ .build()
915
+ try {
916
+ val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0
917
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type)
918
+ } catch (error: Exception) {
919
+ Log.w("CraftHeartRate", "The heart-rate service could not start in the foreground", error)
920
+ stopSelf(startId)
921
+ return START_NOT_STICKY
922
+ }
923
+ val monitor = CraftHeartRate.get(this)
924
+ if (intent == null) monitor.resumeAfterRestart()
925
+ else if (!monitor.keepsRecording()) {
926
+ stopSelf(startId)
927
+ return START_NOT_STICKY
928
+ }
929
+ return START_STICKY
930
+ }
931
+
932
+ override fun onBind(intent: Intent?): IBinder? = null
933
+
934
+ companion object {
935
+ private const val CHANNEL = "craft_heart_rate_recording"
936
+ private const val NOTIFICATION_ID = 7002
937
+ }
938
+ }