craft-native 0.0.246 → 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.
- package/dist/android/src/index.d.ts +9 -0
- package/dist/android/src/index.js +11 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +136 -1
- package/dist/android/templates/CraftHeartRate.kt.template +938 -0
- package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
- package/dist/android/templates/CraftNativeServices.kt.template +34 -1
- package/dist/android/templates/CraftNativeShell.kt.template +20 -6
- package/dist/api/mobile.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/mobile-testing.js +19 -1
- package/dist/testing/media-cache/media-cache.kt +4 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|