ReviewOS

also looking at this

craft-native/craft

Android: Bluetooth heart-rate straps, and offline media on WorkManager

#370
Merged chris wants to merge feat/android-heart-rate into main
21 files +2307 -56
packages/android/README.mdmodified+29-0
Changes to packages/android/README.md
@@ -391,6 +391,7 @@ Edit `craft.config.json` in your Android project:
391391 "enableGeolocation": true,
392392 "enableBackgroundLocation": false,
393393 "enableHealthConnect": false,
394 "enableHeartRate": false,
394395 "enableKeepAwake": true,
395396 "enableDeepLinks": true,
396397 "urlSchemes": ["myapp"],
@@ -515,6 +516,33 @@ window.addEventListener('craftBluetoothDevice', (e) => {
515516});
516517window.craft.bluetooth.stopScan();
517518
519// Heart rate (enableHeartRate): a Bluetooth strap, or a watch broadcasting its
520// heart rate, through the standard Heart Rate Service (0x180D) and its Heart
521// Rate Measurement (0x2A37): bpm, sensor contact, energy and RR intervals. The
522// same API, answers and events as iOS (CraftHeartRate.kt). Scanning and
523// connecting ask for Nearby devices on Android 12+ (location before), or call
524// requestAuthorization() from a tap to ask first. A strap's id is its address.
525window.addEventListener('craftHeartRate', (event) => {
526 // { type: 'device', device: { id, name, rssi } } a scan found a strap
527 // { type: 'reading', bpm, rrIntervals, contact, energyExpended, timestamp, deviceId }
528 // { type: 'state', connection, bluetooth, device, lastDevice, bpm, ... }
529});
530await window.craft.heartRate.scan({ timeout: 15 });
531const strap = (await window.craft.heartRate.getDevices())[0]; // strongest signal first
532await window.craft.heartRate.connect(strap.id); // remembered; rejects TIMEOUT after 15 s while still asking
533await window.craft.heartRate.reconnect(); // next time: the remembered strap, no scan
534// A strap that drops out is asked for again (connection 'reconnecting') with
535// an automatic GATT connection until disconnect({ forget }) lets it go.
536await window.craft.heartRate.startRecording(); // every reading kept natively, app closed or not
537const { samples } = await window.craft.heartRate.stopRecording(); // [{ timestamp, bpm, rr? }]
538// While a recording runs a connectedDevice foreground service (and its
539// notification) keeps the process reading with the screen locked. Native
540// screens call the same service as the HeartRate module (capability
541// `heartRate`) and hear the same events. On an emulator,
542// `adb shell run-as <package> touch files/CraftFakeHeartRateMonitor` stands in
543// a strap ("Simulated HRM", id 00000000-0000-4000-8000-00000000180D) whose
544// readings take the 0x2A37 path, as the iOS simulator's does.
545
518546// NFC Tag Reading
519547const nfcData = await window.craft.scanNFC();
520548console.log(nfcData); // Tag content
@@ -847,6 +875,7 @@ The generator adds manifest permissions from enabled capabilities:
847875| `enablePushNotifications` | `POST_NOTIFICATIONS` |
848876| `enableKeepAwake` | `WAKE_LOCK` |
849877| `enableHealthConnect` | the configured Health Connect read/write permissions |
878| `enableHeartRate` | `BLUETOOTH_SCAN` (`neverForLocation`), `BLUETOOTH_CONNECT`; `BLUETOOTH`, `BLUETOOTH_ADMIN` and `ACCESS_FINE_LOCATION` up to Android 11; `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_CONNECTED_DEVICE` for the recording's service |
850879
851880Use `permissions.check('location')` and `permissions.request('location')` from
852881`craft-native/mobile` for runtime authorization. Android precise and
packages/android/compile-fixtures/CraftNativeResumableDownloadTest.ktmodified+47-24
Changes to packages/android/compile-fixtures/CraftNativeResumableDownloadTest.kt
@@ -1,8 +1,8 @@
11package {{PACKAGE_NAME}}
22
3import com.sun.net.httpserver.HttpServer
43import java.io.File
5import java.net.InetSocketAddress
4import java.net.InetAddress
5import java.net.ServerSocket
66import java.nio.file.Files
77import org.junit.Assert.assertArrayEquals
88import org.junit.Assert.assertEquals
@@ -19,32 +19,46 @@ import org.junit.Test
1919class CraftNativeResumableDownloadTest {
2020 private val body = "0123456789abcdefghij".toByteArray()
2121
22 /** Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records each request's Range. */
22 /**
23 * Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records
24 * each request's Range. A plain socket: Android's unit-test classpath has
25 * no com.sun.net.httpserver.
26 */
2327 private fun serve(ranges: Boolean = true, status: Int = 200, check: (String, MutableList<String?>) -> Unit) {
24 val seen = mutableListOf<String?>()
25 val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
26 server.createContext("/") { exchange ->
27 val range = exchange.requestHeaders.getFirst("Range")
28 seen.add(range)
29 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
30 when {
31 status != 200 -> exchange.sendResponseHeaders(status, -1)
32 ranges && start != null && start >= body.size -> exchange.sendResponseHeaders(416, -1)
33 ranges && start != null -> {
34 exchange.responseHeaders.add("Content-Range", "bytes $start-${body.size - 1}/${body.size}")
35 exchange.sendResponseHeaders(206, (body.size - start).toLong())
36 exchange.responseBody.use { it.write(body, start, body.size - start) }
37 }
38 else -> {
39 exchange.sendResponseHeaders(200, body.size.toLong())
40 exchange.responseBody.use { it.write(body) }
28 val seen = java.util.Collections.synchronizedList(mutableListOf<String?>())
29 val server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
30 val thread = Thread {
31 while (!server.isClosed) {
32 val socket = runCatching { server.accept() }.getOrNull() ?: break
33 socket.use { client ->
34 val reader = client.getInputStream().bufferedReader(Charsets.ISO_8859_1)
35 val lines = generateSequence { reader.readLine() }.takeWhile { it.isNotEmpty() }.toList()
36 val range = lines.drop(1).firstOrNull { it.startsWith("Range:", ignoreCase = true) }?.substringAfter(':')?.trim()
37 seen.add(range)
38 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
39 val out = client.getOutputStream()
40 fun head(code: Int, length: Int, extra: String = "") =
41 out.write("HTTP/1.1 $code X\r\nContent-Length: $length\r\n${extra}Connection: close\r\n\r\n".toByteArray(Charsets.ISO_8859_1))
42 when {
43 status != 200 -> head(status, 0)
44 ranges && start != null && start >= body.size -> head(416, 0)
45 ranges && start != null -> {
46 head(206, body.size - start, "Content-Range: bytes $start-${body.size - 1}/${body.size}\r\n")
47 out.write(body, start, body.size - start)
48 }
49 else -> {
50 head(200, body.size)
51 out.write(body)
52 }
53 }
54 out.flush()
4155 }
4256 }
43 exchange.close()
4457 }
45 server.start()
46 try { check("http://127.0.0.1:${server.address.port}/clip.mp4", seen) }
47 finally { server.stop(0) }
58 thread.isDaemon = true
59 thread.start()
60 try { check("http://127.0.0.1:${server.localPort}/clip.mp4", seen) }
61 finally { server.close(); thread.join(2000) }
4862 }
4963
5064 private fun part(check: (File) -> Unit) {
@@ -100,6 +114,15 @@ class CraftNativeResumableDownloadTest {
100114 }
101115 }
102116
117 @Test
118 fun refusesAPartNobodyAskedFor() = part { file ->
119 serve(status = 206) { address, seen ->
120 assertEquals(CraftResumableDownload.Fetched.Refused(206), CraftResumableDownload().fetch(address, emptyMap(), file) { false })
121 assertEquals(listOf<String?>(null), seen)
122 assertFalse(file.exists())
123 }
124 }
125
103126 @Test
104127 fun keepsWhatItHasWhenStopped() = part { file ->
105128 serve { address, _ ->
packages/android/scripts/compile-templates.tsmodified+4-0
Changes to packages/android/scripts/compile-templates.ts
@@ -50,6 +50,8 @@ const fixtures: CompileFixture[] = [
5050 config: {
5151 renderer: 'native',
5252 enableHealthConnect: true,
53 // A native screen's HeartRate module (CraftHeartRate.kt).
54 enableHeartRate: true,
5355 enableHaptics: true,
5456 enableShare: true,
5557 enableSecureStorage: true,
@@ -83,6 +85,8 @@ const fixtures: CompileFixture[] = [
8385 enableGeolocation: true,
8486 enableHaptics: true,
8587 enableHealthConnect: true,
88 // The page's craft.heartRate and its foreground service.
89 enableHeartRate: true,
8690 enableKeepAwake: true,
8791 enablePushNotifications: true,
8892 enableSecureStorage: true,
packages/android/src/fixtures/heart-rate.ktadded+316-0
Changes to packages/android/src/fixtures/heart-rate.kt
@@ -0,0 +1,316 @@
1// Runs after CraftHeartRateCore.kt.template (src/heart-rate.test.ts): the
2// 0x2A37 decoder, the stand-in strap and the recording kept on disk, against
3// the vectors and cases of iOS's HeartRateUnitTests.swift.
4
5import java.io.File
6import java.nio.file.Files
7import kotlin.math.abs
8import kotlin.math.sqrt
9
10/** org.json stands in for this on a phone; the JVM has none, so a small codec. */
11internal object FixtureJson : CraftHeartRateJson {
12 override fun encode(value: Any?): String = when (value) {
13 null -> "null"
14 is String -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
15 is Boolean, is Int, is Long -> value.toString()
16 is Double -> if (value == Math.floor(value) && abs(value) < 1e15) value.toLong().toString() else value.toString()
17 is Map<*, *> -> value.entries.joinToString(",", "{", "}") { encode(it.key.toString()) + ":" + encode(it.value) }
18 is List<*> -> value.joinToString(",", "[", "]") { encode(it) }
19 else -> error("Cannot encode ${value::class}")
20 }
21
22 override fun decode(text: String): Any? {
23 var at = 0
24 fun space() { while (at < text.length && text[at].isWhitespace()) at++ }
25 fun value(): Any? {
26 space()
27 val c = text[at]
28 return when {
29 c == '{' -> {
30 at++
31 val map = linkedMapOf<String, Any?>()
32 space()
33 if (text[at] == '}') { at++; return map }
34 while (true) {
35 val key = value() as String
36 space(); check(text[at++] == ':')
37 map[key] = value()
38 space()
39 if (text[at++] == '}') break
40 }
41 map
42 }
43 c == '[' -> {
44 at++
45 val list = mutableListOf<Any?>()
46 space()
47 if (text[at] == ']') { at++; return list }
48 while (true) {
49 list.add(value())
50 space()
51 if (text[at++] == ']') break
52 }
53 list
54 }
55 c == '"' -> {
56 at++
57 val out = StringBuilder()
58 while (text[at] != '"') {
59 if (text[at] == '\\') at++
60 out.append(text[at++])
61 }
62 at++
63 out.toString()
64 }
65 text.startsWith("true", at) -> { at += 4; true }
66 text.startsWith("false", at) -> { at += 5; false }
67 text.startsWith("null", at) -> { at += 4; null }
68 else -> {
69 val start = at
70 while (at < text.length && (text[at].isDigit() || text[at] in "+-.eE")) at++
71 val number = text.substring(start, at)
72 number.toLongOrNull() ?: number.toDouble()
73 }
74 }
75 }
76 return value()
77 }
78}
79
80fun bytes(vararg values: Int) = IntArray(values.size) { values[it] }
81
82fun expectCode(code: String, label: String, run: () -> Unit) {
83 try {
84 run()
85 } catch (error: CraftHeartRateException) {
86 check(error.code == code) { "$label: expected $code, got ${error.code} (${error.message})" }
87 return
88 }
89 error("$label: expected $code, got an answer")
90}
91
92fun directory(): File = Files.createTempDirectory("craft-heart-rate-").toFile()
93
94internal fun recorder(directory: File, writer: (ByteArray, File) -> Unit = ::craftHeartRateWriteAtomically) =
95 CraftHeartRateRecording(directory, FixtureJson, { 1_800_000_000_000L }, writer)
96
97@Suppress("UNCHECKED_CAST")
98fun map(value: Any?) = value as Map<String, Any?>
99
100@Suppress("UNCHECKED_CAST")
101fun list(value: Any?) = value as List<Map<String, Any?>>
102
103fun measurements() {
104 // A uint8 rate with nothing else.
105 check(CraftHeartRateMeasurement.parse(bytes(0x00, 72)) == CraftHeartRateMeasurement(72, null, null, emptyList()))
106 // A uint16 rate.
107 check(CraftHeartRateMeasurement.parse(bytes(0x01, 0x2C, 0x01))?.bpm == 300)
108 // Sensor contact only when the strap reports it.
109 check(CraftHeartRateMeasurement.parse(bytes(0x06, 80))?.sensorContact == true)
110 check(CraftHeartRateMeasurement.parse(bytes(0x04, 80))?.sensorContact == false)
111 check(CraftHeartRateMeasurement.parse(bytes(0x02, 80))?.sensorContact == null)
112 // 150 bpm, 258 kJ, RR 1024/1024 s and 512/1024 s, in milliseconds.
113 val reading = CraftHeartRateMeasurement.parse(bytes(0x18, 150, 0x02, 0x01, 0x00, 0x04, 0x00, 0x02))
114 check(reading?.bpm == 150)
115 check(reading?.energyExpended == 258)
116 check(reading?.rrIntervals == listOf(1000.0, 500.0)) { "RR intervals: ${reading?.rrIntervals}" }
117 // A trailing odd byte is not half an interval.
118 check(CraftHeartRateMeasurement.parse(bytes(0x10, 60, 0x00, 0x04, 0x01))?.rrIntervals == listOf(1000.0))
119 // A payload shorter than its flags say is no reading.
120 check(CraftHeartRateMeasurement.parse(IntArray(0)) == null)
121 check(CraftHeartRateMeasurement.parse(bytes(0x01, 0x2C)) == null)
122 check(CraftHeartRateMeasurement.parse(bytes(0x08, 60, 0x01)) == null)
123 // Bytes as Android hands them over: signed.
124 check(CraftHeartRateMeasurement.parse(byteArrayOf(0x00, 0xC8.toByte()))?.bpm == 200)
125 // The stand-in's payloads decode like a strap's.
126 val simulated = CraftHeartRateMeasurement.parse(CraftHeartRateMeasurement.encode(142, sensorContact = true, rrIntervals = listOf(422.5)))
127 check(simulated?.bpm == 142)
128 check(simulated?.sensorContact == true)
129 check(abs((simulated?.rrIntervals?.first() ?: 0.0) - 422.5) <= 1)
130 check(CraftHeartRateMeasurement.parse(CraftHeartRateMeasurement.encode(260))?.bpm == 260)
131 check(CraftHeartRateMeasurement.parse(CraftHeartRateMeasurement.encode(90, sensorContact = null, energyExpended = 12))
132 == CraftHeartRateMeasurement(90, null, 12, emptyList()))
133}
134
135fun simulatedBeats() {
136 val (intervals, reached) = craftHeartRateSimulatedIntervals(60.0, 0.0, 30_000.0) { 0.0 }
137 check(reached >= 30_000)
138 check(abs(intervals.sum() - reached) < 0.001)
139 // About a second apart at 60 bpm, closer on the in-breath and wider on the out-breath.
140 check(abs(intervals.average() - 1000) <= 15) { "mean ${intervals.average()}" }
141 val spread = intervals.max() - intervals.min()
142 check(spread > 50 && spread < 90) { "spread $spread" }
143 // Successive beats differ, so RMSSD is not zero.
144 val squares = intervals.zipWithNext { a, b -> (b - a) * (b - a) }
145 check(sqrt(squares.average()) > 5)
146}
147
148fun decisions() {
149 // A healthy subscription is kept; a connected link quiet for 20 s is rebuilt.
150 check(!craftHeartRateShouldRestart("connected", true, 20_000))
151 check(craftHeartRateShouldRestart("connected", true, 20_001))
152 // A link that came up but never subscribed: after 30 s.
153 check(!craftHeartRateShouldRestart("connecting", true, 30_000))
154 check(craftHeartRateShouldRestart("reconnecting", true, 31_000))
155 // A pending connection waits for the strap however long it takes.
156 check(!craftHeartRateShouldRestart("reconnecting", false, 600_000))
157 check((0..8).map(::craftHeartRateReconnectDelayMs) == listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L, 30_000L, 30_000L, 30_000L))
158 check(craftHeartRateReconnectDelayMs(-3) == 1_000L)
159 check(craftHeartRateAuthorizationName(granted = true, asked = false) == "authorized")
160 check(craftHeartRateAuthorizationName(granted = false, asked = false) == "notDetermined")
161 check(craftHeartRateAuthorizationName(granted = false, asked = true) == "denied")
162 check(craftHeartRateBluetoothName(false, null, "authorized") == "unsupported")
163 check(craftHeartRateBluetoothName(true, 12, "denied") == "unauthorized")
164 check(craftHeartRateBluetoothName(true, 12, "notDetermined") == "poweredOn")
165 check(craftHeartRateBluetoothName(true, 10, "authorized") == "poweredOff")
166 check(craftHeartRateBluetoothName(true, 11, "authorized") == "resetting")
167 check(craftHeartRateBluetoothName(true, 13, "authorized") == "resetting")
168 check(craftHeartRateBluetoothName(true, null, "authorized") == "unknown")
169}
170
171fun startingAgainPreservesTheActiveRecording() {
172 val dir = directory()
173 val first = recorder(dir)
174 val id = map(first.perform("startRecording", emptyMap()))["id"] as String
175 val samples = "{\"timestamp\":1000,\"bpm\":140}\n"
176 val file = File(dir, "craft-heart-rate-recording.jsonl")
177 file.writeText(samples)
178 // Restore through the disk reader, as after a relaunch.
179 val restored = recorder(dir)
180 expectCode("RECORDING_ALREADY_ACTIVE", "second start") { restored.perform("startRecording", emptyMap()) }
181 val state = map(restored.perform("getRecordingState", emptyMap()))
182 check(state["id"] == id && state["active"] == true && state["sampleCount"] == 1) { "state $state" }
183 check(file.readText() == samples)
184 dir.deleteRecursively()
185}
186
187fun staleIdentityCannotTouchAnotherRecording() {
188 val dir = directory()
189 val monitor = recorder(dir)
190 val oldId = map(monitor.perform("startRecording", emptyMap()))["id"] as String
191 monitor.perform("stopRecording", mapOf("expectedId" to oldId))
192 val newId = map(monitor.perform("startRecording", emptyMap()))["id"] as String
193 check(oldId != newId)
194 val samples = "{\"timestamp\":2000,\"bpm\":150}\n"
195 val file = File(dir, "craft-heart-rate-recording.jsonl")
196 file.writeText(samples)
197 val restored = recorder(dir)
198 for (method in listOf("pauseRecording", "resumeRecording")) {
199 expectCode("RECORDING_CONFLICT", method) { restored.perform(method, mapOf("expectedId" to oldId)) }
200 val state = map(restored.perform("getRecordingState", emptyMap()))
201 check(state["id"] == newId && state["active"] == true && state["paused"] == false) { "$method: $state" }
202 check(file.readText() == samples)
203 }
204 restored.perform("pauseRecording", mapOf("expectedId" to newId))
205 check(!restored.append(mapOf("timestamp" to 2500L, "bpm" to 151)))
206 restored.perform("resumeRecording", mapOf("expectedId" to newId))
207 check(list(restored.perform("readRecording", mapOf("expectedId" to newId))).first()["bpm"] == 150L)
208 val stopped = map(restored.perform("stopRecording", mapOf("expectedId" to newId)))
209 check(stopped["id"] == newId && stopped["active"] == false && list(stopped["samples"]).size == 1) { "stopped $stopped" }
210 check(file.readText() == samples)
211 val completed = recorder(dir)
212 val recovered = map(completed.perform("getRecordingState", emptyMap()))
213 check(recovered["id"] == newId && recovered["active"] == false)
214 check(list(map(completed.perform("stopRecording", mapOf("expectedId" to newId)))["samples"]).size == 1)
215 completed.perform("startRecording", emptyMap())
216 // The old one was archived by the start and still reads back.
217 check(list(completed.perform("readRecording", mapOf("expectedId" to newId))).size == 1)
218 dir.deleteRecursively()
219}
220
221fun plannedIdentityAndSamples() {
222 val dir = directory()
223 val monitor = recorder(dir)
224 expectCode("INVALID_ARGUMENT", "blank recordingId") { monitor.perform("startRecording", mapOf("recordingId" to " ")) }
225 expectCode("INVALID_ARGUMENT", "acknowledge without expectedId") { monitor.perform("acknowledgeRecording", emptyMap()) }
226 expectCode("NO_ACTIVE_RECORDING", "pause with nothing running") { monitor.perform("pauseRecording", emptyMap()) }
227 val started = map(monitor.perform("startRecording", mapOf("recordingId" to "planned")))
228 check(started["id"] == "planned" && started["startedAt"] == 1_800_000_000_000L && started["sampleCount"] == 0) { "started $started" }
229 check(monitor.append(mapOf("timestamp" to 1000L, "bpm" to 140, "rr" to listOf(428.7, 431.2))))
230 check(monitor.append(mapOf("timestamp" to 2000L, "bpm" to 141)))
231 val since = list(monitor.perform("readRecording", mapOf("since" to 1000L)))
232 check(since.size == 1 && since.first()["timestamp"] == 2000L) { "since $since" }
233 expectCode("RECORDING_CONFLICT", "stale read") { monitor.perform("readRecording", mapOf("expectedId" to "other")) }
234 expectCode("INVALID_ARGUMENT", "blank expectedId") { monitor.perform("getRecordingState", mapOf("expectedId" to "")) }
235 expectCode("RECORDING_ALREADY_ACTIVE", "acknowledge a running one") { monitor.perform("acknowledgeRecording", mapOf("expectedId" to "planned")) }
236 val stopped = map(monitor.perform("stopRecording", mapOf("expectedId" to "planned")))
237 check(list(stopped["samples"]).first()["rr"] == listOf(428.7, 431.2)) { "rr ${stopped["samples"]}" }
238 // An ID is never used twice, even after its recording ended.
239 expectCode("RECORDING_CONFLICT", "reused ID") { monitor.perform("startRecording", mapOf("recordingId" to "planned")) }
240 check(monitor.perform("acknowledgeRecording", mapOf("expectedId" to "planned")) == true)
241 check(!File(dir, "craft-heart-rate-recording.jsonl").exists())
242 expectCode("RECORDING_CONFLICT", "reused acknowledged ID") { recorder(dir).perform("startRecording", mapOf("recordingId" to "planned")) }
243 expectCode("RECORDING_CONFLICT", "unknown acknowledgement") { monitor.perform("acknowledgeRecording", mapOf("expectedId" to "never")) }
244 dir.deleteRecursively()
245}
246
247fun completedOwnerSurvivesAnotherStartUntilAcknowledged() {
248 val dir = directory()
249 val first = recorder(dir)
250 val a = map(first.perform("startRecording", emptyMap()))["id"] as String
251 val file = File(dir, "craft-heart-rate-recording.jsonl")
252 file.writeText("{\"timestamp\":1000,\"bpm\":140}\n")
253 first.perform("stopRecording", mapOf("expectedId" to a))
254 // The app dies before it durably checkpoints A's stop response.
255 val second = recorder(dir)
256 val b = map(second.perform("startRecording", emptyMap()))["id"] as String
257 check(a != b)
258 val dataB = "{\"timestamp\":2000,\"bpm\":170}\n"
259 file.writeText(dataB)
260 val restored = recorder(dir)
261 val stateA = map(restored.perform("getRecordingState", mapOf("expectedId" to a)))
262 check(stateA["id"] == a && stateA["active"] == false && !stateA.containsKey("samples")) { "A $stateA" }
263 check(list(restored.perform("readRecording", mapOf("expectedId" to a))).first()["bpm"] == 140L)
264 check(map(restored.perform("stopRecording", mapOf("expectedId" to a)))["id"] == a)
265 check(file.readText() == dataB)
266 for (method in listOf("pauseRecording", "resumeRecording")) {
267 expectCode("RECORDING_CONFLICT", "stale owner $method") { restored.perform(method, mapOf("expectedId" to a)) }
268 }
269 check(restored.perform("acknowledgeRecording", mapOf("expectedId" to a)) == true)
270 check(restored.perform("acknowledgeRecording", mapOf("expectedId" to a)) == true)
271 expectCode("RECORDING_NOT_FOUND", "acknowledged data reappeared") { restored.perform("readRecording", mapOf("expectedId" to a)) }
272 check(map(restored.perform("getRecordingState", mapOf("expectedId" to b)))["active"] == true)
273 check(file.readText() == dataB)
274 dir.deleteRecursively()
275}
276
277fun archiveFailureRefusesReplacement() {
278 val dir = directory()
279 val id = "a-recording"
280 File(dir, "craft-heart-rate-recording-state.json").writeText("{\"id\":\"$id\",\"active\":false,\"paused\":false,\"startedAt\":1000}")
281 val samples = "{\"timestamp\":1000,\"bpm\":140}\n"
282 val file = File(dir, "craft-heart-rate-recording.jsonl")
283 file.writeText(samples)
284 // A real failed archive write, rather than a successful empty backup.
285 val failing = recorder(dir) { _, _ -> throw java.io.IOException("No space left on device") }
286 expectCode("RECORDING_STORAGE_ERROR", "replacement ignored archive failure") { failing.perform("startRecording", emptyMap()) }
287 check(file.readText() == samples)
288 val state = map(failing.perform("getRecordingState", emptyMap()))
289 check(state["id"] == id && state["active"] == false)
290 // The archive's own name taken by a directory fails the same way.
291 val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray()).joinToString("") { "%02x".format(it) }
292 File(dir, "craft-heart-rate-completed-$digest.json").mkdirs()
293 expectCode("RECORDING_STORAGE_ERROR", "unreadable archive") { recorder(dir).perform("startRecording", emptyMap()) }
294 check(file.readText() == samples)
295 dir.deleteRecursively()
296}
297
298fun missingMetadataIsAnError() {
299 val dir = directory()
300 File(dir, "craft-heart-rate-recording.jsonl").writeText("{\"timestamp\":1000,\"bpm\":140}\n")
301 expectCode("RECORDING_STORAGE_ERROR", "samples without state") { recorder(dir).perform("getRecordingState", emptyMap()) }
302 dir.deleteRecursively()
303}
304
305fun main() {
306 measurements()
307 simulatedBeats()
308 decisions()
309 startingAgainPreservesTheActiveRecording()
310 staleIdentityCannotTouchAnotherRecording()
311 plannedIdentityAndSamples()
312 completedOwnerSurvivesAnotherStartUntilAcknowledged()
313 archiveFailureRefusesReplacement()
314 missingMetadataIsAnError()
315 println("Android heart rate: parser, stand-in, reconnect and recording passed")
316}
packages/android/src/heart-rate.test.tsadded+17-0
Changes to packages/android/src/heart-rate.test.ts
@@ -0,0 +1,17 @@
1/** Run the Android heart-rate core (CraftHeartRateCore.kt) on the JVM against iOS's HeartRateUnitTests vectors. */
2import { test } from 'bun:test'
3import { runKotlinFixture } from './testing'
4
5/** The core template and the fixture as one file: one package-less source, its imports first. */
6async function heartRateFixtureSource(): Promise<string> {
7 const core = await Bun.file(new URL('../templates/CraftHeartRateCore.kt.template', import.meta.url)).text()
8 const fixture = await Bun.file(new URL('./fixtures/heart-rate.kt', import.meta.url)).text()
9 if (!core.startsWith('package \n')) throw new Error('CraftHeartRateCore.kt.template no longer starts with its package')
10 const lines = `${core.replace('package \n', '')}\n${fixture}`.split('\n')
11 const imports = [...new Set(lines.filter(line => line.startsWith('import ')))]
12 return [...imports, ...lines.filter(line => !line.startsWith('import '))].join('\n')
13}
14
15test('decodes 0x2A37 payloads and keeps the recording exactly as iOS does', async () => {
16 await runKotlinFixture(await heartRateFixtureSource())
17}, 240_000)
packages/android/src/index.test.tsmodified+70-2
Changes to packages/android/src/index.test.ts
@@ -1482,9 +1482,12 @@ describe('Craft Android builder', () => {
14821482 const shell = readFileSync(join(output, 'app/src/main/java/dev/craft/media/CraftNativeShell.kt'), 'utf8')
14831483 const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')
14841484 expect(gradle).toContain('androidx.work:work-runtime-ktx')
1485 // A job per address and connection rule, joined rather than started twice, run when its network is there.
1485 // A job per address and connection rule: a running one is joined, one waiting (or in a retry's backoff) runs now.
14861486 expect(shell).toContain('internal class CraftNativeMediaWorker(')
1487 expect(shell).toContain('work.enqueueUniqueWork(workName(address, wifiOnly), androidx.work.ExistingWorkPolicy.KEEP, request)')
1487 expect(shell).toContain('work.enqueueUniqueWork(name, if (running) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request)')
1488 // A prune never stops the transfer a keep still waits on; a 206 nobody asked for is refused.
1489 expect(shell).toContain('owner in wanted || owner in active')
1490 expect(shell).toContain('if (status == 206 && have == 0L) return Fetched.Refused(status)')
14881491 expect(shell).toContain('if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED')
14891492 // Resumed from the part file an earlier attempt left.
14901493 expect(shell).toContain('connection.setRequestProperty("Range", "bytes=$have-")')
@@ -2477,3 +2480,68 @@ it('emits native Bluetooth GATT support and versioned permissions only when enab
24772480 }
24782481 finally { rmSync(output, { recursive: true, force: true }) }
24792482})
2483
2484it('reads a heart-rate strap on the page and on native screens alike, declared only when enabled', async () => {
2485 for (const renderer of ['native', 'web'] as const) {
2486 const output = createTempDir(`craft-android-heart-rate-${renderer}-`)
2487 const off = createTempDir(`craft-android-heart-rate-off-${renderer}-`)
2488 try {
2489 await init({ name: 'Strap', output, packageName: 'dev.craft.strap', runtimeDir: null, config: { renderer, enableHeartRate: true } })
2490 const java = join(output, 'app/src/main/java/dev/craft/strap')
2491 const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8')
2492 // Nearby devices on Android 12+, never for location; location below it.
2493 expect(manifest).toContain('<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />')
2494 expect(manifest).toContain('<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />')
2495 expect(manifest).toContain('<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />')
2496 expect(manifest).toContain('<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />')
2497 // The recording keeps the process in the foreground as a connectedDevice service.
2498 expect(manifest).toContain('<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />')
2499 expect(manifest).toContain('<service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />')
2500 const service = readFileSync(join(java, 'CraftHeartRate.kt'), 'utf8')
2501 const core = readFileSync(join(java, 'CraftHeartRateCore.kt'), 'utf8')
2502 expect(service.startsWith('package dev.craft.strap\n')).toBe(true)
2503 expect(core.startsWith('package dev.craft.strap\n')).toBe(true)
2504 expect(service).toContain('UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")')
2505 expect(service).toContain('UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")')
2506 expect(service).toContain('peer.setCharacteristicNotification(measurement, true)')
2507 expect(service).toContain('BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE')
2508 // A dropped link: the old client closed, an automatic connection asked for.
2509 expect(service).toMatch(/closeGatt\(\)\n\s+if \(wantsConnection\) \{\n\s+connection = "reconnecting"\n\s+scheduleReconnect\(\)/)
2510 expect(service).toContain('openGatt(automatic = true)')
2511 // The stand-in strap is an emulator's only.
2512 expect(service).toContain('isEmulator() && File(context.filesDir, "CraftFakeHeartRateMonitor").exists()')
2513 const bridge = readFileSync(join(java, 'CraftBridge.kt'), 'utf8')
2514 expect(bridge).toContain('heartRate: true,')
2515 expect(bridge).toContain('private val heartRateEnabled = true')
2516 if (renderer === 'native') {
2517 const services = readFileSync(join(java, 'CraftNativeServices.kt'), 'utf8')
2518 expect(services).toContain('if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))')
2519 expect(services).toContain('"Location", "Media", "CrashReporter", "HeartRate",')
2520 expect(services).toContain('if (!heartRateEnabled) return done(failure("CAPABILITY_DISABLED", "Heart rate is disabled"))')
2521 expect(services).toContain('CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHeartRate").put("detail", detail))')
2522 expect(JSON.parse(readFileSync(join(output, 'app/src/main/assets/craft.config.json'), 'utf8')).enableHeartRate).toBe(true)
2523 }
2524
2525 // Without the flag the sources still compile in (the bridge names
2526 // them), but nothing is declared, advertised or asked for.
2527 await init({ name: 'Plain', output: off, packageName: 'dev.craft.plain', runtimeDir: null, config: { renderer } })
2528 const plain = readFileSync(join(off, 'app/src/main/AndroidManifest.xml'), 'utf8')
2529 expect(plain).not.toContain('BLUETOOTH')
2530 expect(plain).not.toContain('CraftHeartRateService')
2531 expect(plain).not.toContain('FOREGROUND_SERVICE_CONNECTED_DEVICE')
2532 expect(existsSync(join(off, 'app/src/main/java/dev/craft/plain/CraftHeartRate.kt'))).toBe(true)
2533 expect(readFileSync(join(off, 'app/src/main/java/dev/craft/plain/CraftBridge.kt'), 'utf8')).toContain('heartRate: false,')
2534 }
2535 finally {
2536 rmSync(output, { recursive: true, force: true })
2537 rmSync(off, { recursive: true, force: true })
2538 }
2539 }
2540})
2541
2542it('declares the Bluetooth permissions once for a strap and a GATT channel together', () => {
2543 const both = renderAndroidPermissions({ appName: 'Both', packageName: 'dev.craft.both', enableBluetooth: true, enableHeartRate: true })
2544 expect(both.match(/BLUETOOTH_CONNECT/g)?.length).toBe(1)
2545 expect(both).toContain('android.permission.FOREGROUND_SERVICE"')
2546 expect(renderAndroidPermissions({ appName: 'Gatt', packageName: 'dev.craft.gatt', enableBluetooth: true })).not.toContain('FOREGROUND_SERVICE_CONNECTED_DEVICE')
2547})
packages/android/src/index.tsmodified+31-1
Changes to packages/android/src/index.ts
@@ -136,6 +136,15 @@ export interface CraftAndroidConfig {
136136 enableGeolocation?: boolean
137137 enableBackgroundLocation?: boolean
138138 enableBluetooth?: boolean
139 /**
140 * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
141 * standard Heart Rate Service), through `craft.heartRate` on a page and the
142 * `HeartRate` module on a native screen (CraftHeartRate.kt): scan, connect,
143 * live readings as `craftHeartRate` events, and a recording kept natively,
144 * in the foreground (a connectedDevice service) while it runs. The iOS
145 * builder's flag of the same name.
146 */
147 enableHeartRate?: boolean
139148 enableHealthConnect?: boolean
140149 /**
141150 * The Health Connect data the app reads and writes, by the shared type
@@ -348,6 +357,7 @@ const DEFAULT_CONFIG: Omit<CraftAndroidConfig, 'appName' | 'packageName'> = {
348357 enableGeolocation: false,
349358 enableBackgroundLocation: false,
350359 enableBluetooth: false,
360 enableHeartRate: false,
351361 enableHealthConnect: false,
352362 enableKeepAwake: false,
353363 enableDeepLinks: false,
@@ -673,7 +683,13 @@ export function renderAndroidPermissions(config: CraftAndroidConfig): string {
673683 if (config.enableHealthConnect) healthConnectPermissions(config).forEach(permission => permissions.add(permission))
674684 // A background refresh (WorkManager) after a restart.
675685 if (config.renderer === 'native' && config.backgroundRefresh?.enabled) permissions.add('android.permission.RECEIVE_BOOT_COMPLETED')
676 const bluetooth = config.enableBluetooth ? [
686 // A heart-rate recording keeps the process in the foreground while it
687 // runs (CraftHeartRateService), as a connectedDevice service.
688 if (config.enableHeartRate) {
689 permissions.add('android.permission.FOREGROUND_SERVICE')
690 permissions.add('android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE')
691 }
692 const bluetooth = config.enableBluetooth || config.enableHeartRate ? [
677693 ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
678694 ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
679695 ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
@@ -1033,6 +1049,7 @@ export async function init(options: InitOptions): Promise<void> {
10331049 .replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake)))
10341050 .replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks)))
10351051 .replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect)))
1052 .replace(/\{\{ENABLE_HEART_RATE\}\}/g, String(Boolean(config.enableHeartRate)))
10361053 .replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? 'import com.google.firebase.messaging.FirebaseMessaging' : '')
10371054 .replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications
10381055 ? `activity.runOnUiThread {
@@ -1079,6 +1096,16 @@ export async function init(options: InitOptions): Promise<void> {
10791096 join(output, 'app/src/main/java', packagePath, 'CraftHealthConnect.kt'),
10801097 healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName),
10811098 )
1099 // The heart-rate strap service, in every app as iOS has CraftHeartRate.swift
1100 // in every app: the page's bridge and the native screens' services refer to
1101 // it. Only `enableHeartRate` registers it, declares its service and asks
1102 // for Bluetooth.
1103 for (const [template, file] of [['CraftHeartRateCore.kt.template', 'CraftHeartRateCore.kt'], ['CraftHeartRate.kt.template', 'CraftHeartRate.kt']]) {
1104 writeFileSync(
1105 join(output, 'app/src/main/java', packagePath, file!),
1106 readFileSync(join(TEMPLATES_DIR, template!), 'utf8').replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName),
1107 )
1108 }
10821109 // Create AndroidManifest.xml
10831110 const manifestTemplate = readFileSync(join(TEMPLATES_DIR, 'AndroidManifest.xml.template'), 'utf-8')
10841111 const manifest = manifestTemplate
@@ -1092,6 +1119,9 @@ export async function init(options: InitOptions): Promise<void> {
10921119 .replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation
10931120 ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />'
10941121 : '')
1122 .replace(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate
1123 ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />'
1124 : '')
10951125 .replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === 'native'
10961126 ? renderNativeShellManifest(config)
10971127 : '')
packages/android/src/page-script.test.tsmodified+89-0
Changes to packages/android/src/page-script.test.ts
@@ -518,3 +518,92 @@ describe('craft.speech on Android', () => {
518518 expect(template).toContain('manager.abandonAudioFocusRequest(request)')
519519 })
520520})
521
522describe('craft.heartRate on Android', () => {
523 // A Bluetooth heart-rate strap (CraftHeartRate.kt), in the iOS page's
524 // names. Every method is one CraftAndroid.heartRate call naming it, with
525 // its options as JSON and an id; Kotlin answers through
526 // window.__craftHeartRateResult / __craftHeartRateError with that id.
527 const call = (page: ReturnType<typeof loadPage>) => {
528 const [method, options, id] = page.argsOf('heartRate') as [string, string, number]
529 return { method, options: JSON.parse(options), id }
530 }
531
532 it('says whether the app was built to read a strap', () => {
533 expect(loadPage(undefined, true).craft.capabilities).toMatchObject({ heartRate: true, heartRateRecordingOwnership: true, heartRateRecordingIntentIdentity: true })
534 expect(loadPage(undefined, false).craft.capabilities).toMatchObject({ heartRate: false, heartRateRecordingOwnership: false, heartRateRecordingIntentIdentity: false })
535 })
536
537 it('sends each call with its method and options, and settles it by id', async () => {
538 const page = loadPage()
539 const state = { available: true, connection: 'connected', bpm: 141 }
540 const now = page.craft.heartRate.getState()
541 expect(call(page)).toMatchObject({ method: 'getState', options: {} })
542 page.page.__craftHeartRateResult(call(page).id, state)
543 expect(await now).toEqual(state)
544
545 void page.craft.heartRate.scan({ timeout: 8 })
546 expect(call(page)).toMatchObject({ method: 'scan', options: { timeout: 8 } })
547 void page.craft.heartRate.scan()
548 expect(call(page)).toMatchObject({ method: 'scan', options: { timeout: null } })
549 for (const id of ['A1:B2:C3:D4:E5:F6', { id: 'A1:B2:C3:D4:E5:F6' }]) {
550 void page.craft.heartRate.connect(id)
551 expect(call(page)).toMatchObject({ method: 'connect', options: { id: 'A1:B2:C3:D4:E5:F6' } })
552 }
553 void page.craft.heartRate.disconnect({ forget: true })
554 expect(call(page)).toMatchObject({ method: 'disconnect', options: { forget: true } })
555 void page.craft.heartRate.readRecording({ since: 1700, expectedId: 'run' })
556 expect(call(page)).toMatchObject({ method: 'readRecording', options: { since: 1700, expectedId: 'run' } })
557
558 for (const method of ['requestAuthorization', 'stopScan', 'getDevices', 'reconnect', 'startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'getRecordingState']) {
559 const done = page.craft.heartRate[method]()
560 expect(call(page).method).toBe(method)
561 page.page.__craftHeartRateResult(call(page).id, { method })
562 expect(await done).toEqual({ method })
563 }
564 })
565
566 it('keeps calls in flight apart, with no deadline for the Nearby devices prompt', async () => {
567 const page = loadPage()
568 const asked = page.craft.heartRate.requestAuthorization()
569 const askedId = call(page).id
570 const connecting = page.craft.heartRate.connect('A1:B2:C3:D4:E5:F6')
571 const connectingId = call(page).id
572 expect(askedId).not.toBe(connectingId)
573 page.page.__craftHeartRateError(connectingId, 'The heart-rate monitor did not answer. Wear it to wake it up.', 'TIMEOUT')
574 page.page.__craftHeartRateResult(askedId, 'authorized')
575 expect(await asked).toBe('authorized')
576 await expect(connecting).rejects.toMatchObject({ code: 'TIMEOUT' })
577 })
578
579 it('hands a build without the capability the refusal Kotlin sends', async () => {
580 const page = loadPage(undefined, false)
581 const scan = page.craft.heartRate.scan()
582 page.page.__craftHeartRateError(call(page).id, 'Heart rate is disabled', 'CAPABILITY_DISABLED')
583 await expect(scan).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED', message: 'Heart rate is disabled' })
584 })
585
586 it('refuses a blank recording identity before it reaches Kotlin', async () => {
587 const page = loadPage()
588 await expect(page.craft.heartRate.startRecording({ recordingId: ' ' })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' })
589 await expect(page.craft.heartRate.stopRecording({ expectedId: '' })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' })
590 expect(page.calls).not.toContain('heartRate')
591 })
592
593 it('rejects what is in flight when the bridge closes', async () => {
594 const page = loadPage()
595 const pending = page.craft.heartRate.getState()
596 page.page.__craftRejectHeartRateRequests('Android bridge closed')
597 await expect(pending).rejects.toThrow('Android bridge closed')
598 await expect(page.craft.heartRate.getState()).rejects.toThrow('Android bridge is closed')
599 expect(template).toContain("window.__craftRejectHeartRateRequests && window.__craftRejectHeartRateRequests('Android bridge closed')")
600 })
601
602 it('hears readings, devices and state as craftHeartRate events', () => {
603 // The page's sink, set while the bridge lives and removed only if it is still this bridge's.
604 expect(template).toContain(`evaluateJavascriptUnlessClosed("window.dispatchEvent(new CustomEvent('craftHeartRate', {detail: $detail}));")`)
605 expect(template).toContain('if (heartRateEnabled) CraftHeartRate.get(activity).sinks["page"] = heartRateSink')
606 expect(template).toContain('CraftHeartRate.get(activity).sinks.remove("page", heartRateSink)')
607 expect(template).toContain('if (heartRateEnabled && CraftHeartRate.get(activity).onPermissions(requestCode)) return true')
608 })
609})
packages/android/templates/AndroidManifest.xml.templatemodified+1-0
Changes to packages/android/templates/AndroidManifest.xml.template
@@ -55,6 +55,7 @@
5555 </activity>
5656
5757
58
5859
5960
6061
packages/android/templates/CraftBridge.kt.templatemodified+136-1
Changes to packages/android/templates/CraftBridge.kt.template
@@ -444,6 +444,9 @@ class CraftBridge(
444444 bluetooth: false,
445445 nfc: false,
446446 health: ,
447 heartRate: ,
448 heartRateRecordingOwnership: ,
449 heartRateRecordingIntentIdentity: ,
447450 screenCapture: false
448451 },
449452
@@ -1808,6 +1811,85 @@ class CraftBridge(
18081811 readRecording: function(options) { return recordingCall('readRecording', options); },
18091812 acknowledgeRecording: function(options) { return recordingCall('acknowledgeRecording', options); }
18101813 };
1814 // A Bluetooth heart-rate strap, or a watch broadcasting its
1815 // heart rate (CraftHeartRate.kt), in the iOS page's names.
1816 // Readings, devices found by scan() and connection changes
1817 // arrive as craftHeartRate events ({type: 'reading' | 'device'
1818 // | 'state'}); startRecording() keeps every reading natively,
1819 // screen locked or app closed, until stopRecording(). Calls
1820 // that wait on Android's Nearby devices prompt have no deadline.
1821 var heartRateSequence = 0;
1822 var heartRateRequests = Object.create(null);
1823 var heartRateClosed = false;
1824 window.__craftHeartRateResult = function(id, value) {
1825 var pending = heartRateRequests[id];
1826 if (!pending) return;
1827 delete heartRateRequests[id];
1828 pending.resolve(value);
1829 };
1830 window.__craftHeartRateError = function(id, message, code) {
1831 var pending = heartRateRequests[id];
1832 if (!pending) return;
1833 delete heartRateRequests[id];
1834 var error = new Error(message);
1835 error.code = code || 'CRAFT_ERROR';
1836 error.bridge = true;
1837 pending.reject(error);
1838 };
1839 window.__craftRejectHeartRateRequests = function(message) {
1840 heartRateClosed = true;
1841 Object.keys(heartRateRequests).forEach(function(id) {
1842 var pending = heartRateRequests[id];
1843 delete heartRateRequests[id];
1844 pending.reject(new Error(message || 'Android bridge closed'));
1845 });
1846 };
1847 function heartRate(method, options) {
1848 options = options || {};
1849 if (['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(method) !== -1) {
1850 if (Object.prototype.hasOwnProperty.call(options, 'recordingId') &&
1851 (typeof options.recordingId !== 'string' || !options.recordingId.trim())) {
1852 var invalidStart = new Error('recordingId must be a nonempty recording ID');
1853 invalidStart.code = 'INVALID_ARGUMENT';
1854 return Promise.reject(invalidStart);
1855 }
1856 if (Object.prototype.hasOwnProperty.call(options, 'expectedId') &&
1857 (typeof options.expectedId !== 'string' || !options.expectedId.trim())) {
1858 var invalid = new Error('expectedId must be a nonempty recording ID');
1859 invalid.code = 'INVALID_ARGUMENT';
1860 return Promise.reject(invalid);
1861 }
1862 }
1863 if (heartRateClosed) return Promise.reject(new Error('Android bridge is closed'));
1864 return new Promise(function(resolve, reject) {
1865 heartRateSequence += 1;
1866 var id = heartRateSequence;
1867 heartRateRequests[id] = {resolve: resolve, reject: reject};
1868 try {
1869 CraftAndroid.heartRate(method, JSON.stringify(options), id);
1870 } catch (error) {
1871 delete heartRateRequests[id];
1872 reject(error);
1873 }
1874 });
1875 }
1876 craft.heartRate = {
1877 getState: function() { return heartRate('getState'); },
1878 requestAuthorization: function() { return heartRate('requestAuthorization'); },
1879 scan: function(options) { return heartRate('scan', {timeout: options && typeof options.timeout === 'number' ? options.timeout : null}); },
1880 stopScan: function() { return heartRate('stopScan'); },
1881 getDevices: function() { return heartRate('getDevices'); },
1882 connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
1883 reconnect: function() { return heartRate('reconnect'); },
1884 disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
1885 startRecording: function(options) { return heartRate('startRecording', options); },
1886 pauseRecording: function(options) { return heartRate('pauseRecording', options); },
1887 resumeRecording: function(options) { return heartRate('resumeRecording', options); },
1888 stopRecording: function(options) { return heartRate('stopRecording', options); },
1889 getRecordingState: function(options) { return heartRate('getRecordingState', options); },
1890 acknowledgeRecording: function(options) { return heartRate('acknowledgeRecording', options); },
1891 readRecording: function(options) { return heartRate('readRecording', options); }
1892 };
18111893 var shareApi = function(text, title) { return legacyShare(text, title); };
18121894 shareApi.share = function(options) {
18131895 options = options || {};
@@ -2537,6 +2619,7 @@ class CraftBridge(
25372619 }
25382620
25392621 fun onRequestPermissionsResult(requestCode: Int): Boolean {
2622 if (heartRateEnabled && CraftHeartRate.get(activity).onPermissions(requestCode)) return true
25402623 if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END
25412624 // startListening asked for the microphone and is waiting on this.
25422625 if (requestCode == REQUEST_SPEECH) {
@@ -2820,6 +2903,54 @@ class CraftBridge(
28202903 return legacyRecordingAction("readRecording")
28212904 }
28222905
2906 // ==================== Heart rate (CraftHeartRate.kt) ====================
2907
2908 private val heartRateEnabled =
2909
2910 /** The strap's events, to this page as `craftHeartRate`. */
2911 private val heartRateSink: (JSONObject) -> Unit = { detail ->
2912 evaluateJavascriptUnlessClosed("window.dispatchEvent(new CustomEvent('craftHeartRate', {detail: $detail}));")
2913 }
2914
2915 init {
2916 if (heartRateEnabled) CraftHeartRate.get(activity).sinks["page"] = heartRateSink
2917 }
2918
2919 /**
2920 * One `craft.heartRate` call, by method name, answered through
2921 * `__craftHeartRateResult` / `__craftHeartRateError` with its id. An
2922 * answer for a page that has since navigated away is dropped.
2923 */
2924 @JavascriptInterface
2925 fun heartRate(method: String, optionsJson: String, callbackId: Int) {
2926 activity.runOnUiThread {
2927 if (closed) return@runOnUiThread
2928 if (!heartRateEnabled) return@runOnUiThread rejectHeartRate(callbackId, navigationGeneration, "Heart rate is disabled", "CAPABILITY_DISABLED")
2929 val generation = navigationGeneration
2930 val options = runCatching { JSONObject(optionsJson) }.getOrNull()
2931 ?: return@runOnUiThread rejectHeartRate(callbackId, generation, "Invalid heart-rate options", "INVALID_ARGUMENT")
2932 CraftHeartRate.get(activity).perform(method, options, activity) { result ->
2933 result.fold(
2934 onSuccess = { value ->
2935 if (generation != navigationGeneration) return@fold
2936 val json = if (value is String) JSONObject.quote(value) else value.toString()
2937 evaluateJavascriptUnlessClosed("window.__craftHeartRateResult && window.__craftHeartRateResult($callbackId, $json)")
2938 },
2939 onFailure = { error ->
2940 rejectHeartRate(callbackId, generation, error.message ?: "Heart rate failed", (error as? CraftHeartRateException)?.code ?: "CRAFT_ERROR")
2941 },
2942 )
2943 }
2944 }
2945 }
2946
2947 private fun rejectHeartRate(callbackId: Int, generation: Int, message: String, code: String) {
2948 if (generation != navigationGeneration) return
2949 evaluateJavascriptUnlessClosed(
2950 "window.__craftHeartRateError && window.__craftHeartRateError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
2951 )
2952 }
2953
28232954 // ==================== Clipboard ====================
28242955
28252956 @JavascriptInterface
@@ -4473,13 +4604,17 @@ class CraftBridge(
44734604 webView.evaluateJavascript(
44744605 "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
44754606 "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
4476 "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
4607 "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed');" +
4608 "window.__craftRejectHeartRateRequests && window.__craftRejectHeartRateRequests('Android bridge closed')",
44774609 null
44784610 )
44794611 }
44804612 }
44814613 CraftNative.close(activity)
44824614 healthConnect.close()
4615 // The strap and its recording carry on (the service is the
4616 // process's); only this page stops hearing them.
4617 if (heartRateEnabled) CraftHeartRate.get(activity).sinks.remove("page", heartRateSink)
44834618
44844619 if (shareReceiverRegistered) {
44854620 runCatching { activity.unregisterReceiver(shareChosenReceiver) }
packages/android/templates/CraftHeartRate.kt.templateadded+938-0
Changes to packages/android/templates/CraftHeartRate.kt.template
@@ -0,0 +1,938 @@
1package
2
3import android.Manifest
4import android.annotation.SuppressLint
5import android.app.Activity
6import android.app.NotificationChannel
7import android.app.NotificationManager
8import android.app.Service
9import android.bluetooth.BluetoothAdapter
10import android.bluetooth.BluetoothDevice
11import android.bluetooth.BluetoothGatt
12import android.bluetooth.BluetoothGattCallback
13import android.bluetooth.BluetoothGattCharacteristic
14import android.bluetooth.BluetoothGattDescriptor
15import android.bluetooth.BluetoothManager
16import android.bluetooth.BluetoothProfile
17import android.bluetooth.BluetoothStatusCodes
18import android.bluetooth.le.ScanCallback
19import android.bluetooth.le.ScanFilter
20import android.bluetooth.le.ScanResult
21import android.bluetooth.le.ScanSettings
22import android.content.BroadcastReceiver
23import android.content.Context
24import android.content.Intent
25import android.content.IntentFilter
26import android.content.pm.PackageManager
27import android.content.pm.ServiceInfo
28import android.os.Build
29import android.os.Handler
30import android.os.IBinder
31import android.os.Looper
32import android.os.ParcelUuid
33import android.util.Log
34import androidx.core.app.ActivityCompat
35import androidx.core.app.NotificationCompat
36import androidx.core.app.ServiceCompat
37import androidx.core.content.ContextCompat
38import org.json.JSONArray
39import org.json.JSONObject
40import org.json.JSONTokener
41import java.io.File
42import java.util.UUID
43import kotlin.math.roundToInt
44import kotlin.math.roundToLong
45import kotlin.math.sin
46
47/** Maps and lists as org.json values, null as JSONObject.NULL, for the bridge and the disk. */
48internal 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. */
57internal 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
64internal 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")
108internal 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 */
901class 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}
packages/android/templates/CraftHeartRateCore.kt.templateadded+485-0
Changes to packages/android/templates/CraftHeartRateCore.kt.template
@@ -0,0 +1,485 @@
1package
2
3import java.io.File
4import java.io.FileOutputStream
5import java.io.IOException
6import java.nio.file.Files
7import java.nio.file.StandardCopyOption
8import java.security.MessageDigest
9import java.util.UUID
10import kotlin.math.PI
11import kotlin.math.min
12import kotlin.math.roundToInt
13import kotlin.math.sin
14import kotlin.random.Random
15
16// The parts of the heart-rate strap service (CraftHeartRate.kt) that need no
17// Android: the 0x2A37 decoder, the stand-in strap's beats, when a quiet strap
18// is asked for again, and the recording kept on disk. Plain Kotlin and
19// java.io, so a JVM fixture runs them (src/heart-rate.test.ts) against the
20// payloads iOS's HeartRateUnitTests use. Ported from CraftHeartRate.swift.
21
22/** A heart-rate call that did not happen: the message a page sees, and the bridge's error code. */
23internal class CraftHeartRateException(val code: String, message: String) : Exception(message)
24
25/**
26 * One Heart Rate Measurement (characteristic 0x2A37 of the Bluetooth SIG's
27 * Heart Rate Service, 0x180D), decoded.
28 *
29 * The payload is a flags byte and then the fields the flags announce, all
30 * little-endian:
31 *
32 * bit 0 the heart rate is a uint16 (else a uint8)
33 * bits 1-2 sensor contact: bit 2 says the strap reports it at all,
34 * bit 1 that the skin is touching (a strap worn loose reads 0)
35 * bit 3 a uint16 of energy expended, kilojoules, follows the rate
36 * bit 4 uint16 RR intervals follow, as many as fit, in 1/1024 s
37 *
38 * Every strap and every watch that broadcasts its heart rate (Polar, Garmin,
39 * Wahoo, a watch with a broadcasting app) sends this one shape.
40 */
41internal data class CraftHeartRateMeasurement(
42 /** Beats per minute. */
43 val bpm: Int,
44 /** Whether the strap is touching skin; null when it cannot tell. */
45 val sensorContact: Boolean?,
46 /** Kilojoules since the strap was last reset; null when it does not count them. */
47 val energyExpended: Int?,
48 /** The time between beats, in milliseconds, oldest first. */
49 val rrIntervals: List<Double>,
50) {
51 companion object {
52 fun parse(bytes: ByteArray): CraftHeartRateMeasurement? = parse(IntArray(bytes.size) { bytes[it].toInt() and 0xFF })
53
54 /** Null for a payload too short for what its flags announce. */
55 fun parse(bytes: IntArray): CraftHeartRateMeasurement? {
56 if (bytes.isEmpty()) return null
57 val flags = bytes[0] and 0xFF
58 var offset = 1
59 fun uint8(): Int? {
60 if (offset >= bytes.size) return null
61 return (bytes[offset++] and 0xFF)
62 }
63 fun uint16(): Int? {
64 if (offset + 1 >= bytes.size) return null
65 val value = (bytes[offset] and 0xFF) or ((bytes[offset + 1] and 0xFF) shl 8)
66 offset += 2
67 return value
68 }
69 val bpm = (if ((flags and 0x01) != 0) uint16() else uint8()) ?: return null
70 val contact: Boolean? = if ((flags and 0x04) != 0) (flags and 0x02) != 0 else null
71 var energy: Int? = null
72 if ((flags and 0x08) != 0) energy = uint16() ?: return null
73 val intervals = mutableListOf<Double>()
74 if ((flags and 0x10) != 0) {
75 while (true) intervals.add((uint16() ?: break) * 1000.0 / 1024)
76 }
77 return CraftHeartRateMeasurement(bpm, contact, energy, intervals)
78 }
79
80 /**
81 * The payload a strap would send for these values: the emulator's
82 * stand-in speaks it, so its readings take the same path as a strap's.
83 */
84 fun encode(bpm: Int, sensorContact: Boolean? = true, energyExpended: Int? = null, rrIntervals: List<Double> = emptyList()): ByteArray {
85 var flags = 0
86 val body = mutableListOf<Int>()
87 fun append16(value: Int) { body.add(value and 0xFF); body.add((value shr 8) and 0xFF) }
88 if (bpm > 255) {
89 flags = flags or 0x01
90 append16(bpm)
91 } else body.add(maxOf(0, bpm))
92 if (sensorContact != null) flags = flags or 0x04 or (if (sensorContact) 0x02 else 0)
93 if (energyExpended != null) {
94 flags = flags or 0x08
95 append16(energyExpended)
96 }
97 if (rrIntervals.isNotEmpty()) {
98 flags = flags or 0x10
99 for (interval in rrIntervals) append16((interval * 1024 / 1000).roundToInt())
100 }
101 return (listOf(flags) + body).map { it.toByte() }.toByteArray()
102 }
103 }
104}
105
106/**
107 * The stand-in strap's beats from `start` (ms) until `end`, each interval in
108 * ms: the rate's own interval, plus respiratory sinus arrhythmia (beats come
109 * closer on the in-breath and spread on the out-breath over a ten-second
110 * breath, ±4% of the interval: ±40 ms at 60 bpm) and a few ms of noise, so a
111 * heart-rate variability worked out from them means something. Answers the
112 * intervals and where the last beat fell.
113 */
114internal fun craftHeartRateSimulatedIntervals(
115 rate: Double,
116 start: Double,
117 end: Double,
118 noise: () -> Double = { Random.nextDouble(-4.0, 4.0) },
119): Pair<List<Double>, Double> {
120 val base = 60_000 / rate
121 var at = start
122 val intervals = mutableListOf<Double>()
123 while (at < end) {
124 val breath = sin(2 * PI * at / 10_000)
125 val interval = base * (1 - 0.04 * breath) + noise()
126 at += interval
127 intervals.add(interval)
128 }
129 return intervals to at
130}
131
132/**
133 * Whether a link that is still wanted has gone quiet long enough to rebuild
134 * its subscription: a connected strap that sent nothing for 20 s (a
135 * broadcasting watch can drop its notifications and keep the link), or a link
136 * that came up but never subscribed for 30 s. A pending connection is left
137 * pending: the strap comes back when it is worn.
138 */
139internal fun craftHeartRateShouldRestart(connection: String, linkConnected: Boolean, quietMs: Long): Boolean =
140 (connection == "connected" && quietMs > 20_000) || (connection != "connected" && linkConnected && quietMs > 30_000)
141
142/**
143 * How long to wait before asking for a dropped strap again: 1, 2, 4, 8, 16
144 * and then every 30 seconds. The ask itself never times out (an automatic
145 * GATT connection waits for the strap); this only spaces out the stack's
146 * own failures (status 133 and friends) so they do not spin.
147 */
148internal fun craftHeartRateReconnectDelayMs(attempt: Int): Long = min(30_000L, 1_000L shl attempt.coerceIn(0, 5))
149
150/**
151 * Whether the person has let the app use Bluetooth, in the iOS names: asked
152 * and refused is `denied`, never asked `notDetermined`.
153 */
154internal fun craftHeartRateAuthorizationName(granted: Boolean, asked: Boolean): String = when {
155 granted -> "authorized"
156 asked -> "denied"
157 else -> "notDetermined"
158}
159
160/**
161 * The radio in the iOS names. `adapterState` is BluetoothAdapter's: 10 off,
162 * 11 turning on, 12 on, 13 turning off. Reading it asks for nothing.
163 */
164internal fun craftHeartRateBluetoothName(hasAdapter: Boolean, adapterState: Int?, authorization: String): String = when {
165 !hasAdapter -> "unsupported"
166 authorization == "denied" -> "unauthorized"
167 adapterState == 12 -> "poweredOn"
168 adapterState == 10 -> "poweredOff"
169 adapterState == 11 || adapterState == 13 -> "resetting"
170 else -> "unknown"
171}
172
173/** JSON text in and out; org.json on a phone, a small codec in the JVM fixture. */
174internal interface CraftHeartRateJson {
175 /** Maps, lists, strings, numbers, booleans and null. */
176 fun encode(value: Any?): String
177 fun decode(text: String): Any?
178}
179
180/** Write to a temporary file beside `destination`, sync it, then rename it into place. */
181internal fun craftHeartRateWriteAtomically(data: ByteArray, destination: File) {
182 destination.parentFile?.mkdirs()
183 val temporary = File(destination.parentFile, ".craft-atomic-" + UUID.randomUUID())
184 try {
185 FileOutputStream(temporary).use { stream -> stream.write(data); stream.fd.sync() }
186 Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
187 } finally {
188 temporary.delete()
189 }
190}
191
192/**
193 * The recording a workout keeps natively, on disk in `directory`, exactly as
194 * CraftHeartRate.swift keeps it: its state in
195 * `craft-heart-rate-recording-state.json`, one sample a line in
196 * `craft-heart-rate-recording.jsonl`, a completed recording archived under
197 * the SHA-256 of its ID when the next one starts, and an acknowledgement
198 * tombstone so an acknowledged recording never comes back. A page reload, a
199 * screen change or the app being closed loses nothing: the next call reads
200 * it all back.
201 *
202 * Main thread only, like the service that owns it.
203 */
204internal class CraftHeartRateRecording(
205 private val directory: File,
206 private val json: CraftHeartRateJson,
207 private val clock: () -> Long = System::currentTimeMillis,
208 private val archiveWriter: (ByteArray, File) -> Unit = ::craftHeartRateWriteAtomically,
209) {
210 companion object {
211 /** Fourteen hours of one reading a second: more is not one workout. */
212 const val MAX_SAMPLES = 50_000
213 val METHODS = setOf("startRecording", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording")
214 private const val KIND = "heart-rate"
215 }
216
217 var active = false
218 private set
219 var paused = false
220 private set
221 var id: String? = null
222 private set
223 private var startedAt: Long? = null
224 private var count = 0
225 private var restored = false
226
227 private val stateFile get() = File(directory, "craft-heart-rate-recording-state.json")
228 private val samplesFile get() = File(directory, "craft-heart-rate-recording.jsonl")
229
230 private fun storage(message: String) = CraftHeartRateException("RECORDING_STORAGE_ERROR", message)
231 private fun invalid(message: String) = CraftHeartRateException("INVALID_ARGUMENT", message)
232
233 /** Anything but our own refusal is the disk's: RECORDING_STORAGE_ERROR, with `message`. */
234 private inline fun <T> guarded(message: String, run: () -> T): T = try {
235 run()
236 } catch (error: CraftHeartRateException) {
237 throw error
238 } catch (error: Exception) {
239 throw storage(message)
240 }
241
242 // MARK: Archive (CraftRecordingArchive.swift, kind "heart-rate")
243
244 private fun archive(id: String, acknowledged: Boolean = false): File {
245 val digest = MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
246 return File(directory, "craft-$KIND-${if (acknowledged) "ack" else "completed"}-$digest.json")
247 }
248
249 private fun isAcknowledged(id: String): Boolean {
250 val marker = archive(id, acknowledged = true)
251 if (!marker.exists()) return false
252 if (!marker.readBytes().contentEquals(id.toByteArray(Charsets.UTF_8))) throw storage("Invalid recording acknowledgement")
253 return true
254 }
255
256 private fun acknowledgeArchive(id: String) {
257 if (!isAcknowledged(id)) craftHeartRateWriteAtomically(id.toByteArray(Charsets.UTF_8), archive(id, acknowledged = true))
258 val completed = archive(id)
259 if (completed.exists() && !completed.delete()) throw IOException("Unable to remove the completed recording")
260 }
261
262 private fun readArchive(id: String): MutableMap<String, Any?>? {
263 if (isAcknowledged(id)) {
264 acknowledgeArchive(id)
265 throw CraftHeartRateException("RECORDING_NOT_FOUND", "This recording has been acknowledged")
266 }
267 val source = archive(id)
268 if (!source.exists()) return null
269 @Suppress("UNCHECKED_CAST")
270 val value = json.decode(String(source.readBytes(), Charsets.UTF_8)) as? Map<String, Any?>
271 if (value == null || value["id"] != id || value["active"] != false || value["samples"] !is List<*>)
272 throw storage("Invalid completed recording archive")
273 return value.toMutableMap()
274 }
275
276 private fun saveArchive(state: Map<String, Any?>, samples: List<Map<String, Any?>>) {
277 val id = state["id"] as? String ?: return
278 if (isAcknowledged(id)) { acknowledgeArchive(id); return }
279 if (readArchive(id) != null) return
280 val completed = state.toMutableMap()
281 completed["active"] = false
282 completed["paused"] = false
283 completed["sampleCount"] = samples.size
284 completed["samples"] = samples
285 archiveWriter(json.encode(completed).toByteArray(Charsets.UTF_8), archive(id))
286 }
287
288 private fun startId(options: Map<String, Any?>): String {
289 val next = if (options.containsKey("recordingId")) {
290 val requested = options["recordingId"] as? String
291 if (requested == null || requested.isBlank()) throw invalid("recordingId must be a nonempty recording ID")
292 requested
293 } else UUID.randomUUID().toString()
294 if (next == id || isAcknowledged(next) || archive(next).exists())
295 throw CraftHeartRateException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
296 return next
297 }
298
299 // MARK: State
300
301 /** A recording that was running when the app last closed carries on. */
302 fun restore() {
303 if (restored) return
304 guarded("Unable to restore the recording") {
305 if (!stateFile.exists()) {
306 if (samplesFile.exists() && samplesFile.length() > 0) throw storage("Recording metadata is missing")
307 restored = true
308 return
309 }
310 @Suppress("UNCHECKED_CAST")
311 val saved = json.decode(stateFile.readText()) as? Map<String, Any?> ?: throw storage("Invalid recording metadata")
312 val wasActive = saved["active"] as? Boolean ?: throw storage("Invalid recording metadata")
313 active = wasActive
314 id = saved["id"] as? String
315 startedAt = (saved["startedAt"] as? Number)?.toLong()
316 paused = saved["paused"] as? Boolean ?: false
317 val current = id
318 if (current != null && isAcknowledged(current)) {
319 acknowledgeArchive(current)
320 if (samplesFile.exists() && !samplesFile.delete()) throw IOException("Unable to remove acknowledged samples")
321 id = null
322 startedAt = null
323 active = false
324 paused = false
325 persist()
326 }
327 count = samples(null).size
328 restored = true
329 }
330 }
331
332 private fun persist() {
333 val saved = mapOf("active" to active, "paused" to paused, "id" to id, "startedAt" to startedAt)
334 craftHeartRateWriteAtomically(json.encode(saved).toByteArray(Charsets.UTF_8), stateFile)
335 }
336
337 fun summary(withCount: Boolean): MutableMap<String, Any?> {
338 val summary = mutableMapOf<String, Any?>("id" to id, "active" to active, "paused" to paused, "startedAt" to startedAt)
339 if (withCount) summary["sampleCount"] = count
340 return summary
341 }
342
343 /** The recording's samples, oldest first; only those after `since` (ms) when given. */
344 private fun samples(since: Double?): List<Map<String, Any?>> {
345 if (id == null) return emptyList()
346 return samplesFile.readText().split("\n").mapNotNull { line ->
347 if (line.isBlank()) return@mapNotNull null
348 @Suppress("UNCHECKED_CAST")
349 val sample = runCatching { json.decode(line) as? Map<String, Any?> }.getOrNull() ?: return@mapNotNull null
350 if (since != null && ((sample["timestamp"] as? Number)?.toDouble() ?: 0.0) <= since) return@mapNotNull null
351 sample
352 }
353 }
354
355 /** Checked on the same main-thread turn as the read or change it guards. */
356 private fun requireOwner(options: Map<String, Any?>) {
357 if (!options.containsKey("expectedId")) return
358 val expected = options["expectedId"] as? String
359 if (expected == null || expected.isBlank()) throw invalid("expectedId must be a nonempty recording ID")
360 if (id != expected) throw CraftHeartRateException("RECORDING_CONFLICT", "The active heart-rate recording has changed")
361 }
362
363 // MARK: Calls
364
365 /** One recording call from a page or a native screen, by method name (`METHODS`). */
366 fun perform(method: String, options: Map<String, Any?>): Any? {
367 if (method == "acknowledgeRecording" && !options.containsKey("expectedId")) throw invalid("Acknowledgement needs expectedId")
368 restore()
369 if (method in setOf("getRecordingState", "readRecording", "stopRecording", "acknowledgeRecording") && options.containsKey("expectedId")) {
370 val expected = options["expectedId"] as? String
371 if (expected == null || expected.isBlank()) throw invalid("expectedId must be a nonempty recording ID")
372 val answer = guarded("Unable to persist the recording") { answerFor(method, expected, options) }
373 if (answer != null) return answer
374 requireOwner(options)
375 }
376 if (method in setOf("pauseRecording", "resumeRecording", "stopRecording", "readRecording")) requireOwner(options)
377 return when (method) {
378 "startRecording" -> start(options)
379 "pauseRecording" -> setPaused(true)
380 "resumeRecording" -> setPaused(false)
381 "stopRecording" -> stop()
382 "getRecordingState" -> summary(true)
383 "readRecording" -> guarded("Unable to read the recording") { samples((options["since"] as? Number)?.toDouble()) }
384 else -> throw CraftHeartRateException("UNKNOWN_ACTION", "Unsupported HeartRate method")
385 }
386 }
387
388 /** The answer a call naming a recording gets from its archive or acknowledgement; null to carry on. */
389 private fun answerFor(method: String, expected: String, options: Map<String, Any?>): Any? {
390 if (method == "acknowledgeRecording") {
391 if (id == expected && active) throw CraftHeartRateException("RECORDING_ALREADY_ACTIVE", "The recording is still active")
392 val known = id == expected || isAcknowledged(expected) || readArchive(expected) != null
393 if (!known) throw CraftHeartRateException("RECORDING_CONFLICT", "Unknown recording ID")
394 acknowledgeArchive(expected)
395 if (id == expected) {
396 if (samplesFile.exists() && !samplesFile.delete()) throw IOException("Unable to remove recording samples")
397 id = null
398 startedAt = null
399 count = 0
400 persist()
401 }
402 return true
403 }
404 val archived = readArchive(expected) ?: return null
405 return when (method) {
406 "readRecording" -> {
407 val since = (options["since"] as? Number)?.toDouble()
408 (archived["samples"] as? List<*>).orEmpty().filter { sample ->
409 since == null || (((sample as? Map<*, *>)?.get("timestamp") as? Number)?.toDouble() ?: 0.0) > since
410 }
411 }
412 "getRecordingState" -> archived.apply { remove("samples") }
413 else -> archived
414 }
415 }
416
417 private fun start(options: Map<String, Any?>): Map<String, Any?> {
418 if (active) throw CraftHeartRateException("RECORDING_ALREADY_ACTIVE", "A heart-rate recording is already active")
419 val next = guarded("Unable to create the heart-rate recording") {
420 val next = startId(options)
421 saveArchive(summary(true), samples(null))
422 directory.mkdirs()
423 craftHeartRateWriteAtomically(ByteArray(0), samplesFile)
424 next
425 }
426 val previous = listOf(active, id, startedAt, paused, count)
427 active = true
428 id = next
429 startedAt = clock()
430 paused = false
431 count = 0
432 try {
433 persist()
434 } catch (error: Exception) {
435 active = previous[0] as Boolean
436 id = previous[1] as String?
437 startedAt = previous[2] as Long?
438 paused = previous[3] as Boolean
439 count = previous[4] as Int
440 throw storage("Unable to persist the recording")
441 }
442 return summary(true)
443 }
444
445 private fun setPaused(value: Boolean): Map<String, Any?> {
446 if (!active) throw CraftHeartRateException("NO_ACTIVE_RECORDING", "No active heart-rate recording")
447 val previous = paused
448 paused = value
449 try {
450 persist()
451 } catch (error: Exception) {
452 paused = previous
453 throw storage("Unable to persist the recording")
454 }
455 return summary(true)
456 }
457
458 private fun stop(): Map<String, Any?> {
459 val kept = guarded("Unable to persist the recording") { samples(null) }
460 val wasActive = active
461 val wasPaused = paused
462 active = false
463 paused = false
464 try {
465 persist()
466 } catch (error: Exception) {
467 active = wasActive
468 paused = wasPaused
469 throw storage("Unable to persist the recording")
470 }
471 return summary(true).apply { put("samples", kept) }
472 }
473
474 /** Keeps one reading while the recording runs and is not paused; false when it was not kept. */
475 fun append(sample: Map<String, Any?>): Boolean {
476 if (!active || paused || count >= MAX_SAMPLES) return false
477 return try {
478 FileOutputStream(samplesFile, true).use { it.write((json.encode(sample) + "\n").toByteArray(Charsets.UTF_8)) }
479 count += 1
480 true
481 } catch (error: Exception) {
482 false
483 }
484 }
485}
packages/android/templates/CraftNativeServices.kt.templatemodified+34-1
Changes to packages/android/templates/CraftNativeServices.kt.template
@@ -59,6 +59,7 @@ internal class CraftNativeServices(
5959 private val activity: () -> Activity?,
6060) {
6161 private val main = Handler(Looper.getMainLooper())
62 private val heartRateEnabled = config.optBoolean("enableHeartRate")
6263 private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
6364 CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
6465 } else null
@@ -78,6 +79,14 @@ internal class CraftNativeServices(
7879 CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id)))
7980 }
8081 }
82 if (heartRateEnabled) {
83 // The strap's readings and connection changes reach every screen
84 // and the app script as `craftHeartRate`, as on iOS. One sink for
85 // the process: each host sets the same one.
86 CraftHeartRate.get(context).sinks["native"] = { detail ->
87 CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHeartRate").put("detail", detail))
88 }
89 }
8190 }
8291
8392 /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
@@ -111,13 +120,16 @@ internal class CraftNativeServices(
111120 if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
112121 }
113122 if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
123 // A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
124 // service: one connection and one recording for every screen.
125 if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))
114126 // craft.crashReporter: the app's crash and error reporter (bughq).
115127 if (CraftCrashReporter.enabled) add("crashReporter")
116128 }
117129
118130 fun handles(module: String): Boolean = module in setOf(
119131 "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
120 "Location", "Media", "CrashReporter",
132 "Location", "Media", "CrashReporter", "HeartRate",
121133 )
122134
123135 fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
@@ -137,6 +149,7 @@ internal class CraftNativeServices(
137149 "StatusBar" -> statusBar(args.optString(0, "default"), done)
138150 "Speech" -> speech(method, args, done)
139151 "Location" -> location(method, args, done)
152 "HeartRate" -> heartRate(method, args, done)
140153 "Media" -> when (method) {
141154 "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done)
142155 "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
@@ -363,6 +376,7 @@ internal class CraftNativeServices(
363376 }
364377
365378 fun onRequestPermissionsResult(requestCode: Int): Boolean {
379 if (heartRateEnabled && CraftHeartRate.get(context).onPermissions(requestCode)) return true
366380 if (requestCode == LOCATION_PERMISSION_REQUEST) {
367381 val pending = pendingLocationPermission
368382 pendingLocationPermission = null
@@ -455,6 +469,25 @@ internal class CraftNativeServices(
455469 }
456470 }
457471
472 // MARK: Heart rate (CraftHeartRate.kt)
473
474 /**
475 * A Bluetooth heart-rate strap, the same service as a page's
476 * `craft.heartRate`, in the iOS host's names (CraftNativeActions.swift's
477 * HeartRate module). Its events reach every screen as `craftHeartRate`.
478 */
479 private fun heartRate(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
480 if (!heartRateEnabled) return done(failure("CAPABILITY_DISABLED", "Heart rate is disabled"))
481 CraftHeartRate.get(context).perform(method, args.optJSONObject(0) ?: JSONObject(), activity()) { result ->
482 result.fold(
483 onSuccess = { done(success(it)) },
484 onFailure = { error ->
485 done(failure((error as? CraftHeartRateException)?.code ?: "CRAFT_ERROR", error.message ?: "Heart rate failed"))
486 },
487 )
488 }
489 }
490
458491 // MARK: Crash reporting (CraftCrashReporter.kt)
459492
460493 private fun crashReporter(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
packages/android/templates/CraftNativeShell.kt.templatemodified+20-6
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -1258,8 +1258,15 @@ internal object CraftNativeMedia {
12581258 .addTag(TAG)
12591259 .addTag("$TAG:${digest(address)}")
12601260 .build()
1261 // KEEP: a transfer already queued or running for this address is joined, not started again.
1262 work.enqueueUniqueWork(workName(address, wifiOnly), androidx.work.ExistingWorkPolicy.KEEP, request)
1261 // A transfer running for this address is joined (KEEP), not started again. One
1262 // only queued, or waiting out a retry's backoff, is replaced: asked for again,
1263 // it runs now and resumes the part the last attempt left.
1264 val name = workName(address, wifiOnly)
1265 background.execute {
1266 val running = runCatching { work.getWorkInfosForUniqueWork(name).get() }.getOrDefault(emptyList())
1267 .any { it.state == androidx.work.WorkInfo.State.RUNNING }
1268 work.enqueueUniqueWork(name, if (running) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request)
1269 }
12631270 val reason = when {
12641271 !online(app) -> "offline"
12651272 wifiOnly && metered(app) -> "cellular"
@@ -1298,8 +1305,13 @@ internal object CraftNativeMedia {
12981305 val target = file(context, address)
12991306 val staging = part(context, address)
13001307 target.parentFile?.mkdirs()
1308 val download = CraftResumableDownload()
1309 val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
13011310 val fetched = try {
1302 CraftResumableDownload().fetch(address, headers.keys().asSequence().associateWith { headers.optString(it) }, staging, stopped)
1311 // A part that no longer lines up was dropped: the whole file, now, not after a backoff.
1312 download.fetch(address, asked, staging, stopped).let { first ->
1313 if (first is CraftResumableDownload.Fetched.Restart) download.fetch(address, asked, staging, stopped) else first
1314 }
13031315 } catch (error: Exception) {
13041316 val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
13051317 it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
@@ -1360,9 +1372,9 @@ internal object CraftNativeMedia {
13601372 fun prune(context: Context, keep: List<String>): Int {
13611373 val wanted = keep.map { digest(it) }.toSet()
13621374 var removed = 0
1375 // A keep still waiting owns its address's files and its transfer until it is answered.
1376 val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
13631377 synchronized(lock) {
1364 // A keep still waiting owns its address's files until it is answered.
1365 val active = waiting.keys.map { digest(it) }.toSet()
13661378 root(context).listFiles()?.forEach { file ->
13671379 val owner = file.name.substringBefore('.')
13681380 // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
@@ -1375,7 +1387,7 @@ internal object CraftNativeMedia {
13751387 runCatching {
13761388 androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
13771389 .filter { !it.state.isFinished }
1378 .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted } }
1390 .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
13791391 .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
13801392 }
13811393 }
@@ -1435,6 +1447,8 @@ internal class CraftResumableDownload(
14351447 if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
14361448 try {
14371449 val status = connection.responseCode
1450 // Part of a file nobody asked a part of: not something to keep.
1451 if (status == 206 && have == 0L) return Fetched.Refused(status)
14381452 val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
14391453 if (status == 416 || (status == 206 && !resumed)) {
14401454 part.delete()
packages/typescript/fixtures/media-cache/media-cache.ktmodified+4-2
Changes to packages/typescript/fixtures/media-cache/media-cache.kt
@@ -98,8 +98,10 @@ class DownloadProbe : android.app.Instrumentation() {
9898 check(!keep(broken), "truncated transfer fails")
9999 check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
100100 val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
101 check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "failed transfer cleans staging files")
102 check(keep(broken), "same URL retries successfully")
101 val brokenTarget = CraftNativeMedia.file(targetContext, broken)
102 check(root.listFiles()?.none { it.name.endsWith(".part") && it.name != "${brokenTarget.name}.part" } != false, "a failed transfer leaves only its own part, to resume")
103 check(keep(broken), "same URL retries successfully, at once")
104 check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
103105 check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
104106 check(!keep("$base/empty.mp4"), "empty file is refused")
105107 val slow = "$base/slow.mp4"
packages/typescript/src/__tests__/bridge-request-id.test.tsmodified+23-2
Changes to packages/typescript/src/__tests__/bridge-request-id.test.ts
@@ -1,4 +1,4 @@
1import { describe, expect, it } from 'bun:test'
1import { afterEach, describe, expect, it } from 'bun:test'
22
33/**
44 * The reply-correlation contract, exercised against the real
@@ -37,6 +37,27 @@ interface Harness {
3737 * they can simply be passed in as parameters — no DOM, no jsdom, and nothing
3838 * shared between tests.
3939 */
40/**
41 * The bridge's timers. A test that sends without answering (an id check, a
42 * fire-and-forget) leaves its call's timeout running; left alone it rejects
43 * during whatever test runs then, as an unhandled error that fails that one.
44 * Each test's bridge stops when the test does.
45 */
46const timers = new Set<ReturnType<typeof setTimeout>>()
47const bridgeSetTimeout = (run: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => {
48 const timer = setTimeout(() => { timers.delete(timer); run(...args) }, ms)
49 timers.add(timer)
50 return timer
51}
52const bridgeClearTimeout = (timer: ReturnType<typeof setTimeout>) => {
53 timers.delete(timer)
54 clearTimeout(timer)
55}
56afterEach(() => {
57 for (const timer of timers) clearTimeout(timer)
58 timers.clear()
59})
60
4061function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness {
4162 const sent: Envelope[] = []
4263 const errors: unknown[][] = []
@@ -57,7 +78,7 @@ function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness {
5778
5879 // eslint-disable-next-line no-new-func
5980 const run = new Function('window', 'document', 'console', 'setTimeout', 'clearTimeout', 'setInterval', BRIDGE_SRC)
60 run(win, doc, console_, setTimeout, clearTimeout, () => 0)
81 run(win, doc, console_, bridgeSetTimeout, bridgeClearTimeout, () => 0)
6182
6283 return {
6384 craft: win.craft,
packages/typescript/src/__tests__/mobile.test.tsmodified+18-1
Changes to packages/typescript/src/__tests__/mobile.test.ts
@@ -24,7 +24,7 @@ import type {
2424 LiveActivityHandle,
2525 LiveActivityOptions,
2626} from '../api/mobile'
27import mobile, { biometrics, brightness, bridgeNotification, crashReporter, health, heartRate, music, normalizeDeepLinkURL, normalizeScheduledWorkout, scheduledWorkouts, normalizeHeartRateSamples, normalizeNowPlaying, notifications, pushNotifications, secureStorage, speech, splash, tabBar, watchConnectivity } from '../api/mobile'
27import mobile, { biometrics, brightness, bridgeNotification, crashReporter, health, heartRate, music, normalizeDeepLinkURL, normalizeScheduledWorkout, scheduledWorkouts, normalizeHeartRateReading, normalizeHeartRateSamples, normalizeHeartRateState, normalizeNowPlaying, notifications, pushNotifications, secureStorage, speech, splash, tabBar, watchConnectivity } from '../api/mobile'
2828
2929describe('Mobile deep links', () => {
3030 it('normalizes native payloads to the public string contract', () => {
@@ -1178,6 +1178,23 @@ describe('heartRate', () => {
11781178 }
11791179 })
11801180
1181 it('reads the shapes Android sends as it reads iOS\'s', () => {
1182 // CraftHeartRate.kt's state() and reading detail, as org.json serializes
1183 // them: a Bluetooth address for an id, null for what the strap does not send.
1184 const strap = { id: 'C4:7F:51:0A:3E:21', name: 'Polar H10 9A1B2C3D' }
1185 expect(normalizeHeartRateState({
1186 available: true, bluetooth: 'poweredOn', authorization: 'notDetermined', scanning: false, connection: 'reconnecting',
1187 device: strap, lastDevice: strap, bpm: null, lastReadingAt: 1_800_000_000_000,
1188 recording: { id: 'run', active: true, paused: false, startedAt: 1_799_999_000_000 },
1189 })).toEqual({
1190 available: true, bluetooth: 'poweredOn', authorization: 'notDetermined', scanning: false, connection: 'reconnecting',
1191 device: { ...strap, rssi: null }, lastDevice: { ...strap, rssi: null }, bpm: null, lastReadingAt: 1_800_000_000_000,
1192 recording: { id: 'run', active: true, paused: false, startedAt: 1_799_999_000_000, sampleCount: null },
1193 })
1194 expect(normalizeHeartRateReading({ type: 'reading', bpm: 142, rrIntervals: [422.9, 418.0], contact: null, energyExpended: null, timestamp: 1_800_000_000_000, deviceId: strap.id }))
1195 .toEqual({ bpm: 142, rrIntervals: [422.9, 418], contact: null, energyExpended: null, timestamp: 1_800_000_000_000, deviceId: strap.id })
1196 })
1197
11811198 it('keeps only samples a heart could give, once each, oldest first', () => {
11821199 expect(normalizeHeartRateSamples([{ timestamp: 5, bpm: 300 }, { timestamp: 4, bpm: 61.6, rr: [] }, { bpm: 70 }, null, { timestamp: 4, bpm: 62 }, { timestamp: 2, bpm: 60, rr: [1000] }]))
11831200 .toEqual([{ timestamp: 2, bpm: 60, rr: [1000] }, { timestamp: 4, bpm: 62 }])
packages/typescript/src/api/mobile.tsmodified+8-7
Changes to packages/typescript/src/api/mobile.ts
@@ -2080,12 +2080,13 @@ export const music = {
20802080 * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
20812081 * standard Heart Rate Service every Polar, Garmin and Wahoo strap speaks).
20822082 *
2083 * iOS only (`enableHeartRate`): scan for straps nearby, connect to one (it is
2084 * remembered, and `reconnect()` finds it again without a scan), hear each
2085 * reading with its RR intervals, and record the readings natively for a
2083 * iOS and Android (`enableHeartRate`): scan for straps nearby, connect to one
2084 * (it is remembered, and `reconnect()` finds it again without a scan), hear
2085 * each reading with its RR intervals, and record the readings natively for a
20862086 * workout: the recording keeps going with the screen locked or the web view
2087 * suspended, and survives the app being closed, like the route recording.
2088 * Off a phone (a browser, Android) `isAvailable()` is false, the state says
2087 * suspended (on Android, in a foreground service), and survives the app being
2088 * closed, like the route recording. Off a phone (a browser), or in a build
2089 * without the flag, `isAvailable()` is false, the state says
20892090 * `available: false`, and nothing ever fires.
20902091 */
20912092export type HeartRateAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable'
@@ -2322,8 +2323,8 @@ export const heartRate = {
23222323 getState: (): Promise<HeartRateState> => heartRateCall('getState', noHeartRate, normalizeHeartRateState),
23232324
23242325 /**
2325 * Ask for Bluetooth: iOS's prompt the first time, its remembered answer
2326 * after that. Scanning and connecting ask too; call this from the person's
2326 * Ask for Bluetooth: the system's prompt the first time (iOS's, or
2327 * Android's Nearby devices), its remembered answer after that. Scanning and connecting ask too; call this from the person's
23272328 * tap to ask first.
23282329 */
23292330 requestAuthorization: (): Promise<HeartRateAuthorization> => heartRateCall('requestAuthorization', () => 'unavailable' as const,
packages/typescript/src/mobile-media-testing.tsmodified+13-2
Changes to packages/typescript/src/mobile-media-testing.ts
@@ -33,6 +33,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
3333 const scratch = await mkdtemp(join(tmpdir(), 'craft-media-probe-'))
3434 const bytes = new Uint8Array(await Bun.file(join(fixtures, 'demo.mp4')).arrayBuffer())
3535 const counts = new Map<string, number>()
36 const resumed = new Set<string>()
3637 const timers = new Set<ReturnType<typeof setTimeout>>()
3738 const server = Bun.listen<{ request: string, answered: boolean }>({
3839 hostname: '0.0.0.0', port: 0,
@@ -45,6 +46,15 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
4546 const path = socket.data.request.split(' ')[1]!
4647 const n = (counts.get(path) || 0) + 1
4748 counts.set(path, n)
49 // Android resumes the download its process died in (CraftResumableDownload): the rest, from where its part stopped.
50 const from = Number(/^range:\s*bytes=(\d+)-\s*$/im.exec(socket.data.request)?.[1] ?? Number.NaN)
51 if (path === '/android/restart.mp4' && n > 1 && from > 0 && from < bytes.length) {
52 resumed.add(path)
53 socket.write(`HTTP/1.1 206 Partial Content\r\nContent-Type: video/mp4\r\nContent-Range: bytes ${from}-${bytes.length - 1}/${bytes.length}\r\nContent-Length: ${bytes.length - from}\r\nConnection: close\r\n\r\n`)
54 socket.write(bytes.subarray(from))
55 socket.end()
56 return
57 }
4858 const empty = path.endsWith('/empty.mp4')
4959 const partial = path.endsWith('/partial.mp4')
5060 const cut = (path.endsWith('/broken.mp4') || path.endsWith('/restart.mp4')) && n === 1
@@ -150,7 +160,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
150160 await Bun.write(join(android, 'settings.gradle.kts'), 'pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }\ndependencyResolutionManagement { repositories { google(); mavenCentral() } }\nrootProject.name = "HQDownloadProbe"\ninclude(":app")')
151161 await Bun.write(join(android, 'build.gradle.kts'), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }')
152162 await Bun.write(join(android, 'local.properties'), `sdk.dir=${sdk}\n`)
153 await Bun.write(join(android, 'gradle.properties'), 'org.gradle.jvmargs=-Xmx768m\norg.gradle.workers.max=2\n')
163 await Bun.write(join(android, 'gradle.properties'), 'org.gradle.jvmargs=-Xmx768m\norg.gradle.workers.max=2\nandroid.useAndroidX=true\n')
154164 await Bun.write(join(app, 'build.gradle.kts'), `plugins { id("com.android.application");
155165 id("org.jetbrains.kotlin.android") }\nandroid { namespace = "${packageName}";
156166 compileSdk = 36;
@@ -192,9 +202,10 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`)
192202 await probe('interrupt', true)
193203 if (counts.get('/android/restart.mp4') !== 1) throw new Error('Android process did not start interrupted download')
194204 await probe('recover')
205 if (!resumed.has('/android/restart.mp4')) throw new Error('Android did not resume the interrupted download from its part file')
195206 if (counts.get('/android/slow.mp4') !== 1) throw new Error('Android duplicate fetch: keep did not coalesce')
196207 if (counts.get('/android/account/slow.mp4') !== 2) throw new Error('Android account switch did not start a fresh transfer')
197 print('PASS: Android emulator interruption, restart, retry, prune and cached video decode')
208 print('PASS: Android emulator interruption, resumed restart, retry, prune and cached video decode')
198209 }
199210 }
200211 }
packages/typescript/types/craft.d.tsmodified+11-5
Changes to packages/typescript/types/craft.d.ts
@@ -637,7 +637,7 @@ export interface CraftBridge {
637637
638638 /**
639639 * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate
640 * (iOS, `enableHeartRate`): scan, connect, live readings as
640 * (iOS and Android, `enableHeartRate`): scan, connect, live readings as
641641 * `craftHeartRate` events, and a recording kept natively.
642642 */
643643 heartRate?: CraftHeartRate;
@@ -1368,7 +1368,7 @@ export type CraftHeartRateConnection = 'disconnected' | 'connecting' | 'connecte
13681368
13691369/** A device advertising the Heart Rate Service (0x180D). */
13701370export interface CraftHeartRateDevice {
1371 /** CoreBluetooth's identifier for it on this phone. */
1371 /** Its identifier on this phone: CoreBluetooth's UUID on iOS, its Bluetooth address on Android. */
13721372 id: string;
13731373 name: string;
13741374 /** Signal strength, dBm; 0 for a strap the phone is already connected to. */
@@ -1429,7 +1429,10 @@ export type CraftHeartRateEventDetail =
14291429
14301430export interface CraftHeartRate {
14311431 getState(): Promise<CraftHeartRateState>;
1432 /** Shows iOS's Bluetooth prompt the first time; after that answers from memory. */
1432 /**
1433 * Shows the Bluetooth prompt the first time (iOS's, or Android's Nearby
1434 * devices; location before Android 12); after that answers from memory.
1435 */
14331436 requestAuthorization(): Promise<CraftHeartRateAuthorization>;
14341437 /** Look for straps for `timeout` seconds (15); each one found is a `device` event. */
14351438 scan(options?: { timeout?: number }): Promise<CraftHeartRateState>;
@@ -1444,7 +1447,10 @@ export interface CraftHeartRate {
14441447 /** Ask for the remembered strap again, without waiting for it. */
14451448 reconnect(): Promise<CraftHeartRateState>;
14461449 disconnect(options?: { forget?: boolean }): Promise<CraftHeartRateState>;
1447 /** Keep every reading natively from now on, screen locked or app closed. */
1450 /**
1451 * Keep every reading natively from now on, screen locked or app closed.
1452 * On Android a foreground service (a notification) keeps it running.
1453 */
14481454 startRecording(options?: { recordingId?: string }): Promise<CraftHeartRateRecordingState>;
14491455 pauseRecording(options?: { expectedId: string }): Promise<CraftHeartRateRecordingState>;
14501456 resumeRecording(options?: { expectedId: string }): Promise<CraftHeartRateRecordingState>;
@@ -1614,7 +1620,7 @@ export interface CraftCapabilities {
16141620 brightness?: boolean;
16151621 /** The Music app's song and transport through `craft.music` (iOS, `enableAppleMusic`). */
16161622 music?: boolean;
1617 /** A Bluetooth heart-rate strap through `craft.heartRate` (iOS, `enableHeartRate`). */
1623 /** A Bluetooth heart-rate strap through `craft.heartRate` (iOS and Android, `enableHeartRate`). */
16181624 heartRate?: boolean;
16191625 heartRateRecordingOwnership?: boolean;
16201626 heartRateRecordingIntentIdentity?: boolean;
packages/zig/test/android_conformance_test.zigmodified+13-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -140,8 +140,11 @@ const zig_sources = [_][]const u8{
140140/// that dims the screen while it shows little (a workout's big timer);
141141/// 41 when the five crashReporter* methods arrived in the Kotlin first, as
142142/// iOS's did: they queue the page's error events in CraftCrashReporter.kt's
143/// on-disk outbox, which the uncaught-exception handler also writes to.
144const max_not_yet_migrated: usize = 41;
143/// on-disk outbox, which the uncaught-exception handler also writes to;
144/// 42 when heartRate did, as iOS's did (CraftHeartRate.swift): one GATT
145/// session, its reconnects and a recording kept on disk, owned by
146/// CraftHeartRate.kt and the foreground service that keeps it reading.
147const max_not_yet_migrated: usize = 42;
145148
146149/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
147150///
@@ -431,6 +434,14 @@ const deliberate_deferrals = [_]Deferral{
431434 .{ .action = "crashReporterFlush", .reason = "drives the reporter's own delivery thread and backoff" },
432435 .{ .action = "crashReporterContext", .reason = "answers the reporter's session and release, which only it holds" },
433436
437 // ---- Heart rate, which arrived in the Kotlin first ---------------
438 //
439 // One Bluetooth GATT session for the whole app, its reconnects, and a
440 // recording kept on disk while a foreground service keeps the strap
441 // reading with the screen locked. The native screens reach the same
442 // object through CraftNativeServices, so the page's door stays with it.
443 .{ .action = "heartRate", .reason = "drives the app-wide GATT session and recording CraftHeartRate.kt and its service own" },
444
434445 // The auth-persistence trio shares one timestamp in Kotlin-configured
435446 // EncryptedSharedPreferences. It now survives process death, but migrating
436447 // any one action would split ownership of the same key; the trio must move