also looking at this
Android: Bluetooth heart-rate straps, and offline media on WorkManager
#370
21 files
+2307
-56
| @@ -391,6 +391,7 @@ Edit `craft.config.json` in your Android project: | ||
| 391 | 391 | "enableGeolocation": true, |
| 392 | 392 | "enableBackgroundLocation": false, |
| 393 | 393 | "enableHealthConnect": false, |
| 394 | "enableHeartRate": false, | |
| 394 | 395 | "enableKeepAwake": true, |
| 395 | 396 | "enableDeepLinks": true, |
| 396 | 397 | "urlSchemes": ["myapp"], |
| @@ -515,6 +516,33 @@ window.addEventListener('craftBluetoothDevice', (e) => { | ||
| 515 | 516 | }); |
| 516 | 517 | window.craft.bluetooth.stopScan(); |
| 517 | 518 | |
| 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. | |
| 525 | window.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 | }); | |
| 530 | await window.craft.heartRate.scan({ timeout: 15 }); | |
| 531 | const strap = (await window.craft.heartRate.getDevices())[0]; // strongest signal first | |
| 532 | await window.craft.heartRate.connect(strap.id); // remembered; rejects TIMEOUT after 15 s while still asking | |
| 533 | await 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. | |
| 536 | await window.craft.heartRate.startRecording(); // every reading kept natively, app closed or not | |
| 537 | const { 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 | ||
| 518 | 546 | // NFC Tag Reading |
| 519 | 547 | const nfcData = await window.craft.scanNFC(); |
| 520 | 548 | console.log(nfcData); // Tag content |
| @@ -847,6 +875,7 @@ The generator adds manifest permissions from enabled capabilities: | ||
| 847 | 875 | | `enablePushNotifications` | `POST_NOTIFICATIONS` | |
| 848 | 876 | | `enableKeepAwake` | `WAKE_LOCK` | |
| 849 | 877 | | `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 | | |
| 850 | 879 | |
| 851 | 880 | Use `permissions.check('location')` and `permissions.request('location')` from |
| 852 | 881 | `craft-native/mobile` for runtime authorization. Android precise and |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | package {{PACKAGE_NAME}} |
| 2 | 2 | |
| 3 | import com.sun.net.httpserver.HttpServer | |
| 4 | 3 | import java.io.File |
| 5 | import java.net.InetSocketAddress | |
| 4 | import java.net.InetAddress | |
| 5 | import java.net.ServerSocket | |
| 6 | 6 | import java.nio.file.Files |
| 7 | 7 | import org.junit.Assert.assertArrayEquals |
| 8 | 8 | import org.junit.Assert.assertEquals |
| @@ -19,32 +19,46 @@ import org.junit.Test | ||
| 19 | 19 | class CraftNativeResumableDownloadTest { |
| 20 | 20 | private val body = "0123456789abcdefghij".toByteArray() |
| 21 | 21 | |
| 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 | */ | |
| 23 | 27 | 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() | |
| 41 | 55 | } |
| 42 | 56 | } |
| 43 | exchange.close() | |
| 44 | 57 | } |
| 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) } | |
| 48 | 62 | } |
| 49 | 63 | |
| 50 | 64 | private fun part(check: (File) -> Unit) { |
| @@ -100,6 +114,15 @@ class CraftNativeResumableDownloadTest { | ||
| 100 | 114 | } |
| 101 | 115 | } |
| 102 | 116 | |
| 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 | ||
| 103 | 126 | @Test |
| 104 | 127 | fun keepsWhatItHasWhenStopped() = part { file -> |
| 105 | 128 | serve { address, _ -> |
| @@ -50,6 +50,8 @@ const fixtures: CompileFixture[] = [ | ||
| 50 | 50 | config: { |
| 51 | 51 | renderer: 'native', |
| 52 | 52 | enableHealthConnect: true, |
| 53 | // A native screen's HeartRate module (CraftHeartRate.kt). | |
| 54 | enableHeartRate: true, | |
| 53 | 55 | enableHaptics: true, |
| 54 | 56 | enableShare: true, |
| 55 | 57 | enableSecureStorage: true, |
| @@ -83,6 +85,8 @@ const fixtures: CompileFixture[] = [ | ||
| 83 | 85 | enableGeolocation: true, |
| 84 | 86 | enableHaptics: true, |
| 85 | 87 | enableHealthConnect: true, |
| 88 | // The page's craft.heartRate and its foreground service. | |
| 89 | enableHeartRate: true, | |
| 86 | 90 | enableKeepAwake: true, |
| 87 | 91 | enablePushNotifications: true, |
| 88 | 92 | enableSecureStorage: true, |
| @@ -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 | ||
| 5 | import java.io.File | |
| 6 | import java.nio.file.Files | |
| 7 | import kotlin.math.abs | |
| 8 | import kotlin.math.sqrt | |
| 9 | ||
| 10 | /** org.json stands in for this on a phone; the JVM has none, so a small codec. */ | |
| 11 | internal 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 | ||
| 80 | fun bytes(vararg values: Int) = IntArray(values.size) { values[it] } | |
| 81 | ||
| 82 | fun 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 | ||
| 92 | fun directory(): File = Files.createTempDirectory("craft-heart-rate-").toFile() | |
| 93 | ||
| 94 | internal fun recorder(directory: File, writer: (ByteArray, File) -> Unit = ::craftHeartRateWriteAtomically) = | |
| 95 | CraftHeartRateRecording(directory, FixtureJson, { 1_800_000_000_000L }, writer) | |
| 96 | ||
| 97 | @Suppress("UNCHECKED_CAST") | |
| 98 | fun map(value: Any?) = value as Map<String, Any?> | |
| 99 | ||
| 100 | @Suppress("UNCHECKED_CAST") | |
| 101 | fun list(value: Any?) = value as List<Map<String, Any?>> | |
| 102 | ||
| 103 | fun 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 | ||
| 135 | fun 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 | ||
| 148 | fun 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 | ||
| 171 | fun 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 | ||
| 187 | fun 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 | ||
| 221 | fun 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 | ||
| 247 | fun 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 | ||
| 277 | fun 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 | ||
| 298 | fun 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 | ||
| 305 | fun 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 | } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | /** Run the Android heart-rate core (CraftHeartRateCore.kt) on the JVM against iOS's HeartRateUnitTests vectors. */ | |
| 2 | import { test } from 'bun:test' | |
| 3 | import { runKotlinFixture } from './testing' | |
| 4 | ||
| 5 | /** The core template and the fixture as one file: one package-less source, its imports first. */ | |
| 6 | async 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 | ||
| 15 | test('decodes 0x2A37 payloads and keeps the recording exactly as iOS does', async () => { | |
| 16 | await runKotlinFixture(await heartRateFixtureSource()) | |
| 17 | }, 240_000) | |
| @@ -1482,9 +1482,12 @@ describe('Craft Android builder', () => { | ||
| 1482 | 1482 | const shell = readFileSync(join(output, 'app/src/main/java/dev/craft/media/CraftNativeShell.kt'), 'utf8') |
| 1483 | 1483 | const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8') |
| 1484 | 1484 | 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. | |
| 1486 | 1486 | 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)') | |
| 1488 | 1491 | expect(shell).toContain('if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED') |
| 1489 | 1492 | // Resumed from the part file an earlier attempt left. |
| 1490 | 1493 | expect(shell).toContain('connection.setRequestProperty("Range", "bytes=$have-")') |
| @@ -2477,3 +2480,68 @@ it('emits native Bluetooth GATT support and versioned permissions only when enab | ||
| 2477 | 2480 | } |
| 2478 | 2481 | finally { rmSync(output, { recursive: true, force: true }) } |
| 2479 | 2482 | }) |
| 2483 | ||
| 2484 | it('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 | ||
| 2542 | it('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 | }) | |
| @@ -136,6 +136,15 @@ export interface CraftAndroidConfig { | ||
| 136 | 136 | enableGeolocation?: boolean |
| 137 | 137 | enableBackgroundLocation?: boolean |
| 138 | 138 | 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 | |
| 139 | 148 | enableHealthConnect?: boolean |
| 140 | 149 | /** |
| 141 | 150 | * The Health Connect data the app reads and writes, by the shared type |
| @@ -348,6 +357,7 @@ const DEFAULT_CONFIG: Omit<CraftAndroidConfig, 'appName' | 'packageName'> = { | ||
| 348 | 357 | enableGeolocation: false, |
| 349 | 358 | enableBackgroundLocation: false, |
| 350 | 359 | enableBluetooth: false, |
| 360 | enableHeartRate: false, | |
| 351 | 361 | enableHealthConnect: false, |
| 352 | 362 | enableKeepAwake: false, |
| 353 | 363 | enableDeepLinks: false, |
| @@ -673,7 +683,13 @@ export function renderAndroidPermissions(config: CraftAndroidConfig): string { | ||
| 673 | 683 | if (config.enableHealthConnect) healthConnectPermissions(config).forEach(permission => permissions.add(permission)) |
| 674 | 684 | // A background refresh (WorkManager) after a restart. |
| 675 | 685 | 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 ? [ | |
| 677 | 693 | ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />', |
| 678 | 694 | ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />', |
| 679 | 695 | ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />', |
| @@ -1033,6 +1049,7 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1033 | 1049 | .replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))) |
| 1034 | 1050 | .replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))) |
| 1035 | 1051 | .replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))) |
| 1052 | .replace(/\{\{ENABLE_HEART_RATE\}\}/g, String(Boolean(config.enableHeartRate))) | |
| 1036 | 1053 | .replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? 'import com.google.firebase.messaging.FirebaseMessaging' : '') |
| 1037 | 1054 | .replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications |
| 1038 | 1055 | ? `activity.runOnUiThread { |
| @@ -1079,6 +1096,16 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1079 | 1096 | join(output, 'app/src/main/java', packagePath, 'CraftHealthConnect.kt'), |
| 1080 | 1097 | healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName), |
| 1081 | 1098 | ) |
| 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 | } | |
| 1082 | 1109 | // Create AndroidManifest.xml |
| 1083 | 1110 | const manifestTemplate = readFileSync(join(TEMPLATES_DIR, 'AndroidManifest.xml.template'), 'utf-8') |
| 1084 | 1111 | const manifest = manifestTemplate |
| @@ -1092,6 +1119,9 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1092 | 1119 | .replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation |
| 1093 | 1120 | ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' |
| 1094 | 1121 | : '') |
| 1122 | .replace(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate | |
| 1123 | ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />' | |
| 1124 | : '') | |
| 1095 | 1125 | .replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === 'native' |
| 1096 | 1126 | ? renderNativeShellManifest(config) |
| 1097 | 1127 | : '') |
| @@ -518,3 +518,92 @@ describe('craft.speech on Android', () => { | ||
| 518 | 518 | expect(template).toContain('manager.abandonAudioFocusRequest(request)') |
| 519 | 519 | }) |
| 520 | 520 | }) |
| 521 | ||
| 522 | describe('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 | }) | |
| @@ -444,6 +444,9 @@ class CraftBridge( | ||
| 444 | 444 | bluetooth: false, |
| 445 | 445 | nfc: false, |
| 446 | 446 | health: , |
| 447 | heartRate: , | |
| 448 | heartRateRecordingOwnership: , | |
| 449 | heartRateRecordingIntentIdentity: , | |
| 447 | 450 | screenCapture: false |
| 448 | 451 | }, |
| 449 | 452 | |
| @@ -1808,6 +1811,85 @@ class CraftBridge( | ||
| 1808 | 1811 | readRecording: function(options) { return recordingCall('readRecording', options); }, |
| 1809 | 1812 | acknowledgeRecording: function(options) { return recordingCall('acknowledgeRecording', options); } |
| 1810 | 1813 | }; |
| 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 | }; | |
| 1811 | 1893 | var shareApi = function(text, title) { return legacyShare(text, title); }; |
| 1812 | 1894 | shareApi.share = function(options) { |
| 1813 | 1895 | options = options || {}; |
| @@ -2537,6 +2619,7 @@ class CraftBridge( | ||
| 2537 | 2619 | } |
| 2538 | 2620 | |
| 2539 | 2621 | fun onRequestPermissionsResult(requestCode: Int): Boolean { |
| 2622 | if (heartRateEnabled && CraftHeartRate.get(activity).onPermissions(requestCode)) return true | |
| 2540 | 2623 | if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END |
| 2541 | 2624 | // startListening asked for the microphone and is waiting on this. |
| 2542 | 2625 | if (requestCode == REQUEST_SPEECH) { |
| @@ -2820,6 +2903,54 @@ class CraftBridge( | ||
| 2820 | 2903 | return legacyRecordingAction("readRecording") |
| 2821 | 2904 | } |
| 2822 | 2905 | |
| 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 | ||
| 2823 | 2954 | // ==================== Clipboard ==================== |
| 2824 | 2955 | |
| 2825 | 2956 | @JavascriptInterface |
| @@ -4473,13 +4604,17 @@ class CraftBridge( | ||
| 4473 | 4604 | webView.evaluateJavascript( |
| 4474 | 4605 | "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" + |
| 4475 | 4606 | "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')", | |
| 4477 | 4609 | null |
| 4478 | 4610 | ) |
| 4479 | 4611 | } |
| 4480 | 4612 | } |
| 4481 | 4613 | CraftNative.close(activity) |
| 4482 | 4614 | 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) | |
| 4483 | 4618 | |
| 4484 | 4619 | if (shareReceiverRegistered) { |
| 4485 | 4620 | runCatching { activity.unregisterReceiver(shareChosenReceiver) } |
packages/android/templates/CraftHeartRate.kt.templateadded+938-0
| @@ -0,0 +1,938 @@ | ||
| 1 | package | |
| 2 | ||
| 3 | import android.Manifest | |
| 4 | import android.annotation.SuppressLint | |
| 5 | import android.app.Activity | |
| 6 | import android.app.NotificationChannel | |
| 7 | import android.app.NotificationManager | |
| 8 | import android.app.Service | |
| 9 | import android.bluetooth.BluetoothAdapter | |
| 10 | import android.bluetooth.BluetoothDevice | |
| 11 | import android.bluetooth.BluetoothGatt | |
| 12 | import android.bluetooth.BluetoothGattCallback | |
| 13 | import android.bluetooth.BluetoothGattCharacteristic | |
| 14 | import android.bluetooth.BluetoothGattDescriptor | |
| 15 | import android.bluetooth.BluetoothManager | |
| 16 | import android.bluetooth.BluetoothProfile | |
| 17 | import android.bluetooth.BluetoothStatusCodes | |
| 18 | import android.bluetooth.le.ScanCallback | |
| 19 | import android.bluetooth.le.ScanFilter | |
| 20 | import android.bluetooth.le.ScanResult | |
| 21 | import android.bluetooth.le.ScanSettings | |
| 22 | import android.content.BroadcastReceiver | |
| 23 | import android.content.Context | |
| 24 | import android.content.Intent | |
| 25 | import android.content.IntentFilter | |
| 26 | import android.content.pm.PackageManager | |
| 27 | import android.content.pm.ServiceInfo | |
| 28 | import android.os.Build | |
| 29 | import android.os.Handler | |
| 30 | import android.os.IBinder | |
| 31 | import android.os.Looper | |
| 32 | import android.os.ParcelUuid | |
| 33 | import android.util.Log | |
| 34 | import androidx.core.app.ActivityCompat | |
| 35 | import androidx.core.app.NotificationCompat | |
| 36 | import androidx.core.app.ServiceCompat | |
| 37 | import androidx.core.content.ContextCompat | |
| 38 | import org.json.JSONArray | |
| 39 | import org.json.JSONObject | |
| 40 | import org.json.JSONTokener | |
| 41 | import java.io.File | |
| 42 | import java.util.UUID | |
| 43 | import kotlin.math.roundToInt | |
| 44 | import kotlin.math.roundToLong | |
| 45 | import kotlin.math.sin | |
| 46 | ||
| 47 | /** Maps and lists as org.json values, null as JSONObject.NULL, for the bridge and the disk. */ | |
| 48 | internal fun craftHeartRateToJson(value: Any?): Any = when (value) { | |
| 49 | null -> JSONObject.NULL | |
| 50 | is JSONObject, is JSONArray -> value | |
| 51 | is Map<*, *> -> JSONObject().also { json -> value.forEach { (key, item) -> json.put(key.toString(), craftHeartRateToJson(item)) } } | |
| 52 | is List<*> -> JSONArray().also { json -> value.forEach { json.put(craftHeartRateToJson(it)) } } | |
| 53 | else -> value | |
| 54 | } | |
| 55 | ||
| 56 | /** org.json values back as maps and lists, JSONObject.NULL as null. */ | |
| 57 | internal fun craftHeartRateFromJson(value: Any?): Any? = when { | |
| 58 | value == null || value === JSONObject.NULL -> null | |
| 59 | value is JSONObject -> value.keys().asSequence().associateWith { craftHeartRateFromJson(value.opt(it)) } | |
| 60 | value is JSONArray -> (0 until value.length()).map { craftHeartRateFromJson(value.opt(it)) } | |
| 61 | else -> value | |
| 62 | } | |
| 63 | ||
| 64 | internal object CraftHeartRateOrgJson : CraftHeartRateJson { | |
| 65 | override fun encode(value: Any?): String = craftHeartRateToJson(value).toString() | |
| 66 | override fun decode(text: String): Any? = craftHeartRateFromJson(JSONTokener(text).nextValue()) | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate, read | |
| 71 | * during a workout: the page's `craft.heartRate` (CraftBridge.kt) and a | |
| 72 | * native screen's `HeartRate` module (CraftNativeServices.kt), one service | |
| 73 | * for the whole process. The Android half of CraftHeartRate.swift, with the | |
| 74 | * same methods, answers and `craftHeartRate` events. | |
| 75 | * | |
| 76 | * A GATT central for the standard Heart Rate Service (0x180D): | |
| 77 | * - `scan` finds what advertises the service nearby, each one a | |
| 78 | * `{type: 'device'}` event with its name and signal strength. | |
| 79 | * - `connect` subscribes to Heart Rate Measurement (0x2A37), writing its | |
| 80 | * client configuration descriptor; every reading is a `{type: 'reading'}` | |
| 81 | * event with bpm and, when the strap sends them, RR intervals. The device | |
| 82 | * (its address) is remembered, and `reconnect` asks for it again next time | |
| 83 | * without a scan. | |
| 84 | * - A strap that drops out (out of range, the battery, a sweaty contact) is | |
| 85 | * asked for again until `disconnect`: the old GATT client is closed and an | |
| 86 | * automatic connection, which never times out, waits for the strap, spaced | |
| 87 | * out by craftHeartRateReconnectDelayMs when the stack itself fails. A strap | |
| 88 | * this process has not seen in a scan is also looked for with a short | |
| 89 | * filtered scan, since Android needs to have seen a random address first. | |
| 90 | * - `startRecording` keeps every reading on disk until `stopRecording` | |
| 91 | * (CraftHeartRateRecording, CraftHeartRateCore.kt). While it runs, | |
| 92 | * CraftHeartRateService keeps the process in the foreground (a | |
| 93 | * connectedDevice service), so readings keep coming with the screen locked, | |
| 94 | * and the samples survive the app being closed. | |
| 95 | * | |
| 96 | * Connection changes are `{type: 'state'}` events. Every event goes to each | |
| 97 | * sink: the page's bridge (`page`) and the native screens' hub (`native`). | |
| 98 | * | |
| 99 | * Emulators: with a file named `CraftFakeHeartRateMonitor` in the app's files | |
| 100 | * directory (`adb shell run-as <package> touch files/CraftFakeHeartRateMonitor`) | |
| 101 | * a made-up strap stands in, whose readings are encoded as 0x2A37 payloads | |
| 102 | * and decoded like a real one's, its RR intervals varying with the breath. | |
| 103 | * Never on a device. | |
| 104 | * | |
| 105 | * Everything runs on the main thread: GATT and scan callbacks hop there first. | |
| 106 | */ | |
| 107 | @SuppressLint("MissingPermission") | |
| 108 | internal class CraftHeartRate private constructor(private val context: Context) { | |
| 109 | companion object { | |
| 110 | val SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") | |
| 111 | val MEASUREMENT: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") | |
| 112 | /** Client Characteristic Configuration: notifications are switched on here. */ | |
| 113 | val CLIENT_CONFIGURATION: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") | |
| 114 | /** How long `connect` waits before answering TIMEOUT. The strap is still asked for after that. */ | |
| 115 | const val CONNECT_TIMEOUT_MS = 15_000L | |
| 116 | /** A reading older than this is no longer "now". */ | |
| 117 | const val STALE_AFTER_MS = 10_000L | |
| 118 | /** How long a strap not yet seen is looked for in a scan, while its connection is pending. */ | |
| 119 | const val SEEK_MS = 30_000L | |
| 120 | const val PERMISSION_REQUEST = 4319 | |
| 121 | /** The stand-in strap's id: the iOS simulator's, so a test names it the same way on both. */ | |
| 122 | const val SIMULATED_ID = "00000000-0000-4000-8000-00000000180D" | |
| 123 | private const val PREFERENCES = "craft-heart-rate" | |
| 124 | private const val TAG = "CraftHeartRate" | |
| 125 | ||
| 126 | @Volatile private var instance: CraftHeartRate? = null | |
| 127 | ||
| 128 | fun get(context: Context): CraftHeartRate = instance ?: synchronized(this) { | |
| 129 | instance ?: CraftHeartRate(context.applicationContext).also { instance = it } | |
| 130 | } | |
| 131 | ||
| 132 | /** Whether this is an emulator: a hint for the stand-in strap, never a security boundary. */ | |
| 133 | fun isEmulator(): Boolean { | |
| 134 | val hardware = Build.HARDWARE.lowercase() | |
| 135 | if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true | |
| 136 | val fingerprint = Build.FINGERPRINT.lowercase() | |
| 137 | if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true | |
| 138 | if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true | |
| 139 | val product = Build.PRODUCT.lowercase() | |
| 140 | if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true | |
| 141 | val model = Build.MODEL.lowercase() | |
| 142 | return model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | private class Found(val device: BluetoothDevice?, val name: String, val rssi: Int) | |
| 147 | ||
| 148 | private val main = Handler(Looper.getMainLooper()) | |
| 149 | private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) | |
| 150 | private val manager get() = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager | |
| 151 | private val adapter: BluetoothAdapter? get() = manager?.adapter | |
| 152 | private val clock: () -> Long = System::currentTimeMillis | |
| 153 | ||
| 154 | /** Where each event goes, by who listens: `page` (CraftBridge) and `native` (the screens' hub). */ | |
| 155 | val sinks = java.util.concurrent.ConcurrentHashMap<String, (JSONObject) -> Unit>() | |
| 156 | ||
| 157 | val recording = CraftHeartRateRecording(context.noBackupFilesDir, CraftHeartRateOrgJson, clock) | |
| 158 | ||
| 159 | private val discovered = linkedMapOf<String, Found>() | |
| 160 | private var scanning = false | |
| 161 | private var scannerRunning = false | |
| 162 | private var scanStop: Runnable? = null | |
| 163 | /** The remembered strap's address while it is looked for in a scan. */ | |
| 164 | private var seeking: String? = null | |
| 165 | private var seekStop: Runnable? = null | |
| 166 | private var gatt: BluetoothGatt? = null | |
| 167 | private var device: BluetoothDevice? = null | |
| 168 | private var deviceId: String? = null | |
| 169 | private var deviceName: String? = null | |
| 170 | private var connection = "disconnected" | |
| 171 | /** Between connect and disconnect: a lost strap is asked for again. */ | |
| 172 | private var wantsConnection = false | |
| 173 | private var linkConnected = false | |
| 174 | private val connectWaiters = mutableListOf<(Result<Any?>) -> Unit>() | |
| 175 | private var connectDeadline: Runnable? = null | |
| 176 | private var latest: CraftHeartRateMeasurement? = null | |
| 177 | private var latestAt: Long? = null | |
| 178 | // Packet freshness is separate from a valid BPM: a monitor reporting no | |
| 179 | // skin contact is still delivering notifications and needs no reconnect. | |
| 180 | private var packetAt: Long? = null | |
| 181 | private var subscriptionAt: Long? = null | |
| 182 | private var recoveryTimer: Runnable? = null | |
| 183 | private var reconnectTimer: Runnable? = null | |
| 184 | private var reconnectAttempt = 0 | |
| 185 | private var hasSubscribed = false | |
| 186 | private var radioWatched = false | |
| 187 | private val permissionWaiters = mutableListOf<(Boolean) -> Unit>() | |
| 188 | ||
| 189 | // MARK: The emulator's stand-in | |
| 190 | ||
| 191 | private val simulated: Boolean by lazy { isEmulator() && File(context.filesDir, "CraftFakeHeartRateMonitor").exists() } | |
| 192 | private var simulatedTimer: Runnable? = null | |
| 193 | private var simulatedBeat = 0 | |
| 194 | /** Milliseconds since the stand-in connected, and how far its beats reach. */ | |
| 195 | private var simulatedElapsed = 0.0 | |
| 196 | private var simulatedBeatsUntil = 0.0 | |
| 197 | ||
| 198 | // MARK: Entry | |
| 199 | ||
| 200 | /** | |
| 201 | * One call from a page or a native screen, by method name. `activity` is | |
| 202 | * the screen that can show Android's permission prompt; null in a | |
| 203 | * background worker, where nothing can ask. | |
| 204 | */ | |
| 205 | fun perform(method: String, options: JSONObject, activity: Activity?, done: (Result<Any?>) -> Unit) { | |
| 206 | @Suppress("UNCHECKED_CAST") | |
| 207 | val values = craftHeartRateFromJson(options) as? Map<String, Any?> ?: emptyMap() | |
| 208 | val answer: (Result<Any?>) -> Unit = { result -> done(result.map { craftHeartRateToJson(it) }) } | |
| 209 | main.post { | |
| 210 | watchRadio() | |
| 211 | try { | |
| 212 | if (method in CraftHeartRateRecording.METHODS) { | |
| 213 | val value = recording.perform(method, values) | |
| 214 | keepRecordingAlive() | |
| 215 | return@post answer(Result.success(value)) | |
| 216 | } | |
| 217 | recording.restore() | |
| 218 | when (method) { | |
| 219 | "getState" -> { | |
| 220 | recoverQuietBroadcast() | |
| 221 | answer(Result.success(state())) | |
| 222 | } | |
| 223 | "requestAuthorization" -> requestAuthorization(activity, answer) | |
| 224 | "scan" -> scan((values["timeout"] as? Number)?.toDouble(), activity, answer) | |
| 225 | "stopScan" -> { | |
| 226 | stopScan() | |
| 227 | answer(Result.success(state())) | |
| 228 | } | |
| 229 | "getDevices" -> answer(Result.success(devices())) | |
| 230 | "connect" -> connect(values["id"] as? String, wait = true, activity = activity, done = answer) | |
| 231 | "reconnect" -> connect(null, wait = false, activity = activity, done = answer) | |
| 232 | "disconnect" -> disconnect(values["forget"] == true, answer) | |
| 233 | else -> throw CraftHeartRateException("UNKNOWN_ACTION", "Unsupported HeartRate method") | |
| 234 | } | |
| 235 | } catch (error: CraftHeartRateException) { | |
| 236 | answer(Result.failure(error)) | |
| 237 | } catch (error: SecurityException) { | |
| 238 | answer(Result.failure(CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed"))) | |
| 239 | } catch (error: Exception) { | |
| 240 | Log.w(TAG, "HeartRate.$method failed", error) | |
| 241 | answer(Result.failure(CraftHeartRateException("CRAFT_ERROR", error.message ?: "Heart rate failed"))) | |
| 242 | } | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | /** CraftHeartRateService, restarted by Android: the recording carries on, and so does its strap. */ | |
| 247 | fun resumeAfterRestart() { | |
| 248 | main.post { | |
| 249 | watchRadio() | |
| 250 | runCatching { recording.restore() }.onFailure { Log.w(TAG, "Unable to restore the heart-rate recording", it) } | |
| 251 | keepRecordingAlive() | |
| 252 | if (recording.active && !wantsConnection && permitted()) connect(null, wait = false, activity = null) {} | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | // MARK: State | |
| 257 | ||
| 258 | private fun permissions(): Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | |
| 259 | arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) | |
| 260 | } else { | |
| 261 | // Before Android 12 a BLE scan needs location; BLUETOOTH and | |
| 262 | // BLUETOOTH_ADMIN are granted at install. | |
| 263 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) | |
| 264 | } | |
| 265 | ||
| 266 | private fun permitted(): Boolean = simulated || permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } | |
| 267 | ||
| 268 | private val authorization: String | |
| 269 | get() = if (simulated) "authorized" else craftHeartRateAuthorizationName(permitted(), preferences.getBoolean("asked", false)) | |
| 270 | ||
| 271 | private val bluetooth: String | |
| 272 | get() { | |
| 273 | if (simulated) return "poweredOn" | |
| 274 | val radio = adapter | |
| 275 | val radioState = try { radio?.state } catch (_: SecurityException) { null } | |
| 276 | return craftHeartRateBluetoothName(radio != null, radioState, authorization) | |
| 277 | } | |
| 278 | ||
| 279 | private fun remembered(): Map<String, Any?>? { | |
| 280 | val id = preferences.getString("lastDeviceId", null) ?: return null | |
| 281 | return mapOf("id" to id, "name" to (preferences.getString("lastDeviceName", null) ?: "Heart rate monitor")) | |
| 282 | } | |
| 283 | ||
| 284 | private fun remember(id: String, name: String) { | |
| 285 | preferences.edit().putString("lastDeviceId", id).putString("lastDeviceName", name).apply() | |
| 286 | } | |
| 287 | ||
| 288 | fun state(): Map<String, Any?> { | |
| 289 | val fresh = latestAt?.let { clock() - it <= STALE_AFTER_MS } ?: false | |
| 290 | return mapOf( | |
| 291 | "available" to (bluetooth != "unsupported"), | |
| 292 | "bluetooth" to bluetooth, | |
| 293 | "authorization" to authorization, | |
| 294 | "scanning" to scanning, | |
| 295 | "connection" to connection, | |
| 296 | "device" to deviceId?.let { mapOf("id" to it, "name" to (deviceName ?: "Heart rate monitor")) }, | |
| 297 | "lastDevice" to remembered(), | |
| 298 | "bpm" to (if (fresh) latest?.bpm else null), | |
| 299 | "lastReadingAt" to latestAt, | |
| 300 | "recording" to recording.summary(false), | |
| 301 | ) | |
| 302 | } | |
| 303 | ||
| 304 | private fun emit(detail: Map<String, Any?>) { | |
| 305 | val json = craftHeartRateToJson(detail) as JSONObject | |
| 306 | sinks.values.forEach { sink -> runCatching { sink(json) }.onFailure { Log.w(TAG, "A heart-rate listener failed", it) } } | |
| 307 | } | |
| 308 | ||
| 309 | private fun emitState() = emit(state() + ("type" to "state")) | |
| 310 | ||
| 311 | // MARK: Bluetooth | |
| 312 | ||
| 313 | /** | |
| 314 | * Android's Nearby devices prompt (location before Android 12), asked for | |
| 315 | * from a screen. Without one, the answer is what was already allowed. | |
| 316 | */ | |
| 317 | private fun withPermission(activity: Activity?, then: (Boolean) -> Unit) { | |
| 318 | if (permitted()) return then(true) | |
| 319 | if (activity == null) return then(false) | |
| 320 | permissionWaiters.add(then) | |
| 321 | if (permissionWaiters.size > 1) return | |
| 322 | preferences.edit().putBoolean("asked", true).apply() | |
| 323 | ActivityCompat.requestPermissions(activity, permissions(), PERMISSION_REQUEST) | |
| 324 | } | |
| 325 | ||
| 326 | /** The screen's permission answer (MainActivity routes PERMISSION_REQUEST here). */ | |
| 327 | fun onPermissions(requestCode: Int): Boolean { | |
| 328 | if (requestCode != PERMISSION_REQUEST) return false | |
| 329 | main.post { | |
| 330 | val granted = permitted() | |
| 331 | val waiting = permissionWaiters.toList() | |
| 332 | permissionWaiters.clear() | |
| 333 | waiting.forEach { it(granted) } | |
| 334 | emitState() | |
| 335 | } | |
| 336 | return true | |
| 337 | } | |
| 338 | ||
| 339 | /** A reason the radio cannot be used now, or null when it can. */ | |
| 340 | private fun unusable(): CraftHeartRateException? { | |
| 341 | if (!permitted()) return CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed") | |
| 342 | return when (bluetooth) { | |
| 343 | "poweredOn" -> null | |
| 344 | "poweredOff" -> CraftHeartRateException("BLUETOOTH_OFF", "Bluetooth is off") | |
| 345 | "unauthorized" -> CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed") | |
| 346 | "unsupported" -> CraftHeartRateException("UNSUPPORTED", "This device has no Bluetooth") | |
| 347 | else -> CraftHeartRateException("BLUETOOTH_UNAVAILABLE", "Bluetooth is not ready") | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 351 | /** Asks when it has to, then runs `run` with a radio that can be used, or answers why not. */ | |
| 352 | private fun whenReady(activity: Activity?, done: (Result<Any?>) -> Unit, run: () -> Unit) { | |
| 353 | withPermission(activity) { | |
| 354 | val reason = unusable() | |
| 355 | if (reason != null) done(Result.failure(reason)) | |
| 356 | else try { run() } catch (_: SecurityException) { done(Result.failure(CraftHeartRateException("NOT_AUTHORIZED", "Bluetooth access was not allowed"))) } | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | private fun requestAuthorization(activity: Activity?, done: (Result<Any?>) -> Unit) { | |
| 361 | withPermission(activity) { done(Result.success(authorization)) } | |
| 362 | } | |
| 363 | ||
| 364 | /** Bluetooth turned off and on again: the strap is asked for again, a scan stops. */ | |
| 365 | private val radioReceiver = object : BroadcastReceiver() { | |
| 366 | override fun onReceive(context: Context, intent: Intent) { | |
| 367 | if (intent.action != BluetoothAdapter.ACTION_STATE_CHANGED) return | |
| 368 | val radioState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) | |
| 369 | main.post { | |
| 370 | if (radioState == BluetoothAdapter.STATE_ON) { | |
| 371 | if (wantsConnection && gatt == null && !simulated) { | |
| 372 | connection = "reconnecting" | |
| 373 | openGatt(automatic = true) | |
| 374 | } | |
| 375 | updateScanner() | |
| 376 | } else if (radioState == BluetoothAdapter.STATE_OFF || radioState == BluetoothAdapter.STATE_TURNING_OFF) { | |
| 377 | scanning = false | |
| 378 | scannerRunning = false | |
| 379 | scanStop?.let(main::removeCallbacks) | |
| 380 | scanStop = null | |
| 381 | if (!simulated) { | |
| 382 | closeGatt() | |
| 383 | if (wantsConnection && connection != "disconnected") connection = "reconnecting" | |
| 384 | } | |
| 385 | } | |
| 386 | emitState() | |
| 387 | } | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | private fun watchRadio() { | |
| 392 | if (radioWatched) return | |
| 393 | radioWatched = true | |
| 394 | // Exported: a protected broadcast only the system sends. Not-exported | |
| 395 | // would, before Android 13, filter out the Bluetooth process sending it. | |
| 396 | ContextCompat.registerReceiver(context, radioReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED) | |
| 397 | } | |
| 398 | ||
| 399 | // MARK: Scanning | |
| 400 | ||
| 401 | private fun devices(): List<Map<String, Any?>> = discovered | |
| 402 | .map { (id, found) -> mapOf<String, Any?>("id" to id, "name" to found.name, "rssi" to found.rssi) } | |
| 403 | .sortedByDescending { it["rssi"] as Int } | |
| 404 | ||
| 405 | private fun found(id: String, device: BluetoothDevice?, name: String, rssi: Int) { | |
| 406 | val known = discovered.containsKey(id) | |
| 407 | discovered[id] = Found(device, name, rssi) | |
| 408 | if (!known && scanning) emit(mapOf("type" to "device", "device" to mapOf("id" to id, "name" to name, "rssi" to rssi))) | |
| 409 | } | |
| 410 | ||
| 411 | private fun scan(seconds: Double?, activity: Activity?, done: (Result<Any?>) -> Unit) { | |
| 412 | whenReady(activity, done) { | |
| 413 | discovered.clear() | |
| 414 | scanning = true | |
| 415 | if (simulated) found(SIMULATED_ID, null, "Simulated HRM", -48) | |
| 416 | else updateScanner() | |
| 417 | scanStop?.let(main::removeCallbacks) | |
| 418 | scanStop = Runnable { stopScan() }.also { main.postDelayed(it, ((seconds ?: 15.0).coerceIn(1.0, 60.0) * 1000).roundToLong()) } | |
| 419 | emitState() | |
| 420 | done(Result.success(state())) | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | private fun stopScan() { | |
| 425 | scanStop?.let(main::removeCallbacks) | |
| 426 | scanStop = null | |
| 427 | if (!scanning) return | |
| 428 | scanning = false | |
| 429 | updateScanner() | |
| 430 | emitState() | |
| 431 | } | |
| 432 | ||
| 433 | /** One LE scan, filtered on 0x180D, while a person scans or a remembered strap is looked for. */ | |
| 434 | private fun updateScanner() { | |
| 435 | if (simulated) return | |
| 436 | val wanted = (scanning || seeking != null) && permitted() && adapter?.isEnabled == true | |
| 437 | if (wanted == scannerRunning) return | |
| 438 | val scanner = adapter?.bluetoothLeScanner | |
| 439 | if (wanted) { | |
| 440 | if (scanner == null) return | |
| 441 | val filters = listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build()) | |
| 442 | val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() | |
| 443 | scannerRunning = runCatching { scanner.startScan(filters, settings, scanCallback) } | |
| 444 | .onFailure { Log.w(TAG, "Heart-rate scan could not start", it) } | |
| 445 | .isSuccess | |
| 446 | } else { | |
| 447 | runCatching { scanner?.stopScan(scanCallback) } | |
| 448 | scannerRunning = false | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | private val scanCallback = object : ScanCallback() { | |
| 453 | override fun onScanResult(callbackType: Int, result: ScanResult) { | |
| 454 | val address = result.device.address | |
| 455 | val advertised = result.scanRecord?.deviceName | |
| 456 | val rssi = result.rssi | |
| 457 | main.post { | |
| 458 | val name = advertised ?: runCatching { result.device.name }.getOrNull() ?: "Heart rate monitor" | |
| 459 | // 127 is "no reading". | |
| 460 | found(address, result.device, name, if (rssi == 127) -100 else rssi) | |
| 461 | if (seeking == address) { | |
| 462 | stopSeeking() | |
| 463 | if (wantsConnection && deviceId == address && !linkConnected) { | |
| 464 | device = result.device | |
| 465 | openGatt(automatic = false) | |
| 466 | } | |
| 467 | } | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| 471 | override fun onScanFailed(errorCode: Int) { | |
| 472 | main.post { | |
| 473 | Log.w(TAG, "Heart-rate scan failed ($errorCode)") | |
| 474 | scannerRunning = false | |
| 475 | if (scanning) { | |
| 476 | scanning = false | |
| 477 | emitState() | |
| 478 | } | |
| 479 | } | |
| 480 | } | |
| 481 | } | |
| 482 | ||
| 483 | private fun seek(address: String) { | |
| 484 | if (simulated) return | |
| 485 | seeking = address | |
| 486 | seekStop?.let(main::removeCallbacks) | |
| 487 | seekStop = Runnable { stopSeeking() }.also { main.postDelayed(it, SEEK_MS) } | |
| 488 | updateScanner() | |
| 489 | } | |
| 490 | ||
| 491 | private fun stopSeeking() { | |
| 492 | seekStop?.let(main::removeCallbacks) | |
| 493 | seekStop = null | |
| 494 | if (seeking == null) return | |
| 495 | seeking = null | |
| 496 | updateScanner() | |
| 497 | } | |
| 498 | ||
| 499 | // MARK: Connecting | |
| 500 | ||
| 501 | private fun connect(id: String?, wait: Boolean, activity: Activity?, done: (Result<Any?>) -> Unit) { | |
| 502 | val target = id ?: remembered()?.get("id") as? String | |
| 503 | if (target == null) { | |
| 504 | if (wait) done(Result.failure(CraftHeartRateException("INVALID_ARGUMENT", "connect needs the id of a device from scan"))) | |
| 505 | else done(Result.success(state())) | |
| 506 | return | |
| 507 | } | |
| 508 | whenReady(activity, done) { | |
| 509 | // A connected link can lose its notification subscription when a | |
| 510 | // broadcasting watch restarts. Keep healthy links, repair quiet ones. | |
| 511 | if (deviceId == target && wantsConnection) { | |
| 512 | recoverQuietBroadcast() | |
| 513 | if (connection == "connected" || !wait) return@whenReady done(Result.success(state())) | |
| 514 | connectWaiters.add(done) | |
| 515 | armConnectDeadline() | |
| 516 | return@whenReady | |
| 517 | } | |
| 518 | release() | |
| 519 | val saved = remembered() | |
| 520 | val savedName = if (saved?.get("id") == target) saved["name"] as? String else null | |
| 521 | val name = discovered[target]?.name ?: savedName ?: "Heart rate monitor" | |
| 522 | if (simulated) { | |
| 523 | if (target != SIMULATED_ID) return@whenReady done(Result.failure(CraftHeartRateException("NOT_FOUND", "No heart-rate monitor with that id is nearby"))) | |
| 524 | } else { | |
| 525 | val next = discovered[target]?.device | |
| 526 | ?: target.takeIf(BluetoothAdapter::checkBluetoothAddress)?.let { adapter?.getRemoteDevice(it) } | |
| 527 | ?: return@whenReady done(Result.failure(CraftHeartRateException("NOT_FOUND", "No heart-rate monitor with that id is nearby"))) | |
| 528 | device = next | |
| 529 | } | |
| 530 | deviceId = target | |
| 531 | deviceName = name | |
| 532 | wantsConnection = true | |
| 533 | connection = "connecting" | |
| 534 | subscriptionAt = clock() | |
| 535 | reconnectAttempt = 0 | |
| 536 | startRecoveryWatch() | |
| 537 | remember(target, name) | |
| 538 | if (wait) { | |
| 539 | connectWaiters.add(done) | |
| 540 | armConnectDeadline() | |
| 541 | } | |
| 542 | if (simulated) main.postDelayed({ simulatedConnected() }, 300) | |
| 543 | else { | |
| 544 | openGatt(automatic = false) | |
| 545 | // Android connects to an address it has seen; one it has not | |
| 546 | // (a random address, after a restart) is found by a scan. | |
| 547 | if (!discovered.containsKey(target)) seek(target) | |
| 548 | } | |
| 549 | emitState() | |
| 550 | if (!wait) done(Result.success(state())) | |
| 551 | } | |
| 552 | } | |
| 553 | ||
| 554 | /** `connect` answers TIMEOUT after a while; the strap is still asked for, since it may wake up once it is worn. */ | |
| 555 | private fun armConnectDeadline() { | |
| 556 | if (connectDeadline != null) return | |
| 557 | connectDeadline = Runnable { | |
| 558 | connectDeadline = null | |
| 559 | if (connection != "connected") settleConnect(Result.failure(CraftHeartRateException("TIMEOUT", "The heart-rate monitor did not answer. Wear it to wake it up."))) | |
| 560 | }.also { main.postDelayed(it, CONNECT_TIMEOUT_MS) } | |
| 561 | } | |
| 562 | ||
| 563 | private fun settleConnect(result: Result<Any?>) { | |
| 564 | connectDeadline?.let(main::removeCallbacks) | |
| 565 | connectDeadline = null | |
| 566 | val waiting = connectWaiters.toList() | |
| 567 | connectWaiters.clear() | |
| 568 | waiting.forEach { it(result) } | |
| 569 | } | |
| 570 | ||
| 571 | /** A GATT client for the chosen strap; `automatic` waits for it as long as it takes. */ | |
| 572 | private fun openGatt(automatic: Boolean) { | |
| 573 | val target = device ?: return | |
| 574 | closeGatt() | |
| 575 | reconnectTimer?.let(main::removeCallbacks) | |
| 576 | reconnectTimer = null | |
| 577 | gatt = target.connectGatt(context, automatic, gattCallback, BluetoothDevice.TRANSPORT_LE) | |
| 578 | } | |
| 579 | ||
| 580 | /** Lets go of the GATT client: disconnected and closed, so Android frees it. */ | |
| 581 | private fun closeGatt() { | |
| 582 | val old = gatt ?: return | |
| 583 | gatt = null | |
| 584 | linkConnected = false | |
| 585 | runCatching { old.disconnect() } | |
| 586 | runCatching { old.close() } | |
| 587 | } | |
| 588 | ||
| 589 | /** After a dropped link: ask again, with an automatic connection, once the delay for this attempt has passed. */ | |
| 590 | private fun scheduleReconnect() { | |
| 591 | reconnectTimer?.let(main::removeCallbacks) | |
| 592 | reconnectTimer = Runnable { | |
| 593 | reconnectTimer = null | |
| 594 | if (!wantsConnection || gatt != null || simulated) return@Runnable | |
| 595 | if (!permitted() || adapter?.isEnabled != true) return@Runnable | |
| 596 | openGatt(automatic = true) | |
| 597 | deviceId?.takeUnless(discovered::containsKey)?.let { seek(it) } | |
| 598 | }.also { main.postDelayed(it, craftHeartRateReconnectDelayMs(reconnectAttempt++)) } | |
| 599 | } | |
| 600 | ||
| 601 | /** Lets go of the current strap without telling anyone. */ | |
| 602 | private fun release() { | |
| 603 | wantsConnection = false | |
| 604 | hasSubscribed = false | |
| 605 | recoveryTimer?.let(main::removeCallbacks) | |
| 606 | recoveryTimer = null | |
| 607 | reconnectTimer?.let(main::removeCallbacks) | |
| 608 | reconnectTimer = null | |
| 609 | packetAt = null | |
| 610 | subscriptionAt = null | |
| 611 | simulatedTimer?.let(main::removeCallbacks) | |
| 612 | simulatedTimer = null | |
| 613 | stopSeeking() | |
| 614 | closeGatt() | |
| 615 | device = null | |
| 616 | deviceId = null | |
| 617 | deviceName = null | |
| 618 | latest = null | |
| 619 | latestAt = null | |
| 620 | connection = "disconnected" | |
| 621 | settleConnect(Result.failure(CraftHeartRateException("CANCELLED", "Another heart-rate monitor was chosen"))) | |
| 622 | } | |
| 623 | ||
| 624 | private fun disconnect(forget: Boolean, done: (Result<Any?>) -> Unit) { | |
| 625 | release() | |
| 626 | if (forget) preferences.edit().remove("lastDeviceId").remove("lastDeviceName").apply() | |
| 627 | emitState() | |
| 628 | done(Result.success(state())) | |
| 629 | } | |
| 630 | ||
| 631 | private val gattCallback = object : BluetoothGattCallback() { | |
| 632 | override fun onConnectionStateChange(peer: BluetoothGatt, status: Int, newState: Int) { | |
| 633 | main.post { | |
| 634 | if (peer !== gatt) { | |
| 635 | // A client this service already let go of. | |
| 636 | if (newState == BluetoothProfile.STATE_DISCONNECTED) runCatching { peer.close() } | |
| 637 | return@post | |
| 638 | } | |
| 639 | if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { | |
| 640 | linkConnected = true | |
| 641 | stopSeeking() | |
| 642 | if (!wantsConnection) return@post closeGatt() | |
| 643 | packetAt = null | |
| 644 | subscriptionAt = clock() | |
| 645 | if (!peer.discoverServices()) restartBroadcast() | |
| 646 | return@post | |
| 647 | } | |
| 648 | // Dropped, or never made: close this client and ask again. | |
| 649 | latest = null | |
| 650 | latestAt = null | |
| 651 | packetAt = null | |
| 652 | subscriptionAt = clock() | |
| 653 | closeGatt() | |
| 654 | if (wantsConnection) { | |
| 655 | connection = "reconnecting" | |
| 656 | scheduleReconnect() | |
| 657 | } else { | |
| 658 | connection = "disconnected" | |
| 659 | } | |
| 660 | emitState() | |
| 661 | } | |
| 662 | } | |
| 663 | ||
| 664 | override fun onServicesDiscovered(peer: BluetoothGatt, status: Int) { | |
| 665 | main.post { | |
| 666 | if (peer !== gatt || !wantsConnection) return@post | |
| 667 | if (status != BluetoothGatt.GATT_SUCCESS) return@post restartBroadcast() | |
| 668 | val measurement = peer.getService(SERVICE)?.getCharacteristic(MEASUREMENT) | |
| 669 | val descriptor = measurement?.getDescriptor(CLIENT_CONFIGURATION) | |
| 670 | if (measurement == null || descriptor == null) { | |
| 671 | if (hasSubscribed) restartBroadcast() else notAHeartRateMonitor() | |
| 672 | return@post | |
| 673 | } | |
| 674 | if (!peer.setCharacteristicNotification(measurement, true) || !writeNotify(peer, descriptor)) restartBroadcast() | |
| 675 | } | |
| 676 | } | |
| 677 | ||
| 678 | override fun onDescriptorWrite(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { | |
| 679 | main.post { | |
| 680 | if (peer !== gatt || !wantsConnection) return@post | |
| 681 | if (descriptor.uuid != CLIENT_CONFIGURATION || descriptor.characteristic.uuid != MEASUREMENT) return@post | |
| 682 | if (status != BluetoothGatt.GATT_SUCCESS) return@post restartBroadcast() | |
| 683 | hasSubscribed = true | |
| 684 | subscriptionAt = clock() | |
| 685 | reconnectAttempt = 0 | |
| 686 | connection = "connected" | |
| 687 | keepRecordingAlive() | |
| 688 | emitState() | |
| 689 | settleConnect(Result.success(state())) | |
| 690 | } | |
| 691 | } | |
| 692 | ||
| 693 | // Garmin may invalidate its service when the heart-rate broadcast is | |
| 694 | // toggled without dropping the link (Android 12+ tells us). Discover | |
| 695 | // and subscribe to the replacement. | |
| 696 | override fun onServiceChanged(peer: BluetoothGatt) { | |
| 697 | main.post { | |
| 698 | if (peer !== gatt || !wantsConnection) return@post | |
| 699 | connection = "reconnecting" | |
| 700 | latest = null | |
| 701 | latestAt = null | |
| 702 | packetAt = null | |
| 703 | subscriptionAt = clock() | |
| 704 | if (!peer.discoverServices()) restartBroadcast() | |
| 705 | emitState() | |
| 706 | } | |
| 707 | } | |
| 708 | ||
| 709 | // Android 13 and later hand over the value with the notification. | |
| 710 | override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { | |
| 711 | notified(peer, characteristic, value.copyOf()) | |
| 712 | } | |
| 713 | ||
| 714 | @Deprecated("Android 12 and earlier") | |
| 715 | @Suppress("DEPRECATION") | |
| 716 | override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { | |
| 717 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return | |
| 718 | notified(peer, characteristic, characteristic.value?.copyOf() ?: return) | |
| 719 | } | |
| 720 | } | |
| 721 | ||
| 722 | private fun notified(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { | |
| 723 | if (characteristic.uuid != MEASUREMENT) return | |
| 724 | main.post { | |
| 725 | if (peer !== gatt || !wantsConnection) return@post | |
| 726 | received(value) | |
| 727 | } | |
| 728 | } | |
| 729 | ||
| 730 | /** Switches notifications on in the client configuration descriptor. */ | |
| 731 | @Suppress("DEPRECATION") | |
| 732 | private fun writeNotify(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor): Boolean { | |
| 733 | val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE | |
| 734 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | |
| 735 | peer.writeDescriptor(descriptor, enable) == BluetoothStatusCodes.SUCCESS | |
| 736 | } else { | |
| 737 | descriptor.value = enable | |
| 738 | peer.writeDescriptor(descriptor) | |
| 739 | } | |
| 740 | } | |
| 741 | ||
| 742 | private fun notAHeartRateMonitor() { | |
| 743 | wantsConnection = false | |
| 744 | hasSubscribed = false | |
| 745 | recoveryTimer?.let(main::removeCallbacks) | |
| 746 | recoveryTimer = null | |
| 747 | closeGatt() | |
| 748 | connection = "disconnected" | |
| 749 | settleConnect(Result.failure(CraftHeartRateException("NOT_HEART_RATE_MONITOR", "That device does not send a heart rate"))) | |
| 750 | emitState() | |
| 751 | } | |
| 752 | ||
| 753 | /** | |
| 754 | * Run independently of any player or big-timer screen. A pending radio | |
| 755 | * connection stays pending until the strap returns; only a link that went | |
| 756 | * quiet is restarted. | |
| 757 | */ | |
| 758 | private fun startRecoveryWatch() { | |
| 759 | recoveryTimer?.let(main::removeCallbacks) | |
| 760 | recoveryTimer = object : Runnable { | |
| 761 | override fun run() { | |
| 762 | recoverQuietBroadcast() | |
| 763 | if (recoveryTimer === this) main.postDelayed(this, 5_000) | |
| 764 | } | |
| 765 | }.also { main.postDelayed(it, 5_000) } | |
| 766 | } | |
| 767 | ||
| 768 | private fun recoverQuietBroadcast() { | |
| 769 | if (!wantsConnection) return | |
| 770 | val since = packetAt ?: subscriptionAt ?: return | |
| 771 | val link = if (simulated) connection == "connected" else linkConnected | |
| 772 | if (craftHeartRateShouldRestart(connection, link, clock() - since)) restartBroadcast() | |
| 773 | } | |
| 774 | ||
| 775 | /** | |
| 776 | * Keep the recording and the device choice while replacing the | |
| 777 | * subscription: a connected link is dropped (its disconnect asks again), | |
| 778 | * anything else is asked for again straight away. | |
| 779 | */ | |
| 780 | private fun restartBroadcast() { | |
| 781 | if (!wantsConnection) return | |
| 782 | connection = "reconnecting" | |
| 783 | latest = null | |
| 784 | latestAt = null | |
| 785 | packetAt = null | |
| 786 | subscriptionAt = clock() | |
| 787 | emitState() | |
| 788 | if (simulated) { | |
| 789 | simulatedTimer?.let(main::removeCallbacks) | |
| 790 | simulatedTimer = null | |
| 791 | main.postDelayed({ simulatedConnected() }, 300) | |
| 792 | return | |
| 793 | } | |
| 794 | if (adapter?.isEnabled != true) return | |
| 795 | val current = gatt | |
| 796 | if (current != null && linkConnected) { | |
| 797 | runCatching { current.disconnect() } | |
| 798 | // Should the stack never report the drop, let go of it anyway. | |
| 799 | main.postDelayed({ | |
| 800 | if (gatt === current && wantsConnection) { | |
| 801 | closeGatt() | |
| 802 | scheduleReconnect() | |
| 803 | } | |
| 804 | }, 3_000) | |
| 805 | } else { | |
| 806 | closeGatt() | |
| 807 | scheduleReconnect() | |
| 808 | } | |
| 809 | } | |
| 810 | ||
| 811 | // MARK: Readings | |
| 812 | ||
| 813 | private fun received(bytes: ByteArray) { | |
| 814 | // A strap reads 0 between losing the skin and giving up; that is no | |
| 815 | // heart rate, and neither is anything a heart could not do. | |
| 816 | packetAt = clock() | |
| 817 | val reading = CraftHeartRateMeasurement.parse(bytes) | |
| 818 | if (reading == null || reading.sensorContact == false || reading.bpm < 20 || reading.bpm > 250) { | |
| 819 | latest = null | |
| 820 | latestAt = null | |
| 821 | emitState() | |
| 822 | return | |
| 823 | } | |
| 824 | val now = clock() | |
| 825 | latest = reading | |
| 826 | latestAt = now | |
| 827 | val intervals = reading.rrIntervals.map { (it * 10).roundToLong() / 10.0 } | |
| 828 | val detail = mutableMapOf<String, Any?>( | |
| 829 | "type" to "reading", | |
| 830 | "bpm" to reading.bpm, | |
| 831 | "rrIntervals" to intervals, | |
| 832 | "contact" to reading.sensorContact, | |
| 833 | "energyExpended" to reading.energyExpended, | |
| 834 | "timestamp" to now, | |
| 835 | ) | |
| 836 | deviceId?.let { detail["deviceId"] = it } | |
| 837 | recording.append(mapOf("timestamp" to now, "bpm" to reading.bpm, "rr" to intervals)) | |
| 838 | emit(detail) | |
| 839 | } | |
| 840 | ||
| 841 | private fun simulatedConnected() { | |
| 842 | if (!simulated || !wantsConnection || deviceId != SIMULATED_ID) return | |
| 843 | connection = "connected" | |
| 844 | subscriptionAt = clock() | |
| 845 | emitState() | |
| 846 | settleConnect(Result.success(state())) | |
| 847 | simulatedTimer?.let(main::removeCallbacks) | |
| 848 | simulatedElapsed = 0.0 | |
| 849 | simulatedBeatsUntil = 0.0 | |
| 850 | simulatedTimer = object : Runnable { | |
| 851 | override fun run() { | |
| 852 | if (simulatedTimer !== this) return | |
| 853 | main.postDelayed(this, 1_000) | |
| 854 | simulatedBeat += 1 | |
| 855 | simulatedElapsed += 1000 | |
| 856 | // A steady run with a slow swell, the way a real one drifts. | |
| 857 | val rate = 142 + sin(simulatedBeat / 20.0) * 12 | |
| 858 | val (intervals, until) = craftHeartRateSimulatedIntervals(rate, simulatedBeatsUntil, simulatedElapsed) | |
| 859 | simulatedBeatsUntil = until | |
| 860 | if (intervals.isEmpty()) return | |
| 861 | // The rate a strap reports is the one its intervals give. | |
| 862 | val bpm = (60_000 / intervals.average()).roundToInt() | |
| 863 | received(CraftHeartRateMeasurement.encode(bpm, sensorContact = true, rrIntervals = intervals)) | |
| 864 | } | |
| 865 | }.also { main.postDelayed(it, 1_000) } | |
| 866 | } | |
| 867 | ||
| 868 | // MARK: The recording in the foreground | |
| 869 | ||
| 870 | /** Whether CraftHeartRateService was asked to keep this recording. */ | |
| 871 | private var serviceStarted = false | |
| 872 | ||
| 873 | /** A running recording keeps CraftHeartRateService up; a finished one lets it go. */ | |
| 874 | private fun keepRecordingAlive() { | |
| 875 | val intent = Intent(context, CraftHeartRateService::class.java) | |
| 876 | if (recording.active) { | |
| 877 | // Android 14 starts a connectedDevice service only with Bluetooth | |
| 878 | // allowed; asked again once it is (a strap subscribed). | |
| 879 | if (serviceStarted || (!permitted() && !simulated)) return | |
| 880 | serviceStarted = runCatching { ContextCompat.startForegroundService(context, intent) } | |
| 881 | .onFailure { Log.w(TAG, "The heart-rate recording could not move to the foreground", it) } | |
| 882 | .isSuccess | |
| 883 | } else { | |
| 884 | // Unconditionally: a relaunched process did not start the one running. | |
| 885 | serviceStarted = false | |
| 886 | runCatching { context.stopService(intent) } | |
| 887 | } | |
| 888 | } | |
| 889 | ||
| 890 | /** Whether the service still has a recording to keep. */ | |
| 891 | fun keepsRecording(): Boolean = recording.active | |
| 892 | } | |
| 893 | ||
| 894 | /** | |
| 895 | * Keeps the process in the foreground while a heart-rate recording runs, as | |
| 896 | * iOS's `bluetooth-central` background mode does: readings keep coming with | |
| 897 | * the screen locked. Declared in the manifest only for an app built with | |
| 898 | * `enableHeartRate`; restarted by Android, it brings the recording and its | |
| 899 | * strap back (CraftHeartRate.resumeAfterRestart). | |
| 900 | */ | |
| 901 | class CraftHeartRateService : Service() { | |
| 902 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | |
| 903 | val manager = getSystemService(NotificationManager::class.java) | |
| 904 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| 905 | manager.createNotificationChannel(NotificationChannel(CHANNEL, "Heart rate", NotificationManager.IMPORTANCE_LOW)) | |
| 906 | } | |
| 907 | val notification = NotificationCompat.Builder(this, CHANNEL) | |
| 908 | .setSmallIcon(R.drawable.craft_notification_icon) | |
| 909 | .setColor(ContextCompat.getColor(this, R.color.craft_notification_color)) | |
| 910 | .setContentTitle("Recording your heart rate") | |
| 911 | .setContentText("${applicationInfo.loadLabel(packageManager)} is reading your heart-rate monitor") | |
| 912 | .setOngoing(true) | |
| 913 | .setSilent(true) | |
| 914 | .build() | |
| 915 | try { | |
| 916 | val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0 | |
| 917 | ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type) | |
| 918 | } catch (error: Exception) { | |
| 919 | Log.w("CraftHeartRate", "The heart-rate service could not start in the foreground", error) | |
| 920 | stopSelf(startId) | |
| 921 | return START_NOT_STICKY | |
| 922 | } | |
| 923 | val monitor = CraftHeartRate.get(this) | |
| 924 | if (intent == null) monitor.resumeAfterRestart() | |
| 925 | else if (!monitor.keepsRecording()) { | |
| 926 | stopSelf(startId) | |
| 927 | return START_NOT_STICKY | |
| 928 | } | |
| 929 | return START_STICKY | |
| 930 | } | |
| 931 | ||
| 932 | override fun onBind(intent: Intent?): IBinder? = null | |
| 933 | ||
| 934 | companion object { | |
| 935 | private const val CHANNEL = "craft_heart_rate_recording" | |
| 936 | private const val NOTIFICATION_ID = 7002 | |
| 937 | } | |
| 938 | } | |
| @@ -0,0 +1,485 @@ | ||
| 1 | package | |
| 2 | ||
| 3 | import java.io.File | |
| 4 | import java.io.FileOutputStream | |
| 5 | import java.io.IOException | |
| 6 | import java.nio.file.Files | |
| 7 | import java.nio.file.StandardCopyOption | |
| 8 | import java.security.MessageDigest | |
| 9 | import java.util.UUID | |
| 10 | import kotlin.math.PI | |
| 11 | import kotlin.math.min | |
| 12 | import kotlin.math.roundToInt | |
| 13 | import kotlin.math.sin | |
| 14 | import 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. */ | |
| 23 | internal 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 | */ | |
| 41 | internal 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 | */ | |
| 114 | internal 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 | */ | |
| 139 | internal 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 | */ | |
| 148 | internal 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 | */ | |
| 154 | internal 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 | */ | |
| 164 | internal 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. */ | |
| 174 | internal 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. */ | |
| 181 | internal 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 | */ | |
| 204 | internal 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 | } | |
| @@ -59,6 +59,7 @@ internal class CraftNativeServices( | ||
| 59 | 59 | private val activity: () -> Activity?, |
| 60 | 60 | ) { |
| 61 | 61 | private val main = Handler(Looper.getMainLooper()) |
| 62 | private val heartRateEnabled = config.optBoolean("enableHeartRate") | |
| 62 | 63 | private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) { |
| 63 | 64 | CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory")) |
| 64 | 65 | } else null |
| @@ -78,6 +79,14 @@ internal class CraftNativeServices( | ||
| 78 | 79 | CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id))) |
| 79 | 80 | } |
| 80 | 81 | } |
| 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 | } | |
| 81 | 90 | } |
| 82 | 91 | |
| 83 | 92 | /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */ |
| @@ -111,13 +120,16 @@ internal class CraftNativeServices( | ||
| 111 | 120 | if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity") |
| 112 | 121 | } |
| 113 | 122 | 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")) | |
| 114 | 126 | // craft.crashReporter: the app's crash and error reporter (bughq). |
| 115 | 127 | if (CraftCrashReporter.enabled) add("crashReporter") |
| 116 | 128 | } |
| 117 | 129 | |
| 118 | 130 | fun handles(module: String): Boolean = module in setOf( |
| 119 | 131 | "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background", |
| 120 | "Location", "Media", "CrashReporter", | |
| 132 | "Location", "Media", "CrashReporter", "HeartRate", | |
| 121 | 133 | ) |
| 122 | 134 | |
| 123 | 135 | fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) { |
| @@ -137,6 +149,7 @@ internal class CraftNativeServices( | ||
| 137 | 149 | "StatusBar" -> statusBar(args.optString(0, "default"), done) |
| 138 | 150 | "Speech" -> speech(method, args, done) |
| 139 | 151 | "Location" -> location(method, args, done) |
| 152 | "HeartRate" -> heartRate(method, args, done) | |
| 140 | 153 | "Media" -> when (method) { |
| 141 | 154 | "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done) |
| 142 | 155 | "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL)) |
| @@ -363,6 +376,7 @@ internal class CraftNativeServices( | ||
| 363 | 376 | } |
| 364 | 377 | |
| 365 | 378 | fun onRequestPermissionsResult(requestCode: Int): Boolean { |
| 379 | if (heartRateEnabled && CraftHeartRate.get(context).onPermissions(requestCode)) return true | |
| 366 | 380 | if (requestCode == LOCATION_PERMISSION_REQUEST) { |
| 367 | 381 | val pending = pendingLocationPermission |
| 368 | 382 | pendingLocationPermission = null |
| @@ -455,6 +469,25 @@ internal class CraftNativeServices( | ||
| 455 | 469 | } |
| 456 | 470 | } |
| 457 | 471 | |
| 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 | ||
| 458 | 491 | // MARK: Crash reporting (CraftCrashReporter.kt) |
| 459 | 492 | |
| 460 | 493 | private fun crashReporter(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) { |
| @@ -1258,8 +1258,15 @@ internal object CraftNativeMedia { | ||
| 1258 | 1258 | .addTag(TAG) |
| 1259 | 1259 | .addTag("$TAG:${digest(address)}") |
| 1260 | 1260 | .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 | } | |
| 1263 | 1270 | val reason = when { |
| 1264 | 1271 | !online(app) -> "offline" |
| 1265 | 1272 | wifiOnly && metered(app) -> "cellular" |
| @@ -1298,8 +1305,13 @@ internal object CraftNativeMedia { | ||
| 1298 | 1305 | val target = file(context, address) |
| 1299 | 1306 | val staging = part(context, address) |
| 1300 | 1307 | target.parentFile?.mkdirs() |
| 1308 | val download = CraftResumableDownload() | |
| 1309 | val asked = headers.keys().asSequence().associateWith { headers.optString(it) } | |
| 1301 | 1310 | 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 | } | |
| 1303 | 1315 | } catch (error: Exception) { |
| 1304 | 1316 | val full = generateSequence<Throwable>(error) { it.cause }.take(16).any { |
| 1305 | 1317 | it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC |
| @@ -1360,9 +1372,9 @@ internal object CraftNativeMedia { | ||
| 1360 | 1372 | fun prune(context: Context, keep: List<String>): Int { |
| 1361 | 1373 | val wanted = keep.map { digest(it) }.toSet() |
| 1362 | 1374 | 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() } | |
| 1363 | 1377 | 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() | |
| 1366 | 1378 | root(context).listFiles()?.forEach { file -> |
| 1367 | 1379 | val owner = file.name.substringBefore('.') |
| 1368 | 1380 | // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again. |
| @@ -1375,7 +1387,7 @@ internal object CraftNativeMedia { | ||
| 1375 | 1387 | runCatching { |
| 1376 | 1388 | androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get() |
| 1377 | 1389 | .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 } } } | |
| 1379 | 1391 | .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) } |
| 1380 | 1392 | } |
| 1381 | 1393 | } |
| @@ -1435,6 +1447,8 @@ internal class CraftResumableDownload( | ||
| 1435 | 1447 | if (have > 0) connection.setRequestProperty("Range", "bytes=$have-") |
| 1436 | 1448 | try { |
| 1437 | 1449 | 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) | |
| 1438 | 1452 | val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have |
| 1439 | 1453 | if (status == 416 || (status == 206 && !resumed)) { |
| 1440 | 1454 | part.delete() |
| @@ -98,8 +98,10 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 98 | 98 | check(!keep(broken), "truncated transfer fails") |
| 99 | 99 | check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache") |
| 100 | 100 | 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") | |
| 103 | 105 | check(!keep("$base/partial.mp4"), "unsolicited partial response is refused") |
| 104 | 106 | check(!keep("$base/empty.mp4"), "empty file is refused") |
| 105 | 107 | val slow = "$base/slow.mp4" |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * The reply-correlation contract, exercised against the real |
| @@ -37,6 +37,27 @@ interface Harness { | ||
| 37 | 37 | * they can simply be passed in as parameters — no DOM, no jsdom, and nothing |
| 38 | 38 | * shared between tests. |
| 39 | 39 | */ |
| 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 | */ | |
| 46 | const timers = new Set<ReturnType<typeof setTimeout>>() | |
| 47 | const 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 | } | |
| 52 | const bridgeClearTimeout = (timer: ReturnType<typeof setTimeout>) => { | |
| 53 | timers.delete(timer) | |
| 54 | clearTimeout(timer) | |
| 55 | } | |
| 56 | afterEach(() => { | |
| 57 | for (const timer of timers) clearTimeout(timer) | |
| 58 | timers.clear() | |
| 59 | }) | |
| 60 | ||
| 40 | 61 | function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness { |
| 41 | 62 | const sent: Envelope[] = [] |
| 42 | 63 | const errors: unknown[][] = [] |
| @@ -57,7 +78,7 @@ function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness { | ||
| 57 | 78 | |
| 58 | 79 | // eslint-disable-next-line no-new-func |
| 59 | 80 | 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) | |
| 61 | 82 | |
| 62 | 83 | return { |
| 63 | 84 | craft: win.craft, |
| @@ -24,7 +24,7 @@ import type { | ||
| 24 | 24 | LiveActivityHandle, |
| 25 | 25 | LiveActivityOptions, |
| 26 | 26 | } from '../api/mobile' |
| 27 | import mobile, { biometrics, brightness, bridgeNotification, crashReporter, health, heartRate, music, normalizeDeepLinkURL, normalizeScheduledWorkout, scheduledWorkouts, normalizeHeartRateSamples, normalizeNowPlaying, notifications, pushNotifications, secureStorage, speech, splash, tabBar, watchConnectivity } from '../api/mobile' | |
| 27 | import 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' | |
| 28 | 28 | |
| 29 | 29 | describe('Mobile deep links', () => { |
| 30 | 30 | it('normalizes native payloads to the public string contract', () => { |
| @@ -1178,6 +1178,23 @@ describe('heartRate', () => { | ||
| 1178 | 1178 | } |
| 1179 | 1179 | }) |
| 1180 | 1180 | |
| 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 | ||
| 1181 | 1198 | it('keeps only samples a heart could give, once each, oldest first', () => { |
| 1182 | 1199 | 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] }])) |
| 1183 | 1200 | .toEqual([{ timestamp: 2, bpm: 60, rr: [1000] }, { timestamp: 4, bpm: 62 }]) |
| @@ -2080,12 +2080,13 @@ export const music = { | ||
| 2080 | 2080 | * A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the |
| 2081 | 2081 | * standard Heart Rate Service every Polar, Garmin and Wahoo strap speaks). |
| 2082 | 2082 | * |
| 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 | |
| 2086 | 2086 | * 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 | |
| 2089 | 2090 | * `available: false`, and nothing ever fires. |
| 2090 | 2091 | */ |
| 2091 | 2092 | export type HeartRateAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable' |
| @@ -2322,8 +2323,8 @@ export const heartRate = { | ||
| 2322 | 2323 | getState: (): Promise<HeartRateState> => heartRateCall('getState', noHeartRate, normalizeHeartRateState), |
| 2323 | 2324 | |
| 2324 | 2325 | /** |
| 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 | |
| 2327 | 2328 | * tap to ask first. |
| 2328 | 2329 | */ |
| 2329 | 2330 | requestAuthorization: (): Promise<HeartRateAuthorization> => heartRateCall('requestAuthorization', () => 'unavailable' as const, |
| @@ -33,6 +33,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 33 | 33 | const scratch = await mkdtemp(join(tmpdir(), 'craft-media-probe-')) |
| 34 | 34 | const bytes = new Uint8Array(await Bun.file(join(fixtures, 'demo.mp4')).arrayBuffer()) |
| 35 | 35 | const counts = new Map<string, number>() |
| 36 | const resumed = new Set<string>() | |
| 36 | 37 | const timers = new Set<ReturnType<typeof setTimeout>>() |
| 37 | 38 | const server = Bun.listen<{ request: string, answered: boolean }>({ |
| 38 | 39 | hostname: '0.0.0.0', port: 0, |
| @@ -45,6 +46,15 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 45 | 46 | const path = socket.data.request.split(' ')[1]! |
| 46 | 47 | const n = (counts.get(path) || 0) + 1 |
| 47 | 48 | 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 | } | |
| 48 | 58 | const empty = path.endsWith('/empty.mp4') |
| 49 | 59 | const partial = path.endsWith('/partial.mp4') |
| 50 | 60 | const cut = (path.endsWith('/broken.mp4') || path.endsWith('/restart.mp4')) && n === 1 |
| @@ -150,7 +160,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 150 | 160 | await Bun.write(join(android, 'settings.gradle.kts'), 'pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }\ndependencyResolutionManagement { repositories { google(); mavenCentral() } }\nrootProject.name = "HQDownloadProbe"\ninclude(":app")') |
| 151 | 161 | 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 }') |
| 152 | 162 | 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') | |
| 154 | 164 | await Bun.write(join(app, 'build.gradle.kts'), `plugins { id("com.android.application"); |
| 155 | 165 | id("org.jetbrains.kotlin.android") }\nandroid { namespace = "${packageName}"; |
| 156 | 166 | compileSdk = 36; |
| @@ -192,9 +202,10 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`) | ||
| 192 | 202 | await probe('interrupt', true) |
| 193 | 203 | if (counts.get('/android/restart.mp4') !== 1) throw new Error('Android process did not start interrupted download') |
| 194 | 204 | await probe('recover') |
| 205 | if (!resumed.has('/android/restart.mp4')) throw new Error('Android did not resume the interrupted download from its part file') | |
| 195 | 206 | if (counts.get('/android/slow.mp4') !== 1) throw new Error('Android duplicate fetch: keep did not coalesce') |
| 196 | 207 | 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') | |
| 198 | 209 | } |
| 199 | 210 | } |
| 200 | 211 | } |
| @@ -637,7 +637,7 @@ export interface CraftBridge { | ||
| 637 | 637 | |
| 638 | 638 | /** |
| 639 | 639 | * 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 | |
| 641 | 641 | * `craftHeartRate` events, and a recording kept natively. |
| 642 | 642 | */ |
| 643 | 643 | heartRate?: CraftHeartRate; |
| @@ -1368,7 +1368,7 @@ export type CraftHeartRateConnection = 'disconnected' | 'connecting' | 'connecte | ||
| 1368 | 1368 | |
| 1369 | 1369 | /** A device advertising the Heart Rate Service (0x180D). */ |
| 1370 | 1370 | export 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. */ | |
| 1372 | 1372 | id: string; |
| 1373 | 1373 | name: string; |
| 1374 | 1374 | /** Signal strength, dBm; 0 for a strap the phone is already connected to. */ |
| @@ -1429,7 +1429,10 @@ export type CraftHeartRateEventDetail = | ||
| 1429 | 1429 | |
| 1430 | 1430 | export interface CraftHeartRate { |
| 1431 | 1431 | 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 | */ | |
| 1433 | 1436 | requestAuthorization(): Promise<CraftHeartRateAuthorization>; |
| 1434 | 1437 | /** Look for straps for `timeout` seconds (15); each one found is a `device` event. */ |
| 1435 | 1438 | scan(options?: { timeout?: number }): Promise<CraftHeartRateState>; |
| @@ -1444,7 +1447,10 @@ export interface CraftHeartRate { | ||
| 1444 | 1447 | /** Ask for the remembered strap again, without waiting for it. */ |
| 1445 | 1448 | reconnect(): Promise<CraftHeartRateState>; |
| 1446 | 1449 | 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 | */ | |
| 1448 | 1454 | startRecording(options?: { recordingId?: string }): Promise<CraftHeartRateRecordingState>; |
| 1449 | 1455 | pauseRecording(options?: { expectedId: string }): Promise<CraftHeartRateRecordingState>; |
| 1450 | 1456 | resumeRecording(options?: { expectedId: string }): Promise<CraftHeartRateRecordingState>; |
| @@ -1614,7 +1620,7 @@ export interface CraftCapabilities { | ||
| 1614 | 1620 | brightness?: boolean; |
| 1615 | 1621 | /** The Music app's song and transport through `craft.music` (iOS, `enableAppleMusic`). */ |
| 1616 | 1622 | 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`). */ | |
| 1618 | 1624 | heartRate?: boolean; |
| 1619 | 1625 | heartRateRecordingOwnership?: boolean; |
| 1620 | 1626 | heartRateRecordingIntentIdentity?: boolean; |
| @@ -140,8 +140,11 @@ const zig_sources = [_][]const u8{ | ||
| 140 | 140 | /// that dims the screen while it shows little (a workout's big timer); |
| 141 | 141 | /// 41 when the five crashReporter* methods arrived in the Kotlin first, as |
| 142 | 142 | /// 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. | |
| 144 | const 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. | |
| 147 | const max_not_yet_migrated: usize = 42; | |
| 145 | 148 | |
| 146 | 149 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 147 | 150 | /// |
| @@ -431,6 +434,14 @@ const deliberate_deferrals = [_]Deferral{ | ||
| 431 | 434 | .{ .action = "crashReporterFlush", .reason = "drives the reporter's own delivery thread and backoff" }, |
| 432 | 435 | .{ .action = "crashReporterContext", .reason = "answers the reporter's session and release, which only it holds" }, |
| 433 | 436 | |
| 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 | ||
| 434 | 445 | // The auth-persistence trio shares one timestamp in Kotlin-configured |
| 435 | 446 | // EncryptedSharedPreferences. It now survives process death, but migrating |
| 436 | 447 | // any one action would split ownership of the same key; the trio must move |