From f1b45fb3936b195677c132e1c682de7a3810d764 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Mon, 3 Aug 2026 15:41:34 +0200 Subject: [PATCH 01/15] Add experimental AirPods heart-rate monitoring --- .../librepods/bluetooth/AACPManager.kt | 71 ++- .../librepods/bluetooth/RtBuddyHeartRate.kt | 494 ++++++++++++++++++ .../presentation/navigation/AppNavGraph.kt | 10 +- .../presentation/navigation/NavigationRoot.kt | 1 + .../presentation/navigation/Screen.kt | 3 + .../presentation/screens/AppSettingsScreen.kt | 12 +- .../screens/HeartRateTestScreen.kt | 251 +++++++++ .../viewmodel/AirPodsViewModel.kt | 42 ++ .../librepods/services/AirPodsService.kt | 176 ++++++- 9 files changed, 1051 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index ac6d356b7..e05eda89e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -62,6 +62,34 @@ class AACPManager { private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) + // Exact AACP 1.3 initialization used by the validated RTBuddy probe before HR streaming. + private val HEART_RATE_CONNECT_SERVICE_0 = byteArrayOf( + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ) + private val HEART_RATE_CAPABILITIES_SERVICE_0 = + byteArrayOf(0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00) + private val HEART_RATE_CONNECT_SERVICE_4 = byteArrayOf( + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ) + private val HEART_RATE_CAPABILITIES_SERVICE_4 = + byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00) + + // Verified RTBuddy SensorDataWX HEARTRATE(19) service-setting frames from the legacy probe. + // These arrays intentionally omit HEADER_BYTES because sendDataPacket() adds it. + private val HEART_RATE_START_1S = byteArrayOf( + 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, + 0x08, 0xE3.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10, + 0x02, 0x1A, 0x05, 0x01, 0x40, 0x42, 0x0F, 0x00 + ) + + private val HEART_RATE_STOP = byteArrayOf( + 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, + 0x08, 0xED.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10, + 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 + ) + data class ControlCommandStatus( val identifier: ControlCommandIdentifiers, val value: ByteArray ) { @@ -235,6 +263,7 @@ class AACPManager { fun onControlCommandReceived(controlCommand: ByteArray) fun onDeviceInformationReceived(deviceInformation: AirPodsInformation) fun onHeadTrackingReceived(headTracking: ByteArray) + fun onHeartRateReceived(sample: HeartRateSample) fun onUnknownPacketReceived(packet: ByteArray) fun onProximityKeysReceived(proximityKeys: ByteArray) fun onStemPressReceived(stemPress: ByteArray) @@ -280,6 +309,7 @@ class AACPManager { } private var callback: PacketCallback? = null + private val heartRateDecoder = RtBuddyHeartRateDecoder() fun setPacketCallback(callback: PacketCallback) { this.callback = callback @@ -306,6 +336,18 @@ class AACPManager { return sendPacket(createDataPacket(data)) } + fun sendHeartRateStartFrame(): Boolean = sendDataPacket(HEART_RATE_START_1S) + + fun sendHeartRateStopFrame(): Boolean = sendDataPacket(HEART_RATE_STOP) + + fun sendHeartRateConnectService0(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_0) + + fun sendHeartRateCapabilitiesService0(): Boolean = sendPacket(HEART_RATE_CAPABILITIES_SERVICE_0) + + fun sendHeartRateConnectService4(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_4) + + fun sendHeartRateCapabilitiesService4(): Boolean = sendPacket(HEART_RATE_CAPABILITIES_SERVICE_4) + fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { val controlPacket = createControlCommandPacket(identifier, value) setControlCommandStatusValue( @@ -397,8 +439,23 @@ class AACPManager { return opcode + data } + fun receivePacket(packet: ByteArray): Boolean { + val heartRateResult = heartRateDecoder.feed(packet) + if (heartRateResult.relatedFrameCount > 0) { + Log.d( + TAG, + "Received RTBuddy heart-rate frames=${heartRateResult.relatedFrameCount}, " + + "rejected=${heartRateResult.rejectedFrameCount}, " + + "samples=${heartRateResult.samples.size}" + ) + } + heartRateResult.samples.forEach { callback?.onHeartRateReceived(it) } + heartRateResult.passthroughPackets.forEach(::receiveStandardPacket) + return heartRateResult.suppressRawLogging + } + @OptIn(ExperimentalStdlibApi::class) - fun receivePacket(packet: ByteArray) { + private fun receiveStandardPacket(packet: ByteArray) { if (!packet.toHexString().startsWith("04000400")) { Log.w( TAG, "Received packet does not start with expected header: ${ @@ -1139,7 +1196,11 @@ class AACPManager { @OptIn(ExperimentalStdlibApi::class) fun sendPacket(packet: ByteArray): Boolean { try { - Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") + if (isHeartRateRtBuddyPacket(packet)) { + Log.d(TAG, "Sending RTBuddy heart-rate stream control packet") + } else { + Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") + } if (packet[4] == Opcodes.CONTROL_COMMAND) { val controlCommand = try { @@ -1269,8 +1330,14 @@ class AACPManager { ) } + private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean { + return packet.contentEquals(HEADER_BYTES + HEART_RATE_START_1S) || + packet.contentEquals(HEADER_BYTES + HEART_RATE_STOP) + } + fun disconnected() { Log.d(TAG, "Disconnected, clearing state") + heartRateDecoder.reset() controlCommandStatusList.clear() controlCommandListeners.clear() owns = false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt new file mode 100644 index 000000000..1ee978edb --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -0,0 +1,494 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.bluetooth + +/** A validated heart-rate sample decoded from an RTBuddy SensorDataWX frame. */ +data class HeartRateSample( + val bpm: Int, + val sequence: Int, + val receivedAtMillis: Long +) + +internal data class HeartRateDecodeResult( + val samples: List = emptyList(), + val relatedFrameCount: Int = 0, + val rejectedFrameCount: Int = 0, + val suppressRawLogging: Boolean = false, + val passthroughPackets: List = emptyList() +) + +/** + * Stateful decoder for the verified RTBuddy HEARTRATE SensorDataWX stream. + * + * Socket reads are arbitrary chunks. A possible partial 0x17/0x00100000 frame is retained until + * its declared payload is complete. Other 0x17 packets are reconstructed and passed to the normal + * AACP parser so head tracking keeps its existing behavior. + */ +internal class RtBuddyHeartRateDecoder { + private var carry = ByteArray(0) + + fun reset() { + carry = ByteArray(0) + } + + fun feed(chunk: ByteArray): HeartRateDecodeResult { + if (chunk.isEmpty()) return HeartRateDecodeResult() + + val hadCarry = carry.isNotEmpty() + val carryWasSensitive = carry.size >= MIN_SENSITIVE_PREFIX_LENGTH + val combined = if (carry.isEmpty()) chunk else carry + chunk + carry = ByteArray(0) + + val samples = mutableListOf() + val passthroughPackets = mutableListOf() + var relatedFrameCount = 0 + var rejectedFrameCount = 0 + var suppressRawLogging = carryWasSensitive + var cursor = 0 + + while (cursor < combined.size) { + val candidateOffset = combined.indexOfPrefix(RTBUDDY_FRAME_PREFIX, cursor) + if (candidateOffset < 0) { + val suffixLength = combined.longestSuffixMatchingPrefix( + prefix = RTBUDDY_FRAME_PREFIX, + startIndex = cursor + ) + val passthroughEnd = combined.size - suffixLength + if (passthroughEnd > cursor) { + passthroughPackets += combined.copyOfRange(cursor, passthroughEnd) + } + if (suffixLength > 0) { + carry = combined.copyOfRange(passthroughEnd, combined.size) + if (suffixLength >= MIN_SENSITIVE_PREFIX_LENGTH) { + suppressRawLogging = true + } + } + break + } + + if (candidateOffset > cursor) { + passthroughPackets += combined.copyOfRange(cursor, candidateOffset) + } + + if (combined.size - candidateOffset < AACP_RTBUDDY_HEADER_LENGTH) { + carry = combined.copyOfRange(candidateOffset, combined.size) + suppressRawLogging = true + break + } + + val declaredLength = combined.readLe16(candidateOffset + 10) + if (declaredLength > MAX_RTBUDDY_PAYLOAD_LENGTH) { + // The exact SensorDataWX prefix is sensitive, but the length is untrusted. Drop the + // remainder rather than exposing it to generic packet logs or interpreting it as + // head tracking. + suppressRawLogging = true + break + } + + val frameLength = AACP_RTBUDDY_HEADER_LENGTH + declaredLength + if (combined.size - candidateOffset < frameLength) { + carry = combined.copyOfRange(candidateOffset, combined.size) + suppressRawLogging = true + break + } + + val frame = combined.copyOfRange(candidateOffset, candidateOffset + frameLength) + val classification = classifyFrame(frame) + if (classification.isHeartRateRelated) { + relatedFrameCount++ + if (classification.sample == null) rejectedFrameCount++ + suppressRawLogging = true + classification.sample?.let(samples::add) + } else { + passthroughPackets += frame + if (hadCarry && candidateOffset == 0) suppressRawLogging = true + } + cursor = candidateOffset + frameLength + } + + return HeartRateDecodeResult( + samples = samples, + relatedFrameCount = relatedFrameCount, + rejectedFrameCount = rejectedFrameCount, + suppressRawLogging = suppressRawLogging, + passthroughPackets = passthroughPackets + ) + } + + private fun classifyFrame(frame: ByteArray): FrameClassification { + if (frame.size < AACP_RTBUDDY_HEADER_LENGTH) return FrameClassification() + if (!frame.startsWithPrefix(RTBUDDY_FRAME_PREFIX)) return FrameClassification() + + val declaredLength = frame.readLe16(10) + if (frame.size != AACP_RTBUDDY_HEADER_LENGTH + declaredLength) { + return FrameClassification() + } + + val hasHeartRateReference = hasHeartRateServiceReference( + frame, + AACP_RTBUDDY_HEADER_LENGTH, + frame.size + ) + val sensorData = parseSensorDataWx(frame, AACP_RTBUDDY_HEADER_LENGTH, frame.size) + ?: return FrameClassification(isHeartRateRelated = hasHeartRateReference) + val heartRateRelated = hasHeartRateReference || + HEART_RATE_SERVICE in sensorData.referencedServices + if (!heartRateRelated || sensorData.logType !in SENSOR_DATA_LOG_STATES) { + return FrameClassification(isHeartRateRelated = heartRateRelated) + } + + val command = sensorData.commands.firstOrNull { command -> + val payload = command.payload ?: return@firstOrNull false + command.service == HEART_RATE_SERVICE && + payload.size == HEART_RATE_PAYLOAD_LENGTH && + payload[15] == 0x10.toByte() && + payload[16] == 0x00.toByte() && + payload[17] == 0x00.toByte() && + payload[1].toInt().and(0xFF) in MIN_BPM..MAX_BPM + } ?: return FrameClassification(isHeartRateRelated = true) + val payload = command.payload ?: return FrameClassification(isHeartRateRelated = true) + + return FrameClassification( + isHeartRateRelated = true, + sample = HeartRateSample( + bpm = payload[1].toInt().and(0xFF), + sequence = sensorData.sequence, + receivedAtMillis = System.currentTimeMillis() + ) + ) + } + + + private fun hasHeartRateServiceReference(data: ByteArray, start: Int, end: Int): Boolean { + var index = start + while (index < end) { + val key = readVarint(data, index, end) ?: return false + index = key.nextIndex + val field = (key.value ushr 3).toInt() + val wireType = (key.value and 0x07).toInt() + + when (wireType) { + WIRE_VARINT -> { + val value = readVarint(data, index, end) ?: return false + index = value.nextIndex + } + + WIRE_LENGTH_DELIMITED -> { + val length = readVarint(data, index, end) ?: return false + if (length.value > Int.MAX_VALUE) return false + index = length.nextIndex + val subEnd = index + length.value.toInt() + if (subEnd < index || subEnd > end) return false + if (field in HEART_RATE_SERVICE_REFERENCE_FIELDS && + parseReferencedService(data, index, subEnd) == HEART_RATE_SERVICE + ) { + return true + } + index = subEnd + } + + WIRE_FIXED64 -> { + if (end - index < 8) return false + index += 8 + } + + WIRE_FIXED32 -> { + if (end - index < 4) return false + index += 4 + } + + else -> return false + } + } + return false + } + + private fun parseSensorDataWx(data: ByteArray, start: Int, end: Int): SensorDataWx? { + var index = start + var sequence = -1 + var logType = -1 + val commands = mutableListOf() + val referencedServices = mutableSetOf() + + while (index < end) { + val key = readVarint(data, index, end) ?: return null + index = key.nextIndex + val field = (key.value ushr 3).toInt() + val wireType = (key.value and 0x07).toInt() + + when (wireType) { + WIRE_VARINT -> { + val value = readVarint(data, index, end) ?: return null + index = value.nextIndex + when (field) { + 1 -> sequence = value.value.toInt() + 2 -> logType = value.value.toInt() + } + } + + WIRE_LENGTH_DELIMITED -> { + val length = readVarint(data, index, end) ?: return null + index = length.nextIndex + if (length.value > Int.MAX_VALUE) return null + val subEnd = index + length.value.toInt() + if (subEnd < index || subEnd > end) return null + + when (field) { + 5, 8, 9, 12 -> parseReferencedService(data, index, subEnd) + ?.let(referencedServices::add) + + 7 -> { + val command = parseCommand(data, index, subEnd) + if (command != null) { + commands += command + if (command.service >= 0) referencedServices += command.service + } else { + parseReferencedService(data, index, subEnd) + ?.let(referencedServices::add) + } + } + } + index = subEnd + } + + WIRE_FIXED64 -> { + if (end - index < 8) return null + index += 8 + } + + WIRE_FIXED32 -> { + if (end - index < 4) return null + index += 4 + } + + else -> return null + } + } + + return SensorDataWx( + sequence = sequence, + logType = logType, + commands = commands, + referencedServices = referencedServices + ) + } + + private fun parseCommand(data: ByteArray, start: Int, end: Int): RtBuddyCommand? { + var index = start + var service = -1 + var payload: ByteArray? = null + var duplicatePayload = false + + while (index < end) { + val key = readVarint(data, index, end) ?: return null + index = key.nextIndex + val field = (key.value ushr 3).toInt() + val wireType = (key.value and 0x07).toInt() + + when (wireType) { + WIRE_VARINT -> { + val value = readVarint(data, index, end) ?: return null + index = value.nextIndex + if (field == 1) service = value.value.toInt() + } + + WIRE_LENGTH_DELIMITED -> { + val length = readVarint(data, index, end) ?: return null + index = length.nextIndex + if (length.value > Int.MAX_VALUE) return null + val subEnd = index + length.value.toInt() + if (subEnd < index || subEnd > end) return null + if (field == 3) { + if (payload != null) { + duplicatePayload = true + } else { + payload = data.copyOfRange(index, subEnd) + } + } + index = subEnd + } + + WIRE_FIXED64 -> { + if (end - index < 8) return null + index += 8 + } + + WIRE_FIXED32 -> { + if (end - index < 4) return null + index += 4 + } + + else -> return null + } + } + + return RtBuddyCommand( + service = service, + payload = if (duplicatePayload) null else payload + ) + } + + + private fun parseReferencedService(data: ByteArray, start: Int, end: Int): Int? { + var index = start + while (index < end) { + val key = readVarint(data, index, end) ?: return null + index = key.nextIndex + val field = (key.value ushr 3).toInt() + val wireType = (key.value and 0x07).toInt() + + when (wireType) { + WIRE_VARINT -> { + val value = readVarint(data, index, end) ?: return null + index = value.nextIndex + if (field == 1) return value.value.toInt() + } + + WIRE_LENGTH_DELIMITED -> { + val length = readVarint(data, index, end) ?: return null + if (length.value > Int.MAX_VALUE) return null + val nextIndex = length.nextIndex + length.value.toInt() + if (nextIndex < length.nextIndex || nextIndex > end) return null + index = nextIndex + } + + WIRE_FIXED64 -> { + if (end - index < 8) return null + index += 8 + } + + WIRE_FIXED32 -> { + if (end - index < 4) return null + index += 4 + } + + else -> return null + } + } + return null + } + + private fun readVarint(data: ByteArray, start: Int, end: Int): VarintRead? { + var value = 0L + var shift = 0 + var index = start + + while (index < end && shift < 64) { + val byte = data[index++].toInt().and(0xFF) + value = value or ((byte and 0x7F).toLong() shl shift) + if (byte and 0x80 == 0) return VarintRead(value, index) + shift += 7 + } + + return null + } + + private data class SensorDataWx( + val sequence: Int, + val logType: Int, + val commands: List, + val referencedServices: Set + ) + + private data class RtBuddyCommand( + val service: Int, + val payload: ByteArray? + ) + + private data class FrameClassification( + val isHeartRateRelated: Boolean = false, + val sample: HeartRateSample? = null + ) + + private data class VarintRead( + val value: Long, + val nextIndex: Int + ) + + private companion object { + const val AACP_RTBUDDY_HEADER_LENGTH = 12 + const val MAX_RTBUDDY_PAYLOAD_LENGTH = 16 * 1024 + const val MIN_SENSITIVE_PREFIX_LENGTH = 5 + + // AirPods firmware has been observed using both 1 and 3 for live SensorDataWX records. + val SENSOR_DATA_LOG_STATES = setOf(1, 3) + const val HEART_RATE_SERVICE = 19 + const val HEART_RATE_PAYLOAD_LENGTH = 18 + const val MIN_BPM = 30 + const val MAX_BPM = 220 + + val HEART_RATE_SERVICE_REFERENCE_FIELDS = setOf(5, 7, 8, 9, 12) + + + const val WIRE_VARINT = 0 + const val WIRE_FIXED64 = 1 + const val WIRE_LENGTH_DELIMITED = 2 + const val WIRE_FIXED32 = 5 + + // type=0x0004, service=0x0004, opcode=0x0017, descriptor=0x00100000 + val RTBUDDY_FRAME_PREFIX = byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x17, 0x00, + 0x00, 0x00, 0x10, 0x00 + ) + } +} + +private fun ByteArray.readLe16(offset: Int): Int = + this[offset].toInt().and(0xFF) or (this[offset + 1].toInt().and(0xFF) shl 8) + +private fun ByteArray.startsWithPrefix(prefix: ByteArray): Boolean { + if (size < prefix.size) return false + for (index in prefix.indices) { + if (this[index] != prefix[index]) return false + } + return true +} + +private fun ByteArray.indexOfPrefix(prefix: ByteArray, startIndex: Int): Int { + if (prefix.isEmpty()) return startIndex.coerceAtMost(size) + val lastStart = size - prefix.size + if (startIndex > lastStart) return -1 + + for (start in startIndex.coerceAtLeast(0)..lastStart) { + var matches = true + for (offset in prefix.indices) { + if (this[start + offset] != prefix[offset]) { + matches = false + break + } + } + if (matches) return start + } + return -1 +} + +private fun ByteArray.longestSuffixMatchingPrefix( + prefix: ByteArray, + startIndex: Int +): Int { + val available = size - startIndex.coerceIn(0, size) + val maxLength = minOf(available, prefix.size - 1) + for (length in maxLength downTo 1) { + var matches = true + val start = size - length + for (offset in 0 until length) { + if (this[start + offset] != prefix[offset]) { + matches = false + break + } + } + if (matches) return length + } + return 0 +} + + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index 14479eb57..fdb7284d9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -24,6 +24,7 @@ import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.screens.CallControlScreen import me.kavishdevar.librepods.presentation.screens.EqualizerRoute import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen +import me.kavishdevar.librepods.presentation.screens.HeartRateTestScreen import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.screens.HearingAidScreen import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen @@ -128,7 +129,8 @@ fun AppNavGraph( navigateToPurchase = ::navigateToPurchase, navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, - navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } + navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }, + navigateToHeartRateTest = { navigate(Screen.HeartRateTest) } ) } @@ -143,6 +145,12 @@ fun AppNavGraph( HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase) } + Screen.HeartRateTest -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HeartRateTestScreen(airPodsViewModel) + } + Screen.Accessibility -> NavEntry(screen) { if (!airPodsViewModel.isReady) LoadingScreen() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index 2bca355a1..c612bdc28 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -59,6 +59,7 @@ fun NavigationRoot( // Screen.CameraControl -> stringResource(R.string.camera_control) Screen.Equalizer -> stringResource(R.string.equalizer) Screen.HeadTracking -> stringResource(R.string.head_tracking) + Screen.HeartRateTest -> "Heart-rate test" Screen.HearingAid -> stringResource(R.string.hearing_aid) Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) Screen.HearingProtection -> stringResource(R.string.hearing_protection) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt index 1a8959f3f..70e0ff2c6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -28,6 +28,9 @@ sealed interface Screen: NavKey { @Serializable data object HeadTracking: Screen + @Serializable + data object HeartRateTest: Screen + @Serializable data object Accessibility: Screen diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 06436561d..4a858d948 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -105,7 +105,8 @@ fun AppSettingsScreen( navigateToPurchase: () -> Unit, navigateToTroubleshooting: () -> Unit, navigateToOpenSourceLicenses: () -> Unit, - navigateToReleaseNotesScreen: () -> Unit + navigateToReleaseNotesScreen: () -> Unit, + navigateToHeartRateTest: () -> Unit ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -383,6 +384,15 @@ fun AppSettingsScreen( ) } + Spacer(modifier = Modifier.height(16.dp)) + StyledList(title = "Tests") { + StyledListItem( + name = "Heart-rate test", + description = "View validated samples from supported AirPods", + onClick = navigateToHeartRateTest, + ) + } + if (!BuildConfig.PLAY_BUILD) { Spacer(modifier = Modifier.height(16.dp)) StyledList { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt new file mode 100644 index 000000000..d7b46595a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -0,0 +1,251 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import java.text.DateFormat +import java.util.Date + +@Composable +fun HeartRateTestScreen(viewModel: AirPodsViewModel) { + val state by viewModel.uiState.collectAsState() + val materialDesign = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (materialDesign) { + 16.dp + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + } + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + + val latestSample = state.heartRateSamples.lastOrNull() + val monitoringStatus = when { + !state.heartRateMonitoringEnabled -> "Disabled" + !state.isLocallyConnected -> "Enabled — waiting for connection" + state.heartRateStreaming -> "Streaming" + else -> "Enabled — awaiting stream" + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + StyledToggle( + title = "Heart-rate test", + label = "Enable monitoring", + description = "Uses the existing AirPods AACP connection and remains enabled across reconnects.", + checked = state.heartRateMonitoringEnabled, + onCheckedChange = viewModel::setHeartRateMonitoringEnabled, + header = true + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column { + Text( + text = latestSample?.bpm?.toString() ?: "—", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = if (state.isLocallyConnected) "Connected" else "Disconnected", + style = MaterialTheme.typography.labelLarge, + color = if (state.isLocallyConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Text( + text = monitoringStatus, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End + ) + } + } + + Text( + text = "Last update: ${formatLastUpdate(latestSample)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Recent samples", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + + HeartRateGraph(samples = state.heartRateSamples) + + Text( + text = "Experimental test data only. Do not use it for medical decisions.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp) + ) + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + +@Composable +private fun HeartRateGraph(samples: List) { + val lineColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) + val pointColor = MaterialTheme.colorScheme.onSurface + + Card( + modifier = Modifier + .fillMaxWidth() + .height(260.dp), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(18.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val chartHeight = size.height + val chartWidth = size.width + val minBpm = 30f + val maxBpm = 220f + + listOf(30f, 60f, 100f, 140f, 180f, 220f).forEach { bpm -> + val y = chartHeight - ((bpm - minBpm) / (maxBpm - minBpm)) * chartHeight + drawLine( + color = gridColor, + start = androidx.compose.ui.geometry.Offset(0f, y), + end = androidx.compose.ui.geometry.Offset(chartWidth, y), + strokeWidth = 1.dp.toPx() + ) + } + + if (samples.isNotEmpty()) { + val path = Path() + samples.forEachIndexed { index, sample -> + val x = if (samples.size == 1) { + chartWidth / 2f + } else { + index.toFloat() / (samples.size - 1).toFloat() * chartWidth + } + val normalized = ((sample.bpm.toFloat() - minBpm) / (maxBpm - minBpm)) + .coerceIn(0f, 1f) + val y = chartHeight - normalized * chartHeight + + if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) + if (index == samples.lastIndex) { + drawCircle( + color = pointColor, + radius = 4.dp.toPx(), + center = androidx.compose.ui.geometry.Offset(x, y) + ) + } + } + if (samples.size > 1) { + drawPath( + path = path, + color = lineColor, + style = Stroke(width = 3.dp.toPx()) + ) + } + } + } + + if (samples.isEmpty()) { + Text( + text = "Waiting for validated heart-rate samples", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} + +private fun formatLastUpdate(sample: HeartRateSample?): String { + if (sample == null) return "No samples yet" + return DateFormat.getTimeInstance(DateFormat.MEDIUM) + .format(Date(sample.receivedAtMillis)) +} + + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 8c99178d6..99057fe2a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -43,6 +43,7 @@ import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandId import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager +import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsNotifications @@ -81,6 +82,10 @@ data class AirPodsUiState( val headTrackingActive: Boolean = false, val headGesturesEnabled: Boolean = true, + val heartRateMonitoringEnabled: Boolean = false, + val heartRateStreaming: Boolean = false, + val heartRateSamples: List = emptyList(), + val eqData: FloatArray = floatArrayOf(), val automaticEarDetectionEnabled: Boolean = true, @@ -210,6 +215,7 @@ class AirPodsViewModel( loadInstance() loadSharedPreferences() observeAACP() + observeHeartRate() loadCurrentStatus() loadEq() loadATT() @@ -460,12 +466,33 @@ class AirPodsViewModel( } } + private fun observeHeartRate() { + viewModelScope.launch { + service.heartRateMonitoringEnabled.collect { enabled -> + _uiState.update { it.copy(heartRateMonitoringEnabled = enabled) } + } + } + viewModelScope.launch { + service.heartRateStreaming.collect { streaming -> + _uiState.update { it.copy(heartRateStreaming = streaming) } + } + } + viewModelScope.launch { + service.heartRateSamples.collect { samples -> + _uiState.update { it.copy(heartRateSamples = samples) } + } + } + } + fun loadCurrentStatus() { if (isDemoMode) return service.let { service -> _uiState.update { it.copy( isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, + heartRateMonitoringEnabled = service.heartRateMonitoringEnabled.value, + heartRateStreaming = service.heartRateStreaming.value, + heartRateSamples = service.heartRateSamples.value, battery = service.getBattery(), ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, controlStates = controlRepo.getMap() @@ -642,6 +669,21 @@ class AirPodsViewModel( _uiState.update { it.copy(headTrackingActive = false) } } + fun setHeartRateMonitoringEnabled(enabled: Boolean) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + heartRateMonitoringEnabled = enabled, + heartRateStreaming = enabled && it.isLocallyConnected, + heartRateSamples = if (enabled) emptyList() else it.heartRateSamples + ) + } + return + } + service.setHeartRateMonitoringEnabled(enabled) + } + fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { when (handle) { // ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction. diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..b3ec122db 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -73,6 +73,9 @@ import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -89,6 +92,7 @@ import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTManagerv2 import me.kavishdevar.librepods.bluetooth.BLEManager import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager +import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.bluetooth.createBluetoothSocket import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels @@ -231,11 +235,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val maxLogEntries = 1000 private val inMemoryLogs = mutableSetOf() + private val heartRateScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val heartRateLock = Any() + private var heartRateStartJob: Job? = null + private var heartRateSessionRequested = false + private var heartRateStreamStarted = false + + private val _heartRateMonitoringEnabled = MutableStateFlow(false) + val heartRateMonitoringEnabled: StateFlow get() = _heartRateMonitoringEnabled + + private val _heartRateStreaming = MutableStateFlow(false) + val heartRateStreaming: StateFlow get() = _heartRateStreaming + + private val _heartRateSamples = MutableStateFlow>(emptyList()) + val heartRateSamples: StateFlow> get() = _heartRateSamples + private var handleIncomingCallOnceConnected = false lateinit var bleManager: BLEManager companion object { + private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" + private const val MAX_HEART_RATE_SAMPLES = 60 + init { System.loadLibrary("bluetooth_socket") } @@ -377,6 +399,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList _packetLogsFlow.value = inMemoryLogs.toSet() sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) + _heartRateMonitoringEnabled.value = sharedPreferences.getBoolean( + HEART_RATE_MONITORING_PREFERENCE, + false + ) initializeConfig() aacpManager = AACPManager() @@ -696,6 +722,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // isConnectedLocally = false popupShown = false updateNotificationContent(false) + stopHeartRateMonitoring() aacpManager.disconnected() BluetoothConnectionManager.aacpSocket = null BluetoothConnectionManager.attSocket = null @@ -1080,6 +1107,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + override fun onHeartRateReceived(sample: HeartRateSample) { + if (!_heartRateMonitoringEnabled.value) return + _heartRateSamples.value = (_heartRateSamples.value + sample).takeLast(MAX_HEART_RATE_SAMPLES) + } + override fun onProximityKeysReceived(proximityKeys: ByteArray) { val keys = aacpManager.parseProximityKeysResponse(proximityKeys) Log.d("AirPodsParser", "Proximity keys: $keys") @@ -2756,13 +2788,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendSomePacketIDontKnowWhatItIs() delay(200) aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() + if (!handleIncomingCallOnceConnected) { + if (!_heartRateMonitoringEnabled.value) startHeadTracking() + } else { + handleIncomingCall() + } Handler(Looper.getMainLooper()).postDelayed({ aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) - if (!handleIncomingCallOnceConnected) stopHeadTracking() + if (!handleIncomingCallOnceConnected && !_heartRateMonitoringEnabled.value) { + stopHeadTracking() + } }, 5000) sendBroadcast( @@ -2772,6 +2810,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }) setupStemActions() + startHeartRateMonitoringIfEnabled() while (socket.isConnected) { try { @@ -2785,7 +2824,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setPackage(packageName) }) val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } // CrossDevice.sendReceivedPacket(bytes) updateNotificationContent( true, @@ -2793,9 +2831,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList batteryNotification.getBattery() ) - aacpManager.receivePacket(data) + val suppressRawPacketLogging = aacpManager.receivePacket(data) - if (!isHeadTrackingData(data)) { + if (!suppressRawPacketLogging && !isHeadTrackingData(data)) { + val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } Log.d("AirPodsData", "Data received: $formattedHex") logPacket(data, "AirPods") } @@ -2805,6 +2844,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) + handleHeartRateDisconnected() aacpManager.disconnected() return@launch } @@ -2814,6 +2854,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) + handleHeartRateDisconnected() aacpManager.disconnected() return@launch } @@ -2821,6 +2862,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } Log.d("AirPods Service", "socket closed") // isConnectedLocally = false + handleHeartRateDisconnected() aacpManager.disconnected() updateNotificationContent(false) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { @@ -2829,6 +2871,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } } catch (e: Exception) { + handleHeartRateDisconnected() e.printStackTrace() Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") @@ -2842,6 +2885,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun disconnectForCD() { + stopHeartRateMonitoring() BluetoothConnectionManager.aacpSocket?.close() MediaController.pausedWhileTakingOver = false Log.d(TAG, "Disconnected from AirPods, showing island.") @@ -2874,6 +2918,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun disconnectAirPods() { if (BluetoothConnectionManager.aacpSocket == null) return + stopHeartRateMonitoring() try { BluetoothConnectionManager.aacpSocket?.close() } catch(e: Exception) { @@ -3139,11 +3184,132 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { telephonyManager.unregisterTelephonyCallback(phoneStateListener) } + stopHeartRateMonitoring() + heartRateScope.cancel() // isConnectedLocally = false // CrossDevice.isAvailable = true super.onDestroy() } + fun setHeartRateMonitoringEnabled(enabled: Boolean) { + val wasEnabled = _heartRateMonitoringEnabled.value + sharedPreferences.edit { putBoolean(HEART_RATE_MONITORING_PREFERENCE, enabled) } + _heartRateMonitoringEnabled.value = enabled + + if (enabled) { + if (!wasEnabled) _heartRateSamples.value = emptyList() + startHeartRateMonitoringIfEnabled() + } else { + stopHeartRateMonitoring(forceStop = wasEnabled) + } + } + + private fun startHeartRateMonitoringIfEnabled() { + if (!_heartRateMonitoringEnabled.value) return + if (BluetoothConnectionManager.aacpSocket?.isConnected != true) { + _heartRateStreaming.value = false + return + } + + synchronized(heartRateLock) { + if (heartRateSessionRequested || heartRateStreamStarted || heartRateStartJob?.isActive == true) return + + heartRateStartJob = heartRateScope.launch { + if (isHeadTrackingActive) { + stopHeadTracking() + delay(220) + } + + val sessionInitialized = initializeHeartRateAacpSession() + if (!sessionInitialized) { + synchronized(heartRateLock) { + heartRateStartJob = null + _heartRateStreaming.value = false + } + return@launch + } + + val enabledSent = synchronized(heartRateLock) { + if (!_heartRateMonitoringEnabled.value || + BluetoothConnectionManager.aacpSocket?.isConnected != true + ) { + heartRateStartJob = null + false + } else { + val sent = aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, + true + ) + if (sent) { + heartRateSessionRequested = true + } else { + heartRateStartJob = null + } + sent + } + } + if (!enabledSent) return@launch + + delay(120) + + synchronized(heartRateLock) startFrame@{ + if (!_heartRateMonitoringEnabled.value || + BluetoothConnectionManager.aacpSocket?.isConnected != true + ) { + heartRateStartJob = null + return@startFrame + } + + val started = aacpManager.sendHeartRateStartFrame() + heartRateStreamStarted = started + _heartRateStreaming.value = started + heartRateStartJob = null + Log.d(TAG, "RTBuddy heart-rate start sent=$started") + } + } + } + } + + private suspend fun initializeHeartRateAacpSession(): Boolean { + fun canContinue(): Boolean = + _heartRateMonitoringEnabled.value && + BluetoothConnectionManager.aacpSocket?.isConnected == true + + if (!canContinue() || !aacpManager.sendHeartRateConnectService0()) return false + delay(180) + if (!canContinue() || !aacpManager.sendHeartRateCapabilitiesService0()) return false + delay(220) + if (!canContinue() || !aacpManager.sendHeartRateConnectService4()) return false + delay(180) + if (!canContinue() || !aacpManager.sendHeartRateCapabilitiesService4()) return false + delay(220) + Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized") + return canContinue() + } + + private fun stopHeartRateMonitoring(forceStop: Boolean = false) { + synchronized(heartRateLock) { + val jobWasActive = heartRateStartJob?.isActive == true + heartRateStartJob?.cancel() + heartRateStartJob = null + + val shouldStop = + forceStop || heartRateSessionRequested || heartRateStreamStarted || jobWasActive + heartRateSessionRequested = false + heartRateStreamStarted = false + _heartRateStreaming.value = false + + if (shouldStop && BluetoothConnectionManager.aacpSocket?.isConnected == true) { + aacpManager.sendHeartRateStopFrame() + } + } + } + + private fun handleHeartRateDisconnected() { + stopHeartRateMonitoring() + _heartRateStreaming.value = false + } + var isHeadTrackingActive = false fun startHeadTracking() { From 2fbe287cdb25a8199af2532a41bdb8dc85794972 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Mon, 3 Aug 2026 20:12:52 +0200 Subject: [PATCH 02/15] Improve heart-rate monitoring and Health Connect export --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 25 + ...althConnectPermissionsRationaleActivity.kt | 47 ++ .../librepods/bluetooth/RtBuddyHeartRate.kt | 4 +- .../health/HealthConnectHeartRateExporter.kt | 648 ++++++++++++++++++ .../presentation/components/HeartRateCard.kt | 101 +++ .../presentation/navigation/AppNavGraph.kt | 4 +- .../presentation/navigation/NavigationRoot.kt | 2 +- .../screens/AirPodsSettingsScreen.kt | 31 +- .../presentation/screens/AppSettingsScreen.kt | 12 +- .../screens/HeartRateTestScreen.kt | 125 +++- .../screens/onboarding/PrivacyPolicyPage.kt | 20 +- .../viewmodel/AirPodsViewModel.kt | 60 ++ .../librepods/services/AirPodsService.kt | 283 ++++++-- android/gradle/libs.versions.toml | 2 + 15 files changed, 1262 insertions(+), 103 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/HealthConnectPermissionsRationaleActivity.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..c4ebaa77d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -129,6 +129,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.health.connect.client) implementation(libs.androidx.activity.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0474dfd88..56c76b448 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,6 +41,11 @@ + + + + + + + + + + + + + + + + + + , + val clientRecordId: String, + val detail: BatchDetail, + val startTimeMillis: Long, + val endTimeMillis: Long, + val partialMinute: Boolean = false + ) + + private val appContext = context.applicationContext + private val mutex = Mutex() + private val pendingSamples = linkedMapOf() + private var pendingBatch: PendingBatch? = null + private var lowDataWindowStartMillis: Long? = null + private var requestedDetailedSamples: Boolean? = null + private var healthConnectClient: HealthConnectClient? = null + private var scheduledFlush: Job? = null + + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow get() = _enabled + + private val _detailedSamples = MutableStateFlow( + sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false) + ) + val detailedSamples: StateFlow get() = _detailedSamples + + private val _status = MutableStateFlow(statusForSdk()) + val status: StateFlow get() = _status + + fun refresh() { + scope.launch { + refreshInternal() + } + } + + suspend fun refreshInternal() { + mutex.withLock { + when (HealthConnectClient.getSdkStatus(appContext)) { + HealthConnectClient.SDK_AVAILABLE -> { + val client = getClient() + val granted = try { + client.permissionController.getGrantedPermissions() + .contains(WRITE_HEART_RATE_PERMISSION) + } catch (error: Exception) { + Log.w(TAG, "Unable to query Health Connect permissions", error) + _enabled.value = false + _status.value = HealthConnectExportStatus.ERROR + return@withLock + } + + val requested = sharedPreferences.getBoolean(EXPORT_PREFERENCE, false) + _enabled.value = requested && granted + _status.value = when { + !granted -> HealthConnectExportStatus.PERMISSION_REQUIRED + _enabled.value -> HealthConnectExportStatus.ENABLED + else -> HealthConnectExportStatus.READY + } + + if (_enabled.value && hasPendingSamplesLocked()) { + scheduleFlushLocked(0L) + } + } + + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> { + healthConnectClient = null + _enabled.value = false + _status.value = HealthConnectExportStatus.UPDATE_REQUIRED + } + + else -> { + healthConnectClient = null + _enabled.value = false + _status.value = HealthConnectExportStatus.UNAVAILABLE + } + } + } + } + + fun setEnabled(enabled: Boolean) { + scope.launch { + setEnabledInternal(enabled) + } + } + + private suspend fun setEnabledInternal(enabled: Boolean) { + mutex.withLock { + if (!enabled) { + scheduledFlush?.cancel() + scheduledFlush = null + flushLocked(forcePartialMinute = true) + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } + _enabled.value = false + _status.value = disabledStatus() + return@withLock + } + + when (HealthConnectClient.getSdkStatus(appContext)) { + HealthConnectClient.SDK_AVAILABLE -> { + val granted = try { + getClient().permissionController.getGrantedPermissions() + .contains(WRITE_HEART_RATE_PERMISSION) + } catch (error: Exception) { + Log.w(TAG, "Unable to enable Health Connect export", error) + _enabled.value = false + _status.value = HealthConnectExportStatus.ERROR + return@withLock + } + + if (!granted) { + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } + _enabled.value = false + _status.value = HealthConnectExportStatus.PERMISSION_REQUIRED + return@withLock + } + + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, true) } + _enabled.value = true + _status.value = HealthConnectExportStatus.ENABLED + if (hasPendingSamplesLocked()) scheduleFlushLocked(0L) + } + + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> { + _enabled.value = false + _status.value = HealthConnectExportStatus.UPDATE_REQUIRED + } + + else -> { + _enabled.value = false + _status.value = HealthConnectExportStatus.UNAVAILABLE + } + } + } + } + + fun setDetailedSamples(detailed: Boolean) { + scope.launch { + mutex.withLock { + if (_detailedSamples.value == detailed) { + requestedDetailedSamples = null + return@withLock + } + + requestedDetailedSamples = detailed + scheduledFlush?.cancel() + scheduledFlush = null + if (hasPendingSamplesLocked()) { + if (!_enabled.value || !flushLocked(forcePartialMinute = true)) { + return@withLock + } + } + + applyRequestedDetailLocked() + } + } + } + + fun markPermissionDenied() { + scope.launch { + mutex.withLock { + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } + _enabled.value = false + _status.value = HealthConnectExportStatus.PERMISSION_DENIED + } + } + } + + fun enqueue(sample: HeartRateSample, deviceModel: String) { + if (!_enabled.value) return + + scope.launch { + val flushNow = mutex.withLock { + if (!_enabled.value) return@withLock false + + val id = clientRecordId(sample) + pendingSamples.putIfAbsent( + id, + PendingSample( + id = id, + sample = sample, + deviceModel = deviceModel.ifBlank { "AirPods" } + ) + ) + if (!_detailedSamples.value && lowDataWindowStartMillis == null) { + lowDataWindowStartMillis = sample.receivedAtMillis + } + trimBufferLocked() + + if (pendingBatch != null) { + false + } else if (_detailedSamples.value) { + if (bufferedSampleCountLocked() >= MAX_BATCH_SIZE) { + scheduledFlush?.cancel() + scheduledFlush = null + true + } else { + scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) + false + } + } else if (hasCompletedLowDataWindowLocked()) { + scheduledFlush?.cancel() + scheduledFlush = null + true + } else { + scheduleLowDataFlushLocked() + false + } + } + + if (flushNow) flush() + } + } + + fun flushAsync() { + scope.launch { flush(forcePartialMinute = true) } + } + + suspend fun flush(forcePartialMinute: Boolean = false) { + mutex.withLock { + scheduledFlush?.cancel() + scheduledFlush = null + flushLocked(forcePartialMinute) + } + } + + suspend fun closeAndFlush() { + flush(forcePartialMinute = true) + } + + private suspend fun flushLocked(forcePartialMinute: Boolean = false): Boolean { + if (!hasPendingSamplesLocked()) { + applyRequestedDetailLocked() + return true + } + if (!_enabled.value) return false + + while (_enabled.value && hasPendingSamplesLocked()) { + val batch = getOrCreatePendingBatchLocked( + forcePartialMinute || requestedDetailedSamples != null + ) + if (batch == null) { + scheduleNextFlushLocked() + return false + } + + try { + getClient().insertRecords(listOf(toRecord(batch))) + completePendingBatchLocked(batch) + _status.value = HealthConnectExportStatus.ENABLED + } catch (error: SecurityException) { + Log.w(TAG, "Health Connect permission was revoked", error) + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } + _enabled.value = false + _status.value = HealthConnectExportStatus.PERMISSION_REQUIRED + return false + } catch (error: IOException) { + Log.w(TAG, "Health Connect write failed; keeping batch for retry", error) + _status.value = HealthConnectExportStatus.ERROR + scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + return false + } catch (error: IllegalStateException) { + Log.w(TAG, "Health Connect is temporarily unavailable", error) + _status.value = HealthConnectExportStatus.ERROR + scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + return false + } catch (error: RuntimeException) { + Log.w(TAG, "Unexpected Health Connect write failure", error) + _status.value = HealthConnectExportStatus.ERROR + scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + return false + } + } + + applyRequestedDetailLocked() + return !hasPendingSamplesLocked() + } + + private fun applyRequestedDetailLocked() { + val detailed = requestedDetailedSamples ?: return + if (hasPendingSamplesLocked()) return + + lowDataWindowStartMillis = null + sharedPreferences.edit { + putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) + } + _detailedSamples.value = detailed + requestedDetailedSamples = null + } + + private fun scheduleFlushLocked(delayMillis: Long) { + if (scheduledFlush?.isActive == true) return + scheduledFlush = scope.launch { + delay(delayMillis) + mutex.withLock { + scheduledFlush = null + flushLocked() + } + } + } + + private fun scheduleNextFlushLocked() { + if (pendingBatch != null || pendingSamples.isEmpty()) return + if (_detailedSamples.value) { + scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) + } else { + scheduleLowDataFlushLocked() + } + } + + private fun scheduleLowDataFlushLocked() { + val windowStart = ensureLowDataWindowStartLocked() ?: return + val windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + val delayMillis = (windowEnd - System.currentTimeMillis()).coerceAtLeast(0L) + scheduleFlushLocked(delayMillis) + } + + private fun getOrCreatePendingBatchLocked(forcePartialMinute: Boolean): PendingBatch? { + pendingBatch?.let { return it } + + return if (_detailedSamples.value) { + createDetailedBatchLocked() + } else { + createLowDataBatchLocked(forcePartialMinute) + } + } + + private fun createDetailedBatchLocked(): PendingBatch? { + val selectedSamples = pendingSamples.values.take(MAX_BATCH_SIZE) + if (selectedSamples.isEmpty()) return null + + selectedSamples.forEach { pendingSamples.remove(it.id) } + val orderedSamples = selectedSamples.sortedWith(PENDING_SAMPLE_COMPARATOR) + val firstSample = orderedSamples.first() + val lastSample = orderedSamples.last() + + return PendingBatch( + samples = orderedSamples, + clientRecordId = batchClientRecordId(orderedSamples), + detail = BatchDetail.DETAILED, + startTimeMillis = firstSample.sample.receivedAtMillis, + endTimeMillis = lastSample.sample.receivedAtMillis + 1L + ).also { pendingBatch = it } + } + + private fun createLowDataBatchLocked(forcePartialMinute: Boolean): PendingBatch? { + val orderedSamples = pendingSamples.values.sortedWith(PENDING_SAMPLE_COMPARATOR) + if (orderedSamples.isEmpty()) return null + + var windowStart = ensureLowDataWindowStartLocked() ?: return null + val earliestTimestamp = orderedSamples.first().sample.receivedAtMillis + var windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + while (earliestTimestamp >= windowEnd) { + windowStart = windowEnd + windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + lowDataWindowStartMillis = windowStart + } + + val hasSampleAfterWindow = orderedSamples.any { + it.sample.receivedAtMillis >= windowEnd + } + val completedWindow = hasSampleAfterWindow || System.currentTimeMillis() >= windowEnd + if (!forcePartialMinute && !completedWindow) return null + + val selectedSamples = orderedSamples.takeWhile { + it.sample.receivedAtMillis < windowEnd + } + if (selectedSamples.isEmpty()) return null + + selectedSamples.forEach { pendingSamples.remove(it.id) } + val firstSampleTime = selectedSamples.first().sample.receivedAtMillis + val lastSampleTime = selectedSamples.last().sample.receivedAtMillis + val partialMinute = !completedWindow + val recordStartTime = maxOf(windowStart, firstSampleTime) + val recordEndTime = if (partialMinute) { + maxOf(recordStartTime + 1L, lastSampleTime + 1L) + } else { + maxOf(recordStartTime + 1L, windowEnd) + } + + return PendingBatch( + samples = selectedSamples, + clientRecordId = minuteAverageClientRecordId( + samples = selectedSamples, + startTimeMillis = recordStartTime, + endTimeMillis = recordEndTime + ), + detail = BatchDetail.MINUTE_AVERAGE, + startTimeMillis = recordStartTime, + endTimeMillis = recordEndTime, + partialMinute = partialMinute + ).also { pendingBatch = it } + } + + private fun completePendingBatchLocked(batch: PendingBatch) { + pendingBatch = null + if (batch.detail == BatchDetail.MINUTE_AVERAGE) { + lowDataWindowStartMillis = if (batch.partialMinute) { + null + } else { + batch.endTimeMillis + } + } + } + + private fun toRecord(batch: PendingBatch): HeartRateRecord { + val firstSample = batch.samples.first() + val startTimestamp = Instant.ofEpochMilli(batch.startTimeMillis) + val endTimestamp = Instant.ofEpochMilli(batch.endTimeMillis) + val zoneRules = ZoneId.systemDefault().rules + val samples = when (batch.detail) { + BatchDetail.DETAILED -> batch.samples.map { pending -> + HeartRateRecord.Sample( + time = Instant.ofEpochMilli(pending.sample.receivedAtMillis), + beatsPerMinute = pending.sample.bpm.toLong() + ) + } + + BatchDetail.MINUTE_AVERAGE -> listOf( + HeartRateRecord.Sample( + time = Instant.ofEpochMilli( + batch.startTimeMillis + + (batch.endTimeMillis - batch.startTimeMillis) / 2L + ), + beatsPerMinute = averageBpm(batch.samples) + ) + ) + } + + return HeartRateRecord( + startTime = startTimestamp, + startZoneOffset = zoneRules.getOffset(startTimestamp), + endTime = endTimestamp, + endZoneOffset = zoneRules.getOffset(endTimestamp), + samples = samples, + metadata = Metadata.autoRecorded( + device = Device( + type = Device.TYPE_UNKNOWN, + manufacturer = "Apple", + model = firstSample.deviceModel + ), + clientRecordId = batch.clientRecordId, + clientRecordVersion = 0L + ) + ) + } + + private fun hasPendingSamplesLocked(): Boolean = + pendingBatch != null || pendingSamples.isNotEmpty() + + private fun bufferedSampleCountLocked(): Int = + pendingSamples.size + (pendingBatch?.samples?.size ?: 0) + + private fun hasCompletedLowDataWindowLocked(): Boolean { + val windowStart = ensureLowDataWindowStartLocked() ?: return false + val windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + return System.currentTimeMillis() >= windowEnd || pendingSamples.values.any { + it.sample.receivedAtMillis >= windowEnd + } + } + + private fun ensureLowDataWindowStartLocked(): Long? { + lowDataWindowStartMillis?.let { return it } + return pendingSamples.values.minOfOrNull { it.sample.receivedAtMillis }?.also { + lowDataWindowStartMillis = it + } + } + + private fun trimBufferLocked() { + while (bufferedSampleCountLocked() > MAX_BUFFERED_SAMPLES) { + val oldestId = pendingSamples.keys.firstOrNull() ?: break + pendingSamples.remove(oldestId) + } + } + + private fun averageBpm(samples: List): Long { + val total = samples.fold(0L) { sum, pending -> + sum + pending.sample.bpm.toLong() + } + return (total + samples.size / 2L) / samples.size + } + + private fun batchClientRecordId(samples: List): String { + val stableBatchDescription = buildString { + append(samples.first().deviceModel) + samples.forEach { pending -> + append('\u0000') + append(pending.id) + } + } + return "$BATCH_CLIENT_RECORD_ID_PREFIX${sha256(stableBatchDescription)}" + } + + private fun minuteAverageClientRecordId( + samples: List, + startTimeMillis: Long, + endTimeMillis: Long + ): String { + val stableBatchDescription = buildString { + append(startTimeMillis) + append('\u0000') + append(endTimeMillis) + samples.forEach { pending -> + append('\u0000') + append(pending.deviceModel) + append('\u0000') + append(pending.id) + } + } + return "$MINUTE_AVERAGE_CLIENT_RECORD_ID_PREFIX${sha256(stableBatchDescription)}" + } + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> + "%02x".format(byte.toInt() and 0xff) + } + + private fun getClient(): HealthConnectClient = healthConnectClient + ?: HealthConnectClient.getOrCreate(appContext).also { healthConnectClient = it } + + private fun statusForSdk(): HealthConnectExportStatus = + when (HealthConnectClient.getSdkStatus(appContext)) { + HealthConnectClient.SDK_AVAILABLE -> HealthConnectExportStatus.PERMISSION_REQUIRED + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectExportStatus.UPDATE_REQUIRED + else -> HealthConnectExportStatus.UNAVAILABLE + } + + private suspend fun disabledStatus(): HealthConnectExportStatus { + return when (HealthConnectClient.getSdkStatus(appContext)) { + HealthConnectClient.SDK_AVAILABLE -> { + val permissionGranted = try { + getClient().permissionController.getGrantedPermissions() + .contains(WRITE_HEART_RATE_PERMISSION) + } catch (error: Exception) { + Log.w(TAG, "Unable to query Health Connect permissions", error) + return HealthConnectExportStatus.ERROR + } + if (permissionGranted) { + HealthConnectExportStatus.READY + } else { + HealthConnectExportStatus.PERMISSION_REQUIRED + } + } + + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> + HealthConnectExportStatus.UPDATE_REQUIRED + + else -> HealthConnectExportStatus.UNAVAILABLE + } + } + + private fun clientRecordId(sample: HeartRateSample): String = + "librepods-heart-rate-v1-${sample.receivedAtMillis}-${sample.sequence}-${sample.bpm}" + + companion object { + private val PENDING_SAMPLE_COMPARATOR = compareBy( + { it.sample.receivedAtMillis }, + { it.sample.sequence }, + { it.id } + ) + + private const val TAG = "HealthConnectHR" + private const val EXPORT_PREFERENCE = "heart_rate_health_connect_export_enabled" + private const val DETAILED_SAMPLES_PREFERENCE = + "heart_rate_health_connect_detailed_samples" + private const val BATCH_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-batch-v1-" + private const val MINUTE_AVERAGE_CLIENT_RECORD_ID_PREFIX = + "librepods-heart-rate-minute-average-v1-" + private const val MAX_BATCH_SIZE = 15 + private const val MAX_BUFFERED_SAMPLES = 300 + private const val FLUSH_INTERVAL_MILLIS = 15_000L + private const val LOW_DATA_WINDOW_MILLIS = 60_000L + private const val RETRY_INTERVAL_MILLIS = 30_000L + + val WRITE_HEART_RATE_PERMISSION: String = + HealthPermission.getWritePermission(HeartRateRecord::class) + val REQUIRED_PERMISSIONS: Set = setOf(WRITE_HEART_RATE_PERMISSION) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt new file mode 100644 index 000000000..341fe3540 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt @@ -0,0 +1,101 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.bluetooth.HeartRateSample + +@Composable +fun HeartRateCard( + monitoringEnabled: Boolean, + streaming: Boolean, + connected: Boolean, + latestSample: HeartRateSample?, + onMonitoringChanged: (Boolean) -> Unit, + onOpenDetails: () -> Unit, + modifier: Modifier = Modifier +) { + val status = when { + !monitoringEnabled -> "Off" + !connected -> "Waiting for connection" + streaming -> "Streaming" + else -> "Awaiting sample" + } + + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onOpenDetails), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Heart rate", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = status, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = if (streaming) latestSample?.bpm?.toString() ?: "—" else "—", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Switch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index fdb7284d9..cc8f19a1d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -112,6 +112,7 @@ fun AppNavGraph( navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, navigateToCallControlScreen = { navigate(Screen.CallControl(it)) }, navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) }, + navigateToHeartRateTest = { navigate(Screen.HeartRateTest) }, ) } @@ -129,8 +130,7 @@ fun AppNavGraph( navigateToPurchase = ::navigateToPurchase, navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, - navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }, - navigateToHeartRateTest = { navigate(Screen.HeartRateTest) } + navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index c612bdc28..8471644a4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -59,7 +59,7 @@ fun NavigationRoot( // Screen.CameraControl -> stringResource(R.string.camera_control) Screen.Equalizer -> stringResource(R.string.equalizer) Screen.HeadTracking -> stringResource(R.string.head_tracking) - Screen.HeartRateTest -> "Heart-rate test" + Screen.HeartRateTest -> "Heart rate" Screen.HearingAid -> stringResource(R.string.hearing_aid) Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) Screen.HearingProtection -> stringResource(R.string.hearing_protection) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 9583cceab..e8e830a23 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -111,6 +111,7 @@ import me.kavishdevar.librepods.presentation.components.BatteryView import me.kavishdevar.librepods.presentation.components.CallControlSettings import me.kavishdevar.librepods.presentation.components.ConnectionSettings import me.kavishdevar.librepods.presentation.components.HearingHealthSettings +import me.kavishdevar.librepods.presentation.components.HeartRateCard import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.NoiseControlSettings import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings @@ -144,7 +145,8 @@ fun AirPodsSettingsRoute( navigateToVersion: () -> Unit, navigateToTroubleshooting: () -> Unit, navigateToCallControlScreen: (action: String) -> Unit, - navigateToMicrophoneSettings: () -> Unit + navigateToMicrophoneSettings: () -> Unit, + navigateToHeartRateTest: () -> Unit ) { val state by viewModel.uiState.collectAsState() @@ -190,6 +192,9 @@ fun AirPodsSettingsRoute( navigateToTroubleshooting = navigateToTroubleshooting, navigateToCallControlScreen = navigateToCallControlScreen, navigateToMicrophoneSettings = navigateToMicrophoneSettings, + navigateToHeartRateTest = navigateToHeartRateTest, + + setHeartRateMonitoringEnabled = viewModel::setHeartRateMonitoringEnabled, activateDemoMode = viewModel::activateDemoMode, reconnectFromSavedMac = viewModel::reconnectFromSavedMac @@ -232,6 +237,9 @@ fun AirPodsSettingsScreen( navigateToTroubleshooting: () -> Unit, navigateToCallControlScreen: (action: String) -> Unit, navigateToMicrophoneSettings: () -> Unit, + navigateToHeartRateTest: () -> Unit, + + setHeartRateMonitoringEnabled: (Boolean) -> Unit, activateDemoMode: () -> Unit, reconnectFromSavedMac: () -> Unit, @@ -316,7 +324,7 @@ fun AirPodsSettingsScreen( ) } item(key = "spacer_battery") { - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) } item(key = "name") { @@ -326,6 +334,19 @@ fun AirPodsSettingsScreen( onClick = navigateToRename, ) } + item(key = "spacer_heart_rate") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "heart_rate") { + HeartRateCard( + monitoringEnabled = state.heartRateMonitoringEnabled, + streaming = state.heartRateStreaming, + connected = state.isLocallyConnected, + latestSample = state.heartRateSamples.lastOrNull(), + onMonitoringChanged = setHeartRateMonitoringEnabled, + onOpenDetails = navigateToHeartRateTest + ) + } val hasHearingAidCapability = state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true @@ -966,6 +987,9 @@ fun AirPodsSettingsScreenPreviewApple() { navigateToTroubleshooting = {}, navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, + navigateToHeartRateTest = {}, + + setHeartRateMonitoringEnabled = {}, activateDemoMode = {}, reconnectFromSavedMac = {} @@ -1013,6 +1037,9 @@ fun AirPodsSettingsScreenPreviewMaterial() { navigateToTroubleshooting = {}, navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, + navigateToHeartRateTest = {}, + + setHeartRateMonitoringEnabled = {}, activateDemoMode = {}, reconnectFromSavedMac = {} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 4a858d948..06436561d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -105,8 +105,7 @@ fun AppSettingsScreen( navigateToPurchase: () -> Unit, navigateToTroubleshooting: () -> Unit, navigateToOpenSourceLicenses: () -> Unit, - navigateToReleaseNotesScreen: () -> Unit, - navigateToHeartRateTest: () -> Unit + navigateToReleaseNotesScreen: () -> Unit ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -384,15 +383,6 @@ fun AppSettingsScreen( ) } - Spacer(modifier = Modifier.height(16.dp)) - StyledList(title = "Tests") { - StyledListItem( - name = "Heart-rate test", - description = "View validated samples from supported AirPods", - onClick = navigateToHeartRateTest, - ) - } - if (!BuildConfig.PLAY_BUILD) { Spacer(modifier = Modifier.height(16.dp)) StyledList { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index d7b46595a..851a77915 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -1,5 +1,5 @@ /* - LibrePods - AirPods liberated from Apple’s ecosystem + LibrePods - AirPods liberated from Apple’s ecosystem Copyright (C) 2025 LibrePods contributors This program is free software: you can redistribute it and/or modify @@ -10,6 +10,7 @@ package me.kavishdevar.librepods.presentation.screens +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -33,6 +34,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -42,7 +44,10 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.health.connect.client.PermissionController import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.health.HealthConnectExportStatus +import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -53,6 +58,20 @@ import java.util.Date @Composable fun HeartRateTestScreen(viewModel: AirPodsViewModel) { val state by viewModel.uiState.collectAsState() + val healthConnectPermissionLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { grantedPermissions: Set -> + if (HealthConnectHeartRateExporter.WRITE_HEART_RATE_PERMISSION in grantedPermissions) { + viewModel.setHealthConnectExportEnabled(true) + } else { + viewModel.markHealthConnectPermissionDenied() + } + } + + LaunchedEffect(Unit) { + viewModel.refreshHealthConnectExportState() + } + val materialDesign = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (materialDesign) { 16.dp @@ -64,10 +83,40 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { val latestSample = state.heartRateSamples.lastOrNull() val monitoringStatus = when { !state.heartRateMonitoringEnabled -> "Disabled" - !state.isLocallyConnected -> "Enabled — waiting for connection" + !state.isLocallyConnected -> "Enabled — waiting for connection" state.heartRateStreaming -> "Streaming" - else -> "Enabled — awaiting stream" + else -> "Enabled — awaiting valid sample" } + val healthConnectDescription = when (state.healthConnectExportStatus) { + HealthConnectExportStatus.UNAVAILABLE -> + "Health Connect is not available on this device." + + HealthConnectExportStatus.UPDATE_REQUIRED -> + "Install or update Health Connect to save heart-rate samples." + + HealthConnectExportStatus.PERMISSION_REQUIRED -> + "Write permission is required before samples can be saved." + + HealthConnectExportStatus.PERMISSION_DENIED -> + "Permission was denied. Turn this on to request it again." + + HealthConnectExportStatus.READY -> + "Available. Enable this to save validated samples on this device." + + HealthConnectExportStatus.ENABLED -> + if (state.healthConnectDetailedSamples) { + "Validated samples are saved in 15-second batches with their original timestamps." + } else { + "Validated samples are averaged into one Health Connect record per minute." + } + + HealthConnectExportStatus.ERROR -> + "A write failed. Buffered samples will be retried without creating duplicates." + } + val healthConnectAvailable = state.healthConnectExportStatus !in setOf( + HealthConnectExportStatus.UNAVAILABLE, + HealthConnectExportStatus.UPDATE_REQUIRED + ) Column( modifier = Modifier @@ -78,17 +127,6 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { ) { Spacer(modifier = Modifier.height(topPadding)) - StyledToggle( - title = "Heart-rate test", - label = "Enable monitoring", - description = "Uses the existing AirPods AACP connection and remains enabled across reconnects.", - checked = state.heartRateMonitoringEnabled, - onCheckedChange = viewModel::setHeartRateMonitoringEnabled, - header = true - ) - - Spacer(modifier = Modifier.height(4.dp)) - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), @@ -105,7 +143,7 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { ) { Column { Text( - text = latestSample?.bpm?.toString() ?: "—", + text = latestSample?.bpm?.toString() ?: "—", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold ) @@ -144,6 +182,59 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(16.dp)) + StyledToggle( + title = "Health Connect", + label = "Save heart-rate samples", + description = healthConnectDescription, + checked = state.healthConnectExportEnabled, + enabled = healthConnectAvailable, + onCheckedChange = { enabled: Boolean -> + if (!enabled) { + viewModel.setHealthConnectExportEnabled(false) + } else { + when (state.healthConnectExportStatus) { + HealthConnectExportStatus.READY, + HealthConnectExportStatus.ENABLED -> + viewModel.setHealthConnectExportEnabled(true) + + HealthConnectExportStatus.PERMISSION_REQUIRED, + HealthConnectExportStatus.PERMISSION_DENIED, + HealthConnectExportStatus.ERROR -> + healthConnectPermissionLauncher.launch( + HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS + ) + + HealthConnectExportStatus.UNAVAILABLE, + HealthConnectExportStatus.UPDATE_REQUIRED -> Unit + } + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + StyledToggle( + title = null, + label = "Detailed samples", + description = if (state.healthConnectDetailedSamples) { + "Export original per-second samples in 15-second batches. AirPods sampling is unchanged." + } else { + "Export one average BPM for each minute. AirPods sampling is unchanged." + }, + checked = state.healthConnectDetailedSamples, + enabled = healthConnectAvailable, + onCheckedChange = viewModel::setHealthConnectDetailedSamples + ) + + Text( + text = "Heart-rate tracking is controlled from the connected-device screen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + Text( text = "Recent samples", style = MaterialTheme.typography.titleMedium, @@ -154,7 +245,7 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { HeartRateGraph(samples = state.heartRateSamples) Text( - text = "Experimental test data only. Do not use it for medical decisions.", + text = "Experimental wellness data only. LibrePods and AirPods are not medical devices; do not use these readings for diagnosis or medical decisions.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp) @@ -247,5 +338,3 @@ private fun formatLastUpdate(sample: HeartRateSample?): String { return DateFormat.getTimeInstance(DateFormat.MEDIUM) .format(Date(sample.receivedAtMillis)) } - - diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt index 23eaa8377..cda9108b9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt @@ -23,7 +23,8 @@ import me.kavishdevar.librepods.R @Composable fun PrivacyPolicyPage( - onForward: () -> Unit + onForward: () -> Unit, + actionLabel: String? = null ) { val scrollState = rememberScrollState() @@ -61,6 +62,21 @@ fun PrivacyPolicyPage( style = MaterialTheme.typography.bodyMedium ) + Text( + text = "Health Connect", + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = "If you enable heart-rate export, LibrePods writes validated AirPods heart-rate samples and their timestamps to Android Health Connect on your device. LibrePods does not upload this data to a LibrePods server, use it for analytics, or share it for advertising.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "You can stop exporting in LibrePods or revoke LibrePods' Health Connect permission at any time. These experimental readings are not intended for medical use and must not be used for diagnosis or medical decisions.", + style = MaterialTheme.typography.bodyMedium + ) + Text( text = "Third Party Services", style = MaterialTheme.typography.titleLarge @@ -186,7 +202,7 @@ fun PrivacyPolicyPage( modifier = Modifier.fillMaxWidth() ) { Text( - text = stringResource(R.string.i_agree), + text = actionLabel ?: stringResource(R.string.i_agree), style = MaterialTheme.typography.labelMediumEmphasized ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 99057fe2a..1b7b315e5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -55,6 +55,7 @@ import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider +import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.services.AirPodsService @Suppress("ArrayInDataClass") @@ -85,6 +86,9 @@ data class AirPodsUiState( val heartRateMonitoringEnabled: Boolean = false, val heartRateStreaming: Boolean = false, val heartRateSamples: List = emptyList(), + val healthConnectExportEnabled: Boolean = false, + val healthConnectExportStatus: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, + val healthConnectDetailedSamples: Boolean = false, val eqData: FloatArray = floatArrayOf(), @@ -482,6 +486,21 @@ class AirPodsViewModel( _uiState.update { it.copy(heartRateSamples = samples) } } } + viewModelScope.launch { + service.healthConnectExportEnabled.collect { enabled -> + _uiState.update { it.copy(healthConnectExportEnabled = enabled) } + } + } + viewModelScope.launch { + service.healthConnectExportStatus.collect { status -> + _uiState.update { it.copy(healthConnectExportStatus = status) } + } + } + viewModelScope.launch { + service.healthConnectDetailedSamples.collect { detailed -> + _uiState.update { it.copy(healthConnectDetailedSamples = detailed) } + } + } } fun loadCurrentStatus() { @@ -493,6 +512,9 @@ class AirPodsViewModel( heartRateMonitoringEnabled = service.heartRateMonitoringEnabled.value, heartRateStreaming = service.heartRateStreaming.value, heartRateSamples = service.heartRateSamples.value, + healthConnectExportEnabled = service.healthConnectExportEnabled.value, + healthConnectExportStatus = service.healthConnectExportStatus.value, + healthConnectDetailedSamples = service.healthConnectDetailedSamples.value, battery = service.getBattery(), ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, controlStates = controlRepo.getMap() @@ -652,6 +674,7 @@ class AirPodsViewModel( } fun reconnectFromSavedMac() { + if (!::service.isInitialized) return service.reconnectFromSavedMac() } @@ -684,6 +707,43 @@ class AirPodsViewModel( service.setHeartRateMonitoringEnabled(enabled) } + fun refreshHealthConnectExportState() { + if (!isReady || isDemoMode) return + service.refreshHealthConnectExportState() + } + + fun setHealthConnectExportEnabled(enabled: Boolean) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + healthConnectExportEnabled = enabled, + healthConnectExportStatus = if (enabled) { + HealthConnectExportStatus.ENABLED + } else { + HealthConnectExportStatus.READY + } + ) + } + return + } + service.setHealthConnectExportEnabled(enabled) + } + + fun setHealthConnectDetailedSamples(detailed: Boolean) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { it.copy(healthConnectDetailedSamples = detailed) } + return + } + service.setHealthConnectDetailedSamples(detailed) + } + + fun markHealthConnectPermissionDenied() { + if (!isReady || isDemoMode) return + service.markHealthConnectPermissionDenied() + } + fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { when (handle) { // ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction. diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index b3ec122db..b4dafae93 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -55,6 +55,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelUuid +import android.os.SystemClock import android.os.UserHandle import android.provider.Settings import android.telecom.TelecomManager @@ -71,6 +72,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.core.app.NotificationCompat import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -105,6 +107,8 @@ import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.isHeadTrackingData +import me.kavishdevar.librepods.health.HealthConnectExportStatus +import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.overlays.IslandType import me.kavishdevar.librepods.presentation.overlays.IslandWindow import me.kavishdevar.librepods.presentation.overlays.PopupWindow @@ -136,6 +140,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.coroutines.coroutineContext import kotlin.time.Duration.Companion.milliseconds private const val TAG = "AirPodsService" @@ -239,7 +244,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val heartRateLock = Any() private var heartRateStartJob: Job? = null private var heartRateSessionRequested = false - private var heartRateStreamStarted = false + private var heartRateStartCommandSent = false + private var lastValidHeartRateSampleElapsedRealtime: Long? = null + + private enum class HeartRateStreamFailure { + FIRST_SAMPLE_TIMEOUT, + STREAM_STALLED + } private val _heartRateMonitoringEnabled = MutableStateFlow(false) val heartRateMonitoringEnabled: StateFlow get() = _heartRateMonitoringEnabled @@ -250,6 +261,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val _heartRateSamples = MutableStateFlow>(emptyList()) val heartRateSamples: StateFlow> get() = _heartRateSamples + private lateinit var heartRateExporter: HealthConnectHeartRateExporter + val healthConnectExportEnabled: StateFlow + get() = heartRateExporter.enabled + val healthConnectExportStatus: StateFlow + get() = heartRateExporter.status + val healthConnectDetailedSamples: StateFlow + get() = heartRateExporter.detailedSamples + private var handleIncomingCallOnceConnected = false lateinit var bleManager: BLEManager @@ -257,6 +276,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList companion object { private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" private const val MAX_HEART_RATE_SAMPLES = 60 + private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 12_000L + private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 6_000L + private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L + private val HEART_RATE_RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) init { System.loadLibrary("bluetooth_socket") @@ -403,6 +426,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList HEART_RATE_MONITORING_PREFERENCE, false ) + heartRateExporter = HealthConnectHeartRateExporter( + context = applicationContext, + sharedPreferences = sharedPreferences, + scope = heartRateScope + ) + heartRateExporter.refresh() initializeConfig() aacpManager = AACPManager() @@ -1108,8 +1137,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onHeartRateReceived(sample: HeartRateSample) { - if (!_heartRateMonitoringEnabled.value) return + val accepted = synchronized(heartRateLock) { + if (!_heartRateMonitoringEnabled.value || + BluetoothConnectionManager.aacpSocket?.isConnected != true + ) { + false + } else { + lastValidHeartRateSampleElapsedRealtime = SystemClock.elapsedRealtime() + if (heartRateStartCommandSent && heartRateStartJob?.isActive == true) { + _heartRateStreaming.value = true + } + true + } + } + if (!accepted) return + _heartRateSamples.value = (_heartRateSamples.value + sample).takeLast(MAX_HEART_RATE_SAMPLES) + heartRateExporter.enqueue( + sample = sample, + deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName } + ) } override fun onProximityKeysReceived(proximityKeys: ByteArray) { @@ -3185,12 +3232,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList telephonyManager.unregisterTelephonyCallback(phoneStateListener) } stopHeartRateMonitoring() + if (::heartRateExporter.isInitialized) { + runBlocking { heartRateExporter.closeAndFlush() } + } heartRateScope.cancel() // isConnectedLocally = false // CrossDevice.isAvailable = true super.onDestroy() } + fun refreshHealthConnectExportState() { + if (::heartRateExporter.isInitialized) heartRateExporter.refresh() + } + + fun setHealthConnectExportEnabled(enabled: Boolean) { + if (::heartRateExporter.isInitialized) heartRateExporter.setEnabled(enabled) + } + + fun setHealthConnectDetailedSamples(detailed: Boolean) { + if (::heartRateExporter.isInitialized) heartRateExporter.setDetailedSamples(detailed) + } + + fun markHealthConnectPermissionDenied() { + if (::heartRateExporter.isInitialized) heartRateExporter.markPermissionDenied() + } + fun setHeartRateMonitoringEnabled(enabled: Boolean) { val wasEnabled = _heartRateMonitoringEnabled.value sharedPreferences.edit { putBoolean(HEART_RATE_MONITORING_PREFERENCE, enabled) } @@ -3200,6 +3266,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!wasEnabled) _heartRateSamples.value = emptyList() startHeartRateMonitoringIfEnabled() } else { + if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync() stopHeartRateMonitoring(forceStop = wasEnabled) } } @@ -3212,79 +3279,175 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } synchronized(heartRateLock) { - if (heartRateSessionRequested || heartRateStreamStarted || heartRateStartJob?.isActive == true) return + if (heartRateStartJob?.isActive == true) return - heartRateStartJob = heartRateScope.launch { - if (isHeadTrackingActive) { - stopHeadTracking() - delay(220) - } + _heartRateStreaming.value = false + val job = heartRateScope.launch(start = CoroutineStart.LAZY) { + runHeartRateMonitoringWatchdog() + } + heartRateStartJob = job + job.start() + } + } + + private suspend fun runHeartRateMonitoringWatchdog() { + val currentJob = coroutineContext[Job] + try { + if (isHeadTrackingActive) { + stopHeadTracking() + delay(220) + } + + var consecutiveRecoveryAttempts = 0 + while (canContinueHeartRateMonitoring()) { + val attemptStartedAt = startHeartRateStreamAttempt() + if (!canContinueHeartRateMonitoring()) return - val sessionInitialized = initializeHeartRateAacpSession() - if (!sessionInitialized) { + val failure = if (attemptStartedAt == null) { synchronized(heartRateLock) { - heartRateStartJob = null - _heartRateStreaming.value = false + stopHeartRateSessionLocked() } - return@launch + HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT + } else { + awaitHeartRateStreamFailure(attemptStartedAt) ?: return } - val enabledSent = synchronized(heartRateLock) { - if (!_heartRateMonitoringEnabled.value || - BluetoothConnectionManager.aacpSocket?.isConnected != true - ) { - heartRateStartJob = null - false - } else { - val sent = aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, - true - ) - if (sent) { - heartRateSessionRequested = true - } else { - heartRateStartJob = null - } - sent - } + if (failure == HeartRateStreamFailure.STREAM_STALLED) { + consecutiveRecoveryAttempts = 0 } - if (!enabledSent) return@launch - - delay(120) - synchronized(heartRateLock) startFrame@{ - if (!_heartRateMonitoringEnabled.value || - BluetoothConnectionManager.aacpSocket?.isConnected != true - ) { - heartRateStartJob = null - return@startFrame - } + if (consecutiveRecoveryAttempts >= HEART_RATE_RETRY_BACKOFF_MILLIS.size) { + Log.w(TAG, "RTBuddy heart-rate recovery retries exhausted") + return + } - val started = aacpManager.sendHeartRateStartFrame() - heartRateStreamStarted = started - _heartRateStreaming.value = started + val backoffMillis = + HEART_RATE_RETRY_BACKOFF_MILLIS[consecutiveRecoveryAttempts] + consecutiveRecoveryAttempts++ + Log.w( + TAG, + "RTBuddy heart-rate ${failure.name.lowercase()} recovery " + + "attempt=$consecutiveRecoveryAttempts backoff=${backoffMillis}ms" + ) + delay(backoffMillis) + } + } finally { + synchronized(heartRateLock) { + if (heartRateStartJob === currentJob) { + stopHeartRateSessionLocked() heartRateStartJob = null - Log.d(TAG, "RTBuddy heart-rate start sent=$started") } } } } + private suspend fun startHeartRateStreamAttempt(): Long? { + if (!initializeHeartRateAacpSession()) return null + + val enabledSent = synchronized(heartRateLock) { + if (!canContinueHeartRateMonitoring()) { + false + } else { + val sent = aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, + true + ) + if (sent) heartRateSessionRequested = true + sent + } + } + if (!enabledSent) return null + + delay(120) + + return synchronized(heartRateLock) { + if (!canContinueHeartRateMonitoring()) { + null + } else { + _heartRateStreaming.value = false + val attemptStartedAt = SystemClock.elapsedRealtime() + val started = aacpManager.sendHeartRateStartFrame() + heartRateStartCommandSent = started + Log.d(TAG, "RTBuddy heart-rate start sent=$started") + if (started) attemptStartedAt else null + } + } + } + + private suspend fun awaitHeartRateStreamFailure( + attemptStartedAt: Long + ): HeartRateStreamFailure? { + while (canContinueHeartRateMonitoring()) { + delay(HEART_RATE_WATCHDOG_INTERVAL_MILLIS) + val now = SystemClock.elapsedRealtime() + val failure = synchronized(heartRateLock) { + if (!canContinueHeartRateMonitoring()) { + null + } else { + val lastSampleAt = lastValidHeartRateSampleElapsedRealtime + when { + lastSampleAt != null && lastSampleAt >= attemptStartedAt && + now - lastSampleAt >= HEART_RATE_STALL_TIMEOUT_MILLIS -> { + stopHeartRateSessionLocked() + HeartRateStreamFailure.STREAM_STALLED + } + + (lastSampleAt == null || lastSampleAt < attemptStartedAt) && + now - attemptStartedAt >= HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS -> { + stopHeartRateSessionLocked() + HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT + } + + else -> null + } + } + } + if (failure != null) return failure + } + return null + } + + private fun canContinueHeartRateMonitoring(): Boolean = + _heartRateMonitoringEnabled.value && + BluetoothConnectionManager.aacpSocket?.isConnected == true + private suspend fun initializeHeartRateAacpSession(): Boolean { - fun canContinue(): Boolean = - _heartRateMonitoringEnabled.value && - BluetoothConnectionManager.aacpSocket?.isConnected == true + if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService0() }) { + return false + } - if (!canContinue() || !aacpManager.sendHeartRateConnectService0()) return false delay(180) - if (!canContinue() || !aacpManager.sendHeartRateCapabilitiesService0()) return false + if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService0() }) { + return false + } delay(220) - if (!canContinue() || !aacpManager.sendHeartRateConnectService4()) return false + if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService4() }) { + return false + } delay(180) - if (!canContinue() || !aacpManager.sendHeartRateCapabilitiesService4()) return false + if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService4() }) { + return false + } delay(220) Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized") - return canContinue() + return canContinueHeartRateMonitoring() + } + + private fun sendHeartRateSessionFrameIfActive(sendFrame: () -> Boolean): Boolean = + synchronized(heartRateLock) { + canContinueHeartRateMonitoring() && sendFrame() + } + + private fun stopHeartRateSessionLocked(forceStop: Boolean = false) { + val shouldStop = + forceStop || heartRateSessionRequested || heartRateStartCommandSent + heartRateSessionRequested = false + heartRateStartCommandSent = false + _heartRateStreaming.value = false + + if (shouldStop && BluetoothConnectionManager.aacpSocket?.isConnected == true) { + aacpManager.sendHeartRateStopFrame() + } } private fun stopHeartRateMonitoring(forceStop: Boolean = false) { @@ -3292,22 +3455,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val jobWasActive = heartRateStartJob?.isActive == true heartRateStartJob?.cancel() heartRateStartJob = null - - val shouldStop = - forceStop || heartRateSessionRequested || heartRateStreamStarted || jobWasActive - heartRateSessionRequested = false - heartRateStreamStarted = false - _heartRateStreaming.value = false - - if (shouldStop && BluetoothConnectionManager.aacpSocket?.isConnected == true) { - aacpManager.sendHeartRateStopFrame() - } + lastValidHeartRateSampleElapsedRealtime = null + stopHeartRateSessionLocked(forceStop = forceStop || jobWasActive) } } private fun handleHeartRateDisconnected() { + if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync() stopHeartRateMonitoring() - _heartRateStreaming.value = false } var isHeadTrackingActive = false diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0999d3957..622ddea0a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -17,6 +17,7 @@ materialIconsCore = "1.7.8" backdrop = "2.0.0-alpha03" billing = "8.3.0" hilt = "2.59.2" +healthConnect = "1.1.0" xposed = "101.0.0" lifecycleProcess = "2.10.0" play = "2.0.2" @@ -52,6 +53,7 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backdrop" } billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" } hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +androidx-health-connect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } From 62e6e0e32dc7811056e4a4c2d89c05c8d672c4e2 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Mon, 3 Aug 2026 23:29:07 +0200 Subject: [PATCH 03/15] update slider --- .../me/kavishdevar/librepods/MainActivity.kt | 22 +++++-------------- .../presentation/components/HeartRateCard.kt | 17 ++++++++++---- .../screens/HeartRateTestScreen.kt | 14 ------------ 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 2e7b49a98..883e7358f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -31,7 +31,6 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.os.Bundle import android.os.IBinder import android.util.Log @@ -40,8 +39,8 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -55,6 +54,7 @@ import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.presentation.navigation.NavigationRoot import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.utils.XposedState import kotlin.io.encoding.ExperimentalEncodingApi @@ -80,23 +80,11 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) } + val appSettingsViewModel: AppSettingsViewModel = viewModel() + val appSettingsState = appSettingsViewModel.uiState.collectAsState() - val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - when (key) { - "m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true) - } - } - - DisposableEffect(Unit) { - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - onDispose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - } - } LibrePodsTheme( - m3eEnabled = m3eEnabled.value + m3eEnabled = appSettingsState.value.m3eEnabled ) { // For demo screenshots // val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt index 341fe3540..208783686 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @Composable fun HeartRateCard( @@ -92,10 +94,17 @@ fun HeartRateCard( Spacer(modifier = Modifier.width(14.dp)) - Switch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged - ) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Switch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + + DesignSystem.Apple -> StyledSwitch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 851a77915..f4d724361 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -226,13 +226,6 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { onCheckedChange = viewModel::setHealthConnectDetailedSamples ) - Text( - text = "Heart-rate tracking is controlled from the connected-device screen.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) Text( @@ -244,13 +237,6 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { HeartRateGraph(samples = state.heartRateSamples) - Text( - text = "Experimental wellness data only. LibrePods and AirPods are not medical devices; do not use these readings for diagnosis or medical decisions.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp) - ) - Spacer(modifier = Modifier.height(bottomPadding)) } } From 04fc490a36f958c51bd60da054e0cbb778876033 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Tue, 4 Aug 2026 23:17:04 +0200 Subject: [PATCH 04/15] Bugfixes, Updates gui and code cleanup. --- ...althConnectPermissionsRationaleActivity.kt | 3 +- .../librepods/bluetooth/AACPManager.kt | 20 +- .../librepods/bluetooth/RtBuddyHeartRate.kt | 119 ++-- .../health/HealthConnectHeartRateExporter.kt | 89 +-- .../presentation/components/HeartRateCard.kt | 168 +++++- .../screens/AirPodsSettingsScreen.kt | 1 + .../screens/HeartRateTestScreen.kt | 532 ++++++++++++++---- .../librepods/services/AirPodsService.kt | 238 +++++++- 8 files changed, 908 insertions(+), 262 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/HealthConnectPermissionsRationaleActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/HealthConnectPermissionsRationaleActivity.kt index 6615efba8..826f75f12 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/HealthConnectPermissionsRationaleActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/HealthConnectPermissionsRationaleActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -30,7 +31,7 @@ class HealthConnectPermissionsRationaleActivity : ComponentActivity() { enableEdgeToEdge() setContent { LibrePodsTheme { - androidx.compose.foundation.layout.Box( + Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index e05eda89e..88a143c09 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -34,6 +34,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi */ class AACPManager { private val TAG = "AACPManager[${System.identityHashCode(this)}]" + private val writerLock = Any() companion object { @Suppress("unused") object Opcodes { @@ -1220,15 +1221,16 @@ class AACPManager { ) } - val socket = BluetoothConnectionManager.aacpSocket ?: return false - - if (socket.isConnected) { - socket.outputStream?.write(packet) - socket.outputStream?.flush() - return true - } else { - Log.d(TAG, "Can't send packet: Socket not initialized or connected") - return false + return synchronized(writerLock) { + val socket = BluetoothConnectionManager.aacpSocket + if (socket?.isConnected == true) { + socket.outputStream.write(packet) + socket.outputStream.flush() + true + } else { + Log.d(TAG, "Can't send packet: Socket not initialized or connected") + false + } } } catch (e: Exception) { Log.e(TAG, "Error sending packet: ${e.message}") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt index 837f47717..88bfb7bb0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -124,14 +124,6 @@ internal class RtBuddyHeartRateDecoder { } private fun classifyFrame(frame: ByteArray): FrameClassification { - if (frame.size < AACP_RTBUDDY_HEADER_LENGTH) return FrameClassification() - if (!frame.startsWithPrefix(RTBUDDY_FRAME_PREFIX)) return FrameClassification() - - val declaredLength = frame.readLe16(10) - if (frame.size != AACP_RTBUDDY_HEADER_LENGTH + declaredLength) { - return FrameClassification() - } - val hasHeartRateReference = hasHeartRateServiceReference( frame, AACP_RTBUDDY_HEADER_LENGTH, @@ -145,16 +137,19 @@ internal class RtBuddyHeartRateDecoder { return FrameClassification(isHeartRateRelated = heartRateRelated) } - val command = sensorData.commands.firstOrNull { command -> - val payload = command.payload ?: return@firstOrNull false - command.service == HEART_RATE_SERVICE && - payload.size == HEART_RATE_PAYLOAD_LENGTH && - payload[15] == 0x10.toByte() && - payload[16] == 0x00.toByte() && - payload[17] == 0x00.toByte() && - payload[1].toInt().and(0xFF) in MIN_BPM..MAX_BPM - } ?: return FrameClassification(isHeartRateRelated = true) - val payload = command.payload ?: return FrameClassification(isHeartRateRelated = true) + val payload = sensorData.commands.asSequence() + .mapNotNull { command -> + command.payload?.takeIf { + command.service == HEART_RATE_SERVICE && + it.size == HEART_RATE_PAYLOAD_LENGTH && + it[15] == 0x10.toByte() && + it[16] == 0x00.toByte() && + it[17] == 0x00.toByte() && + it[1].toInt().and(0xFF) in MIN_BPM..MAX_BPM + } + } + .firstOrNull() + ?: return FrameClassification(isHeartRateRelated = true) return FrameClassification( isHeartRateRelated = true, @@ -182,17 +177,17 @@ internal class RtBuddyHeartRateDecoder { } WIRE_LENGTH_DELIMITED -> { - val length = readVarint(data, index, end) ?: return false - if (length.value > Int.MAX_VALUE) return false - index = length.nextIndex - val subEnd = index + length.value.toInt() - if (subEnd < index || subEnd > end) return false + val fieldValue = readLengthDelimited(data, index, end) ?: return false if (field in HEART_RATE_SERVICE_REFERENCE_FIELDS && - parseReferencedService(data, index, subEnd) == HEART_RATE_SERVICE + parseReferencedService( + data, + fieldValue.startIndex, + fieldValue.endIndex + ) == HEART_RATE_SERVICE ) { return true } - index = subEnd + index = fieldValue.endIndex } WIRE_FIXED64 -> { @@ -235,28 +230,36 @@ internal class RtBuddyHeartRateDecoder { } WIRE_LENGTH_DELIMITED -> { - val length = readVarint(data, index, end) ?: return null - index = length.nextIndex - if (length.value > Int.MAX_VALUE) return null - val subEnd = index + length.value.toInt() - if (subEnd < index || subEnd > end) return null + val fieldValue = readLengthDelimited(data, index, end) ?: return null when (field) { - 5, 8, 9, 12 -> parseReferencedService(data, index, subEnd) + 5, 8, 9, 12 -> parseReferencedService( + data, + fieldValue.startIndex, + fieldValue.endIndex + ) ?.let(referencedServices::add) 7 -> { - val command = parseCommand(data, index, subEnd) + val command = parseCommand( + data, + fieldValue.startIndex, + fieldValue.endIndex + ) if (command != null) { commands += command if (command.service >= 0) referencedServices += command.service } else { - parseReferencedService(data, index, subEnd) + parseReferencedService( + data, + fieldValue.startIndex, + fieldValue.endIndex + ) ?.let(referencedServices::add) } } } - index = subEnd + index = fieldValue.endIndex } WIRE_FIXED64 -> { @@ -301,19 +304,18 @@ internal class RtBuddyHeartRateDecoder { } WIRE_LENGTH_DELIMITED -> { - val length = readVarint(data, index, end) ?: return null - index = length.nextIndex - if (length.value > Int.MAX_VALUE) return null - val subEnd = index + length.value.toInt() - if (subEnd < index || subEnd > end) return null + val fieldValue = readLengthDelimited(data, index, end) ?: return null if (field == 3) { if (payload != null) { duplicatePayload = true } else { - payload = data.copyOfRange(index, subEnd) + payload = data.copyOfRange( + fieldValue.startIndex, + fieldValue.endIndex + ) } } - index = subEnd + index = fieldValue.endIndex } WIRE_FIXED64 -> { @@ -353,11 +355,8 @@ internal class RtBuddyHeartRateDecoder { } WIRE_LENGTH_DELIMITED -> { - val length = readVarint(data, index, end) ?: return null - if (length.value > Int.MAX_VALUE) return null - val nextIndex = length.nextIndex + length.value.toInt() - if (nextIndex < length.nextIndex || nextIndex > end) return null - index = nextIndex + val fieldValue = readLengthDelimited(data, index, end) ?: return null + index = fieldValue.endIndex } WIRE_FIXED64 -> { @@ -391,6 +390,22 @@ internal class RtBuddyHeartRateDecoder { return null } + private fun readLengthDelimited( + data: ByteArray, + start: Int, + end: Int + ): LengthDelimitedRead? { + val length = readVarint(data, start, end) ?: return null + if (length.value > Int.MAX_VALUE) return null + + val valueEnd = length.nextIndex + length.value.toInt() + if (valueEnd < length.nextIndex || valueEnd > end) return null + return LengthDelimitedRead( + startIndex = length.nextIndex, + endIndex = valueEnd + ) + } + private data class SensorDataWx( val sequence: Int, val logType: Int, @@ -413,6 +428,11 @@ internal class RtBuddyHeartRateDecoder { val nextIndex: Int ) + private data class LengthDelimitedRead( + val startIndex: Int, + val endIndex: Int + ) + private companion object { const val AACP_RTBUDDY_HEADER_LENGTH = 12 const val MAX_RTBUDDY_PAYLOAD_LENGTH = 16 * 1024 @@ -445,13 +465,6 @@ internal class RtBuddyHeartRateDecoder { private fun ByteArray.readLe16(offset: Int): Int = this[offset].toInt().and(0xFF) or (this[offset + 1].toInt().and(0xFF) shl 8) -private fun ByteArray.startsWithPrefix(prefix: ByteArray): Boolean { - if (size < prefix.size) return false - for (index in prefix.indices) { - if (this[index] != prefix[index]) return false - } - return true -} private fun ByteArray.indexOfPrefix(prefix: ByteArray, startIndex: Int): Int { if (prefix.isEmpty()) return startIndex.coerceAtMost(size) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt index a3141d172..f5c8b4710 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt @@ -80,7 +80,7 @@ class HealthConnectHeartRateExporter( private val mutex = Mutex() private val pendingSamples = linkedMapOf() private var pendingBatch: PendingBatch? = null - private var lowDataWindowStartMillis: Long? = null + private var minuteWindowStartMillis: Long? = null private var requestedDetailedSamples: Boolean? = null private var healthConnectClient: HealthConnectClient? = null private var scheduledFlush: Job? = null @@ -108,8 +108,7 @@ class HealthConnectHeartRateExporter( HealthConnectClient.SDK_AVAILABLE -> { val client = getClient() val granted = try { - client.permissionController.getGrantedPermissions() - .contains(WRITE_HEART_RATE_PERMISSION) + hasWritePermission(client) } catch (error: Exception) { Log.w(TAG, "Unable to query Health Connect permissions", error) _enabled.value = false @@ -166,8 +165,7 @@ class HealthConnectHeartRateExporter( when (HealthConnectClient.getSdkStatus(appContext)) { HealthConnectClient.SDK_AVAILABLE -> { val granted = try { - getClient().permissionController.getGrantedPermissions() - .contains(WRITE_HEART_RATE_PERMISSION) + hasWritePermission(getClient()) } catch (error: Exception) { Log.w(TAG, "Unable to enable Health Connect export", error) _enabled.value = false @@ -249,8 +247,8 @@ class HealthConnectHeartRateExporter( deviceModel = deviceModel.ifBlank { "AirPods" } ) ) - if (!_detailedSamples.value && lowDataWindowStartMillis == null) { - lowDataWindowStartMillis = sample.receivedAtMillis + if (!_detailedSamples.value && minuteWindowStartMillis == null) { + minuteWindowStartMillis = sample.receivedAtMillis } trimBufferLocked() @@ -265,12 +263,12 @@ class HealthConnectHeartRateExporter( scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) false } - } else if (hasCompletedLowDataWindowLocked()) { + } else if (hasCompletedMinuteWindowLocked()) { scheduledFlush?.cancel() scheduledFlush = null true } else { - scheduleLowDataFlushLocked() + scheduleMinuteFlushLocked() false } } @@ -322,32 +320,41 @@ class HealthConnectHeartRateExporter( _status.value = HealthConnectExportStatus.PERMISSION_REQUIRED return false } catch (error: IOException) { - Log.w(TAG, "Health Connect write failed; keeping batch for retry", error) - _status.value = HealthConnectExportStatus.ERROR - scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + handleRetryableWriteFailureLocked( + "Health Connect write failed; keeping batch for retry", + error + ) return false } catch (error: IllegalStateException) { - Log.w(TAG, "Health Connect is temporarily unavailable", error) - _status.value = HealthConnectExportStatus.ERROR - scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + handleRetryableWriteFailureLocked( + "Health Connect is temporarily unavailable", + error + ) return false } catch (error: RuntimeException) { - Log.w(TAG, "Unexpected Health Connect write failure", error) - _status.value = HealthConnectExportStatus.ERROR - scheduleFlushLocked(RETRY_INTERVAL_MILLIS) + handleRetryableWriteFailureLocked( + "Unexpected Health Connect write failure", + error + ) return false } } applyRequestedDetailLocked() - return !hasPendingSamplesLocked() + return true + } + + private fun handleRetryableWriteFailureLocked(message: String, error: Exception) { + Log.w(TAG, message, error) + _status.value = HealthConnectExportStatus.ERROR + scheduleFlushLocked(RETRY_INTERVAL_MILLIS) } private fun applyRequestedDetailLocked() { val detailed = requestedDetailedSamples ?: return if (hasPendingSamplesLocked()) return - lowDataWindowStartMillis = null + minuteWindowStartMillis = null sharedPreferences.edit { putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) } @@ -371,13 +378,13 @@ class HealthConnectHeartRateExporter( if (_detailedSamples.value) { scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) } else { - scheduleLowDataFlushLocked() + scheduleMinuteFlushLocked() } } - private fun scheduleLowDataFlushLocked() { - val windowStart = ensureLowDataWindowStartLocked() ?: return - val windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + private fun scheduleMinuteFlushLocked() { + val windowStart = ensureMinuteWindowStartLocked() ?: return + val windowEnd = windowStart + MINUTE_WINDOW_MILLIS val delayMillis = (windowEnd - System.currentTimeMillis()).coerceAtLeast(0L) scheduleFlushLocked(delayMillis) } @@ -388,7 +395,7 @@ class HealthConnectHeartRateExporter( return if (_detailedSamples.value) { createDetailedBatchLocked() } else { - createLowDataBatchLocked(forcePartialMinute) + createMinuteAverageBatchLocked(forcePartialMinute) } } @@ -410,17 +417,17 @@ class HealthConnectHeartRateExporter( ).also { pendingBatch = it } } - private fun createLowDataBatchLocked(forcePartialMinute: Boolean): PendingBatch? { + private fun createMinuteAverageBatchLocked(forcePartialMinute: Boolean): PendingBatch? { val orderedSamples = pendingSamples.values.sortedWith(PENDING_SAMPLE_COMPARATOR) if (orderedSamples.isEmpty()) return null - var windowStart = ensureLowDataWindowStartLocked() ?: return null + var windowStart = ensureMinuteWindowStartLocked() ?: return null val earliestTimestamp = orderedSamples.first().sample.receivedAtMillis - var windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + var windowEnd = windowStart + MINUTE_WINDOW_MILLIS while (earliestTimestamp >= windowEnd) { windowStart = windowEnd - windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS - lowDataWindowStartMillis = windowStart + windowEnd = windowStart + MINUTE_WINDOW_MILLIS + minuteWindowStartMillis = windowStart } val hasSampleAfterWindow = orderedSamples.any { @@ -462,7 +469,7 @@ class HealthConnectHeartRateExporter( private fun completePendingBatchLocked(batch: PendingBatch) { pendingBatch = null if (batch.detail == BatchDetail.MINUTE_AVERAGE) { - lowDataWindowStartMillis = if (batch.partialMinute) { + minuteWindowStartMillis = if (batch.partialMinute) { null } else { batch.endTimeMillis @@ -518,18 +525,18 @@ class HealthConnectHeartRateExporter( private fun bufferedSampleCountLocked(): Int = pendingSamples.size + (pendingBatch?.samples?.size ?: 0) - private fun hasCompletedLowDataWindowLocked(): Boolean { - val windowStart = ensureLowDataWindowStartLocked() ?: return false - val windowEnd = windowStart + LOW_DATA_WINDOW_MILLIS + private fun hasCompletedMinuteWindowLocked(): Boolean { + val windowStart = ensureMinuteWindowStartLocked() ?: return false + val windowEnd = windowStart + MINUTE_WINDOW_MILLIS return System.currentTimeMillis() >= windowEnd || pendingSamples.values.any { it.sample.receivedAtMillis >= windowEnd } } - private fun ensureLowDataWindowStartLocked(): Long? { - lowDataWindowStartMillis?.let { return it } + private fun ensureMinuteWindowStartLocked(): Long? { + minuteWindowStartMillis?.let { return it } return pendingSamples.values.minOfOrNull { it.sample.receivedAtMillis }?.also { - lowDataWindowStartMillis = it + minuteWindowStartMillis = it } } @@ -587,6 +594,9 @@ class HealthConnectHeartRateExporter( private fun getClient(): HealthConnectClient = healthConnectClient ?: HealthConnectClient.getOrCreate(appContext).also { healthConnectClient = it } + private suspend fun hasWritePermission(client: HealthConnectClient): Boolean = + WRITE_HEART_RATE_PERMISSION in client.permissionController.getGrantedPermissions() + private fun statusForSdk(): HealthConnectExportStatus = when (HealthConnectClient.getSdkStatus(appContext)) { HealthConnectClient.SDK_AVAILABLE -> HealthConnectExportStatus.PERMISSION_REQUIRED @@ -598,8 +608,7 @@ class HealthConnectHeartRateExporter( return when (HealthConnectClient.getSdkStatus(appContext)) { HealthConnectClient.SDK_AVAILABLE -> { val permissionGranted = try { - getClient().permissionController.getGrantedPermissions() - .contains(WRITE_HEART_RATE_PERMISSION) + hasWritePermission(getClient()) } catch (error: Exception) { Log.w(TAG, "Unable to query Health Connect permissions", error) return HealthConnectExportStatus.ERROR @@ -638,7 +647,7 @@ class HealthConnectHeartRateExporter( private const val MAX_BATCH_SIZE = 15 private const val MAX_BUFFERED_SAMPLES = 300 private const val FLUSH_INTERVAL_MILLIS = 15_000L - private const val LOW_DATA_WINDOW_MILLIS = 60_000L + private const val MINUTE_WINDOW_MILLIS = 60_000L private const val RETRY_INTERVAL_MILLIS = 30_000L val WRITE_HEART_RATE_PERMISSION: String = diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt index 208783686..86b66816c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt @@ -10,12 +10,14 @@ package me.kavishdevar.librepods.presentation.components +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,8 +27,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.bluetooth.HeartRateSample @@ -39,15 +47,19 @@ fun HeartRateCard( streaming: Boolean, connected: Boolean, latestSample: HeartRateSample?, + heartRateSamples: List, onMonitoringChanged: (Boolean) -> Unit, onOpenDetails: () -> Unit, modifier: Modifier = Modifier ) { - val status = when { - !monitoringEnabled -> "Off" - !connected -> "Waiting for connection" - streaming -> "Streaming" - else -> "Awaiting sample" + val status = heartRateStatus(monitoringEnabled, connected, streaming) + val displayedBpm = latestSample + ?.takeIf { streaming } + ?.bpm + ?.toString() + ?: EM_DASH + val graphValues = remember(heartRateSamples) { + normalizedRecentHeartRates(heartRateSamples) } Card( @@ -63,6 +75,10 @@ fun HeartRateCard( modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { + HeartRateMiniGraph(values = graphValues) + + Spacer(modifier = Modifier.width(12.dp)) + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -81,7 +97,7 @@ fun HeartRateCard( Column(horizontalAlignment = Alignment.End) { Text( - text = if (streaming) latestSample?.bpm?.toString() ?: "—" else "—", + text = displayedBpm, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold ) @@ -108,3 +124,143 @@ fun HeartRateCard( } } } + +@Composable +private fun HeartRateMiniGraph( + values: List, + modifier: Modifier = Modifier +) { + val graphColor = MaterialTheme.colorScheme.primary + val guideColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + + Canvas( + modifier = modifier + .width(GRAPH_WIDTH) + .height(GRAPH_HEIGHT) + ) { + val horizontalPadding = 2.dp.toPx() + val verticalPadding = 4.dp.toPx() + val left = horizontalPadding + val right = size.width - horizontalPadding + val top = verticalPadding + val bottom = size.height - verticalPadding + + if (values.isEmpty()) { + val middleY = (top + bottom) / 2f + drawLine( + color = guideColor, + start = Offset(left, top), + end = Offset(right, top), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = guideColor, + start = Offset(left, middleY), + end = Offset(right, middleY), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = guideColor, + start = Offset(left, bottom), + end = Offset(right, bottom), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round + ) + return@Canvas + } + + drawLine( + color = guideColor, + start = Offset(left, bottom), + end = Offset(right, bottom), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round + ) + + val availableWidth = right - left + val availableHeight = bottom - top + val xStep = if (values.size > 1) availableWidth / values.lastIndex else 0f + val path = Path() + + values.forEachIndexed { index, value -> + val x = if (values.size == 1) size.width / 2f else left + (index * xStep) + val y = bottom - (value * availableHeight) + + drawLine( + color = graphColor.copy(alpha = 0.18f), + start = Offset(x, bottom), + end = Offset(x, y), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round + ) + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + if (values.size == 1) { + drawCircle( + color = graphColor, + radius = 2.dp.toPx(), + center = Offset(size.width / 2f, bottom - (values.single() * availableHeight)) + ) + } else { + drawPath( + path = path, + color = graphColor, + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + + val lastY = bottom - (values.last() * availableHeight) + drawCircle( + color = graphColor, + radius = 2.dp.toPx(), + center = Offset(right, lastY) + ) + } + } +} + +private fun normalizedRecentHeartRates(samples: List): List { + val recentBpms = samples + .takeLast(MAX_GRAPH_SAMPLES) + .map { it.bpm.toFloat() } + + if (recentBpms.isEmpty()) return emptyList() + + val observedMin = recentBpms.minOrNull() ?: return emptyList() + val observedMax = recentBpms.maxOrNull() ?: return emptyList() + val center = (observedMin + observedMax) / 2f + val span = maxOf(observedMax - observedMin, MIN_GRAPH_BPM_SPAN) + val lowerBound = center - (span / 2f) + + return recentBpms.map { bpm -> + ((bpm - lowerBound) / span).coerceIn(0f, 1f) + } +} + +private fun heartRateStatus( + monitoringEnabled: Boolean, + connected: Boolean, + streaming: Boolean +): String = when { + !monitoringEnabled -> "Off" + !connected -> "Waiting for connection" + streaming -> "Streaming" + else -> "Awaiting sample" +} + +private val GRAPH_WIDTH = 60.dp +private val GRAPH_HEIGHT = 44.dp +private const val MAX_GRAPH_SAMPLES = 24 +private const val MIN_GRAPH_BPM_SPAN = 20f +private const val EM_DASH = "—" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index e8e830a23..5b990d60a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -343,6 +343,7 @@ fun AirPodsSettingsScreen( streaming = state.heartRateStreaming, connected = state.isLocallyConnected, latestSample = state.heartRateSamples.lastOrNull(), + heartRateSamples = state.heartRateSamples, onMonitoringChanged = setHeartRateMonitoringEnabled, onOpenDetails = navigateToHeartRateTest ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index f4d724361..0bd2ffde2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -10,6 +10,8 @@ package me.kavishdevar.librepods.presentation.screens +import android.graphics.Paint +import android.graphics.Typeface import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -37,13 +39,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.health.connect.client.PermissionController import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.health.HealthConnectExportStatus @@ -54,6 +62,10 @@ import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import java.text.DateFormat import java.util.Date +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.round @Composable fun HeartRateTestScreen(viewModel: AirPodsViewModel) { @@ -81,41 +93,10 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp val latestSample = state.heartRateSamples.lastOrNull() - val monitoringStatus = when { - !state.heartRateMonitoringEnabled -> "Disabled" - !state.isLocallyConnected -> "Enabled — waiting for connection" - state.heartRateStreaming -> "Streaming" - else -> "Enabled — awaiting valid sample" - } - val healthConnectDescription = when (state.healthConnectExportStatus) { - HealthConnectExportStatus.UNAVAILABLE -> - "Health Connect is not available on this device." - - HealthConnectExportStatus.UPDATE_REQUIRED -> - "Install or update Health Connect to save heart-rate samples." - - HealthConnectExportStatus.PERMISSION_REQUIRED -> - "Write permission is required before samples can be saved." - - HealthConnectExportStatus.PERMISSION_DENIED -> - "Permission was denied. Turn this on to request it again." - - HealthConnectExportStatus.READY -> - "Available. Enable this to save validated samples on this device." - - HealthConnectExportStatus.ENABLED -> - if (state.healthConnectDetailedSamples) { - "Validated samples are saved in 15-second batches with their original timestamps." - } else { - "Validated samples are averaged into one Health Connect record per minute." - } - - HealthConnectExportStatus.ERROR -> - "A write failed. Buffered samples will be retried without creating duplicates." - } - val healthConnectAvailable = state.healthConnectExportStatus !in setOf( - HealthConnectExportStatus.UNAVAILABLE, - HealthConnectExportStatus.UPDATE_REQUIRED + val monitoringStatus = monitoringStatus( + enabled = state.heartRateMonitoringEnabled, + connected = state.isLocallyConnected, + streaming = state.heartRateStreaming ) Column( @@ -127,68 +108,19 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { ) { Spacer(modifier = Modifier.height(topPadding)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - Column { - Text( - text = latestSample?.bpm?.toString() ?: "—", - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "BPM", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = if (state.isLocallyConnected) "Connected" else "Disconnected", - style = MaterialTheme.typography.labelLarge, - color = if (state.isLocallyConnected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - Text( - text = monitoringStatus, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End - ) - } - } - - Text( - text = "Last update: ${formatLastUpdate(latestSample)}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + HeartRateSummaryCard( + latestSample = latestSample, + connected = state.isLocallyConnected, + monitoringStatus = monitoringStatus + ) Spacer(modifier = Modifier.height(16.dp)) - StyledToggle( - title = "Health Connect", - label = "Save heart-rate samples", - description = healthConnectDescription, - checked = state.healthConnectExportEnabled, - enabled = healthConnectAvailable, - onCheckedChange = { enabled: Boolean -> + HealthConnectControls( + status = state.healthConnectExportStatus, + exportEnabled = state.healthConnectExportEnabled, + detailedSamples = state.healthConnectDetailedSamples, + onExportChanged = { enabled -> if (!enabled) { viewModel.setHealthConnectExportEnabled(false) } else { @@ -208,22 +140,8 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { HealthConnectExportStatus.UPDATE_REQUIRED -> Unit } } - } - ) - - Spacer(modifier = Modifier.height(8.dp)) - - StyledToggle( - title = null, - label = "Detailed samples", - description = if (state.healthConnectDetailedSamples) { - "Export original per-second samples in 15-second batches. AirPods sampling is unchanged." - } else { - "Export one average BPM for each minute. AirPods sampling is unchanged." }, - checked = state.healthConnectDetailedSamples, - enabled = healthConnectAvailable, - onCheckedChange = viewModel::setHealthConnectDetailedSamples + onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples ) Spacer(modifier = Modifier.height(12.dp)) @@ -241,11 +159,168 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { } } +@Composable +private fun HeartRateSummaryCard( + latestSample: HeartRateSample?, + connected: Boolean, + monitoringStatus: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column { + Text( + text = latestSample?.bpm?.toString() ?: EM_DASH, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = if (connected) "Connected" else "Disconnected", + style = MaterialTheme.typography.labelLarge, + color = if (connected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Text( + text = monitoringStatus, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End + ) + } + } + + Text( + text = "Last update: ${formatLastUpdate(latestSample)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun HealthConnectControls( + status: HealthConnectExportStatus, + exportEnabled: Boolean, + detailedSamples: Boolean, + onExportChanged: (Boolean) -> Unit, + onDetailedSamplesChanged: (Boolean) -> Unit +) { + val available = status != HealthConnectExportStatus.UNAVAILABLE && + status != HealthConnectExportStatus.UPDATE_REQUIRED + + StyledToggle( + title = "Health Connect", + label = "Save heart-rate samples", + description = healthConnectDescription(status, detailedSamples), + checked = exportEnabled, + enabled = available, + onCheckedChange = onExportChanged + ) + + Spacer(modifier = Modifier.height(8.dp)) + + StyledToggle( + title = null, + label = "Detailed samples", + description = if (detailedSamples) { + "Export original per-second samples in 15-second batches. AirPods sampling is unchanged." + } else { + "Export one average BPM for each minute. AirPods sampling is unchanged." + }, + checked = detailedSamples, + enabled = available, + onCheckedChange = onDetailedSamplesChanged + ) +} + +private fun monitoringStatus( + enabled: Boolean, + connected: Boolean, + streaming: Boolean +): String = when { + !enabled -> "Disabled" + !connected -> "Enabled — waiting for connection" + streaming -> "Streaming" + else -> "Enabled — awaiting valid sample" +} + +private fun healthConnectDescription( + status: HealthConnectExportStatus, + detailedSamples: Boolean +): String = when (status) { + HealthConnectExportStatus.UNAVAILABLE -> + "Health Connect is not available on this device." + + HealthConnectExportStatus.UPDATE_REQUIRED -> + "Install or update Health Connect to save heart-rate samples." + + HealthConnectExportStatus.PERMISSION_REQUIRED -> + "Write permission is required before samples can be saved." + + HealthConnectExportStatus.PERMISSION_DENIED -> + "Permission was denied. Turn this on to request it again." + + HealthConnectExportStatus.READY -> + "Available. Enable this to save validated samples on this device." + + HealthConnectExportStatus.ENABLED -> if (detailedSamples) { + "Validated samples are saved in 15-second batches with their original timestamps." + } else { + "Validated samples are averaged into one Health Connect record per minute." + } + + HealthConnectExportStatus.ERROR -> + "A write failed. Buffered samples will be retried without creating duplicates." +} + @Composable private fun HeartRateGraph(samples: List) { + val chartScale = remember(samples) { + calculateHeartRateChartScale(samples.map { it.bpm.toFloat() }) + } val lineColor = MaterialTheme.colorScheme.primary val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) + val axisColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f) + val axisLineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.32f) val pointColor = MaterialTheme.colorScheme.onSurface + val density = LocalDensity.current + val axisLabelPaint = remember(axisColor, density) { + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = axisColor.toArgb() + textSize = with(density) { 11.sp.toPx() } + textAlign = Paint.Align.RIGHT + } + } + val axisTitlePaint = remember(axisColor, density) { + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = axisColor.toArgb() + textSize = with(density) { 9.sp.toPx() } + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + } Card( modifier = Modifier @@ -261,39 +336,71 @@ private fun HeartRateGraph(samples: List) { contentAlignment = Alignment.Center ) { Canvas(modifier = Modifier.fillMaxSize()) { - val chartHeight = size.height - val chartWidth = size.width - val minBpm = 30f - val maxBpm = 220f + val plotLeft = CHART_AXIS_WIDTH.toPx() + val plotRight = size.width + val plotTop = CHART_TOP_INSET.toPx() + val plotBottom = size.height - CHART_BOTTOM_INSET.toPx() + val plotWidth = (plotRight - plotLeft).coerceAtLeast(0f) + val plotHeight = (plotBottom - plotTop).coerceAtLeast(0f) + val labelX = plotLeft - CHART_AXIS_LABEL_GAP.toPx() + val labelMetrics = axisLabelPaint.fontMetrics + val labelBaselineOffset = -(labelMetrics.ascent + labelMetrics.descent) / 2f + val titleMetrics = axisTitlePaint.fontMetrics + + drawContext.canvas.nativeCanvas.drawText( + "BPM", + plotLeft / 2f, + -titleMetrics.ascent, + axisTitlePaint + ) + + drawLine( + color = axisLineColor, + start = Offset(plotLeft, plotTop), + end = Offset(plotLeft, plotBottom), + strokeWidth = 1.dp.toPx() + ) + + chartScale.gridLines.forEach { bpm -> + val normalized = + (bpm - chartScale.minBpm) / chartScale.spanBpm + val y = plotBottom - normalized * plotHeight - listOf(30f, 60f, 100f, 140f, 180f, 220f).forEach { bpm -> - val y = chartHeight - ((bpm - minBpm) / (maxBpm - minBpm)) * chartHeight drawLine( color = gridColor, - start = androidx.compose.ui.geometry.Offset(0f, y), - end = androidx.compose.ui.geometry.Offset(chartWidth, y), + start = Offset(plotLeft, y), + end = Offset(plotRight, y), strokeWidth = 1.dp.toPx() ) + drawContext.canvas.nativeCanvas.drawText( + bpm.toInt().toString(), + labelX, + y + labelBaselineOffset, + axisLabelPaint + ) } if (samples.isNotEmpty()) { val path = Path() samples.forEachIndexed { index, sample -> val x = if (samples.size == 1) { - chartWidth / 2f + plotLeft + plotWidth / 2f } else { - index.toFloat() / (samples.size - 1).toFloat() * chartWidth + plotLeft + + index.toFloat() / (samples.size - 1).toFloat() * plotWidth } - val normalized = ((sample.bpm.toFloat() - minBpm) / (maxBpm - minBpm)) - .coerceIn(0f, 1f) - val y = chartHeight - normalized * chartHeight + val normalized = ( + (sample.bpm.toFloat() - chartScale.minBpm) / + chartScale.spanBpm + ).coerceIn(0f, 1f) + val y = plotBottom - normalized * plotHeight if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) if (index == samples.lastIndex) { drawCircle( color = pointColor, radius = 4.dp.toPx(), - center = androidx.compose.ui.geometry.Offset(x, y) + center = Offset(x, y) ) } } @@ -312,15 +419,192 @@ private fun HeartRateGraph(samples: List) { text = "Waiting for validated heart-rate samples", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = CHART_AXIS_WIDTH) ) } } } } +private data class HeartRateChartScale( + val minBpm: Float, + val maxBpm: Float, + val gridLines: List +) { + val spanBpm: Float + get() = maxBpm - minBpm +} + +private fun calculateHeartRateChartScale(bpms: List): HeartRateChartScale { + if (bpms.isEmpty()) { + return createHeartRateChartScale( + minBpm = CHART_DEFAULT_MIN_BPM, + maxBpm = CHART_DEFAULT_MAX_BPM + ) + } + + val dataMin = bpms.minOrNull()!! + val dataMax = bpms.maxOrNull()!! + val dataRange = dataMax - dataMin + val margin = max(CHART_MIN_MARGIN_BPM, dataRange * CHART_MARGIN_FRACTION) + val requiredSpan = dataRange + margin * 2f + + val initialBounds = if (requiredSpan <= CHART_MIN_SPAN_BPM) { + val center = (dataMin + dataMax) / 2f + val roundedCenter = roundToIncrement(center, CHART_NARROW_CENTER_INCREMENT_BPM) + val halfSpan = CHART_MIN_SPAN_BPM / 2f + roundedCenter - halfSpan to roundedCenter + halfSpan + } else { + val boundIncrement = if (requiredSpan <= CHART_FINE_BOUND_THRESHOLD_BPM) { + CHART_FINE_BOUND_INCREMENT_BPM + } else { + CHART_COARSE_BOUND_INCREMENT_BPM + } + floorToIncrement(dataMin - margin, boundIncrement) to + ceilToIncrement(dataMax + margin, boundIncrement) + } + + val constrainedBounds = constrainHeartRateBounds( + minBpm = initialBounds.first, + maxBpm = initialBounds.second, + dataMin = dataMin, + dataMax = dataMax + ) + + return createHeartRateChartScale( + minBpm = constrainedBounds.first, + maxBpm = constrainedBounds.second + ) +} + +private fun constrainHeartRateBounds( + minBpm: Float, + maxBpm: Float, + dataMin: Float, + dataMax: Float +): Pair { + val preferredBounds = fitBoundsWithinLimits( + minBpm = minBpm, + maxBpm = maxBpm, + dataMin = dataMin, + dataMax = dataMax, + limitMin = CHART_SAFETY_MIN_BPM, + limitMax = CHART_SAFETY_MAX_BPM + ) + return fitBoundsWithinLimits( + minBpm = preferredBounds.first, + maxBpm = preferredBounds.second, + dataMin = dataMin, + dataMax = dataMax, + limitMin = CHART_OUTER_MIN_BPM, + limitMax = CHART_OUTER_MAX_BPM + ) +} + +private fun fitBoundsWithinLimits( + minBpm: Float, + maxBpm: Float, + dataMin: Float, + dataMax: Float, + limitMin: Float, + limitMax: Float +): Pair { + val safetyInset = CHART_MIN_MARGIN_BPM + if (dataMin < limitMin + safetyInset || dataMax > limitMax - safetyInset) { + return minBpm to maxBpm + } + + val span = maxBpm - minBpm + val limitSpan = limitMax - limitMin + if (span >= limitSpan) { + return limitMin to limitMax + } + + var adjustedMin = minBpm + var adjustedMax = maxBpm + if (adjustedMin < limitMin) { + val shift = limitMin - adjustedMin + adjustedMin += shift + adjustedMax += shift + } + if (adjustedMax > limitMax) { + val shift = adjustedMax - limitMax + adjustedMin -= shift + adjustedMax -= shift + } + return adjustedMin to adjustedMax +} + +private fun createHeartRateChartScale( + minBpm: Float, + maxBpm: Float +): HeartRateChartScale { + val span = (maxBpm - minBpm).coerceAtLeast(CHART_MIN_SPAN_BPM) + val adjustedMax = minBpm + span + val tickStep = calculateHeartRateTickStep(span) + val intervalCount = floor(span / tickStep).toInt() + val gridLines = (0..intervalCount).map { index -> + minBpm + index * tickStep + } + + return HeartRateChartScale( + minBpm = minBpm, + maxBpm = adjustedMax, + gridLines = gridLines + ) +} + +private fun calculateHeartRateTickStep(spanBpm: Float): Float { + val rawStep = spanBpm / CHART_TARGET_GRID_INTERVALS + val increment = if (rawStep <= CHART_FINE_TICK_THRESHOLD_BPM) { + CHART_FINE_TICK_INCREMENT_BPM + } else { + CHART_COARSE_TICK_INCREMENT_BPM + } + var step = max(increment, roundToIncrement(rawStep, increment)) + + while (floor(spanBpm / step).toInt() + 1 > CHART_MAX_GRID_LINES) { + step += increment + } + return step +} + +private fun roundToIncrement(value: Float, increment: Float): Float = + round(value / increment) * increment + +private fun floorToIncrement(value: Float, increment: Float): Float = + floor(value / increment) * increment + +private fun ceilToIncrement(value: Float, increment: Float): Float = + ceil(value / increment) * increment + private fun formatLastUpdate(sample: HeartRateSample?): String { if (sample == null) return "No samples yet" return DateFormat.getTimeInstance(DateFormat.MEDIUM) .format(Date(sample.receivedAtMillis)) } + +private const val EM_DASH = "—" +private const val CHART_DEFAULT_MIN_BPM = 60f +private const val CHART_DEFAULT_MAX_BPM = 100f +private const val CHART_MIN_SPAN_BPM = 40f +private const val CHART_MIN_MARGIN_BPM = 5f +private const val CHART_MARGIN_FRACTION = 0.10f +private const val CHART_NARROW_CENTER_INCREMENT_BPM = 5f +private const val CHART_FINE_BOUND_THRESHOLD_BPM = 80f +private const val CHART_FINE_BOUND_INCREMENT_BPM = 5f +private const val CHART_COARSE_BOUND_INCREMENT_BPM = 10f +private const val CHART_SAFETY_MIN_BPM = 20f +private const val CHART_SAFETY_MAX_BPM = 240f +private const val CHART_OUTER_MIN_BPM = 0f +private const val CHART_OUTER_MAX_BPM = 260f +private const val CHART_TARGET_GRID_INTERVALS = 5f +private const val CHART_FINE_TICK_THRESHOLD_BPM = 25f +private const val CHART_FINE_TICK_INCREMENT_BPM = 5f +private const val CHART_COARSE_TICK_INCREMENT_BPM = 10f +private const val CHART_MAX_GRID_LINES = 7 +private val CHART_AXIS_WIDTH = 42.dp +private val CHART_AXIS_LABEL_GAP = 8.dp +private val CHART_TOP_INSET = 20.dp +private val CHART_BOTTOM_INSET = 8.dp diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index b4dafae93..96e481524 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -242,6 +242,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val heartRateScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val heartRateLock = Any() + private val transportRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val transportRecoveryLock = Any() + private var aacpReconnectJob: Job? = null + private var aacpReconnectSuppressed = false private var heartRateStartJob: Job? = null private var heartRateSessionRequested = false private var heartRateStartCommandSent = false @@ -279,6 +283,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 12_000L private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 6_000L private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L + private const val AACP_RECONNECT_DELAY_MILLIS = 750L + private const val EXTRA_AACP_TRANSPORT_FAILURE = + "me.kavishdevar.librepods.extra.AACP_TRANSPORT_FAILURE" private val HEART_RATE_RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) init { @@ -719,6 +726,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) { + cancelAacpReconnect( + source = "connection-detected", + suppressFutureReconnects = false + ) + synchronized(transportRecoveryLock) { + aacpReconnectSuppressed = false + } device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra("device", BluetoothDevice::class.java)!! } else { @@ -747,14 +761,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // } } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { + val isLocalTransportFailure = intent.getBooleanExtra( + EXTRA_AACP_TRANSPORT_FAILURE, + false + ) + if (!isLocalTransportFailure) { + suppressAacpReconnect("physical-disconnect-broadcast") + clearAacpTransport( + source = "physical-disconnect-broadcast", + expectedSocket = null + ) + } device = null // isConnectedLocally = false popupShown = false - updateNotificationContent(false) - stopHeartRateMonitoring() - aacpManager.disconnected() - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null } } } @@ -2888,37 +2908,53 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else if (bytesRead == -1) { Log.d("AirPodsService", "socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - handleHeartRateDisconnected() - aacpManager.disconnected() + if (handleAacpTransportFailure( + failedSocket = socket, + reconnectDevice = device, + source = "reader-eof" + ) + ) { + broadcastAacpTransportFailure() + } return@launch } } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - handleHeartRateDisconnected() - aacpManager.disconnected() + Log.w(TAG, "AACP transport failure source=reader-exception: ${e.message}", e) + if (handleAacpTransportFailure( + failedSocket = socket, + reconnectDevice = device, + source = "reader-exception" + ) + ) { + broadcastAacpTransportFailure() + } return@launch } } Log.d("AirPods Service", "socket closed") // isConnectedLocally = false - handleHeartRateDisconnected() - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) + if (handleAacpTransportFailure( + failedSocket = socket, + reconnectDevice = device, + source = "reader-loop-ended" + ) + ) { + broadcastAacpTransportFailure() + } } } } catch (e: Exception) { - handleHeartRateDisconnected() + if (handleAacpTransportFailure( + failedSocket = socket, + reconnectDevice = device, + source = "connection-setup-exception" + ) + ) { + broadcastAacpTransportFailure() + } else { + handleHeartRateDisconnected() + } e.printStackTrace() Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") @@ -2931,7 +2967,138 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // } } + private fun closeSocketQuietly(socket: BluetoothSocket?, label: String) { + if (socket == null) return + try { + socket.close() + } catch (e: Exception) { + Log.w(TAG, "Failed to close $label: ${e.message}") + } + } + + /** + * Removes a dead AACP transport without sending any more packets through it. The expected + * socket check prevents an old reader from tearing down a newer connection. + */ + private fun clearAacpTransport( + source: String, + expectedSocket: BluetoothSocket? + ): Boolean { + val aacpSocketToClose: BluetoothSocket? + val attSocketToClose: BluetoothSocket? + synchronized(transportRecoveryLock) { + val currentSocket = BluetoothConnectionManager.aacpSocket + if (expectedSocket != null && currentSocket !== expectedSocket) { + Log.i(TAG, "Ignoring stale AACP cleanup source=$source") + return false + } + + aacpSocketToClose = currentSocket + attSocketToClose = BluetoothConnectionManager.attSocket + BluetoothConnectionManager.aacpSocket = null + BluetoothConnectionManager.attSocket = null + } + + closeSocketQuietly(aacpSocketToClose, "AACP socket") + closeSocketQuietly(attSocketToClose, "ATT socket") + handleHeartRateDisconnected() + aacpManager.disconnected() + updateNotificationContent(false) + Log.w( + TAG, + "AACP transport cleaned source=$source socketId=" + + aacpSocketToClose?.let { System.identityHashCode(it) } + ) + return aacpSocketToClose != null + } + + private fun handleAacpTransportFailure( + failedSocket: BluetoothSocket, + reconnectDevice: BluetoothDevice, + source: String + ): Boolean { + val cleared = clearAacpTransport(source, expectedSocket = failedSocket) + if (cleared) { + scheduleAacpReconnect(reconnectDevice, source) + } + return cleared + } + + private fun broadcastAacpTransportFailure() { + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + putExtra(EXTRA_AACP_TRANSPORT_FAILURE, true) + setPackage(packageName) + }) + } + + private fun scheduleAacpReconnect(reconnectDevice: BluetoothDevice, source: String) { + synchronized(transportRecoveryLock) { + if (aacpReconnectSuppressed || aacpReconnectJob?.isActive == true) { + Log.i(TAG, "Skipping AACP reconnect source=$source") + return + } + + val job = transportRecoveryScope.launch(start = CoroutineStart.LAZY) { + val currentJob = coroutineContext[Job] ?: return@launch + try { + delay(AACP_RECONNECT_DELAY_MILLIS) + val shouldReconnect = synchronized(transportRecoveryLock) { + aacpReconnectJob === currentJob && + !aacpReconnectSuppressed && + BluetoothConnectionManager.aacpSocket == null + } + if (!shouldReconnect) return@launch + + Log.i(TAG, "AACP reconnect starting source=$source") + val adapter = getSystemService(BluetoothManager::class.java).adapter + connectToSocket(adapter, reconnectDevice) + + val reconnectWasCancelled = synchronized(transportRecoveryLock) { + aacpReconnectJob !== currentJob || aacpReconnectSuppressed + } + if (reconnectWasCancelled) { + clearAacpTransport( + source = "cancelled-reconnect", + expectedSocket = BluetoothConnectionManager.aacpSocket + ) + } + Log.i( + TAG, + "AACP reconnect result source=$source success=" + + (BluetoothConnectionManager.aacpSocket?.isConnected == true) + ) + } catch (e: Exception) { + Log.w(TAG, "AACP reconnect failed source=$source: ${e.message}", e) + } finally { + synchronized(transportRecoveryLock) { + if (aacpReconnectJob === currentJob) { + aacpReconnectJob = null + } + } + } + } + aacpReconnectJob = job + job.start() + } + } + + private fun cancelAacpReconnect(source: String, suppressFutureReconnects: Boolean) { + val job = synchronized(transportRecoveryLock) { + if (suppressFutureReconnects) aacpReconnectSuppressed = true + aacpReconnectJob.also { aacpReconnectJob = null } + } + if (job?.isActive == true) { + Log.i(TAG, "Cancelling pending AACP reconnect source=$source") + job.cancel() + } + } + + private fun suppressAacpReconnect(source: String) { + cancelAacpReconnect(source, suppressFutureReconnects = true) + } + fun disconnectForCD() { + suppressAacpReconnect("cross-device-disconnect") stopHeartRateMonitoring() BluetoothConnectionManager.aacpSocket?.close() MediaController.pausedWhileTakingOver = false @@ -3236,6 +3403,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList runBlocking { heartRateExporter.closeAndFlush() } } heartRateScope.cancel() + suppressAacpReconnect("service-destroyed") + transportRecoveryScope.cancel() // isConnectedLocally = false // CrossDevice.isAvailable = true super.onDestroy() @@ -3438,31 +3607,42 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList canContinueHeartRateMonitoring() && sendFrame() } - private fun stopHeartRateSessionLocked(forceStop: Boolean = false) { + private fun stopHeartRateSessionLocked( + forceStop: Boolean = false, + sendStopFrame: Boolean = true + ) { val shouldStop = forceStop || heartRateSessionRequested || heartRateStartCommandSent heartRateSessionRequested = false heartRateStartCommandSent = false _heartRateStreaming.value = false - if (shouldStop && BluetoothConnectionManager.aacpSocket?.isConnected == true) { + if (sendStopFrame && shouldStop && + BluetoothConnectionManager.aacpSocket?.isConnected == true + ) { aacpManager.sendHeartRateStopFrame() } } - private fun stopHeartRateMonitoring(forceStop: Boolean = false) { + private fun stopHeartRateMonitoring( + forceStop: Boolean = false, + sendStopFrame: Boolean = true + ) { synchronized(heartRateLock) { val jobWasActive = heartRateStartJob?.isActive == true heartRateStartJob?.cancel() heartRateStartJob = null lastValidHeartRateSampleElapsedRealtime = null - stopHeartRateSessionLocked(forceStop = forceStop || jobWasActive) + stopHeartRateSessionLocked( + forceStop = forceStop || jobWasActive, + sendStopFrame = sendStopFrame + ) } } private fun handleHeartRateDisconnected() { if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync() - stopHeartRateMonitoring() + stopHeartRateMonitoring(sendStopFrame = false) } var isHeadTrackingActive = false From f8f9a1e901c558cb3f2b8aeaeb47146db657c12a Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Wed, 5 Aug 2026 13:32:33 +0200 Subject: [PATCH 05/15] Strengthened reconnect, and added heart rate slider not showing on unsupported models --- .../screens/AirPodsSettingsScreen.kt | 30 +- .../viewmodel/AirPodsViewModel.kt | 3 +- .../librepods/services/AirPodsService.kt | 435 +++++++++++++----- 3 files changed, 333 insertions(+), 135 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 5b990d60a..60b13f331 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -334,19 +334,23 @@ fun AirPodsSettingsScreen( onClick = navigateToRename, ) } - item(key = "spacer_heart_rate") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "heart_rate") { - HeartRateCard( - monitoringEnabled = state.heartRateMonitoringEnabled, - streaming = state.heartRateStreaming, - connected = state.isLocallyConnected, - latestSample = state.heartRateSamples.lastOrNull(), - heartRateSamples = state.heartRateSamples, - onMonitoringChanged = setHeartRateMonitoringEnabled, - onOpenDetails = navigateToHeartRateTest - ) + val hasHeartRateCapability = + state.instance?.model?.capabilities?.contains(Capability.HRM) == true + if (hasHeartRateCapability) { + item(key = "spacer_heart_rate") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "heart_rate") { + HeartRateCard( + monitoringEnabled = state.heartRateMonitoringEnabled, + streaming = state.heartRateStreaming, + connected = state.isLocallyConnected, + latestSample = state.heartRateSamples.lastOrNull(), + heartRateSamples = state.heartRateSamples, + onMonitoringChanged = setHeartRateMonitoringEnabled, + onOpenDetails = navigateToHeartRateTest + ) + } } val hasHearingAidCapability = diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 1b7b315e5..0314f86bd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -42,7 +42,6 @@ import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels @@ -508,7 +507,7 @@ class AirPodsViewModel( service.let { service -> _uiState.update { it.copy( - isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, + isLocallyConnected = service.isAacpTransportHealthy(), heartRateMonitoringEnabled = service.heartRateMonitoringEnabled.value, heartRateStreaming = service.heartRateStreaming.value, heartRateSamples = service.heartRateSamples.value, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 96e481524..697bdc144 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -84,7 +84,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R @@ -136,12 +135,13 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD +import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicReference import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.coroutines.coroutineContext -import kotlin.time.Duration.Companion.milliseconds private const val TAG = "AirPodsService" @@ -244,8 +244,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val heartRateLock = Any() private val transportRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val transportRecoveryLock = Any() + private val aacpConnectLock = Any() private var aacpReconnectJob: Job? = null + private var aacpLivenessJob: Job? = null private var aacpReconnectSuppressed = false + private var aacpConnectionGeneration = 0L + private var aacpTransportResponsive = false + @Volatile + private var lastAacpPacketElapsedRealtime = 0L private var heartRateStartJob: Job? = null private var heartRateSessionRequested = false private var heartRateStartCommandSent = false @@ -283,9 +289,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 12_000L private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 6_000L private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L - private const val AACP_RECONNECT_DELAY_MILLIS = 750L + private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L + private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L + private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L private const val EXTRA_AACP_TRANSPORT_FAILURE = "me.kavishdevar.librepods.extra.AACP_TRANSPORT_FAILURE" + private val AACP_RECONNECT_BACKOFF_MILLIS = + longArrayOf(750L, 1_500L, 3_000L, 5_000L, 10_000L) private val HEART_RATE_RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) init { @@ -310,7 +320,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return + if (isAacpTransportHealthy()) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -343,7 +353,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" ) - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return + if (isAacpTransportHealthy()) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -377,7 +387,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return + if (isAacpTransportHealthy()) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -732,6 +742,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) synchronized(transportRecoveryLock) { aacpReconnectSuppressed = false + aacpConnectionGeneration++ } device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra("device", BluetoothDevice::class.java)!! @@ -850,10 +861,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList putString("mac_address", macAddress) } // } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { - setPackage(packageName) - }) } } bluetoothAdapter.closeProfileProxy(profile, proxy) @@ -2138,6 +2145,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connected: Boolean, airpodsName: String? = null, batteryList: List? = null ) { val notificationManager = getSystemService(NotificationManager::class.java) + if (!connected) { + notificationManager.cancel(2) + return + } val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -2150,7 +2161,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (BluetoothConnectionManager.aacpSocket == null) { return } - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { + if (isAacpTransportHealthy()) { val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status") .setSmallIcon(R.drawable.airpods) @@ -2196,8 +2207,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(2, updatedNotification) notificationManager.cancel(1) - } else if (!connected) { - notificationManager.cancel(2) } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs") } @@ -2517,12 +2526,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ?.getString("mac_address", "") ?: "" val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true - if (matchedByUuid || matchedByMac) { + val isA2dpConnected = context + ?.getSystemService(BluetoothManager::class.java) + ?.adapter + ?.getProfileConnectionState(BluetoothProfile.A2DP) == + BluetoothProfile.STATE_CONNECTED + if ((matchedByUuid || matchedByMac) && isA2dpConnected) { val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) intent.putExtra("name", name) intent.putExtra("device", bluetoothDevice) context?.sendBroadcast(intent) } + } else if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action) { + val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) + ?.getString("mac_address", "") ?: "" + if (savedMac.isNotEmpty() && bluetoothDevice.address == savedMac) { + context?.sendBroadcast( + Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + setPackage(context.packageName) + } + ) + } } } } @@ -2736,6 +2760,41 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false ) { + if (manual) { + val staleSocket = synchronized(transportRecoveryLock) { + BluetoothConnectionManager.aacpSocket.takeIf { !aacpTransportResponsive } + } + if (staleSocket != null) { + clearAacpTransport("manual-reconnect", expectedSocket = staleSocket) + } + } + val connectionGeneration = synchronized(transportRecoveryLock) { + if (manual) { + aacpReconnectSuppressed = false + aacpConnectionGeneration++ + } + if (aacpReconnectSuppressed) null else aacpConnectionGeneration + } + if (connectionGeneration == null) { + Log.i(TAG, "Skipping suppressed AACP connection attempt") + return + } + synchronized(aacpConnectLock) { + connectToSocketLocked(adapter, device, manual, connectionGeneration) + } + } + + @SuppressLint("MissingPermission") + private fun connectToSocketLocked( + adapter: BluetoothAdapter, + device: BluetoothDevice, + manual: Boolean, + connectionGeneration: Long + ) { + val attemptIsCurrent = synchronized(transportRecoveryLock) { + !aacpReconnectSuppressed && connectionGeneration == aacpConnectionGeneration + } + if (!attemptIsCurrent) return if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return Log.d(TAG, " Connecting to socket") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") @@ -2745,100 +2804,105 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") + if (!manual) scheduleAacpReconnect(device, "socket-creation-failed") return } + var attSocket: BluetoothSocket? = null + var socketInstalled = false try { - runBlocking { - withTimeout(5000.milliseconds) { - try { - socket.connect() - this@AirPodsService.device = device - val xposedRemotePref = XposedRemotePrefProvider.create() - val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - attSocket?.connect() - - if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) - } + try { + connectSocketWithTimeout(socket, "AACP") + val xposedRemotePref = XposedRemotePrefProvider.create() + attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { + createBluetoothSocket( + adapter, + device, + ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), + 31 + ) + } else null + attSocket?.let { connectSocketWithTimeout(it, "ATT") } + socketInstalled = synchronized(transportRecoveryLock) { + if (aacpReconnectSuppressed || + connectionGeneration != aacpConnectionGeneration || + BluetoothConnectionManager.aacpSocket != null + ) { + false + } else { BluetoothConnectionManager.aacpSocket = socket BluetoothConnectionManager.attSocket = attSocket + true + } + } + if (!socketInstalled) { + closeSocketQuietly(socket, "superseded AACP socket") + closeSocketQuietly(attSocket, "superseded ATT socket") + Log.i(TAG, "Discarding superseded AACP connection attempt") + return + } - // Create AirPodsInstance from stored config if available - if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { - val model = - AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - setMetadatas(device) - } - } + this@AirPodsService.device = device + startAacpLivenessWatchdog(socket, device) - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() + if (attSocket != null) { + attManager.startReader() + attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) + attManager.readCharacteristic(ATTHandles.TRANSPARENCY) + attManager.readCharacteristic(ATTHandles.HEARING_AID) + } + + // Create AirPodsInstance from stored config if available + if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { + val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + if (model != null) { + airpodsInstance = AirPodsInstance( + name = config.airpodsName, + model = model, + actualModelNumber = config.airpodsModelNumber, + serialNumber = config.airpodsSerialNumber, + leftSerialNumber = config.airpodsLeftSerialNumber, + rightSerialNumber = config.airpodsRightSerialNumber, + version1 = config.airpodsVersion1, + version2 = config.airpodsVersion2, + version3 = config.airpodsVersion3, ) - Log.d(TAG, " Socket connected") - sharedPreferences.edit { putBoolean("connection_successful", true) } - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong( - "first_connection_successful_time", - System.currentTimeMillis() - ) - } - } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) - } catch (e: Exception) { -// sharedPreferences.edit { putBoolean("connection_successful", false) } - Log.d( - TAG, " Socket not connected, ${e.message}" + setMetadatas(device) + } + } + + Log.d(TAG, " Socket connected") + } catch (e: Exception) { + if (socketInstalled) { + if (handleAacpTransportFailure( + failedSocket = socket, + reconnectDevice = device, + source = "connection-initialization-failed" ) - if (manual) { - sendToast( - "Couldn't connect to socket: ${e.localizedMessage}" - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") - } - return@withTimeout -// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history + ) { + broadcastAacpTransportFailure() } + } else { + closeSocketQuietly(socket, "failed AACP socket") + closeSocketQuietly(attSocket, "failed ATT socket") } - } - if (!socket.isConnected) { - Log.d(TAG, " socket not connected") + Log.d(TAG, " Socket not connected, ${e.message}") if (manual) { - sendToast( - "Couldn't connect to socket: timeout." - ) + sendToast("Couldn't connect to socket: ${e.localizedMessage}") } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") + showSocketConnectionFailureNotification( + "Couldn't connect to socket: ${e.localizedMessage}" + ) + if (!socketInstalled) { + scheduleAacpReconnect(device, "connection-attempt-failed") + } } return } + this@AirPodsService.device = device - BluetoothConnectionManager.aacpSocket?.let { + socket.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() @@ -2870,12 +2934,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } }, 5000) - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) - .apply { - setPackage(packageName) - }) - setupStemActions() startHeartRateMonitoringIfEnabled() @@ -2885,6 +2943,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val bytesRead = it.inputStream.read(buffer) var data: ByteArray if (bytesRead > 0) { + noteAacpPacketReceived(socket, device) data = buffer.copyOfRange(0, bytesRead) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { putExtra("data", buffer.copyOfRange(0, bytesRead)) @@ -2967,6 +3026,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // } } + private fun connectSocketWithTimeout(socket: BluetoothSocket, label: String) { + val failure = AtomicReference(null) + val connectThread = Thread({ + try { + socket.connect() + } catch (throwable: Throwable) { + failure.set(throwable) + } + }, "LibrePods-$label-connect") + connectThread.start() + connectThread.join(5_000L) + + if (connectThread.isAlive) { + closeSocketQuietly(socket, "$label socket after connect timeout") + connectThread.join(1_000L) + throw IOException("$label socket connection timed out") + } + failure.get()?.let { throw IOException("$label socket connection failed", it) } + if (!socket.isConnected) { + throw IOException("$label socket did not enter the connected state") + } + } + private fun closeSocketQuietly(socket: BluetoothSocket?, label: String) { if (socket == null) return try { @@ -2986,6 +3068,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ): Boolean { val aacpSocketToClose: BluetoothSocket? val attSocketToClose: BluetoothSocket? + val livenessJobToCancel: Job? synchronized(transportRecoveryLock) { val currentSocket = BluetoothConnectionManager.aacpSocket if (expectedSocket != null && currentSocket !== expectedSocket) { @@ -2997,8 +3080,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList attSocketToClose = BluetoothConnectionManager.attSocket BluetoothConnectionManager.aacpSocket = null BluetoothConnectionManager.attSocket = null + aacpTransportResponsive = false + lastAacpPacketElapsedRealtime = 0L + livenessJobToCancel = aacpLivenessJob + aacpLivenessJob = null } + livenessJobToCancel?.cancel() closeSocketQuietly(aacpSocketToClose, "AACP socket") closeSocketQuietly(attSocketToClose, "ATT socket") handleHeartRateDisconnected() @@ -3012,6 +3100,110 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return aacpSocketToClose != null } + fun isAacpTransportHealthy(): Boolean = synchronized(transportRecoveryLock) { + BluetoothConnectionManager.aacpSocket?.isConnected == true && aacpTransportResponsive + } + + private fun noteAacpPacketReceived(socket: BluetoothSocket, connectedDevice: BluetoothDevice) { + val becameResponsive = synchronized(transportRecoveryLock) { + if (BluetoothConnectionManager.aacpSocket !== socket) { + false + } else { + lastAacpPacketElapsedRealtime = SystemClock.elapsedRealtime() + if (aacpTransportResponsive) { + false + } else { + aacpTransportResponsive = true + true + } + } + } + if (!becameResponsive) return + + updateNotificationContent(true, config.deviceName, batteryNotification.getBattery()) + sharedPreferences.edit { putBoolean("connection_successful", true) } + if (!sharedPreferences.contains("first_connection_successful_time")) { + sharedPreferences.edit { + putLong("first_connection_successful_time", System.currentTimeMillis()) + } + } + sendBroadcast( + Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED) + .putExtra("device", connectedDevice) + .apply { setPackage(packageName) } + ) + Log.i(TAG, "AACP transport became responsive") + } + + private fun startAacpLivenessWatchdog(socket: BluetoothSocket, connectedDevice: BluetoothDevice) { + val job = synchronized(transportRecoveryLock) { + aacpLivenessJob?.cancel() + aacpTransportResponsive = false + lastAacpPacketElapsedRealtime = 0L + + transportRecoveryScope.launch(start = CoroutineStart.LAZY) { + delay(AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS) + if (!isCurrentAacpSocket(socket)) return@launch + + if (!isAacpTransportHealthy() && !probeAacpTransport(socket)) { + failUnresponsiveAacpTransport(socket, connectedDevice, "initial-response-timeout") + return@launch + } + + while (isCurrentAacpSocket(socket)) { + delay(AACP_IDLE_PROBE_INTERVAL_MILLIS) + if (!isCurrentAacpSocket(socket)) return@launch + + val silentFor = SystemClock.elapsedRealtime() - lastAacpPacketElapsedRealtime + if (silentFor < AACP_IDLE_PROBE_INTERVAL_MILLIS) continue + + if (!probeAacpTransport(socket)) { + failUnresponsiveAacpTransport(socket, connectedDevice, "idle-probe-timeout") + return@launch + } + } + }.also { aacpLivenessJob = it } + } + job.start() + } + + private fun isCurrentAacpSocket(socket: BluetoothSocket): Boolean = + synchronized(transportRecoveryLock) { + BluetoothConnectionManager.aacpSocket === socket && socket.isConnected + } + + private suspend fun probeAacpTransport(socket: BluetoothSocket): Boolean { + if (!isCurrentAacpSocket(socket)) return true + var probeStartedAt = SystemClock.elapsedRealtime() + Log.i(TAG, "Probing silent AACP transport") + if (!aacpManager.sendNotificationRequest()) return false + delay(AACP_PROBE_RESPONSE_TIMEOUT_MILLIS) + if (!isCurrentAacpSocket(socket)) return true + if (lastAacpPacketElapsedRealtime >= probeStartedAt) return true + + // Retry with the normal handshake as well before declaring an otherwise-open socket dead. + probeStartedAt = SystemClock.elapsedRealtime() + if (!aacpManager.sendPacket(aacpManager.createHandshakePacket()) || + !aacpManager.sendNotificationRequest() + ) { + return false + } + delay(AACP_PROBE_RESPONSE_TIMEOUT_MILLIS) + if (!isCurrentAacpSocket(socket)) return true + return lastAacpPacketElapsedRealtime >= probeStartedAt + } + + private fun failUnresponsiveAacpTransport( + socket: BluetoothSocket, + connectedDevice: BluetoothDevice, + source: String + ) { + Log.w(TAG, "AACP transport did not respond source=$source") + if (handleAacpTransportFailure(socket, connectedDevice, source)) { + broadcastAacpTransportFailure() + } + } + private fun handleAacpTransportFailure( failedSocket: BluetoothSocket, reconnectDevice: BluetoothDevice, @@ -3041,32 +3233,32 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val job = transportRecoveryScope.launch(start = CoroutineStart.LAZY) { val currentJob = coroutineContext[Job] ?: return@launch try { - delay(AACP_RECONNECT_DELAY_MILLIS) - val shouldReconnect = synchronized(transportRecoveryLock) { - aacpReconnectJob === currentJob && - !aacpReconnectSuppressed && - BluetoothConnectionManager.aacpSocket == null - } - if (!shouldReconnect) return@launch - - Log.i(TAG, "AACP reconnect starting source=$source") - val adapter = getSystemService(BluetoothManager::class.java).adapter - connectToSocket(adapter, reconnectDevice) + for ((attemptIndex, backoffMillis) in AACP_RECONNECT_BACKOFF_MILLIS.withIndex()) { + delay(backoffMillis) + val shouldReconnect = synchronized(transportRecoveryLock) { + aacpReconnectJob === currentJob && + !aacpReconnectSuppressed && + BluetoothConnectionManager.aacpSocket == null + } + if (!shouldReconnect) return@launch - val reconnectWasCancelled = synchronized(transportRecoveryLock) { - aacpReconnectJob !== currentJob || aacpReconnectSuppressed - } - if (reconnectWasCancelled) { - clearAacpTransport( - source = "cancelled-reconnect", - expectedSocket = BluetoothConnectionManager.aacpSocket + Log.i( + TAG, + "AACP reconnect starting source=$source attempt=${attemptIndex + 1}" ) + val adapter = getSystemService(BluetoothManager::class.java).adapter + connectToSocket(adapter, reconnectDevice) + + val reconnectWasCancelled = synchronized(transportRecoveryLock) { + aacpReconnectJob !== currentJob || aacpReconnectSuppressed + } + if (reconnectWasCancelled) return@launch + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { + Log.i(TAG, "AACP reconnect socket established source=$source") + return@launch + } } - Log.i( - TAG, - "AACP reconnect result source=$source success=" + - (BluetoothConnectionManager.aacpSocket?.isConnected == true) - ) + Log.w(TAG, "AACP reconnect attempts exhausted source=$source") } catch (e: Exception) { Log.w(TAG, "AACP reconnect failed source=$source: ${e.message}", e) } finally { @@ -3084,7 +3276,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun cancelAacpReconnect(source: String, suppressFutureReconnects: Boolean) { val job = synchronized(transportRecoveryLock) { - if (suppressFutureReconnects) aacpReconnectSuppressed = true + if (suppressFutureReconnects) { + aacpReconnectSuppressed = true + aacpConnectionGeneration++ + } aacpReconnectJob.also { aacpReconnectJob = null } } if (job?.isActive == true) { @@ -3578,7 +3773,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun canContinueHeartRateMonitoring(): Boolean = _heartRateMonitoringEnabled.value && - BluetoothConnectionManager.aacpSocket?.isConnected == true + isAacpTransportHealthy() private suspend fun initializeHeartRateAacpSession(): Boolean { if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService0() }) { From bfbe370fd2fb641edec0573aa573163b71bcc504 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Wed, 5 Aug 2026 17:09:40 +0200 Subject: [PATCH 06/15] Fix coexist connection video crash --- .../librepods/presentation/overlays/IslandWindow.kt | 7 ++++++- .../librepods/presentation/overlays/PopupWindow.kt | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt index bf5eff89e..7de61721c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt @@ -374,8 +374,13 @@ class IslandWindow(private val context: Context) { } val videoView = islandView.findViewById(R.id.island_video_view) - val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() + val videoUri = "android.resource://${context.packageName}/${R.raw.island}".toUri() videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) + videoView.setOnErrorListener { _, what, extra -> + e("IslandWindow", "Island video playback failed what=$what extra=$extra") + videoView.visibility = View.GONE + true + } videoView.setVideoURI(videoUri) videoView.setOnPreparedListener { mediaPlayer -> mediaPlayer.isLooping = true diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt index 4247ea47a..74df5cae4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt @@ -139,7 +139,15 @@ class PopupWindow( val vid = mView.findViewById(R.id.video) vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) - vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) + vid.setOnErrorListener { _, what, extra -> + Log.e( + "PopupWindow", + "Connection video playback failed what=$what extra=$extra" + ) + vid.visibility = View.GONE + true + } + vid.setVideoPath("android.resource://${context.packageName}/${R.raw.connected}") vid.resolveAdjustedSize(vid.width, vid.height) vid.start() vid.setOnCompletionListener { From fc56a0021efdef7a3b60d479114bad31e7e1b1bf Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Thu, 6 Aug 2026 01:48:55 +0200 Subject: [PATCH 07/15] Heart rate with 1 airpod --- .../librepods/bluetooth/AACPManager.kt | 137 ++++- .../librepods/bluetooth/RtBuddyHeartRate.kt | 555 ++++++++++++------ .../screens/HeartRateTestScreen.kt | 85 +-- .../librepods/services/AirPodsService.kt | 199 +++++-- 4 files changed, 725 insertions(+), 251 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index 88a143c09..c60bfd0c4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -90,6 +90,14 @@ class AACPManager { 0x08, 0xED.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 ) + private val HEART_RATE_START_PACKET = HEADER_BYTES + HEART_RATE_START_1S + private val HEART_RATE_STOP_PACKET = HEADER_BYTES + HEART_RATE_STOP + + private const val HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS = 10_000L + private const val HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD = 10 + private const val HEART_RATE_DIAGNOSTIC_COUNT_LIMIT = 1_000 + private const val HEART_RATE_DIAGNOSTIC_STRUCTURE_LIMIT = 4 + private const val HEART_RATE_DIAGNOSTIC_OVERFLOW_KEY = "other_structures" data class ControlCommandStatus( val identifier: ControlCommandIdentifiers, val value: ByteArray @@ -311,6 +319,14 @@ class AACPManager { private var callback: PacketCallback? = null private val heartRateDecoder = RtBuddyHeartRateDecoder() + private val heartRateDiagnosticLock = Any() + private var heartRateAcceptedSampleLogged = false + private var heartRateDiagnosticWindowStartedAtMillis = 0L + private var heartRateDiagnosticRelatedFrames = 0 + private var heartRateDiagnosticRejectedFrames = 0 + private val heartRateDiagnosticRejectionReasons = + mutableMapOf() + private val heartRateDiagnosticStructures = linkedMapOf() fun setPacketCallback(callback: PacketCallback) { this.callback = callback @@ -442,19 +458,116 @@ class AACPManager { fun receivePacket(packet: ByteArray): Boolean { val heartRateResult = heartRateDecoder.feed(packet) - if (heartRateResult.relatedFrameCount > 0) { - Log.d( - TAG, - "Received RTBuddy heart-rate frames=${heartRateResult.relatedFrameCount}, " + - "rejected=${heartRateResult.rejectedFrameCount}, " + - "samples=${heartRateResult.samples.size}" - ) - } + recordHeartRateDecodeDiagnostics(heartRateResult) heartRateResult.samples.forEach { callback?.onHeartRateReceived(it) } heartRateResult.passthroughPackets.forEach(::receiveStandardPacket) return heartRateResult.suppressRawLogging } + private fun recordHeartRateDecodeDiagnostics(result: HeartRateDecodeResult) { + if (result.relatedFrameCount == 0) return + + var logAcceptedSample = false + var rejectionSummary: String? = null + synchronized(heartRateDiagnosticLock) { + if (result.samples.isNotEmpty() && !heartRateAcceptedSampleLogged) { + heartRateAcceptedSampleLogged = true + logAcceptedSample = true + } + + if (result.rejectedFrameCount > 0) { + val now = System.currentTimeMillis() + if (heartRateDiagnosticWindowStartedAtMillis == 0L) { + heartRateDiagnosticWindowStartedAtMillis = now + } + heartRateDiagnosticRelatedFrames = boundedDiagnosticCount( + heartRateDiagnosticRelatedFrames, + result.relatedFrameCount + ) + heartRateDiagnosticRejectedFrames = boundedDiagnosticCount( + heartRateDiagnosticRejectedFrames, + result.rejectedFrameCount + ) + result.rejectionReasons.forEach { (reason, count) -> + heartRateDiagnosticRejectionReasons.incrementBounded(reason, count) + } + result.structuralDiagnostics.forEach { (structure, count) -> + val existing = heartRateDiagnosticStructures[structure] + when { + existing != null -> { + heartRateDiagnosticStructures.incrementBounded(structure, count) + } + + heartRateDiagnosticStructures.size < HEART_RATE_DIAGNOSTIC_STRUCTURE_LIMIT -> { + heartRateDiagnosticStructures[structure] = count.coerceAtMost( + HEART_RATE_DIAGNOSTIC_COUNT_LIMIT + ) + } + + else -> { + heartRateDiagnosticStructures.incrementBounded( + HEART_RATE_DIAGNOSTIC_OVERFLOW_KEY, + count + ) + } + } + } + + val windowElapsed = now - heartRateDiagnosticWindowStartedAtMillis >= + HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS + val thresholdReached = heartRateDiagnosticRejectedFrames >= + HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD + if (windowElapsed || thresholdReached) { + val reasons = heartRateDiagnosticRejectionReasons.entries + .sortedBy { it.key.name } + .joinToString(",") { (reason, count) -> + "${reason.name.lowercase()}=$count" + } + val structures = heartRateDiagnosticStructures.entries + .sortedByDescending { it.value } + .joinToString(" || ") { (structure, count) -> + "$count*$structure" + } + .ifEmpty { "none" } + rejectionSummary = + "RTBuddy heart-rate decode window frames=$heartRateDiagnosticRelatedFrames " + + "rejected=$heartRateDiagnosticRejectedFrames reasons=$reasons; " + + "structures=$structures; raw frame data suppressed" + clearHeartRateDiagnosticWindowLocked() + } + } + } + + if (logAcceptedSample) { + Log.d(TAG, "Validated first RTBuddy heart-rate sample for this AACP connection") + } + rejectionSummary?.let { Log.w(TAG, it) } + } + + private fun boundedDiagnosticCount(current: Int, increment: Int): Int = + (current.toLong() + increment.toLong()) + .coerceAtMost(HEART_RATE_DIAGNOSTIC_COUNT_LIMIT.toLong()) + .toInt() + + private fun MutableMap.incrementBounded(key: K, increment: Int) { + this[key] = boundedDiagnosticCount(getOrDefault(key, 0), increment) + } + + private fun clearHeartRateDiagnosticWindowLocked() { + heartRateDiagnosticWindowStartedAtMillis = 0L + heartRateDiagnosticRelatedFrames = 0 + heartRateDiagnosticRejectedFrames = 0 + heartRateDiagnosticRejectionReasons.clear() + heartRateDiagnosticStructures.clear() + } + + private fun resetHeartRateDiagnostics() { + synchronized(heartRateDiagnosticLock) { + heartRateAcceptedSampleLogged = false + clearHeartRateDiagnosticWindowLocked() + } + } + @OptIn(ExperimentalStdlibApi::class) private fun receiveStandardPacket(packet: ByteArray) { if (!packet.toHexString().startsWith("04000400")) { @@ -1332,14 +1445,14 @@ class AACPManager { ) } - private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean { - return packet.contentEquals(HEADER_BYTES + HEART_RATE_START_1S) || - packet.contentEquals(HEADER_BYTES + HEART_RATE_STOP) - } + private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean = + packet.contentEquals(HEART_RATE_START_PACKET) || + packet.contentEquals(HEART_RATE_STOP_PACKET) fun disconnected() { Log.d(TAG, "Disconnected, clearing state") heartRateDecoder.reset() + resetHeartRateDiagnostics() controlCommandStatusList.clear() controlCommandListeners.clear() owns = false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt index 88bfb7bb0..54b545d45 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -17,10 +17,19 @@ data class HeartRateSample( val receivedAtMillis: Long ) +internal enum class HeartRateRejectionReason { + MALFORMED_SENSOR_DATA, + UNSUPPORTED_LOG_TYPE, + MISSING_HEART_RATE_PAYLOAD, + UNRECOGNIZED_HEART_RATE_PAYLOAD +} + internal data class HeartRateDecodeResult( val samples: List = emptyList(), val relatedFrameCount: Int = 0, val rejectedFrameCount: Int = 0, + val rejectionReasons: Map = emptyMap(), + val structuralDiagnostics: Map = emptyMap(), val suppressRawLogging: Boolean = false, val passthroughPackets: List = emptyList() ) @@ -31,6 +40,12 @@ internal data class HeartRateDecodeResult( * Socket reads are arbitrary chunks. A possible partial 0x17/0x00100000 frame is retained until * its declared payload is complete. Other 0x17 packets are reconstructed and passed to the normal * AACP parser so head tracking keeps its existing behavior. + * + * The known heart-rate value is accepted only from a live SensorDataWX record: an exact 18-byte + * HEARTRATE(19) command payload with one of the observed status trailers and a BPM in the validated + * physiological range. Firmware may use either observed live log type and may repeat command payload + * field 3 or place that exact payload inside one or more protobuf length-delimited wrappers; those + * structural variants are traversed without reading arbitrary offsets. */ internal class RtBuddyHeartRateDecoder { private var carry = ByteArray(0) @@ -51,6 +66,8 @@ internal class RtBuddyHeartRateDecoder { val passthroughPackets = mutableListOf() var relatedFrameCount = 0 var rejectedFrameCount = 0 + val rejectionReasons = mutableMapOf() + val structuralDiagnostics = linkedMapOf() var suppressRawLogging = carryWasSensitive var cursor = 0 @@ -104,7 +121,11 @@ internal class RtBuddyHeartRateDecoder { val classification = classifyFrame(frame) if (classification.isHeartRateRelated) { relatedFrameCount++ - if (classification.sample == null) rejectedFrameCount++ + if (classification.sample == null) { + rejectedFrameCount++ + classification.rejectionReason?.let { rejectionReasons.increment(it) } + } + classification.structuralDiagnostic?.let { structuralDiagnostics.increment(it) } suppressRawLogging = true classification.sample?.let(samples::add) } else { @@ -118,6 +139,8 @@ internal class RtBuddyHeartRateDecoder { samples = samples, relatedFrameCount = relatedFrameCount, rejectedFrameCount = rejectedFrameCount, + rejectionReasons = rejectionReasons, + structuralDiagnostics = structuralDiagnostics, suppressRawLogging = suppressRawLogging, passthroughPackets = passthroughPackets ) @@ -130,149 +153,165 @@ internal class RtBuddyHeartRateDecoder { frame.size ) val sensorData = parseSensorDataWx(frame, AACP_RTBUDDY_HEADER_LENGTH, frame.size) - ?: return FrameClassification(isHeartRateRelated = hasHeartRateReference) + ?: return FrameClassification( + isHeartRateRelated = hasHeartRateReference, + rejectionReason = HeartRateRejectionReason.MALFORMED_SENSOR_DATA + .takeIf { hasHeartRateReference }, + structuralDiagnostic = MALFORMED_STRUCTURE_DIAGNOSTIC + .takeIf { hasHeartRateReference } + ) val heartRateRelated = hasHeartRateReference || HEART_RATE_SERVICE in sensorData.referencedServices - if (!heartRateRelated || sensorData.logType !in SENSOR_DATA_LOG_STATES) { - return FrameClassification(isHeartRateRelated = heartRateRelated) + if (!heartRateRelated) return FrameClassification() + if (sensorData.logType !in LIVE_SENSOR_DATA_LOG_TYPES) { + return FrameClassification( + isHeartRateRelated = true, + rejectionReason = HeartRateRejectionReason.UNSUPPORTED_LOG_TYPE, + structuralDiagnostic = buildStructuralDiagnostic(sensorData, emptyList()) + ) } - val payload = sensorData.commands.asSequence() - .mapNotNull { command -> - command.payload?.takeIf { - command.service == HEART_RATE_SERVICE && - it.size == HEART_RATE_PAYLOAD_LENGTH && - it[15] == 0x10.toByte() && - it[16] == 0x00.toByte() && - it[17] == 0x00.toByte() && - it[1].toInt().and(0xFF) in MIN_BPM..MAX_BPM - } + val heartRateCommands = sensorData.commands.filter { it.service == HEART_RATE_SERVICE } + val analyses = heartRateCommands.flatMap { command -> + command.payloadCandidates.map { candidate -> + PayloadAnalysis( + command = command, + candidate = candidate, + failures = validateHeartRatePayload(candidate.bytes) + ) } - .firstOrNull() - ?: return FrameClassification(isHeartRateRelated = true) + } + val accepted = analyses.firstOrNull { it.failures.isEmpty() } + if (accepted == null) { + val hasPayloadCandidate = heartRateCommands.any { + it.directPayloadCount > 0 || it.payloadCandidates.isNotEmpty() + } + return FrameClassification( + isHeartRateRelated = true, + rejectionReason = if (hasPayloadCandidate) { + HeartRateRejectionReason.UNRECOGNIZED_HEART_RATE_PAYLOAD + } else { + HeartRateRejectionReason.MISSING_HEART_RATE_PAYLOAD + }, + structuralDiagnostic = buildStructuralDiagnostic(sensorData, analyses) + ) + } + val payload = accepted.candidate.bytes return FrameClassification( isHeartRateRelated = true, sample = HeartRateSample( - bpm = payload[1].toInt().and(0xFF), + bpm = payload.unsignedByteAt(HEART_RATE_BPM_OFFSET), sequence = sensorData.sequence, receivedAtMillis = System.currentTimeMillis() ) ) } + private fun validateHeartRatePayload(payload: ByteArray): Set { + if (payload.size != HEART_RATE_PAYLOAD_LENGTH) { + return setOf(PayloadValidationFailure.LENGTH) + } - private fun hasHeartRateServiceReference(data: ByteArray, start: Int, end: Int): Boolean { - var index = start - while (index < end) { - val key = readVarint(data, index, end) ?: return false - index = key.nextIndex - val field = (key.value ushr 3).toInt() - val wireType = (key.value and 0x07).toInt() + val failures = linkedSetOf() + if (!payload.hasKnownHeartRateStatusTail()) { + failures += PayloadValidationFailure.STATUS_TAIL + } + if (payload.unsignedByteAt(HEART_RATE_BPM_OFFSET) !in MIN_BPM..MAX_BPM) { + failures += PayloadValidationFailure.BPM_RANGE + } + return failures + } - when (wireType) { - WIRE_VARINT -> { - val value = readVarint(data, index, end) ?: return false - index = value.nextIndex - } + private fun ByteArray.hasKnownHeartRateStatusTail(): Boolean { + if (size != HEART_RATE_PAYLOAD_LENGTH) return false + return KNOWN_HEART_RATE_STATUS_TAILS.any { tail -> + tail.indices.all { index -> + this[HEART_RATE_STATUS_TAIL_OFFSET + index] == tail[index] + } + } + } - WIRE_LENGTH_DELIMITED -> { - val fieldValue = readLengthDelimited(data, index, end) ?: return false - if (field in HEART_RATE_SERVICE_REFERENCE_FIELDS && - parseReferencedService( - data, - fieldValue.startIndex, - fieldValue.endIndex - ) == HEART_RATE_SERVICE - ) { - return true - } - index = fieldValue.endIndex - } + private fun ByteArray.unsignedByteAt(index: Int): Int = this[index].toInt().and(0xFF) - WIRE_FIXED64 -> { - if (end - index < 8) return false - index += 8 - } + private fun MutableMap.increment(key: K) { + this[key] = getOrDefault(key, 0) + 1 + } - WIRE_FIXED32 -> { - if (end - index < 4) return false - index += 4 - } + private fun hasHeartRateServiceReference(data: ByteArray, start: Int, end: Int): Boolean { + val message = parseProtoMessage(data, start, end) ?: return false + return message.entries.any { entry -> + entry.wireType == WIRE_LENGTH_DELIMITED && + entry.field in SENSOR_DATA_COMMAND_FIELDS && + containsServiceReference( + data = data, + start = entry.valueStart, + end = entry.valueEnd, + depth = 0 + ) + } + } - else -> return false + private fun containsServiceReference( + data: ByteArray, + start: Int, + end: Int, + depth: Int + ): Boolean { + if (depth > MAX_COMMAND_ENVELOPE_DEPTH) return false + val message = parseProtoMessage(data, start, end) ?: return false + if (message.entries.any { + it.field == 1 && + it.wireType == WIRE_VARINT && + it.varintValue == HEART_RATE_SERVICE.toLong() } + ) { + return true + } + if (depth == MAX_COMMAND_ENVELOPE_DEPTH) return false + return message.entries.any { entry -> + entry.wireType == WIRE_LENGTH_DELIMITED && + containsServiceReference( + data, + entry.valueStart, + entry.valueEnd, + depth + 1 + ) } - return false } private fun parseSensorDataWx(data: ByteArray, start: Int, end: Int): SensorDataWx? { - var index = start + val message = parseProtoMessage(data, start, end) ?: return null var sequence = -1 var logType = -1 val commands = mutableListOf() val referencedServices = mutableSetOf() + val fieldOccurrences = mutableMapOf() - while (index < end) { - val key = readVarint(data, index, end) ?: return null - index = key.nextIndex - val field = (key.value ushr 3).toInt() - val wireType = (key.value and 0x07).toInt() - - when (wireType) { - WIRE_VARINT -> { - val value = readVarint(data, index, end) ?: return null - index = value.nextIndex - when (field) { - 1 -> sequence = value.value.toInt() - 2 -> logType = value.value.toInt() - } + message.entries.forEach { entry -> + when { + entry.wireType == WIRE_VARINT && entry.field == 1 -> { + sequence = entry.varintValue?.toInt() ?: sequence } - WIRE_LENGTH_DELIMITED -> { - val fieldValue = readLengthDelimited(data, index, end) ?: return null - - when (field) { - 5, 8, 9, 12 -> parseReferencedService( - data, - fieldValue.startIndex, - fieldValue.endIndex - ) - ?.let(referencedServices::add) - - 7 -> { - val command = parseCommand( - data, - fieldValue.startIndex, - fieldValue.endIndex - ) - if (command != null) { - commands += command - if (command.service >= 0) referencedServices += command.service - } else { - parseReferencedService( - data, - fieldValue.startIndex, - fieldValue.endIndex - ) - ?.let(referencedServices::add) - } - } - } - index = fieldValue.endIndex + entry.wireType == WIRE_VARINT && entry.field == 2 -> { + logType = entry.varintValue?.toInt() ?: logType } - WIRE_FIXED64 -> { - if (end - index < 8) return null - index += 8 - } - - WIRE_FIXED32 -> { - if (end - index < 4) return null - index += 4 + entry.wireType == WIRE_LENGTH_DELIMITED && + entry.field in SENSOR_DATA_COMMAND_FIELDS -> { + val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) + fieldOccurrences[entry.field] = occurrence + 1 + inspectCommandEnvelope( + data = data, + start = entry.valueStart, + end = entry.valueEnd, + path = "f${entry.field}[$occurrence]", + depth = 0, + commands = commands, + referencedServices = referencedServices + ) } - - else -> return null } } @@ -280,101 +319,220 @@ internal class RtBuddyHeartRateDecoder { sequence = sequence, logType = logType, commands = commands, - referencedServices = referencedServices + referencedServices = referencedServices, + topLevelShape = message.shape ) } - private fun parseCommand(data: ByteArray, start: Int, end: Int): RtBuddyCommand? { - var index = start - var service = -1 - var payload: ByteArray? = null - var duplicatePayload = false - - while (index < end) { - val key = readVarint(data, index, end) ?: return null - index = key.nextIndex - val field = (key.value ushr 3).toInt() - val wireType = (key.value and 0x07).toInt() + private fun inspectCommandEnvelope( + data: ByteArray, + start: Int, + end: Int, + path: String, + depth: Int, + commands: MutableList, + referencedServices: MutableSet + ) { + if (depth > MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return + val message = parseProtoMessage(data, start, end) ?: return + val service = message.entries.firstOrNull { + it.field == 1 && it.wireType == WIRE_VARINT + }?.varintValue?.toInt() + + if (service != null && service >= 0) { + referencedServices += service + val directPayloadEntries = message.entries.filter { + it.field == 3 && it.wireType == WIRE_LENGTH_DELIMITED + } + val payloadCandidates = mutableListOf() + directPayloadEntries.forEachIndexed { index, entry -> + collectPayloadCandidates( + data = data, + start = entry.valueStart, + end = entry.valueEnd, + path = "$path.f3[$index]", + wrapperDepth = 0, + candidates = payloadCandidates + ) + } + commands += RtBuddyCommand( + service = service, + directPayloadCount = directPayloadEntries.size, + payloadCandidates = payloadCandidates, + path = path, + shape = message.shape + ) + } - when (wireType) { - WIRE_VARINT -> { - val value = readVarint(data, index, end) ?: return null - index = value.nextIndex - if (field == 1) service = value.value.toInt() - } + if (depth == MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return + val fieldOccurrences = mutableMapOf() + message.entries.forEach { entry -> + if (entry.wireType != WIRE_LENGTH_DELIMITED || commands.size >= MAX_COMMANDS_PER_FRAME) { + return@forEach + } + val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) + fieldOccurrences[entry.field] = occurrence + 1 + inspectCommandEnvelope( + data = data, + start = entry.valueStart, + end = entry.valueEnd, + path = "$path.f${entry.field}[$occurrence]", + depth = depth + 1, + commands = commands, + referencedServices = referencedServices + ) + } + } - WIRE_LENGTH_DELIMITED -> { - val fieldValue = readLengthDelimited(data, index, end) ?: return null - if (field == 3) { - if (payload != null) { - duplicatePayload = true - } else { - payload = data.copyOfRange( - fieldValue.startIndex, - fieldValue.endIndex - ) - } - } - index = fieldValue.endIndex - } + private fun collectPayloadCandidates( + data: ByteArray, + start: Int, + end: Int, + path: String, + wrapperDepth: Int, + candidates: MutableList + ) { + if (candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND) return + val direct = data.copyOfRange(start, end) + if (candidates.none { it.bytes.contentEquals(direct) }) { + candidates += PayloadCandidate(bytes = direct, path = path) + } - WIRE_FIXED64 -> { - if (end - index < 8) return null - index += 8 - } + if (wrapperDepth >= MAX_PAYLOAD_WRAPPER_DEPTH || + candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND + ) { + return + } + val wrapper = parseProtoMessage(data, start, end) ?: return + val lengthEntries = wrapper.entries.filter { it.wireType == WIRE_LENGTH_DELIMITED } + if (lengthEntries.isEmpty()) return + + val fieldOccurrences = mutableMapOf() + lengthEntries.forEach { entry -> + if (candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND) return@forEach + val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) + fieldOccurrences[entry.field] = occurrence + 1 + collectPayloadCandidates( + data = data, + start = entry.valueStart, + end = entry.valueEnd, + path = "$path.f${entry.field}[$occurrence]", + wrapperDepth = wrapperDepth + 1, + candidates = candidates + ) + } + } - WIRE_FIXED32 -> { - if (end - index < 4) return null - index += 4 + private fun buildStructuralDiagnostic( + sensorData: SensorDataWx, + analyses: List + ): String { + val heartRateCommands = sensorData.commands.filter { it.service == HEART_RATE_SERVICE } + val commandText = if (heartRateCommands.isEmpty()) { + "none" + } else { + heartRateCommands.take(MAX_DIAGNOSTIC_COMMANDS).joinToString("|") { command -> + val commandAnalyses = analyses.filter { it.command === command } + val candidates = if (commandAnalyses.isEmpty()) { + "none" + } else { + commandAnalyses.take(MAX_DIAGNOSTIC_PAYLOADS_PER_COMMAND) + .joinToString(",") { analysis -> + val relativePath = analysis.candidate.path.removePrefix(command.path) + val failures = analysis.failures + .joinToString("+") { it.diagnosticCode } + .ifEmpty { "ok" } + "$relativePath:${analysis.candidate.bytes.size}:$failures" + } } - - else -> return null + "${command.path}{${command.shape};p3x${command.directPayloadCount};c=$candidates}" } } - - return RtBuddyCommand( - service = service, - payload = if (duplicatePayload) null else payload - ) + val diagnostic = + "log=${sensorData.logType};top=${sensorData.topLevelShape};hr=$commandText" + return diagnostic.take(MAX_DIAGNOSTIC_SIGNATURE_LENGTH) } - - private fun parseReferencedService(data: ByteArray, start: Int, end: Int): Int? { + private fun parseProtoMessage(data: ByteArray, start: Int, end: Int): ProtoMessage? { + if (start < 0 || end < start || end > data.size || end - start > MAX_PROTO_MESSAGE_LENGTH) { + return null + } var index = start + val entries = mutableListOf() + while (index < end) { + if (entries.size >= MAX_PROTO_FIELDS) return null val key = readVarint(data, index, end) ?: return null index = key.nextIndex - val field = (key.value ushr 3).toInt() + val fieldLong = key.value ushr 3 + if (fieldLong <= 0 || fieldLong > MAX_PROTO_FIELD_NUMBER) return null + val field = fieldLong.toInt() val wireType = (key.value and 0x07).toInt() when (wireType) { WIRE_VARINT -> { val value = readVarint(data, index, end) ?: return null + entries += ProtoEntry( + field = field, + wireType = wireType, + varintValue = value.value, + valueStart = index, + valueEnd = value.nextIndex + ) index = value.nextIndex - if (field == 1) return value.value.toInt() } WIRE_LENGTH_DELIMITED -> { - val fieldValue = readLengthDelimited(data, index, end) ?: return null - index = fieldValue.endIndex + val value = readLengthDelimited(data, index, end) ?: return null + entries += ProtoEntry( + field = field, + wireType = wireType, + valueStart = value.startIndex, + valueEnd = value.endIndex + ) + index = value.endIndex } WIRE_FIXED64 -> { if (end - index < 8) return null + entries += ProtoEntry( + field = field, + wireType = wireType, + valueStart = index, + valueEnd = index + 8 + ) index += 8 } WIRE_FIXED32 -> { if (end - index < 4) return null + entries += ProtoEntry( + field = field, + wireType = wireType, + valueStart = index, + valueEnd = index + 4 + ) index += 4 } else -> return null } } - return null + + return ProtoMessage(entries = entries, shape = buildProtoShape(entries)) } + private fun buildProtoShape(entries: List): String = + entries.take(MAX_DIAGNOSTIC_SHAPE_FIELDS).joinToString(",") { entry -> + if (entry.wireType == WIRE_LENGTH_DELIMITED) { + "f${entry.field}/${entry.wireType}:${entry.valueEnd - entry.valueStart}" + } else { + "f${entry.field}/${entry.wireType}" + } + }.let { shape -> + if (entries.size > MAX_DIAGNOSTIC_SHAPE_FIELDS) "$shape,..." else shape + }.ifEmpty { "empty" } + private fun readVarint(data: ByteArray, start: Int, end: Int): VarintRead? { var value = 0L var shift = 0 @@ -406,21 +564,57 @@ internal class RtBuddyHeartRateDecoder { ) } + private enum class PayloadValidationFailure(val diagnosticCode: String) { + LENGTH("len"), + STATUS_TAIL("tail"), + BPM_RANGE("bpm_range") + } + private data class SensorDataWx( val sequence: Int, val logType: Int, val commands: List, - val referencedServices: Set + val referencedServices: Set, + val topLevelShape: String ) private data class RtBuddyCommand( val service: Int, - val payload: ByteArray? + val directPayloadCount: Int, + val payloadCandidates: List, + val path: String, + val shape: String + ) + + private data class PayloadCandidate( + val bytes: ByteArray, + val path: String + ) + + private data class PayloadAnalysis( + val command: RtBuddyCommand, + val candidate: PayloadCandidate, + val failures: Set + ) + + private data class ProtoMessage( + val entries: List, + val shape: String + ) + + private data class ProtoEntry( + val field: Int, + val wireType: Int, + val varintValue: Long? = null, + val valueStart: Int, + val valueEnd: Int ) private data class FrameClassification( val isHeartRateRelated: Boolean = false, - val sample: HeartRateSample? = null + val sample: HeartRateSample? = null, + val rejectionReason: HeartRateRejectionReason? = null, + val structuralDiagnostic: String? = null ) private data class VarintRead( @@ -438,15 +632,41 @@ internal class RtBuddyHeartRateDecoder { const val MAX_RTBUDDY_PAYLOAD_LENGTH = 16 * 1024 const val MIN_SENSITIVE_PREFIX_LENGTH = 5 - // AirPods firmware has been observed using both 1 and 3 for live SensorDataWX records. - val SENSOR_DATA_LOG_STATES = setOf(1, 3) + // AirPods firmware has emitted live HEARTRATE records using both log types and different + // exact status trailers depending on whether one or both earbuds participate in the session. + // Keep this an exact whitelist: the trailer is the discriminator that prevents startup/control + // service-19 records becoming BPM. + val LIVE_SENSOR_DATA_LOG_TYPES = setOf(1, 3) + val KNOWN_HEART_RATE_STATUS_TAILS = arrayOf( + byteArrayOf(0x10, 0x00, 0x00), + byteArrayOf(0x20, 0x00, 0x00), + byteArrayOf(0x20, 0x02, 0x80.toByte()), + byteArrayOf(0x20, 0x82.toByte(), 0x80.toByte()) + ) const val HEART_RATE_SERVICE = 19 const val HEART_RATE_PAYLOAD_LENGTH = 18 + const val HEART_RATE_BPM_OFFSET = 1 + const val HEART_RATE_STATUS_TAIL_LENGTH = 3 + const val HEART_RATE_STATUS_TAIL_OFFSET = + HEART_RATE_PAYLOAD_LENGTH - HEART_RATE_STATUS_TAIL_LENGTH const val MIN_BPM = 30 const val MAX_BPM = 220 - val HEART_RATE_SERVICE_REFERENCE_FIELDS = setOf(5, 7, 8, 9, 12) + val SENSOR_DATA_COMMAND_FIELDS = setOf(5, 7, 8, 9, 12) + const val MAX_COMMAND_ENVELOPE_DEPTH = 3 + const val MAX_PAYLOAD_WRAPPER_DEPTH = 3 + const val MAX_COMMANDS_PER_FRAME = 16 + const val MAX_PAYLOAD_CANDIDATES_PER_COMMAND = 12 + const val MAX_PROTO_MESSAGE_LENGTH = MAX_RTBUDDY_PAYLOAD_LENGTH + const val MAX_PROTO_FIELDS = 96 + const val MAX_PROTO_FIELD_NUMBER = 4_096L + + const val MAX_DIAGNOSTIC_SHAPE_FIELDS = 12 + const val MAX_DIAGNOSTIC_COMMANDS = 4 + const val MAX_DIAGNOSTIC_PAYLOADS_PER_COMMAND = 5 + const val MAX_DIAGNOSTIC_SIGNATURE_LENGTH = 480 + const val MALFORMED_STRUCTURE_DIAGNOSTIC = "malformed_sensor_data;raw=suppressed" const val WIRE_VARINT = 0 const val WIRE_FIXED64 = 1 @@ -465,7 +685,6 @@ internal class RtBuddyHeartRateDecoder { private fun ByteArray.readLe16(offset: Int): Int = this[offset].toInt().and(0xFF) or (this[offset + 1].toInt().and(0xFF) shl 8) - private fun ByteArray.indexOfPrefix(prefix: ByteArray, startIndex: Int): Int { if (prefix.isEmpty()) return startIndex.coerceAtMost(size) val lastStart = size - prefix.size diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 0bd2ffde2..1a4e44892 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -121,24 +121,15 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { exportEnabled = state.healthConnectExportEnabled, detailedSamples = state.healthConnectDetailedSamples, onExportChanged = { enabled -> - if (!enabled) { - viewModel.setHealthConnectExportEnabled(false) - } else { - when (state.healthConnectExportStatus) { - HealthConnectExportStatus.READY, - HealthConnectExportStatus.ENABLED -> - viewModel.setHealthConnectExportEnabled(true) - - HealthConnectExportStatus.PERMISSION_REQUIRED, - HealthConnectExportStatus.PERMISSION_DENIED, - HealthConnectExportStatus.ERROR -> - healthConnectPermissionLauncher.launch( - HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS - ) - - HealthConnectExportStatus.UNAVAILABLE, - HealthConnectExportStatus.UPDATE_REQUIRED -> Unit - } + when { + !enabled -> viewModel.setHealthConnectExportEnabled(false) + state.healthConnectExportStatus.canEnableExport -> + viewModel.setHealthConnectExportEnabled(true) + + state.healthConnectExportStatus.requiresPermissionRequest -> + healthConnectPermissionLauncher.launch( + HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS + ) } }, onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples @@ -227,8 +218,7 @@ private fun HealthConnectControls( onExportChanged: (Boolean) -> Unit, onDetailedSamplesChanged: (Boolean) -> Unit ) { - val available = status != HealthConnectExportStatus.UNAVAILABLE && - status != HealthConnectExportStatus.UPDATE_REQUIRED + val available = status.isAvailable StyledToggle( title = "Health Connect", @@ -245,7 +235,7 @@ private fun HealthConnectControls( title = null, label = "Detailed samples", description = if (detailedSamples) { - "Export original per-second samples in 15-second batches. AirPods sampling is unchanged." + "Save heart-rate data every second." } else { "Export one average BPM for each minute. AirPods sampling is unchanged." }, @@ -266,6 +256,19 @@ private fun monitoringStatus( else -> "Enabled — awaiting valid sample" } +private val HealthConnectExportStatus.isAvailable: Boolean + get() = this != HealthConnectExportStatus.UNAVAILABLE && + this != HealthConnectExportStatus.UPDATE_REQUIRED + +private val HealthConnectExportStatus.canEnableExport: Boolean + get() = this == HealthConnectExportStatus.READY || + this == HealthConnectExportStatus.ENABLED + +private val HealthConnectExportStatus.requiresPermissionRequest: Boolean + get() = this == HealthConnectExportStatus.PERMISSION_REQUIRED || + this == HealthConnectExportStatus.PERMISSION_DENIED || + this == HealthConnectExportStatus.ERROR + private fun healthConnectDescription( status: HealthConnectExportStatus, detailedSamples: Boolean @@ -286,7 +289,7 @@ private fun healthConnectDescription( "Available. Enable this to save validated samples on this device." HealthConnectExportStatus.ENABLED -> if (detailedSamples) { - "Validated samples are saved in 15-second batches with their original timestamps." + "Validated heart-rate data is saved every second." } else { "Validated samples are averaged into one Health Connect record per minute." } @@ -383,17 +386,17 @@ private fun HeartRateGraph(samples: List) { if (samples.isNotEmpty()) { val path = Path() samples.forEachIndexed { index, sample -> - val x = if (samples.size == 1) { - plotLeft + plotWidth / 2f - } else { - plotLeft + - index.toFloat() / (samples.size - 1).toFloat() * plotWidth - } - val normalized = ( - (sample.bpm.toFloat() - chartScale.minBpm) / - chartScale.spanBpm - ).coerceIn(0f, 1f) - val y = plotBottom - normalized * plotHeight + val x = sampleX( + index = index, + sampleCount = samples.size, + plotLeft = plotLeft, + plotWidth = plotWidth + ) + val y = chartScale.bpmY( + bpm = sample.bpm.toFloat(), + plotBottom = plotBottom, + plotHeight = plotHeight + ) if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) if (index == samples.lastIndex) { @@ -434,6 +437,22 @@ private data class HeartRateChartScale( ) { val spanBpm: Float get() = maxBpm - minBpm + + fun bpmY(bpm: Float, plotBottom: Float, plotHeight: Float): Float { + val normalized = ((bpm - minBpm) / spanBpm).coerceIn(0f, 1f) + return plotBottom - normalized * plotHeight + } +} + +private fun sampleX( + index: Int, + sampleCount: Int, + plotLeft: Float, + plotWidth: Float +): Float = if (sampleCount == 1) { + plotLeft + plotWidth / 2f +} else { + plotLeft + index.toFloat() / (sampleCount - 1).toFloat() * plotWidth } private fun calculateHeartRateChartScale(bpms: List): HeartRateChartScale { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 697bdc144..c4cfe3801 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -256,12 +256,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var heartRateSessionRequested = false private var heartRateStartCommandSent = false private var lastValidHeartRateSampleElapsedRealtime: Long? = null + private var heartRateSamplesToDiscardAfterRefresh = 0 + private var activeHeartRateRefreshReason: HeartRateRefreshReason? = null + private var heartRateRefreshAttemptCount = 0 + private var heartRateRefreshAttemptStartedAt: Long? = null + + private enum class HeartRateRefreshReason(val diagnosticName: String) { + FIRST_SAMPLE_TIMEOUT("first-sample-timeout"), + STREAM_STALLED("stream-stalled") + } private enum class HeartRateStreamFailure { FIRST_SAMPLE_TIMEOUT, STREAM_STALLED } + private data class HeartRateRefreshCompletion( + val reason: HeartRateRefreshReason, + val attempt: Int + ) + private val _heartRateMonitoringEnabled = MutableStateFlow(false) val heartRateMonitoringEnabled: StateFlow get() = _heartRateMonitoringEnabled @@ -289,6 +303,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 12_000L private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 6_000L private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L + private const val HEART_RATE_REFRESH_SAMPLES_TO_DISCARD = 3 private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L @@ -1164,22 +1179,50 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onHeartRateReceived(sample: HeartRateSample) { + var completedRefresh: HeartRateRefreshCompletion? = null + var shouldPublish = false val accepted = synchronized(heartRateLock) { if (!_heartRateMonitoringEnabled.value || BluetoothConnectionManager.aacpSocket?.isConnected != true ) { false } else { - lastValidHeartRateSampleElapsedRealtime = SystemClock.elapsedRealtime() + val receivedAt = SystemClock.elapsedRealtime() + lastValidHeartRateSampleElapsedRealtime = receivedAt if (heartRateStartCommandSent && heartRateStartJob?.isActive == true) { _heartRateStreaming.value = true } + + if (heartRateSamplesToDiscardAfterRefresh > 0) { + heartRateSamplesToDiscardAfterRefresh-- + } else { + val refreshAttemptStartedAt = heartRateRefreshAttemptStartedAt + val refreshReason = activeHeartRateRefreshReason + if (refreshReason != null && refreshAttemptStartedAt != null && + receivedAt >= refreshAttemptStartedAt + ) { + completedRefresh = HeartRateRefreshCompletion( + reason = refreshReason, + attempt = heartRateRefreshAttemptCount + ) + clearActiveHeartRateRefreshLocked() + } + shouldPublish = true + } true } } - if (!accepted) return + if (!accepted || !shouldPublish) return - _heartRateSamples.value = (_heartRateSamples.value + sample).takeLast(MAX_HEART_RATE_SAMPLES) + completedRefresh?.let { refresh -> + Log.i( + TAG, + "RTBuddy heart-rate refresh succeeded " + + "reason=${refresh.reason.diagnosticName} attempt=${refresh.attempt}" + ) + } + _heartRateSamples.value = + (_heartRateSamples.value + sample).takeLast(MAX_HEART_RATE_SAMPLES) heartRateExporter.enqueue( sample = sample, deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName } @@ -1344,15 +1387,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) var justEnabledA2dp = false earDetectionNotification.setStatus(earDetection) - if (config.earDetectionEnabled) { - val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size) - inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte() + val data = if (earDetection.size >= 2) { + earDetection.copyOfRange(earDetection.size - 2, earDetection.size) + } else { + null + } + val newInEarData = data?.let { + listOf(it[0] == 0x00.toByte(), it[1] == 0x00.toByte()) + } - val newInEarData = listOf( - data[0] == 0x00.toByte(), data[1] == 0x00.toByte() - ) + // Do not proactively restart the heart-rate session when the ear status changes. The + // AirPods can keep the active session running on the remaining/primary bud; the watchdog + // below still refreshes it if samples actually stop arriving. + + if (config.earDetectionEnabled) { + val currentData = data ?: return + val currentInEarData = newInEarData ?: return + inEar = currentData[0] == 0x00.toByte() && currentData[1] == 0x00.toByte() - if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf( + if (inEarData.sorted() == listOf(false, false) && currentInEarData.sorted() != listOf( false, false ) && islandWindow?.isVisible != true ) { @@ -1366,25 +1419,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) } - if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { + if (currentInEarData == listOf(false, false) && islandWindow?.isVisible == true) { islandWindow?.close() } - if (newInEarData.contains(true) && inEarData == listOf(false, false)) { + if (currentInEarData.contains(true) && inEarData == listOf(false, false)) { connectAudio(this@AirPodsService, device) justEnabledA2dp = true registerA2dpConnectionReceiver() if (MediaController.getMusicActive()) { MediaController.userPlayedTheMedia = true } - } else if (newInEarData == listOf(false, false)) { + } else if (currentInEarData == listOf(false, false)) { MediaController.sendPause(force = true) if (config.disconnectWhenNotWearing) { disconnectAudio(this@AirPodsService, device) } } val wasNone = inEarData == listOf(false, false) - val nowSingle = newInEarData.count { it } == 1 + val nowSingle = currentInEarData.count { it } == 1 if (wasNone && nowSingle) { MediaController.sendPlay() @@ -1392,22 +1445,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } - if (inEarData.contains(false) && newInEarData == listOf(true, true)) { + if (inEarData.contains(false) && currentInEarData == listOf(true, true)) { Log.d("AirPodsParser", "User put in both AirPods from just one.") MediaController.userPlayedTheMedia = false } - if (newInEarData.contains(false) && inEarData == listOf(true, true)) { + if (currentInEarData.contains(false) && inEarData == listOf(true, true)) { Log.d("AirPodsParser", "User took one of two out.") MediaController.userPlayedTheMedia = false } Log.d( "AirPodsParser", - "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}" + "inEarData: ${inEarData.sorted()}, newInEarData: ${currentInEarData.sorted()}" ) - if (newInEarData.sorted() != inEarData.sorted()) { + if (currentInEarData.sorted() != inEarData.sorted()) { if (inEar) { if (!justEnabledA2dp) { MediaController.sendPlay() @@ -3133,6 +3186,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .apply { setPackage(packageName) } ) Log.i(TAG, "AACP transport became responsive") + startHeartRateMonitoringIfEnabled() } private fun startAacpLivenessWatchdog(socket: BluetoothSocket, connectedDevice: BluetoothDevice) { @@ -3662,49 +3716,80 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList delay(220) } - var consecutiveRecoveryAttempts = 0 while (canContinueHeartRateMonitoring()) { val attemptStartedAt = startHeartRateStreamAttempt() if (!canContinueHeartRateMonitoring()) return val failure = if (attemptStartedAt == null) { - synchronized(heartRateLock) { + val newlyRequestedReason = synchronized(heartRateLock) { + val reason = beginHeartRateRefreshLocked( + HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT + ) stopHeartRateSessionLocked() + reason } + newlyRequestedReason?.let(::logHeartRateRefreshRequested) HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT } else { awaitHeartRateStreamFailure(attemptStartedAt) ?: return } - if (failure == HeartRateStreamFailure.STREAM_STALLED) { - consecutiveRecoveryAttempts = 0 - } - - if (consecutiveRecoveryAttempts >= HEART_RATE_RETRY_BACKOFF_MILLIS.size) { - Log.w(TAG, "RTBuddy heart-rate recovery retries exhausted") - return - } - - val backoffMillis = - HEART_RATE_RETRY_BACKOFF_MILLIS[consecutiveRecoveryAttempts] - consecutiveRecoveryAttempts++ - Log.w( - TAG, - "RTBuddy heart-rate ${failure.name.lowercase()} recovery " + - "attempt=$consecutiveRecoveryAttempts backoff=${backoffMillis}ms" - ) - delay(backoffMillis) + Log.d(TAG, "RTBuddy heart-rate stream event=${failure.name.lowercase()}") + if (!waitForNextHeartRateRefreshAttempt()) return } } finally { synchronized(heartRateLock) { if (heartRateStartJob === currentJob) { stopHeartRateSessionLocked() + clearActiveHeartRateRefreshLocked() heartRateStartJob = null } } } } + private suspend fun waitForNextHeartRateRefreshAttempt(): Boolean { + var refreshReason: HeartRateRefreshReason? = null + var refreshAttempt = 0 + var backoffMillis = 0L + var retriesExhausted = false + + synchronized(heartRateLock) { + val activeReason = activeHeartRateRefreshReason + if (activeReason != null) { + refreshReason = activeReason + if (heartRateRefreshAttemptCount >= HEART_RATE_RETRY_BACKOFF_MILLIS.size) { + retriesExhausted = true + clearActiveHeartRateRefreshLocked() + } else { + backoffMillis = + HEART_RATE_RETRY_BACKOFF_MILLIS[heartRateRefreshAttemptCount] + heartRateRefreshAttemptCount++ + refreshAttempt = heartRateRefreshAttemptCount + heartRateRefreshAttemptStartedAt = null + } + } + } + + val reason = refreshReason ?: return canContinueHeartRateMonitoring() + if (retriesExhausted) { + Log.w( + TAG, + "RTBuddy heart-rate refresh failed reason=${reason.diagnosticName} " + + "attempts=${HEART_RATE_RETRY_BACKOFF_MILLIS.size}" + ) + return false + } + + Log.w( + TAG, + "RTBuddy heart-rate refresh attempt=$refreshAttempt " + + "reason=${reason.diagnosticName} backoff=${backoffMillis}ms" + ) + delay(backoffMillis) + return canContinueHeartRateMonitoring() + } + private suspend fun startHeartRateStreamAttempt(): Long? { if (!initializeHeartRateAacpSession()) return null @@ -3732,6 +3817,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val attemptStartedAt = SystemClock.elapsedRealtime() val started = aacpManager.sendHeartRateStartFrame() heartRateStartCommandSent = started + if (started && activeHeartRateRefreshReason != null) { + heartRateRefreshAttemptStartedAt = attemptStartedAt + } Log.d(TAG, "RTBuddy heart-rate start sent=$started") if (started) attemptStartedAt else null } @@ -3744,6 +3832,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList while (canContinueHeartRateMonitoring()) { delay(HEART_RATE_WATCHDOG_INTERVAL_MILLIS) val now = SystemClock.elapsedRealtime() + var newlyRequestedReason: HeartRateRefreshReason? = null val failure = synchronized(heartRateLock) { if (!canContinueHeartRateMonitoring()) { null @@ -3752,12 +3841,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList when { lastSampleAt != null && lastSampleAt >= attemptStartedAt && now - lastSampleAt >= HEART_RATE_STALL_TIMEOUT_MILLIS -> { + newlyRequestedReason = beginHeartRateRefreshLocked( + HeartRateRefreshReason.STREAM_STALLED + ) stopHeartRateSessionLocked() HeartRateStreamFailure.STREAM_STALLED } (lastSampleAt == null || lastSampleAt < attemptStartedAt) && now - attemptStartedAt >= HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS -> { + newlyRequestedReason = beginHeartRateRefreshLocked( + HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT + ) stopHeartRateSessionLocked() HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT } @@ -3766,11 +3861,38 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } } + newlyRequestedReason?.let(::logHeartRateRefreshRequested) if (failure != null) return failure } return null } + private fun beginHeartRateRefreshLocked( + reason: HeartRateRefreshReason + ): HeartRateRefreshReason? { + if (activeHeartRateRefreshReason != null) return null + activeHeartRateRefreshReason = reason + heartRateRefreshAttemptCount = 0 + heartRateRefreshAttemptStartedAt = null + heartRateSamplesToDiscardAfterRefresh = HEART_RATE_REFRESH_SAMPLES_TO_DISCARD + return reason + } + + private fun clearActiveHeartRateRefreshLocked() { + activeHeartRateRefreshReason = null + heartRateRefreshAttemptCount = 0 + heartRateRefreshAttemptStartedAt = null + heartRateSamplesToDiscardAfterRefresh = 0 + } + + private fun logHeartRateRefreshRequested(reason: HeartRateRefreshReason) { + Log.i( + TAG, + "RTBuddy heart-rate refresh requested reason=${reason.diagnosticName} " + + "transport=healthy" + ) + } + private fun canContinueHeartRateMonitoring(): Boolean = _heartRateMonitoringEnabled.value && isAacpTransportHealthy() @@ -3832,6 +3954,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList forceStop = forceStop || jobWasActive, sendStopFrame = sendStopFrame ) + clearActiveHeartRateRefreshLocked() } } From f87506a362a6c871eee58397b9c19388c97fb073 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Thu, 6 Aug 2026 15:14:25 +0200 Subject: [PATCH 08/15] forgot adding it to initial connect --- .../librepods/services/AirPodsService.kt | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index c4cfe3801..a4790a647 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -1189,13 +1189,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { val receivedAt = SystemClock.elapsedRealtime() lastValidHeartRateSampleElapsedRealtime = receivedAt - if (heartRateStartCommandSent && heartRateStartJob?.isActive == true) { - _heartRateStreaming.value = true - } - - if (heartRateSamplesToDiscardAfterRefresh > 0) { - heartRateSamplesToDiscardAfterRefresh-- - } else { + if (!consumeHeartRateWarmupSampleLocked()) { val refreshAttemptStartedAt = heartRateRefreshAttemptStartedAt val refreshReason = activeHeartRateRefreshReason if (refreshReason != null && refreshAttemptStartedAt != null && @@ -3817,8 +3811,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val attemptStartedAt = SystemClock.elapsedRealtime() val started = aacpManager.sendHeartRateStartFrame() heartRateStartCommandSent = started - if (started && activeHeartRateRefreshReason != null) { - heartRateRefreshAttemptStartedAt = attemptStartedAt + if (started) { + heartRateSamplesToDiscardAfterRefresh = + HEART_RATE_REFRESH_SAMPLES_TO_DISCARD + if (activeHeartRateRefreshReason != null) { + heartRateRefreshAttemptStartedAt = attemptStartedAt + } } Log.d(TAG, "RTBuddy heart-rate start sent=$started") if (started) attemptStartedAt else null @@ -3867,6 +3865,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return null } + /** + * Updates warm-up and streaming state for one validated heart-rate sample. + * Returns true when the sample belongs to the warm-up discard window. + */ + private fun consumeHeartRateWarmupSampleLocked(): Boolean { + val shouldDiscard = heartRateSamplesToDiscardAfterRefresh > 0 + if (shouldDiscard) { + heartRateSamplesToDiscardAfterRefresh-- + } + + _heartRateStreaming.value = + heartRateStartCommandSent && + heartRateStartJob?.isActive == true && + heartRateSamplesToDiscardAfterRefresh == 0 + return shouldDiscard + } + private fun beginHeartRateRefreshLocked( reason: HeartRateRefreshReason ): HeartRateRefreshReason? { From e685c9020f5999c98073b80ce91ac0d01267a49c Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Thu, 6 Aug 2026 23:55:17 +0200 Subject: [PATCH 09/15] Code cleanup, stronger reconnecting, and improved streaming states --- .../librepods/bluetooth/ATTManager.kt | 13 +- .../librepods/bluetooth/RtBuddyHeartRate.kt | 10 +- .../health/HealthConnectHeartRateExporter.kt | 289 +++++++----------- .../presentation/components/HeartRateCard.kt | 66 ++-- .../components/HeartRateStatusChip.kt | 104 +++++++ .../screens/AirPodsSettingsScreen.kt | 8 +- .../screens/HeartRateTestScreen.kt | 91 +++--- .../viewmodel/AirPodsViewModel.kt | 20 +- .../librepods/services/AirPodsService.kt | 205 ++++++++++--- .../services/HeartRateMonitoringStatus.kt | 21 ++ 10 files changed, 532 insertions(+), 295 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt index 753c4d3d7..588f6d66d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt @@ -65,9 +65,17 @@ class ATTManagerv2 { } fun stopReader() { + val thread = readerThread readerRunning.set(false) - readerThread?.interrupt() - readerThread = null + thread?.interrupt() + if (thread != null && thread !== Thread.currentThread()) { + try { + thread.join(500L) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + if (readerThread === thread) readerThread = null } fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) { @@ -140,6 +148,7 @@ class ATTManagerv2 { fun disconnected() { characteristicList.clear() + responseQueues.clear() stopReader() val socket = BluetoothConnectionManager.attSocket?: return try { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt index 54b545d45..7187e52ee 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -10,11 +10,14 @@ package me.kavishdevar.librepods.bluetooth +import android.os.SystemClock + /** A validated heart-rate sample decoded from an RTBuddy SensorDataWX frame. */ data class HeartRateSample( val bpm: Int, val sequence: Int, - val receivedAtMillis: Long + val receivedAtMillis: Long, + val receivedAtElapsedRealtime: Long = SystemClock.elapsedRealtime() ) internal enum class HeartRateRejectionReason { @@ -50,10 +53,12 @@ internal data class HeartRateDecodeResult( internal class RtBuddyHeartRateDecoder { private var carry = ByteArray(0) + @Synchronized fun reset() { carry = ByteArray(0) } + @Synchronized fun feed(chunk: ByteArray): HeartRateDecodeResult { if (chunk.isEmpty()) return HeartRateDecodeResult() @@ -203,7 +208,8 @@ internal class RtBuddyHeartRateDecoder { sample = HeartRateSample( bpm = payload.unsignedByteAt(HEART_RATE_BPM_OFFSET), sequence = sensorData.sequence, - receivedAtMillis = System.currentTimeMillis() + receivedAtMillis = System.currentTimeMillis(), + receivedAtElapsedRealtime = SystemClock.elapsedRealtime() ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt index f5c8b4710..bd88b0ca9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt @@ -19,6 +19,7 @@ import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.metadata.Device import androidx.health.connect.client.records.metadata.Metadata +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -45,10 +46,10 @@ enum class HealthConnectExportStatus { } /** - * Buffers validated AirPods heart-rate samples and writes them to Health Connect. + * Writes validated AirPods heart-rate samples to Health Connect at the selected interval. * - * Each batch is assigned a stable client record ID derived from its ordered sample contents and - * device metadata. Retrying a failed batch therefore remains idempotent even if Health Connect + * Each record is assigned a stable client record ID derived from its source sample contents and + * device metadata. Retrying a failed record therefore remains idempotent even if Health Connect * accepted the record before returning an error. */ class HealthConnectHeartRateExporter( @@ -56,31 +57,25 @@ class HealthConnectHeartRateExporter( private val sharedPreferences: SharedPreferences, private val scope: CoroutineScope ) { - private enum class BatchDetail { - MINUTE_AVERAGE, - DETAILED - } - private data class PendingSample( val id: String, val sample: HeartRateSample, val deviceModel: String ) - private data class PendingBatch( + private data class PendingRecord( val samples: List, val clientRecordId: String, - val detail: BatchDetail, val startTimeMillis: Long, val endTimeMillis: Long, - val partialMinute: Boolean = false + val partialInterval: Boolean ) private val appContext = context.applicationContext private val mutex = Mutex() private val pendingSamples = linkedMapOf() - private var pendingBatch: PendingBatch? = null - private var minuteWindowStartMillis: Long? = null + private var pendingRecord: PendingRecord? = null + private var intervalWindowStartMillis: Long? = null private var requestedDetailedSamples: Boolean? = null private var healthConnectClient: HealthConnectClient? = null private var scheduledFlush: Job? = null @@ -109,6 +104,8 @@ class HealthConnectHeartRateExporter( val client = getClient() val granted = try { hasWritePermission(client) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { Log.w(TAG, "Unable to query Health Connect permissions", error) _enabled.value = false @@ -117,14 +114,15 @@ class HealthConnectHeartRateExporter( } val requested = sharedPreferences.getBoolean(EXPORT_PREFERENCE, false) - _enabled.value = requested && granted + val exportEnabled = requested && granted + _enabled.value = exportEnabled _status.value = when { !granted -> HealthConnectExportStatus.PERMISSION_REQUIRED - _enabled.value -> HealthConnectExportStatus.ENABLED + exportEnabled -> HealthConnectExportStatus.ENABLED else -> HealthConnectExportStatus.READY } - if (_enabled.value && hasPendingSamplesLocked()) { + if (exportEnabled && hasPendingSamplesLocked()) { scheduleFlushLocked(0L) } } @@ -155,7 +153,7 @@ class HealthConnectHeartRateExporter( if (!enabled) { scheduledFlush?.cancel() scheduledFlush = null - flushLocked(forcePartialMinute = true) + flushLocked(forcePartialInterval = true) sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } _enabled.value = false _status.value = disabledStatus() @@ -166,6 +164,8 @@ class HealthConnectHeartRateExporter( HealthConnectClient.SDK_AVAILABLE -> { val granted = try { hasWritePermission(getClient()) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { Log.w(TAG, "Unable to enable Health Connect export", error) _enabled.value = false @@ -210,12 +210,11 @@ class HealthConnectHeartRateExporter( requestedDetailedSamples = detailed scheduledFlush?.cancel() scheduledFlush = null - if (hasPendingSamplesLocked()) { - if (!_enabled.value || !flushLocked(forcePartialMinute = true)) { - return@withLock - } + if (hasPendingSamplesLocked() && + (!_enabled.value || !flushLocked(forcePartialInterval = true)) + ) { + return@withLock } - applyRequestedDetailLocked() } } @@ -235,8 +234,8 @@ class HealthConnectHeartRateExporter( if (!_enabled.value) return scope.launch { - val flushNow = mutex.withLock { - if (!_enabled.value) return@withLock false + mutex.withLock { + if (!_enabled.value) return@withLock val id = clientRecordId(sample) pendingSamples.putIfAbsent( @@ -247,53 +246,39 @@ class HealthConnectHeartRateExporter( deviceModel = deviceModel.ifBlank { "AirPods" } ) ) - if (!_detailedSamples.value && minuteWindowStartMillis == null) { - minuteWindowStartMillis = sample.receivedAtMillis - } trimBufferLocked() - if (pendingBatch != null) { - false - } else if (_detailedSamples.value) { - if (bufferedSampleCountLocked() >= MAX_BATCH_SIZE) { - scheduledFlush?.cancel() - scheduledFlush = null - true - } else { - scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) - false - } - } else if (hasCompletedMinuteWindowLocked()) { + if (pendingRecord != null) { + return@withLock + } + if (hasCompletedIntervalWindowLocked()) { scheduledFlush?.cancel() scheduledFlush = null - true + flushLocked() } else { - scheduleMinuteFlushLocked() - false + scheduleNextFlushLocked() } } - - if (flushNow) flush() } } fun flushAsync() { - scope.launch { flush(forcePartialMinute = true) } + scope.launch { flush(forcePartialInterval = true) } } - suspend fun flush(forcePartialMinute: Boolean = false) { + suspend fun flush(forcePartialInterval: Boolean = false) { mutex.withLock { scheduledFlush?.cancel() scheduledFlush = null - flushLocked(forcePartialMinute) + flushLocked(forcePartialInterval) } } suspend fun closeAndFlush() { - flush(forcePartialMinute = true) + flush(forcePartialInterval = true) } - private suspend fun flushLocked(forcePartialMinute: Boolean = false): Boolean { + private suspend fun flushLocked(forcePartialInterval: Boolean = false): Boolean { if (!hasPendingSamplesLocked()) { applyRequestedDetailLocked() return true @@ -301,18 +286,20 @@ class HealthConnectHeartRateExporter( if (!_enabled.value) return false while (_enabled.value && hasPendingSamplesLocked()) { - val batch = getOrCreatePendingBatchLocked( - forcePartialMinute || requestedDetailedSamples != null + val record = getOrCreatePendingRecordLocked( + forcePartialInterval || requestedDetailedSamples != null ) - if (batch == null) { + if (record == null) { scheduleNextFlushLocked() return false } try { - getClient().insertRecords(listOf(toRecord(batch))) - completePendingBatchLocked(batch) + getClient().insertRecords(listOf(toRecord(record))) + completePendingRecordLocked(record) _status.value = HealthConnectExportStatus.ENABLED + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { Log.w(TAG, "Health Connect permission was revoked", error) sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } @@ -321,7 +308,7 @@ class HealthConnectHeartRateExporter( return false } catch (error: IOException) { handleRetryableWriteFailureLocked( - "Health Connect write failed; keeping batch for retry", + "Health Connect write failed; keeping record for retry", error ) return false @@ -354,10 +341,8 @@ class HealthConnectHeartRateExporter( val detailed = requestedDetailedSamples ?: return if (hasPendingSamplesLocked()) return - minuteWindowStartMillis = null - sharedPreferences.edit { - putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) - } + intervalWindowStartMillis = null + sharedPreferences.edit { putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) } _detailedSamples.value = detailed requestedDetailedSamples = null } @@ -374,67 +359,33 @@ class HealthConnectHeartRateExporter( } private fun scheduleNextFlushLocked() { - if (pendingBatch != null || pendingSamples.isEmpty()) return - if (_detailedSamples.value) { - scheduleFlushLocked(FLUSH_INTERVAL_MILLIS) - } else { - scheduleMinuteFlushLocked() - } - } - - private fun scheduleMinuteFlushLocked() { - val windowStart = ensureMinuteWindowStartLocked() ?: return - val windowEnd = windowStart + MINUTE_WINDOW_MILLIS + if (pendingRecord != null || pendingSamples.isEmpty()) return + val windowStart = ensureIntervalWindowStartLocked() ?: return + val windowEnd = windowStart + exportIntervalMillis() val delayMillis = (windowEnd - System.currentTimeMillis()).coerceAtLeast(0L) scheduleFlushLocked(delayMillis) } - private fun getOrCreatePendingBatchLocked(forcePartialMinute: Boolean): PendingBatch? { - pendingBatch?.let { return it } - - return if (_detailedSamples.value) { - createDetailedBatchLocked() - } else { - createMinuteAverageBatchLocked(forcePartialMinute) - } - } + private fun getOrCreatePendingRecordLocked( + forcePartialInterval: Boolean + ): PendingRecord? { + pendingRecord?.let { return it } - private fun createDetailedBatchLocked(): PendingBatch? { - val selectedSamples = pendingSamples.values.take(MAX_BATCH_SIZE) - if (selectedSamples.isEmpty()) return null - - selectedSamples.forEach { pendingSamples.remove(it.id) } - val orderedSamples = selectedSamples.sortedWith(PENDING_SAMPLE_COMPARATOR) - val firstSample = orderedSamples.first() - val lastSample = orderedSamples.last() - - return PendingBatch( - samples = orderedSamples, - clientRecordId = batchClientRecordId(orderedSamples), - detail = BatchDetail.DETAILED, - startTimeMillis = firstSample.sample.receivedAtMillis, - endTimeMillis = lastSample.sample.receivedAtMillis + 1L - ).also { pendingBatch = it } - } - - private fun createMinuteAverageBatchLocked(forcePartialMinute: Boolean): PendingBatch? { val orderedSamples = pendingSamples.values.sortedWith(PENDING_SAMPLE_COMPARATOR) if (orderedSamples.isEmpty()) return null - var windowStart = ensureMinuteWindowStartLocked() ?: return null + var windowStart = ensureIntervalWindowStartLocked() ?: return null val earliestTimestamp = orderedSamples.first().sample.receivedAtMillis - var windowEnd = windowStart + MINUTE_WINDOW_MILLIS + val intervalMillis = exportIntervalMillis() + var windowEnd = windowStart + intervalMillis while (earliestTimestamp >= windowEnd) { windowStart = windowEnd - windowEnd = windowStart + MINUTE_WINDOW_MILLIS - minuteWindowStartMillis = windowStart + windowEnd = windowStart + intervalMillis + intervalWindowStartMillis = windowStart } - val hasSampleAfterWindow = orderedSamples.any { - it.sample.receivedAtMillis >= windowEnd - } - val completedWindow = hasSampleAfterWindow || System.currentTimeMillis() >= windowEnd - if (!forcePartialMinute && !completedWindow) return null + val completedInterval = isIntervalCompleteLocked(windowEnd) + if (!forcePartialInterval && !completedInterval) return null val selectedSamples = orderedSamples.takeWhile { it.sample.receivedAtMillis < windowEnd @@ -444,62 +395,46 @@ class HealthConnectHeartRateExporter( selectedSamples.forEach { pendingSamples.remove(it.id) } val firstSampleTime = selectedSamples.first().sample.receivedAtMillis val lastSampleTime = selectedSamples.last().sample.receivedAtMillis - val partialMinute = !completedWindow + val partialInterval = !completedInterval val recordStartTime = maxOf(windowStart, firstSampleTime) - val recordEndTime = if (partialMinute) { + val recordEndTime = if (partialInterval) { maxOf(recordStartTime + 1L, lastSampleTime + 1L) } else { maxOf(recordStartTime + 1L, windowEnd) } - return PendingBatch( + return PendingRecord( samples = selectedSamples, - clientRecordId = minuteAverageClientRecordId( + clientRecordId = recordClientRecordId( samples = selectedSamples, startTimeMillis = recordStartTime, endTimeMillis = recordEndTime ), - detail = BatchDetail.MINUTE_AVERAGE, startTimeMillis = recordStartTime, endTimeMillis = recordEndTime, - partialMinute = partialMinute - ).also { pendingBatch = it } + partialInterval = partialInterval + ).also { pendingRecord = it } } - private fun completePendingBatchLocked(batch: PendingBatch) { - pendingBatch = null - if (batch.detail == BatchDetail.MINUTE_AVERAGE) { - minuteWindowStartMillis = if (batch.partialMinute) { - null - } else { - batch.endTimeMillis - } - } + private fun completePendingRecordLocked(record: PendingRecord) { + pendingRecord = null + intervalWindowStartMillis = if (record.partialInterval) null else record.endTimeMillis } - private fun toRecord(batch: PendingBatch): HeartRateRecord { - val firstSample = batch.samples.first() - val startTimestamp = Instant.ofEpochMilli(batch.startTimeMillis) - val endTimestamp = Instant.ofEpochMilli(batch.endTimeMillis) + private fun toRecord(record: PendingRecord): HeartRateRecord { + val firstSample = record.samples.first() + val startTimestamp = Instant.ofEpochMilli(record.startTimeMillis) + val endTimestamp = Instant.ofEpochMilli(record.endTimeMillis) val zoneRules = ZoneId.systemDefault().rules - val samples = when (batch.detail) { - BatchDetail.DETAILED -> batch.samples.map { pending -> - HeartRateRecord.Sample( - time = Instant.ofEpochMilli(pending.sample.receivedAtMillis), - beatsPerMinute = pending.sample.bpm.toLong() - ) - } - - BatchDetail.MINUTE_AVERAGE -> listOf( - HeartRateRecord.Sample( - time = Instant.ofEpochMilli( - batch.startTimeMillis + - (batch.endTimeMillis - batch.startTimeMillis) / 2L - ), - beatsPerMinute = averageBpm(batch.samples) - ) + val samples = listOf( + HeartRateRecord.Sample( + time = Instant.ofEpochMilli( + record.startTimeMillis + + (record.endTimeMillis - record.startTimeMillis) / 2L + ), + beatsPerMinute = averageBpm(record.samples) ) - } + ) return HeartRateRecord( startTime = startTimestamp, @@ -513,33 +448,41 @@ class HealthConnectHeartRateExporter( manufacturer = "Apple", model = firstSample.deviceModel ), - clientRecordId = batch.clientRecordId, + clientRecordId = record.clientRecordId, clientRecordVersion = 0L ) ) } private fun hasPendingSamplesLocked(): Boolean = - pendingBatch != null || pendingSamples.isNotEmpty() + pendingRecord != null || pendingSamples.isNotEmpty() private fun bufferedSampleCountLocked(): Int = - pendingSamples.size + (pendingBatch?.samples?.size ?: 0) + pendingSamples.size + (pendingRecord?.samples?.size ?: 0) - private fun hasCompletedMinuteWindowLocked(): Boolean { - val windowStart = ensureMinuteWindowStartLocked() ?: return false - val windowEnd = windowStart + MINUTE_WINDOW_MILLIS - return System.currentTimeMillis() >= windowEnd || pendingSamples.values.any { + private fun hasCompletedIntervalWindowLocked(): Boolean { + val windowStart = ensureIntervalWindowStartLocked() ?: return false + return isIntervalCompleteLocked(windowStart + exportIntervalMillis()) + } + + private fun isIntervalCompleteLocked(windowEnd: Long): Boolean = + System.currentTimeMillis() >= windowEnd || pendingSamples.values.any { it.sample.receivedAtMillis >= windowEnd } - } - private fun ensureMinuteWindowStartLocked(): Long? { - minuteWindowStartMillis?.let { return it } + private fun ensureIntervalWindowStartLocked(): Long? { + intervalWindowStartMillis?.let { return it } return pendingSamples.values.minOfOrNull { it.sample.receivedAtMillis }?.also { - minuteWindowStartMillis = it + intervalWindowStartMillis = it } } + private fun exportIntervalMillis(): Long = if (_detailedSamples.value) { + SECOND_INTERVAL_MILLIS + } else { + MINUTE_INTERVAL_MILLIS + } + private fun trimBufferLocked() { while (bufferedSampleCountLocked() > MAX_BUFFERED_SAMPLES) { val oldestId = pendingSamples.keys.firstOrNull() ?: break @@ -548,40 +491,27 @@ class HealthConnectHeartRateExporter( } private fun averageBpm(samples: List): Long { - val total = samples.fold(0L) { sum, pending -> - sum + pending.sample.bpm.toLong() - } + val total = samples.sumOf { it.sample.bpm.toLong() } return (total + samples.size / 2L) / samples.size } - private fun batchClientRecordId(samples: List): String { - val stableBatchDescription = buildString { - append(samples.first().deviceModel) - samples.forEach { pending -> - append('\u0000') - append(pending.id) - } - } - return "$BATCH_CLIENT_RECORD_ID_PREFIX${sha256(stableBatchDescription)}" - } - - private fun minuteAverageClientRecordId( + private fun recordClientRecordId( samples: List, startTimeMillis: Long, endTimeMillis: Long ): String { - val stableBatchDescription = buildString { + val stableRecordDescription = buildString { append(startTimeMillis) append('\u0000') append(endTimeMillis) + append('\u0000') + append(samples.first().deviceModel) samples.forEach { pending -> - append('\u0000') - append(pending.deviceModel) append('\u0000') append(pending.id) } } - return "$MINUTE_AVERAGE_CLIENT_RECORD_ID_PREFIX${sha256(stableBatchDescription)}" + return "$RECORD_CLIENT_RECORD_ID_PREFIX${sha256(stableRecordDescription)}" } private fun sha256(value: String): String = @@ -609,6 +539,8 @@ class HealthConnectHeartRateExporter( HealthConnectClient.SDK_AVAILABLE -> { val permissionGranted = try { hasWritePermission(getClient()) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { Log.w(TAG, "Unable to query Health Connect permissions", error) return HealthConnectExportStatus.ERROR @@ -641,13 +573,10 @@ class HealthConnectHeartRateExporter( private const val EXPORT_PREFERENCE = "heart_rate_health_connect_export_enabled" private const val DETAILED_SAMPLES_PREFERENCE = "heart_rate_health_connect_detailed_samples" - private const val BATCH_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-batch-v1-" - private const val MINUTE_AVERAGE_CLIENT_RECORD_ID_PREFIX = - "librepods-heart-rate-minute-average-v1-" - private const val MAX_BATCH_SIZE = 15 + private const val RECORD_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-record-v1-" private const val MAX_BUFFERED_SAMPLES = 300 - private const val FLUSH_INTERVAL_MILLIS = 15_000L - private const val MINUTE_WINDOW_MILLIS = 60_000L + private const val SECOND_INTERVAL_MILLIS = 1_000L + private const val MINUTE_INTERVAL_MILLIS = 60_000L private const val RETRY_INTERVAL_MILLIS = 30_000L val WRITE_HEART_RATE_PERMISSION: String = diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt index 86b66816c..299811006 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt @@ -40,27 +40,32 @@ import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.services.HeartRateMonitoringStatus @Composable fun HeartRateCard( monitoringEnabled: Boolean, - streaming: Boolean, - connected: Boolean, + monitoringStatus: HeartRateMonitoringStatus, latestSample: HeartRateSample?, heartRateSamples: List, onMonitoringChanged: (Boolean) -> Unit, + onReconnectAacp: () -> Unit, onOpenDetails: () -> Unit, modifier: Modifier = Modifier ) { - val status = heartRateStatus(monitoringEnabled, connected, streaming) + val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable( + sample = latestSample, + monitoringStatus = monitoringStatus + ) val displayedBpm = latestSample - ?.takeIf { streaming } + ?.takeIf { sampleIsDisplayable } ?.bpm ?.toString() ?: EM_DASH val graphValues = remember(heartRateSamples) { normalizedRecentHeartRates(heartRateSamples) } + val canReconnectAacp = monitoringStatus == HeartRateMonitoringStatus.COULDNT_START Card( modifier = modifier @@ -88,27 +93,33 @@ fun HeartRateCard( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold ) - Text( - text = status, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HeartRateStatusChip( + status = monitoringStatus, + onRetry = onReconnectAacp.takeIf { canReconnectAacp } + ) + } } - Column(horizontalAlignment = Alignment.End) { - Text( - text = displayedBpm, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "BPM", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + if (!canReconnectAacp) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = displayedBpm, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(14.dp)) + } when (LocalDesignSystem.current) { DesignSystem.Material -> Switch( @@ -248,17 +259,6 @@ private fun normalizedRecentHeartRates(samples: List): List "Off" - !connected -> "Waiting for connection" - streaming -> "Streaming" - else -> "Awaiting sample" -} - private val GRAPH_WIDTH = 60.dp private val GRAPH_HEIGHT = 44.dp private const val MAX_GRAPH_SAMPLES = 24 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt new file mode 100644 index 000000000..119f5ff72 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt @@ -0,0 +1,104 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.presentation.components + +import android.os.SystemClock +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.services.HeartRateMonitoringStatus + +@Composable +fun HeartRateStatusChip( + status: HeartRateMonitoringStatus, + onRetry: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val containerColor = when (status) { + HeartRateMonitoringStatus.LIVE -> MaterialTheme.colorScheme.primaryContainer + HeartRateMonitoringStatus.COULDNT_START -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = when (status) { + HeartRateMonitoringStatus.LIVE -> MaterialTheme.colorScheme.onPrimaryContainer + HeartRateMonitoringStatus.COULDNT_START -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + modifier = if (onRetry == null) modifier else modifier.clickable(onClick = onRetry), + shape = RoundedCornerShape(999.dp), + color = containerColor, + contentColor = contentColor + ) { + Text( + text = if (onRetry == null) status.label else "${status.label} · Retry", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } +} + +@Composable +fun rememberHeartRateSampleIsDisplayable( + sample: HeartRateSample?, + monitoringStatus: HeartRateMonitoringStatus +): Boolean { + val statusAllowsDisplay = monitoringStatus.allowsSampleDisplay + var sampleIsFresh by remember(sample?.receivedAtElapsedRealtime, statusAllowsDisplay) { + mutableStateOf( + sample != null && + statusAllowsDisplay && + SystemClock.elapsedRealtime() - sample.receivedAtElapsedRealtime <= + HEART_RATE_SAMPLE_STALE_AFTER_MILLIS + ) + } + + LaunchedEffect(sample?.receivedAtElapsedRealtime, statusAllowsDisplay) { + if (!sampleIsFresh || sample == null || !statusAllowsDisplay) return@LaunchedEffect + + val expiresAtElapsedRealtime = + sample.receivedAtElapsedRealtime + HEART_RATE_SAMPLE_STALE_AFTER_MILLIS + delay((expiresAtElapsedRealtime - SystemClock.elapsedRealtime()).coerceAtLeast(0L)) + sampleIsFresh = false + } + + return sampleIsFresh +} + +private const val HEART_RATE_SAMPLE_STALE_AFTER_MILLIS = 10_000L + +private val HeartRateMonitoringStatus.allowsSampleDisplay: Boolean + get() = this == HeartRateMonitoringStatus.LIVE + +private val HeartRateMonitoringStatus.label: String + get() = when (this) { + HeartRateMonitoringStatus.OFF -> "Off" + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS -> "Waiting for AirPods" + HeartRateMonitoringStatus.STARTING -> "Starting" + HeartRateMonitoringStatus.CALIBRATING -> "Calibrating" + HeartRateMonitoringStatus.LIVE -> "Live" + HeartRateMonitoringStatus.RECONNECTING -> "Reconnecting" + HeartRateMonitoringStatus.COULDNT_START -> "Couldn’t start" + } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 60b13f331..0a4c69487 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -195,6 +195,7 @@ fun AirPodsSettingsRoute( navigateToHeartRateTest = navigateToHeartRateTest, setHeartRateMonitoringEnabled = viewModel::setHeartRateMonitoringEnabled, + reconnectAacpForHeartRate = viewModel::reconnectAacpForHeartRate, activateDemoMode = viewModel::activateDemoMode, reconnectFromSavedMac = viewModel::reconnectFromSavedMac @@ -240,6 +241,7 @@ fun AirPodsSettingsScreen( navigateToHeartRateTest: () -> Unit, setHeartRateMonitoringEnabled: (Boolean) -> Unit, + reconnectAacpForHeartRate: () -> Unit, activateDemoMode: () -> Unit, reconnectFromSavedMac: () -> Unit, @@ -343,11 +345,11 @@ fun AirPodsSettingsScreen( item(key = "heart_rate") { HeartRateCard( monitoringEnabled = state.heartRateMonitoringEnabled, - streaming = state.heartRateStreaming, - connected = state.isLocallyConnected, + monitoringStatus = state.heartRateMonitoringStatus, latestSample = state.heartRateSamples.lastOrNull(), heartRateSamples = state.heartRateSamples, onMonitoringChanged = setHeartRateMonitoringEnabled, + onReconnectAacp = reconnectAacpForHeartRate, onOpenDetails = navigateToHeartRateTest ) } @@ -995,6 +997,7 @@ fun AirPodsSettingsScreenPreviewApple() { navigateToHeartRateTest = {}, setHeartRateMonitoringEnabled = {}, + reconnectAacpForHeartRate = {}, activateDemoMode = {}, reconnectFromSavedMac = {} @@ -1045,6 +1048,7 @@ fun AirPodsSettingsScreenPreviewMaterial() { navigateToHeartRateTest = {}, setHeartRateMonitoringEnabled = {}, + reconnectAacpForHeartRate = {}, activateDemoMode = {}, reconnectFromSavedMac = {} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 1a4e44892..3357b820d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -56,10 +56,14 @@ import androidx.health.connect.client.PermissionController import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter +import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip +import me.kavishdevar.librepods.presentation.components.StyledSwitch import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.components.rememberHeartRateSampleIsDisplayable import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.services.HeartRateMonitoringStatus import java.text.DateFormat import java.util.Date import kotlin.math.ceil @@ -93,12 +97,10 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp val latestSample = state.heartRateSamples.lastOrNull() - val monitoringStatus = monitoringStatus( - enabled = state.heartRateMonitoringEnabled, - connected = state.isLocallyConnected, - streaming = state.heartRateStreaming + val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable( + sample = latestSample, + monitoringStatus = state.heartRateMonitoringStatus ) - Column( modifier = Modifier .fillMaxSize() @@ -109,9 +111,12 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(topPadding)) HeartRateSummaryCard( + monitoringEnabled = state.heartRateMonitoringEnabled, latestSample = latestSample, - connected = state.isLocallyConnected, - monitoringStatus = monitoringStatus + sampleIsDisplayable = sampleIsDisplayable, + monitoringStatus = state.heartRateMonitoringStatus, + onReconnectAacp = viewModel::reconnectAacpForHeartRate, + onMonitoringChanged = viewModel::setHeartRateMonitoringEnabled ) Spacer(modifier = Modifier.height(16.dp)) @@ -144,6 +149,13 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) ) + Text( + text = formatGraphSummary(state.heartRateSamples), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + HeartRateGraph(samples = state.heartRateSamples) Spacer(modifier = Modifier.height(bottomPadding)) @@ -152,9 +164,12 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { @Composable private fun HeartRateSummaryCard( + monitoringEnabled: Boolean, latestSample: HeartRateSample?, - connected: Boolean, - monitoringStatus: String + sampleIsDisplayable: Boolean, + monitoringStatus: HeartRateMonitoringStatus, + onReconnectAacp: () -> Unit, + onMonitoringChanged: (Boolean) -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), @@ -172,7 +187,7 @@ private fun HeartRateSummaryCard( ) { Column { Text( - text = latestSample?.bpm?.toString() ?: EM_DASH, + text = latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold ) @@ -183,26 +198,23 @@ private fun HeartRateSummaryCard( ) } Column(horizontalAlignment = Alignment.End) { - Text( - text = if (connected) "Connected" else "Disconnected", - style = MaterialTheme.typography.labelLarge, - color = if (connected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant + Box(modifier = Modifier.padding(end = 4.dp, bottom = 8.dp)) { + StyledSwitch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + } + HeartRateStatusChip( + status = monitoringStatus, + onRetry = onReconnectAacp.takeIf { + monitoringStatus == HeartRateMonitoringStatus.COULDNT_START } ) - Text( - text = monitoringStatus, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End - ) } } Text( - text = "Last update: ${formatLastUpdate(latestSample)}", + text = formatLastReading(latestSample), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -235,9 +247,9 @@ private fun HealthConnectControls( title = null, label = "Detailed samples", description = if (detailedSamples) { - "Save heart-rate data every second." + "Save one BPM record every second." } else { - "Export one average BPM for each minute. AirPods sampling is unchanged." + "Save one average BPM record every minute." }, checked = detailedSamples, enabled = available, @@ -245,17 +257,6 @@ private fun HealthConnectControls( ) } -private fun monitoringStatus( - enabled: Boolean, - connected: Boolean, - streaming: Boolean -): String = when { - !enabled -> "Disabled" - !connected -> "Enabled — waiting for connection" - streaming -> "Streaming" - else -> "Enabled — awaiting valid sample" -} - private val HealthConnectExportStatus.isAvailable: Boolean get() = this != HealthConnectExportStatus.UNAVAILABLE && this != HealthConnectExportStatus.UPDATE_REQUIRED @@ -430,6 +431,15 @@ private fun HeartRateGraph(samples: List) { } } +private fun formatGraphSummary(samples: List): String { + if (samples.isEmpty()) return "Min $EM_DASH · Avg $EM_DASH · Max $EM_DASH BPM" + + val min = samples.minOf { it.bpm } + val max = samples.maxOf { it.bpm } + val average = samples.sumOf { it.bpm }.toFloat() / samples.size + return "Min $min · Avg ${average.toInt()} · Max $max BPM" +} + private data class HeartRateChartScale( val minBpm: Float, val maxBpm: Float, @@ -598,10 +608,11 @@ private fun floorToIncrement(value: Float, increment: Float): Float = private fun ceilToIncrement(value: Float, increment: Float): Float = ceil(value / increment) * increment -private fun formatLastUpdate(sample: HeartRateSample?): String { - if (sample == null) return "No samples yet" - return DateFormat.getTimeInstance(DateFormat.MEDIUM) +private fun formatLastReading(sample: HeartRateSample?): String { + if (sample == null) return "Last reading: $EM_DASH" + val time = DateFormat.getTimeInstance(DateFormat.SHORT) .format(Date(sample.receivedAtMillis)) + return "Last reading: ${sample.bpm} BPM at $time" } private const val EM_DASH = "—" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 0314f86bd..6ebb905f5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -56,6 +56,7 @@ import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.HeartRateMonitoringStatus @Suppress("ArrayInDataClass") data class AirPodsUiState( @@ -84,6 +85,8 @@ data class AirPodsUiState( val heartRateMonitoringEnabled: Boolean = false, val heartRateStreaming: Boolean = false, + val heartRateMonitoringStatus: HeartRateMonitoringStatus = + HeartRateMonitoringStatus.OFF, val heartRateSamples: List = emptyList(), val healthConnectExportEnabled: Boolean = false, val healthConnectExportStatus: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, @@ -480,6 +483,11 @@ class AirPodsViewModel( _uiState.update { it.copy(heartRateStreaming = streaming) } } } + viewModelScope.launch { + service.heartRateMonitoringStatus.collect { status -> + _uiState.update { it.copy(heartRateMonitoringStatus = status) } + } + } viewModelScope.launch { service.heartRateSamples.collect { samples -> _uiState.update { it.copy(heartRateSamples = samples) } @@ -510,6 +518,7 @@ class AirPodsViewModel( isLocallyConnected = service.isAacpTransportHealthy(), heartRateMonitoringEnabled = service.heartRateMonitoringEnabled.value, heartRateStreaming = service.heartRateStreaming.value, + heartRateMonitoringStatus = service.heartRateMonitoringStatus.value, heartRateSamples = service.heartRateSamples.value, healthConnectExportEnabled = service.healthConnectExportEnabled.value, healthConnectExportStatus = service.healthConnectExportStatus.value, @@ -698,7 +707,11 @@ class AirPodsViewModel( it.copy( heartRateMonitoringEnabled = enabled, heartRateStreaming = enabled && it.isLocallyConnected, - heartRateSamples = if (enabled) emptyList() else it.heartRateSamples + heartRateMonitoringStatus = when { + !enabled -> HeartRateMonitoringStatus.OFF + it.isLocallyConnected -> HeartRateMonitoringStatus.LIVE + else -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } ) } return @@ -706,6 +719,11 @@ class AirPodsViewModel( service.setHeartRateMonitoringEnabled(enabled) } + fun reconnectAacpForHeartRate() { + if (!isReady || isDemoMode) return + service.reconnectAacpForHeartRate() + } + fun refreshHealthConnectExportState() { if (!isReady || isDemoMode) return service.refreshHealthConnectExportState() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index a4790a647..9ff90a63f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -71,6 +71,7 @@ import androidx.annotation.RequiresPermission import androidx.compose.material3.ExperimentalMaterial3Api import androidx.core.app.NotificationCompat import androidx.core.content.edit +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -260,17 +261,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var activeHeartRateRefreshReason: HeartRateRefreshReason? = null private var heartRateRefreshAttemptCount = 0 private var heartRateRefreshAttemptStartedAt: Long? = null + private var heartRateRefreshStartedAtElapsedRealtime: Long? = null private enum class HeartRateRefreshReason(val diagnosticName: String) { FIRST_SAMPLE_TIMEOUT("first-sample-timeout"), STREAM_STALLED("stream-stalled") } - private enum class HeartRateStreamFailure { - FIRST_SAMPLE_TIMEOUT, - STREAM_STALLED - } - private data class HeartRateRefreshCompletion( val reason: HeartRateRefreshReason, val attempt: Int @@ -282,6 +279,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val _heartRateStreaming = MutableStateFlow(false) val heartRateStreaming: StateFlow get() = _heartRateStreaming + private val _heartRateMonitoringStatus = + MutableStateFlow(HeartRateMonitoringStatus.OFF) + val heartRateMonitoringStatus: StateFlow + get() = _heartRateMonitoringStatus + private val _heartRateSamples = MutableStateFlow>(emptyList()) val heartRateSamples: StateFlow> get() = _heartRateSamples @@ -300,10 +302,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList companion object { private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" private const val MAX_HEART_RATE_SAMPLES = 60 - private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 12_000L - private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 6_000L + private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 8_000L + private const val HEART_RATE_RECONNECT_TIMEOUT_MILLIS = 15_000L + private const val HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS = 3_000L + private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 2_000L private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L - private const val HEART_RATE_REFRESH_SAMPLES_TO_DISCARD = 3 + private const val HEART_RATE_REFRESH_SAMPLES_TO_DISCARD = 4 private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L @@ -458,6 +462,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList HEART_RATE_MONITORING_PREFERENCE, false ) + _heartRateMonitoringStatus.value = if (_heartRateMonitoringEnabled.value) { + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } else { + HeartRateMonitoringStatus.OFF + } heartRateExporter = HealthConnectHeartRateExporter( context = applicationContext, sharedPreferences = sharedPreferences, @@ -1180,16 +1189,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onHeartRateReceived(sample: HeartRateSample) { var completedRefresh: HeartRateRefreshCompletion? = null - var shouldPublish = false - val accepted = synchronized(heartRateLock) { + val shouldPublish = synchronized(heartRateLock) { if (!_heartRateMonitoringEnabled.value || BluetoothConnectionManager.aacpSocket?.isConnected != true ) { false + } else if (!heartRateStartCommandSent) { + false } else { val receivedAt = SystemClock.elapsedRealtime() - lastValidHeartRateSampleElapsedRealtime = receivedAt - if (!consumeHeartRateWarmupSampleLocked()) { + if (consumeHeartRateWarmupSampleLocked()) { + false + } else { + lastValidHeartRateSampleElapsedRealtime = receivedAt val refreshAttemptStartedAt = heartRateRefreshAttemptStartedAt val refreshReason = activeHeartRateRefreshReason if (refreshReason != null && refreshAttemptStartedAt != null && @@ -1201,12 +1213,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) clearActiveHeartRateRefreshLocked() } - shouldPublish = true + true } - true } } - if (!accepted || !shouldPublish) return + if (!shouldPublish) return completedRefresh?.let { refresh -> Log.i( @@ -2957,14 +2968,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { delay(200) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendPacket(aacpManager.createHandshakePacket()) delay(200) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendSetFeatureFlagsPacket() delay(200) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendNotificationRequest() delay(200) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendSomePacketIDontKnowWhatItIs() delay(200) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) if (!handleIncomingCallOnceConnected) { if (!_heartRateMonitoringEnabled.value) startHeadTracking() @@ -2972,6 +2988,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList handleIncomingCall() } Handler(Looper.getMainLooper()).postDelayed({ + if (!isCurrentAacpConnection(socket, connectionGeneration)) { + return@postDelayed + } aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() @@ -2981,15 +3000,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } }, 5000) + if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch setupStemActions() startHeartRateMonitoringIfEnabled() - while (socket.isConnected) { + while (isCurrentAacpConnection(socket, connectionGeneration)) { try { val buffer = ByteArray(1024) val bytesRead = it.inputStream.read(buffer) var data: ByteArray if (bytesRead > 0) { + if (!isCurrentAacpConnection(socket, connectionGeneration)) { + return@launch + } noteAacpPacketReceived(socket, device) data = buffer.copyOfRange(0, bytesRead) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { @@ -3105,6 +3128,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + private fun isCurrentAacpConnection( + socket: BluetoothSocket, + connectionGeneration: Long + ): Boolean = synchronized(transportRecoveryLock) { + !aacpReconnectSuppressed && + connectionGeneration == aacpConnectionGeneration && + BluetoothConnectionManager.aacpSocket === socket && + socket.isConnected + } + /** * Removes a dead AACP transport without sending any more packets through it. The expected * socket check prevents an old reader from tearing down a newer connection. @@ -3136,6 +3169,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList livenessJobToCancel?.cancel() closeSocketQuietly(aacpSocketToClose, "AACP socket") closeSocketQuietly(attSocketToClose, "ATT socket") + if (::attManager.isInitialized) attManager.disconnected() handleHeartRateDisconnected() aacpManager.disconnected() updateNotificationContent(false) @@ -3307,8 +3341,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } Log.w(TAG, "AACP reconnect attempts exhausted source=$source") - } catch (e: Exception) { - Log.w(TAG, "AACP reconnect failed source=$source: ${e.message}", e) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "AACP reconnect failed source=$source: ${error.message}", error) } finally { synchronized(transportRecoveryLock) { if (aacpReconnectJob === currentJob) { @@ -3675,7 +3711,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList _heartRateMonitoringEnabled.value = enabled if (enabled) { - if (!wasEnabled) _heartRateSamples.value = emptyList() + if (!wasEnabled) { + _heartRateMonitoringStatus.value = if (isAacpTransportHealthy()) { + HeartRateMonitoringStatus.STARTING + } else { + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } + } startHeartRateMonitoringIfEnabled() } else { if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync() @@ -3684,9 +3726,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private fun startHeartRateMonitoringIfEnabled() { - if (!_heartRateMonitoringEnabled.value) return - if (BluetoothConnectionManager.aacpSocket?.isConnected != true) { + if (!_heartRateMonitoringEnabled.value) { + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.OFF + return + } + if (!isAacpTransportHealthy()) { _heartRateStreaming.value = false + _heartRateMonitoringStatus.value = + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS return } @@ -3694,6 +3741,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (heartRateStartJob?.isActive == true) return _heartRateStreaming.value = false + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.STARTING val job = heartRateScope.launch(start = CoroutineStart.LAZY) { runHeartRateMonitoringWatchdog() } @@ -3711,6 +3759,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } while (canContinueHeartRateMonitoring()) { + synchronized(heartRateLock) { + _heartRateMonitoringStatus.value = + if (activeHeartRateRefreshReason == null) { + HeartRateMonitoringStatus.STARTING + } else { + HeartRateMonitoringStatus.RECONNECTING + } + } val attemptStartedAt = startHeartRateStreamAttempt() if (!canContinueHeartRateMonitoring()) return @@ -3723,7 +3779,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList reason } newlyRequestedReason?.let(::logHeartRateRefreshRequested) - HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT + HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT } else { awaitHeartRateStreamFailure(attemptStartedAt) ?: return } @@ -3752,12 +3808,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val activeReason = activeHeartRateRefreshReason if (activeReason != null) { refreshReason = activeReason - if (heartRateRefreshAttemptCount >= HEART_RATE_RETRY_BACKOFF_MILLIS.size) { + val refreshStartedAt = heartRateRefreshStartedAtElapsedRealtime + val reconnectTimedOut = refreshStartedAt != null && + SystemClock.elapsedRealtime() - refreshStartedAt >= + HEART_RATE_RECONNECT_TIMEOUT_MILLIS + if (reconnectTimedOut) { retriesExhausted = true clearActiveHeartRateRefreshLocked() } else { - backoffMillis = - HEART_RATE_RETRY_BACKOFF_MILLIS[heartRateRefreshAttemptCount] + val backoffIndex = heartRateRefreshAttemptCount + .coerceAtMost(HEART_RATE_RETRY_BACKOFF_MILLIS.lastIndex) + backoffMillis = HEART_RATE_RETRY_BACKOFF_MILLIS[backoffIndex] heartRateRefreshAttemptCount++ refreshAttempt = heartRateRefreshAttemptCount heartRateRefreshAttemptStartedAt = null @@ -3772,15 +3833,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "RTBuddy heart-rate refresh failed reason=${reason.diagnosticName} " + "attempts=${HEART_RATE_RETRY_BACKOFF_MILLIS.size}" ) + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.COULDNT_START + Log.i(TAG, "Waiting for a manual AACP reconnect after heart-rate retries failed") return false } + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.RECONNECTING Log.w( TAG, "RTBuddy heart-rate refresh attempt=$refreshAttempt " + "reason=${reason.diagnosticName} backoff=${backoffMillis}ms" ) delay(backoffMillis) + + val reconnectTimedOut = synchronized(heartRateLock) { + val refreshStartedAt = heartRateRefreshStartedAtElapsedRealtime + val timedOut = activeHeartRateRefreshReason != null && + refreshStartedAt != null && + SystemClock.elapsedRealtime() - refreshStartedAt >= + HEART_RATE_RECONNECT_TIMEOUT_MILLIS + if (timedOut) clearActiveHeartRateRefreshLocked() + timedOut + } + if (reconnectTimedOut) { + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.COULDNT_START + Log.w(TAG, "RTBuddy heart-rate reconnect window expired after 15 seconds") + return false + } return canContinueHeartRateMonitoring() } @@ -3826,7 +3905,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private suspend fun awaitHeartRateStreamFailure( attemptStartedAt: Long - ): HeartRateStreamFailure? { + ): HeartRateRefreshReason? { + val firstSampleTimeoutMillis = synchronized(heartRateLock) { + val reconnectStartedAt = heartRateRefreshStartedAtElapsedRealtime + .takeIf { activeHeartRateRefreshReason != null } + if (reconnectStartedAt == null) { + HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS + } else { + (reconnectStartedAt + HEART_RATE_RECONNECT_TIMEOUT_MILLIS - attemptStartedAt) + .coerceAtLeast(0L) + } + } while (canContinueHeartRateMonitoring()) { delay(HEART_RATE_WATCHDOG_INTERVAL_MILLIS) val now = SystemClock.elapsedRealtime() @@ -3843,16 +3932,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList HeartRateRefreshReason.STREAM_STALLED ) stopHeartRateSessionLocked() - HeartRateStreamFailure.STREAM_STALLED + HeartRateRefreshReason.STREAM_STALLED } (lastSampleAt == null || lastSampleAt < attemptStartedAt) && - now - attemptStartedAt >= HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS -> { + now - attemptStartedAt >= firstSampleTimeoutMillis -> { newlyRequestedReason = beginHeartRateRefreshLocked( HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT ) stopHeartRateSessionLocked() - HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT + HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT } else -> null @@ -3870,16 +3959,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList * Returns true when the sample belongs to the warm-up discard window. */ private fun consumeHeartRateWarmupSampleLocked(): Boolean { - val shouldDiscard = heartRateSamplesToDiscardAfterRefresh > 0 - if (shouldDiscard) { + if (heartRateSamplesToDiscardAfterRefresh > 0) { heartRateSamplesToDiscardAfterRefresh-- + _heartRateStreaming.value = false + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.CALIBRATING + return true } _heartRateStreaming.value = - heartRateStartCommandSent && - heartRateStartJob?.isActive == true && - heartRateSamplesToDiscardAfterRefresh == 0 - return shouldDiscard + heartRateStartCommandSent && heartRateStartJob?.isActive == true + _heartRateMonitoringStatus.value = if (_heartRateStreaming.value) { + HeartRateMonitoringStatus.LIVE + } else { + HeartRateMonitoringStatus.STARTING + } + return false } private fun beginHeartRateRefreshLocked( @@ -3887,8 +3981,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ): HeartRateRefreshReason? { if (activeHeartRateRefreshReason != null) return null activeHeartRateRefreshReason = reason + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.RECONNECTING heartRateRefreshAttemptCount = 0 heartRateRefreshAttemptStartedAt = null + heartRateRefreshStartedAtElapsedRealtime = SystemClock.elapsedRealtime() heartRateSamplesToDiscardAfterRefresh = HEART_RATE_REFRESH_SAMPLES_TO_DISCARD return reason } @@ -3897,6 +3993,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList activeHeartRateRefreshReason = null heartRateRefreshAttemptCount = 0 heartRateRefreshAttemptStartedAt = null + heartRateRefreshStartedAtElapsedRealtime = null heartRateSamplesToDiscardAfterRefresh = 0 } @@ -3970,6 +4067,44 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendStopFrame = sendStopFrame ) clearActiveHeartRateRefreshLocked() + _heartRateMonitoringStatus.value = if (_heartRateMonitoringEnabled.value) { + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } else { + HeartRateMonitoringStatus.OFF + } + } + } + + fun reconnectAacpForHeartRate() { + if (!_heartRateMonitoringEnabled.value) return + val reconnectDevice = device ?: run { + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + return + } + + cancelAacpReconnect( + source = "heart-rate-manual-reconnect", + suppressFutureReconnects = false + ) + _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + transportRecoveryScope.launch { + stopHeartRateMonitoring(forceStop = true) + BluetoothConnectionManager.aacpSocket?.let { socket -> + clearAacpTransport( + source = "heart-rate-manual-reconnect", + expectedSocket = socket + ) + } + Log.i( + TAG, + "Waiting ${HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS}ms " + + "before rebuilding AACP after heart-rate reset" + ) + delay(HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS) + if (!_heartRateMonitoringEnabled.value) return@launch + val adapter = getSystemService(BluetoothManager::class.java).adapter + Log.i(TAG, "Starting manual AACP reconnect for heart-rate monitoring") + connectToSocket(adapter, reconnectDevice, manual = true) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt new file mode 100644 index 000000000..b5051f8c2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt @@ -0,0 +1,21 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.services + +enum class HeartRateMonitoringStatus { + OFF, + WAITING_FOR_AIRPODS, + STARTING, + CALIBRATING, + LIVE, + RECONNECTING, + COULDNT_START +} From 328fb95c9a40653400a2bdda367b5088c5129740 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Fri, 7 Aug 2026 00:27:11 +0200 Subject: [PATCH 10/15] Use themed switch in heart-rate summary --- .../presentation/screens/HeartRateTestScreen.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 3357b820d..2b0f4773b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -199,10 +200,17 @@ private fun HeartRateSummaryCard( } Column(horizontalAlignment = Alignment.End) { Box(modifier = Modifier.padding(end = 4.dp, bottom = 8.dp)) { - StyledSwitch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged - ) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Switch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + + DesignSystem.Apple -> StyledSwitch( + checked = monitoringEnabled, + onCheckedChange = onMonitoringChanged + ) + } } HeartRateStatusChip( status = monitoringStatus, From c3550a003c3beafaab635396dc07b74af3a7a21f Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Fri, 7 Aug 2026 01:10:06 +0200 Subject: [PATCH 11/15] Update gui and code cleanup --- android/app/build.gradle.kts | 5 + .../librepods/bluetooth/AACPManager.kt | 56 +- .../librepods/bluetooth/RtBuddyHeartRate.kt | 610 +++++------------- .../health/HealthConnectHeartRateExporter.kt | 228 +++---- .../presentation/components/HeartRateCard.kt | 171 ++++- .../components/HeartRateStatusChip.kt | 14 +- .../screens/AirPodsSettingsScreen.kt | 5 +- .../screens/HeartRateTestScreen.kt | 344 ++++------ .../viewmodel/AirPodsViewModel.kt | 94 +-- .../librepods/services/AirPodsService.kt | 512 ++------------- .../librepods/services/HeartRateMonitor.kt | 357 ++++++++++ .../services/HeartRateMonitoringStatus.kt | 11 + 12 files changed, 1027 insertions(+), 1380 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c4ebaa77d..e3ca5223b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -86,6 +86,11 @@ android { dimension = "env" buildConfigField("Boolean", "PLAY_BUILD", "false") } + create("coexist") { + dimension = "env" + applicationIdSuffix = ".hearttest" + buildConfigField("Boolean", "PLAY_BUILD", "false") + } create("play") { dimension = "env" buildConfigField("Boolean", "PLAY_BUILD", "true") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index c60bfd0c4..86ef37018 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -96,8 +96,6 @@ class AACPManager { private const val HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS = 10_000L private const val HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD = 10 private const val HEART_RATE_DIAGNOSTIC_COUNT_LIMIT = 1_000 - private const val HEART_RATE_DIAGNOSTIC_STRUCTURE_LIMIT = 4 - private const val HEART_RATE_DIAGNOSTIC_OVERFLOW_KEY = "other_structures" data class ControlCommandStatus( val identifier: ControlCommandIdentifiers, val value: ByteArray @@ -326,7 +324,6 @@ class AACPManager { private var heartRateDiagnosticRejectedFrames = 0 private val heartRateDiagnosticRejectionReasons = mutableMapOf() - private val heartRateDiagnosticStructures = linkedMapOf() fun setPacketCallback(callback: PacketCallback) { this.callback = callback @@ -467,14 +464,13 @@ class AACPManager { private fun recordHeartRateDecodeDiagnostics(result: HeartRateDecodeResult) { if (result.relatedFrameCount == 0) return - var logAcceptedSample = false + var acceptedFirstSample = false var rejectionSummary: String? = null synchronized(heartRateDiagnosticLock) { if (result.samples.isNotEmpty() && !heartRateAcceptedSampleLogged) { heartRateAcceptedSampleLogged = true - logAcceptedSample = true + acceptedFirstSample = true } - if (result.rejectedFrameCount > 0) { val now = System.currentTimeMillis() if (heartRateDiagnosticWindowStartedAtMillis == 0L) { @@ -491,61 +487,36 @@ class AACPManager { result.rejectionReasons.forEach { (reason, count) -> heartRateDiagnosticRejectionReasons.incrementBounded(reason, count) } - result.structuralDiagnostics.forEach { (structure, count) -> - val existing = heartRateDiagnosticStructures[structure] - when { - existing != null -> { - heartRateDiagnosticStructures.incrementBounded(structure, count) - } - heartRateDiagnosticStructures.size < HEART_RATE_DIAGNOSTIC_STRUCTURE_LIMIT -> { - heartRateDiagnosticStructures[structure] = count.coerceAtMost( - HEART_RATE_DIAGNOSTIC_COUNT_LIMIT - ) - } - - else -> { - heartRateDiagnosticStructures.incrementBounded( - HEART_RATE_DIAGNOSTIC_OVERFLOW_KEY, - count - ) - } - } - } - - val windowElapsed = now - heartRateDiagnosticWindowStartedAtMillis >= - HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS - val thresholdReached = heartRateDiagnosticRejectedFrames >= - HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD - if (windowElapsed || thresholdReached) { + val shouldLog = + now - heartRateDiagnosticWindowStartedAtMillis >= + HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS || + heartRateDiagnosticRejectedFrames >= + HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD + if (shouldLog) { val reasons = heartRateDiagnosticRejectionReasons.entries .sortedBy { it.key.name } .joinToString(",") { (reason, count) -> "${reason.name.lowercase()}=$count" } - val structures = heartRateDiagnosticStructures.entries - .sortedByDescending { it.value } - .joinToString(" || ") { (structure, count) -> - "$count*$structure" - } - .ifEmpty { "none" } rejectionSummary = - "RTBuddy heart-rate decode window frames=$heartRateDiagnosticRelatedFrames " + + "RTBuddy heart-rate decode window " + + "frames=$heartRateDiagnosticRelatedFrames " + "rejected=$heartRateDiagnosticRejectedFrames reasons=$reasons; " + - "structures=$structures; raw frame data suppressed" + "raw frame data suppressed" clearHeartRateDiagnosticWindowLocked() } } } - if (logAcceptedSample) { + if (acceptedFirstSample) { Log.d(TAG, "Validated first RTBuddy heart-rate sample for this AACP connection") } rejectionSummary?.let { Log.w(TAG, it) } } private fun boundedDiagnosticCount(current: Int, increment: Int): Int = - (current.toLong() + increment.toLong()) + (current.toLong() + increment) .coerceAtMost(HEART_RATE_DIAGNOSTIC_COUNT_LIMIT.toLong()) .toInt() @@ -558,7 +529,6 @@ class AACPManager { heartRateDiagnosticRelatedFrames = 0 heartRateDiagnosticRejectedFrames = 0 heartRateDiagnosticRejectionReasons.clear() - heartRateDiagnosticStructures.clear() } private fun resetHeartRateDiagnostics() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt index 7187e52ee..8b3a85eed 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -21,7 +21,6 @@ data class HeartRateSample( ) internal enum class HeartRateRejectionReason { - MALFORMED_SENSOR_DATA, UNSUPPORTED_LOG_TYPE, MISSING_HEART_RATE_PAYLOAD, UNRECOGNIZED_HEART_RATE_PAYLOAD @@ -30,25 +29,21 @@ internal enum class HeartRateRejectionReason { internal data class HeartRateDecodeResult( val samples: List = emptyList(), val relatedFrameCount: Int = 0, - val rejectedFrameCount: Int = 0, val rejectionReasons: Map = emptyMap(), - val structuralDiagnostics: Map = emptyMap(), val suppressRawLogging: Boolean = false, val passthroughPackets: List = emptyList() -) +) { + val rejectedFrameCount: Int + get() = rejectionReasons.values.sum() +} /** - * Stateful decoder for the verified RTBuddy HEARTRATE SensorDataWX stream. - * - * Socket reads are arbitrary chunks. A possible partial 0x17/0x00100000 frame is retained until - * its declared payload is complete. Other 0x17 packets are reconstructed and passed to the normal - * AACP parser so head tracking keeps its existing behavior. + * Reassembles RTBuddy frames and extracts the verified HEARTRATE SensorDataWX payload. * - * The known heart-rate value is accepted only from a live SensorDataWX record: an exact 18-byte - * HEARTRATE(19) command payload with one of the observed status trailers and a BPM in the validated - * physiological range. Firmware may use either observed live log type and may repeat command payload - * field 3 or place that exact payload inside one or more protobuf length-delimited wrappers; those - * structural variants are traversed without reading arbitrary offsets. + * The parser deliberately keeps the protocol checks that prevent control/startup frames from being + * interpreted as BPM: live log type, service 19, exact 18-byte payload, known status trailer, and + * the validated physiological range. Length-delimited wrappers are traversed only to the same + * bounded depth as the observed firmware variants. */ internal class RtBuddyHeartRateDecoder { private var carry = ByteArray(0) @@ -64,329 +59,179 @@ internal class RtBuddyHeartRateDecoder { val hadCarry = carry.isNotEmpty() val carryWasSensitive = carry.size >= MIN_SENSITIVE_PREFIX_LENGTH - val combined = if (carry.isEmpty()) chunk else carry + chunk + val data = if (carry.isEmpty()) chunk else carry + chunk carry = ByteArray(0) val samples = mutableListOf() val passthroughPackets = mutableListOf() - var relatedFrameCount = 0 - var rejectedFrameCount = 0 val rejectionReasons = mutableMapOf() - val structuralDiagnostics = linkedMapOf() + var relatedFrameCount = 0 var suppressRawLogging = carryWasSensitive var cursor = 0 - while (cursor < combined.size) { - val candidateOffset = combined.indexOfPrefix(RTBUDDY_FRAME_PREFIX, cursor) - if (candidateOffset < 0) { - val suffixLength = combined.longestSuffixMatchingPrefix( + while (cursor < data.size) { + val frameOffset = data.indexOfPrefix(RTBUDDY_FRAME_PREFIX, cursor) + if (frameOffset < 0) { + val suffixLength = data.longestSuffixMatchingPrefix( prefix = RTBUDDY_FRAME_PREFIX, startIndex = cursor ) - val passthroughEnd = combined.size - suffixLength + val passthroughEnd = data.size - suffixLength if (passthroughEnd > cursor) { - passthroughPackets += combined.copyOfRange(cursor, passthroughEnd) + passthroughPackets += data.copyOfRange(cursor, passthroughEnd) } if (suffixLength > 0) { - carry = combined.copyOfRange(passthroughEnd, combined.size) - if (suffixLength >= MIN_SENSITIVE_PREFIX_LENGTH) { - suppressRawLogging = true - } + carry = data.copyOfRange(passthroughEnd, data.size) + suppressRawLogging = suppressRawLogging || + suffixLength >= MIN_SENSITIVE_PREFIX_LENGTH } break } - if (candidateOffset > cursor) { - passthroughPackets += combined.copyOfRange(cursor, candidateOffset) + if (frameOffset > cursor) { + passthroughPackets += data.copyOfRange(cursor, frameOffset) } - - if (combined.size - candidateOffset < AACP_RTBUDDY_HEADER_LENGTH) { - carry = combined.copyOfRange(candidateOffset, combined.size) + if (data.size - frameOffset < AACP_RTBUDDY_HEADER_LENGTH) { + carry = data.copyOfRange(frameOffset, data.size) suppressRawLogging = true break } - val declaredLength = combined.readLe16(candidateOffset + 10) - if (declaredLength > MAX_RTBUDDY_PAYLOAD_LENGTH) { - // The exact SensorDataWX prefix is sensitive, but the length is untrusted. Drop the - // remainder rather than exposing it to generic packet logs or interpreting it as - // head tracking. + val payloadLength = data.readLe16(frameOffset + 10) + if (payloadLength > MAX_RTBUDDY_PAYLOAD_LENGTH) { + // The exact SensorDataWX prefix is sensitive, but the declared length is untrusted. suppressRawLogging = true break } - val frameLength = AACP_RTBUDDY_HEADER_LENGTH + declaredLength - if (combined.size - candidateOffset < frameLength) { - carry = combined.copyOfRange(candidateOffset, combined.size) + val frameLength = AACP_RTBUDDY_HEADER_LENGTH + payloadLength + if (data.size - frameOffset < frameLength) { + carry = data.copyOfRange(frameOffset, data.size) suppressRawLogging = true break } - val frame = combined.copyOfRange(candidateOffset, candidateOffset + frameLength) + val frame = data.copyOfRange(frameOffset, frameOffset + frameLength) val classification = classifyFrame(frame) - if (classification.isHeartRateRelated) { + if (classification.related) { relatedFrameCount++ - if (classification.sample == null) { - rejectedFrameCount++ - classification.rejectionReason?.let { rejectionReasons.increment(it) } - } - classification.structuralDiagnostic?.let { structuralDiagnostics.increment(it) } - suppressRawLogging = true + classification.rejectionReason?.let { rejectionReasons.increment(it) } classification.sample?.let(samples::add) + suppressRawLogging = true } else { passthroughPackets += frame - if (hadCarry && candidateOffset == 0) suppressRawLogging = true + if (hadCarry && frameOffset == 0) suppressRawLogging = true } - cursor = candidateOffset + frameLength + cursor = frameOffset + frameLength } return HeartRateDecodeResult( samples = samples, relatedFrameCount = relatedFrameCount, - rejectedFrameCount = rejectedFrameCount, rejectionReasons = rejectionReasons, - structuralDiagnostics = structuralDiagnostics, suppressRawLogging = suppressRawLogging, passthroughPackets = passthroughPackets ) } private fun classifyFrame(frame: ByteArray): FrameClassification { - val hasHeartRateReference = hasHeartRateServiceReference( + val topLevel = parseProtoMessage( frame, AACP_RTBUDDY_HEADER_LENGTH, frame.size - ) - val sensorData = parseSensorDataWx(frame, AACP_RTBUDDY_HEADER_LENGTH, frame.size) - ?: return FrameClassification( - isHeartRateRelated = hasHeartRateReference, - rejectionReason = HeartRateRejectionReason.MALFORMED_SENSOR_DATA - .takeIf { hasHeartRateReference }, - structuralDiagnostic = MALFORMED_STRUCTURE_DIAGNOSTIC - .takeIf { hasHeartRateReference } - ) - val heartRateRelated = hasHeartRateReference || - HEART_RATE_SERVICE in sensorData.referencedServices - if (!heartRateRelated) return FrameClassification() - if (sensorData.logType !in LIVE_SENSOR_DATA_LOG_TYPES) { - return FrameClassification( - isHeartRateRelated = true, - rejectionReason = HeartRateRejectionReason.UNSUPPORTED_LOG_TYPE, - structuralDiagnostic = buildStructuralDiagnostic(sensorData, emptyList()) - ) - } - - val heartRateCommands = sensorData.commands.filter { it.service == HEART_RATE_SERVICE } - val analyses = heartRateCommands.flatMap { command -> - command.payloadCandidates.map { candidate -> - PayloadAnalysis( - command = command, - candidate = candidate, - failures = validateHeartRatePayload(candidate.bytes) + ) ?: return FrameClassification() + + val sequence = topLevel.firstVarint(FIELD_SEQUENCE)?.toInt() ?: -1 + val logType = topLevel.firstVarint(FIELD_LOG_TYPE)?.toInt() ?: -1 + val commands = mutableListOf() + + topLevel.fields.forEach { field -> + if (field.wireType == WIRE_LENGTH_DELIMITED && + field.number in SENSOR_DATA_COMMAND_FIELDS && + commands.size < MAX_COMMANDS_PER_FRAME + ) { + collectHeartRateCommands( + data = frame, + start = field.valueStart, + end = field.valueEnd, + depth = 0, + commands = commands ) } } - val accepted = analyses.firstOrNull { it.failures.isEmpty() } - if (accepted == null) { - val hasPayloadCandidate = heartRateCommands.any { - it.directPayloadCount > 0 || it.payloadCandidates.isNotEmpty() - } + + if (commands.isEmpty()) return FrameClassification() + if (logType !in LIVE_SENSOR_DATA_LOG_TYPES) { return FrameClassification( - isHeartRateRelated = true, - rejectionReason = if (hasPayloadCandidate) { - HeartRateRejectionReason.UNRECOGNIZED_HEART_RATE_PAYLOAD - } else { - HeartRateRejectionReason.MISSING_HEART_RATE_PAYLOAD - }, - structuralDiagnostic = buildStructuralDiagnostic(sensorData, analyses) + related = true, + rejectionReason = HeartRateRejectionReason.UNSUPPORTED_LOG_TYPE ) } - val payload = accepted.candidate.bytes + val payloads = commands.flatMap { it.payloadCandidates } + val acceptedPayload = payloads.firstOrNull(::isValidHeartRatePayload) + ?: return FrameClassification( + related = true, + rejectionReason = if (payloads.isEmpty()) { + HeartRateRejectionReason.MISSING_HEART_RATE_PAYLOAD + } else { + HeartRateRejectionReason.UNRECOGNIZED_HEART_RATE_PAYLOAD + } + ) + return FrameClassification( - isHeartRateRelated = true, + related = true, sample = HeartRateSample( - bpm = payload.unsignedByteAt(HEART_RATE_BPM_OFFSET), - sequence = sensorData.sequence, + bpm = acceptedPayload.unsignedByteAt(HEART_RATE_BPM_OFFSET), + sequence = sequence, receivedAtMillis = System.currentTimeMillis(), receivedAtElapsedRealtime = SystemClock.elapsedRealtime() ) ) } - private fun validateHeartRatePayload(payload: ByteArray): Set { - if (payload.size != HEART_RATE_PAYLOAD_LENGTH) { - return setOf(PayloadValidationFailure.LENGTH) - } - - val failures = linkedSetOf() - if (!payload.hasKnownHeartRateStatusTail()) { - failures += PayloadValidationFailure.STATUS_TAIL - } - if (payload.unsignedByteAt(HEART_RATE_BPM_OFFSET) !in MIN_BPM..MAX_BPM) { - failures += PayloadValidationFailure.BPM_RANGE - } - return failures - } - - private fun ByteArray.hasKnownHeartRateStatusTail(): Boolean { - if (size != HEART_RATE_PAYLOAD_LENGTH) return false - return KNOWN_HEART_RATE_STATUS_TAILS.any { tail -> - tail.indices.all { index -> - this[HEART_RATE_STATUS_TAIL_OFFSET + index] == tail[index] - } - } - } - - private fun ByteArray.unsignedByteAt(index: Int): Int = this[index].toInt().and(0xFF) - - private fun MutableMap.increment(key: K) { - this[key] = getOrDefault(key, 0) + 1 - } - - private fun hasHeartRateServiceReference(data: ByteArray, start: Int, end: Int): Boolean { - val message = parseProtoMessage(data, start, end) ?: return false - return message.entries.any { entry -> - entry.wireType == WIRE_LENGTH_DELIMITED && - entry.field in SENSOR_DATA_COMMAND_FIELDS && - containsServiceReference( - data = data, - start = entry.valueStart, - end = entry.valueEnd, - depth = 0 - ) - } - } - - private fun containsServiceReference( + private fun collectHeartRateCommands( data: ByteArray, start: Int, end: Int, - depth: Int - ): Boolean { - if (depth > MAX_COMMAND_ENVELOPE_DEPTH) return false - val message = parseProtoMessage(data, start, end) ?: return false - if (message.entries.any { - it.field == 1 && - it.wireType == WIRE_VARINT && - it.varintValue == HEART_RATE_SERVICE.toLong() - } - ) { - return true - } - if (depth == MAX_COMMAND_ENVELOPE_DEPTH) return false - return message.entries.any { entry -> - entry.wireType == WIRE_LENGTH_DELIMITED && - containsServiceReference( - data, - entry.valueStart, - entry.valueEnd, - depth + 1 - ) - } - } - - private fun parseSensorDataWx(data: ByteArray, start: Int, end: Int): SensorDataWx? { - val message = parseProtoMessage(data, start, end) ?: return null - var sequence = -1 - var logType = -1 - val commands = mutableListOf() - val referencedServices = mutableSetOf() - val fieldOccurrences = mutableMapOf() - - message.entries.forEach { entry -> - when { - entry.wireType == WIRE_VARINT && entry.field == 1 -> { - sequence = entry.varintValue?.toInt() ?: sequence - } - - entry.wireType == WIRE_VARINT && entry.field == 2 -> { - logType = entry.varintValue?.toInt() ?: logType - } - - entry.wireType == WIRE_LENGTH_DELIMITED && - entry.field in SENSOR_DATA_COMMAND_FIELDS -> { - val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) - fieldOccurrences[entry.field] = occurrence + 1 - inspectCommandEnvelope( + depth: Int, + commands: MutableList + ) { + if (depth > MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return + val message = parseProtoMessage(data, start, end) ?: return + val service = message.firstVarint(FIELD_SERVICE)?.toInt() + + if (service == HEART_RATE_SERVICE) { + val payloads = mutableListOf() + message.fields.forEach { field -> + if (field.number == FIELD_COMMAND_PAYLOAD && + field.wireType == WIRE_LENGTH_DELIMITED + ) { + collectPayloadCandidates( data = data, - start = entry.valueStart, - end = entry.valueEnd, - path = "f${entry.field}[$occurrence]", + start = field.valueStart, + end = field.valueEnd, depth = 0, - commands = commands, - referencedServices = referencedServices + candidates = payloads ) } } + commands += HeartRateCommand(payloads) } - return SensorDataWx( - sequence = sequence, - logType = logType, - commands = commands, - referencedServices = referencedServices, - topLevelShape = message.shape - ) - } - - private fun inspectCommandEnvelope( - data: ByteArray, - start: Int, - end: Int, - path: String, - depth: Int, - commands: MutableList, - referencedServices: MutableSet - ) { - if (depth > MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return - val message = parseProtoMessage(data, start, end) ?: return - val service = message.entries.firstOrNull { - it.field == 1 && it.wireType == WIRE_VARINT - }?.varintValue?.toInt() - - if (service != null && service >= 0) { - referencedServices += service - val directPayloadEntries = message.entries.filter { - it.field == 3 && it.wireType == WIRE_LENGTH_DELIMITED - } - val payloadCandidates = mutableListOf() - directPayloadEntries.forEachIndexed { index, entry -> - collectPayloadCandidates( + if (depth == MAX_COMMAND_ENVELOPE_DEPTH) return + message.fields.forEach { field -> + if (field.wireType == WIRE_LENGTH_DELIMITED && + commands.size < MAX_COMMANDS_PER_FRAME + ) { + collectHeartRateCommands( data = data, - start = entry.valueStart, - end = entry.valueEnd, - path = "$path.f3[$index]", - wrapperDepth = 0, - candidates = payloadCandidates + start = field.valueStart, + end = field.valueEnd, + depth = depth + 1, + commands = commands ) } - commands += RtBuddyCommand( - service = service, - directPayloadCount = directPayloadEntries.size, - payloadCandidates = payloadCandidates, - path = path, - shape = message.shape - ) - } - - if (depth == MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return - val fieldOccurrences = mutableMapOf() - message.entries.forEach { entry -> - if (entry.wireType != WIRE_LENGTH_DELIMITED || commands.size >= MAX_COMMANDS_PER_FRAME) { - return@forEach - } - val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) - fieldOccurrences[entry.field] = occurrence + 1 - inspectCommandEnvelope( - data = data, - start = entry.valueStart, - end = entry.valueEnd, - path = "$path.f${entry.field}[$occurrence]", - depth = depth + 1, - commands = commands, - referencedServices = referencedServices - ) } } @@ -394,92 +239,63 @@ internal class RtBuddyHeartRateDecoder { data: ByteArray, start: Int, end: Int, - path: String, - wrapperDepth: Int, - candidates: MutableList + depth: Int, + candidates: MutableList ) { if (candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND) return + val direct = data.copyOfRange(start, end) - if (candidates.none { it.bytes.contentEquals(direct) }) { - candidates += PayloadCandidate(bytes = direct, path = path) - } + if (candidates.none(direct::contentEquals)) candidates += direct + if (depth >= MAX_PAYLOAD_WRAPPER_DEPTH) return - if (wrapperDepth >= MAX_PAYLOAD_WRAPPER_DEPTH || - candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND - ) { - return - } val wrapper = parseProtoMessage(data, start, end) ?: return - val lengthEntries = wrapper.entries.filter { it.wireType == WIRE_LENGTH_DELIMITED } - if (lengthEntries.isEmpty()) return - - val fieldOccurrences = mutableMapOf() - lengthEntries.forEach { entry -> - if (candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND) return@forEach - val occurrence = fieldOccurrences.getOrDefault(entry.field, 0) - fieldOccurrences[entry.field] = occurrence + 1 - collectPayloadCandidates( - data = data, - start = entry.valueStart, - end = entry.valueEnd, - path = "$path.f${entry.field}[$occurrence]", - wrapperDepth = wrapperDepth + 1, - candidates = candidates - ) + wrapper.fields.forEach { field -> + if (field.wireType == WIRE_LENGTH_DELIMITED && + candidates.size < MAX_PAYLOAD_CANDIDATES_PER_COMMAND + ) { + collectPayloadCandidates( + data = data, + start = field.valueStart, + end = field.valueEnd, + depth = depth + 1, + candidates = candidates + ) + } } } - private fun buildStructuralDiagnostic( - sensorData: SensorDataWx, - analyses: List - ): String { - val heartRateCommands = sensorData.commands.filter { it.service == HEART_RATE_SERVICE } - val commandText = if (heartRateCommands.isEmpty()) { - "none" - } else { - heartRateCommands.take(MAX_DIAGNOSTIC_COMMANDS).joinToString("|") { command -> - val commandAnalyses = analyses.filter { it.command === command } - val candidates = if (commandAnalyses.isEmpty()) { - "none" - } else { - commandAnalyses.take(MAX_DIAGNOSTIC_PAYLOADS_PER_COMMAND) - .joinToString(",") { analysis -> - val relativePath = analysis.candidate.path.removePrefix(command.path) - val failures = analysis.failures - .joinToString("+") { it.diagnosticCode } - .ifEmpty { "ok" } - "$relativePath:${analysis.candidate.bytes.size}:$failures" - } - } - "${command.path}{${command.shape};p3x${command.directPayloadCount};c=$candidates}" + private fun isValidHeartRatePayload(payload: ByteArray): Boolean { + if (payload.size != HEART_RATE_PAYLOAD_LENGTH) return false + if (payload.unsignedByteAt(HEART_RATE_BPM_OFFSET) !in MIN_BPM..MAX_BPM) return false + + return KNOWN_HEART_RATE_STATUS_TAILS.any { tail -> + tail.indices.all { index -> + payload[HEART_RATE_STATUS_TAIL_OFFSET + index] == tail[index] } } - val diagnostic = - "log=${sensorData.logType};top=${sensorData.topLevelShape};hr=$commandText" - return diagnostic.take(MAX_DIAGNOSTIC_SIGNATURE_LENGTH) } private fun parseProtoMessage(data: ByteArray, start: Int, end: Int): ProtoMessage? { if (start < 0 || end < start || end > data.size || end - start > MAX_PROTO_MESSAGE_LENGTH) { return null } - var index = start - val entries = mutableListOf() + val fields = mutableListOf() + var index = start while (index < end) { - if (entries.size >= MAX_PROTO_FIELDS) return null + if (fields.size >= MAX_PROTO_FIELDS) return null val key = readVarint(data, index, end) ?: return null index = key.nextIndex - val fieldLong = key.value ushr 3 - if (fieldLong <= 0 || fieldLong > MAX_PROTO_FIELD_NUMBER) return null - val field = fieldLong.toInt() + + val fieldNumber = key.value ushr 3 + if (fieldNumber <= 0 || fieldNumber > MAX_PROTO_FIELD_NUMBER) return null val wireType = (key.value and 0x07).toInt() when (wireType) { WIRE_VARINT -> { val value = readVarint(data, index, end) ?: return null - entries += ProtoEntry( - field = field, + fields += ProtoField( + number = fieldNumber.toInt(), wireType = wireType, varintValue = value.value, valueStart = index, @@ -489,20 +305,24 @@ internal class RtBuddyHeartRateDecoder { } WIRE_LENGTH_DELIMITED -> { - val value = readLengthDelimited(data, index, end) ?: return null - entries += ProtoEntry( - field = field, + val length = readVarint(data, index, end) ?: return null + if (length.value > Int.MAX_VALUE) return null + val valueEnd = length.nextIndex + length.value.toInt() + if (valueEnd < length.nextIndex || valueEnd > end) return null + + fields += ProtoField( + number = fieldNumber.toInt(), wireType = wireType, - valueStart = value.startIndex, - valueEnd = value.endIndex + valueStart = length.nextIndex, + valueEnd = valueEnd ) - index = value.endIndex + index = valueEnd } WIRE_FIXED64 -> { if (end - index < 8) return null - entries += ProtoEntry( - field = field, + fields += ProtoField( + number = fieldNumber.toInt(), wireType = wireType, valueStart = index, valueEnd = index + 8 @@ -512,8 +332,8 @@ internal class RtBuddyHeartRateDecoder { WIRE_FIXED32 -> { if (end - index < 4) return null - entries += ProtoEntry( - field = field, + fields += ProtoField( + number = fieldNumber.toInt(), wireType = wireType, valueStart = index, valueEnd = index + 4 @@ -524,21 +344,9 @@ internal class RtBuddyHeartRateDecoder { else -> return null } } - - return ProtoMessage(entries = entries, shape = buildProtoShape(entries)) + return ProtoMessage(fields) } - private fun buildProtoShape(entries: List): String = - entries.take(MAX_DIAGNOSTIC_SHAPE_FIELDS).joinToString(",") { entry -> - if (entry.wireType == WIRE_LENGTH_DELIMITED) { - "f${entry.field}/${entry.wireType}:${entry.valueEnd - entry.valueStart}" - } else { - "f${entry.field}/${entry.wireType}" - } - }.let { shape -> - if (entries.size > MAX_DIAGNOSTIC_SHAPE_FIELDS) "$shape,..." else shape - }.ifEmpty { "empty" } - private fun readVarint(data: ByteArray, start: Int, end: Int): VarintRead? { var value = 0L var shift = 0 @@ -550,87 +358,37 @@ internal class RtBuddyHeartRateDecoder { if (byte and 0x80 == 0) return VarintRead(value, index) shift += 7 } - return null } - private fun readLengthDelimited( - data: ByteArray, - start: Int, - end: Int - ): LengthDelimitedRead? { - val length = readVarint(data, start, end) ?: return null - if (length.value > Int.MAX_VALUE) return null - - val valueEnd = length.nextIndex + length.value.toInt() - if (valueEnd < length.nextIndex || valueEnd > end) return null - return LengthDelimitedRead( - startIndex = length.nextIndex, - endIndex = valueEnd - ) - } + private fun ByteArray.unsignedByteAt(index: Int): Int = this[index].toInt().and(0xFF) - private enum class PayloadValidationFailure(val diagnosticCode: String) { - LENGTH("len"), - STATUS_TAIL("tail"), - BPM_RANGE("bpm_range") + private fun MutableMap.increment(key: K) { + this[key] = getOrDefault(key, 0) + 1 } - private data class SensorDataWx( - val sequence: Int, - val logType: Int, - val commands: List, - val referencedServices: Set, - val topLevelShape: String - ) - - private data class RtBuddyCommand( - val service: Int, - val directPayloadCount: Int, - val payloadCandidates: List, - val path: String, - val shape: String - ) - - private data class PayloadCandidate( - val bytes: ByteArray, - val path: String - ) - - private data class PayloadAnalysis( - val command: RtBuddyCommand, - val candidate: PayloadCandidate, - val failures: Set - ) + private data class HeartRateCommand(val payloadCandidates: List) - private data class ProtoMessage( - val entries: List, - val shape: String - ) + private data class ProtoMessage(val fields: List) { + fun firstVarint(fieldNumber: Int): Long? = fields.firstOrNull { + it.number == fieldNumber && it.wireType == WIRE_VARINT + }?.varintValue + } - private data class ProtoEntry( - val field: Int, + private data class ProtoField( + val number: Int, val wireType: Int, val varintValue: Long? = null, val valueStart: Int, val valueEnd: Int ) + private data class VarintRead(val value: Long, val nextIndex: Int) + private data class FrameClassification( - val isHeartRateRelated: Boolean = false, + val related: Boolean = false, val sample: HeartRateSample? = null, - val rejectionReason: HeartRateRejectionReason? = null, - val structuralDiagnostic: String? = null - ) - - private data class VarintRead( - val value: Long, - val nextIndex: Int - ) - - private data class LengthDelimitedRead( - val startIndex: Int, - val endIndex: Int + val rejectionReason: HeartRateRejectionReason? = null ) private companion object { @@ -638,10 +396,8 @@ internal class RtBuddyHeartRateDecoder { const val MAX_RTBUDDY_PAYLOAD_LENGTH = 16 * 1024 const val MIN_SENSITIVE_PREFIX_LENGTH = 5 - // AirPods firmware has emitted live HEARTRATE records using both log types and different - // exact status trailers depending on whether one or both earbuds participate in the session. - // Keep this an exact whitelist: the trailer is the discriminator that prevents startup/control - // service-19 records becoming BPM. + // Firmware has emitted live records with both log types and different exact status trailers + // depending on whether one or both earbuds participate in the session. val LIVE_SENSOR_DATA_LOG_TYPES = setOf(1, 3) val KNOWN_HEART_RATE_STATUS_TAILS = arrayOf( byteArrayOf(0x10, 0x00, 0x00), @@ -649,17 +405,19 @@ internal class RtBuddyHeartRateDecoder { byteArrayOf(0x20, 0x02, 0x80.toByte()), byteArrayOf(0x20, 0x82.toByte(), 0x80.toByte()) ) + + const val FIELD_SEQUENCE = 1 + const val FIELD_LOG_TYPE = 2 + const val FIELD_SERVICE = 1 + const val FIELD_COMMAND_PAYLOAD = 3 const val HEART_RATE_SERVICE = 19 const val HEART_RATE_PAYLOAD_LENGTH = 18 const val HEART_RATE_BPM_OFFSET = 1 - const val HEART_RATE_STATUS_TAIL_LENGTH = 3 - const val HEART_RATE_STATUS_TAIL_OFFSET = - HEART_RATE_PAYLOAD_LENGTH - HEART_RATE_STATUS_TAIL_LENGTH + const val HEART_RATE_STATUS_TAIL_OFFSET = 15 const val MIN_BPM = 30 const val MAX_BPM = 220 val SENSOR_DATA_COMMAND_FIELDS = setOf(5, 7, 8, 9, 12) - const val MAX_COMMAND_ENVELOPE_DEPTH = 3 const val MAX_PAYLOAD_WRAPPER_DEPTH = 3 const val MAX_COMMANDS_PER_FRAME = 16 @@ -668,12 +426,6 @@ internal class RtBuddyHeartRateDecoder { const val MAX_PROTO_FIELDS = 96 const val MAX_PROTO_FIELD_NUMBER = 4_096L - const val MAX_DIAGNOSTIC_SHAPE_FIELDS = 12 - const val MAX_DIAGNOSTIC_COMMANDS = 4 - const val MAX_DIAGNOSTIC_PAYLOADS_PER_COMMAND = 5 - const val MAX_DIAGNOSTIC_SIGNATURE_LENGTH = 480 - const val MALFORMED_STRUCTURE_DIAGNOSTIC = "malformed_sensor_data;raw=suppressed" - const val WIRE_VARINT = 0 const val WIRE_FIXED64 = 1 const val WIRE_LENGTH_DELIMITED = 2 @@ -697,14 +449,7 @@ private fun ByteArray.indexOfPrefix(prefix: ByteArray, startIndex: Int): Int { if (startIndex > lastStart) return -1 for (start in startIndex.coerceAtLeast(0)..lastStart) { - var matches = true - for (offset in prefix.indices) { - if (this[start + offset] != prefix[offset]) { - matches = false - break - } - } - if (matches) return start + if (prefix.indices.all { this[start + it] == prefix[it] }) return start } return -1 } @@ -716,15 +461,8 @@ private fun ByteArray.longestSuffixMatchingPrefix( val available = size - startIndex.coerceIn(0, size) val maxLength = minOf(available, prefix.size - 1) for (length in maxLength downTo 1) { - var matches = true val start = size - length - for (offset in 0 until length) { - if (this[start + offset] != prefix[offset]) { - matches = false - break - } - } - if (matches) return length + if ((0 until length).all { this[start + it] == prefix[it] }) return length } return 0 } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt index bd88b0ca9..c257da64f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt @@ -45,6 +45,12 @@ enum class HealthConnectExportStatus { ERROR } +data class HealthConnectExportState( + val enabled: Boolean = false, + val status: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, + val detailedSamples: Boolean = false +) + /** * Writes validated AirPods heart-rate samples to Health Connect at the selected interval. * @@ -80,16 +86,21 @@ class HealthConnectHeartRateExporter( private var healthConnectClient: HealthConnectClient? = null private var scheduledFlush: Job? = null - private val _enabled = MutableStateFlow(false) - val enabled: StateFlow get() = _enabled - - private val _detailedSamples = MutableStateFlow( - sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false) + private val _state = MutableStateFlow( + HealthConnectExportState( + status = statusForSdk(), + detailedSamples = sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false) + ) ) - val detailedSamples: StateFlow get() = _detailedSamples - - private val _status = MutableStateFlow(statusForSdk()) - val status: StateFlow get() = _status + val state: StateFlow get() = _state + + private fun updateState( + enabled: Boolean = _state.value.enabled, + status: HealthConnectExportStatus = _state.value.status, + detailedSamples: Boolean = _state.value.detailedSamples + ) { + _state.value = HealthConnectExportState(enabled, status, detailedSamples) + } fun refresh() { scope.launch { @@ -99,102 +110,80 @@ class HealthConnectHeartRateExporter( suspend fun refreshInternal() { mutex.withLock { - when (HealthConnectClient.getSdkStatus(appContext)) { - HealthConnectClient.SDK_AVAILABLE -> { - val client = getClient() - val granted = try { - hasWritePermission(client) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - Log.w(TAG, "Unable to query Health Connect permissions", error) - _enabled.value = false - _status.value = HealthConnectExportStatus.ERROR - return@withLock - } - - val requested = sharedPreferences.getBoolean(EXPORT_PREFERENCE, false) - val exportEnabled = requested && granted - _enabled.value = exportEnabled - _status.value = when { - !granted -> HealthConnectExportStatus.PERMISSION_REQUIRED - exportEnabled -> HealthConnectExportStatus.ENABLED - else -> HealthConnectExportStatus.READY - } - - if (exportEnabled && hasPendingSamplesLocked()) { - scheduleFlushLocked(0L) - } - } - - HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> { - healthConnectClient = null - _enabled.value = false - _status.value = HealthConnectExportStatus.UPDATE_REQUIRED - } - - else -> { - healthConnectClient = null - _enabled.value = false - _status.value = HealthConnectExportStatus.UNAVAILABLE - } + val requested = sharedPreferences.getBoolean(EXPORT_PREFERENCE, false) + _state.value = resolveStateLocked(requested) + if (_state.value.enabled && hasPendingSamplesLocked()) { + scheduleFlushLocked(0L) } } } fun setEnabled(enabled: Boolean) { scope.launch { - setEnabledInternal(enabled) - } - } - - private suspend fun setEnabledInternal(enabled: Boolean) { - mutex.withLock { - if (!enabled) { - scheduledFlush?.cancel() - scheduledFlush = null - flushLocked(forcePartialInterval = true) - sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } - _enabled.value = false - _status.value = disabledStatus() - return@withLock - } + mutex.withLock { + if (!enabled) { + scheduledFlush?.cancel() + scheduledFlush = null + flushLocked(forcePartialInterval = true) + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } + _state.value = resolveStateLocked(requested = false) + return@withLock + } - when (HealthConnectClient.getSdkStatus(appContext)) { - HealthConnectClient.SDK_AVAILABLE -> { - val granted = try { - hasWritePermission(getClient()) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - Log.w(TAG, "Unable to enable Health Connect export", error) - _enabled.value = false - _status.value = HealthConnectExportStatus.ERROR - return@withLock - } + val nextState = resolveStateLocked(requested = true) + when (nextState.status) { + HealthConnectExportStatus.ENABLED -> + sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, true) } - if (!granted) { + HealthConnectExportStatus.PERMISSION_REQUIRED -> sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } - _enabled.value = false - _status.value = HealthConnectExportStatus.PERMISSION_REQUIRED - return@withLock - } - sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, true) } - _enabled.value = true - _status.value = HealthConnectExportStatus.ENABLED - if (hasPendingSamplesLocked()) scheduleFlushLocked(0L) + else -> Unit } - - HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> { - _enabled.value = false - _status.value = HealthConnectExportStatus.UPDATE_REQUIRED + _state.value = nextState + if (nextState.enabled && hasPendingSamplesLocked()) { + scheduleFlushLocked(0L) } + } + } + } - else -> { - _enabled.value = false - _status.value = HealthConnectExportStatus.UNAVAILABLE + private suspend fun resolveStateLocked(requested: Boolean): HealthConnectExportState { + val current = _state.value + return when (HealthConnectClient.getSdkStatus(appContext)) { + HealthConnectClient.SDK_AVAILABLE -> { + val granted = try { + hasWritePermission(getClient()) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "Unable to query Health Connect permissions", error) + return current.copy(enabled = false, status = HealthConnectExportStatus.ERROR) } + current.copy( + enabled = requested && granted, + status = when { + !granted -> HealthConnectExportStatus.PERMISSION_REQUIRED + requested -> HealthConnectExportStatus.ENABLED + else -> HealthConnectExportStatus.READY + } + ) + } + + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> { + healthConnectClient = null + current.copy( + enabled = false, + status = HealthConnectExportStatus.UPDATE_REQUIRED + ) + } + + else -> { + healthConnectClient = null + current.copy( + enabled = false, + status = HealthConnectExportStatus.UNAVAILABLE + ) } } } @@ -202,7 +191,7 @@ class HealthConnectHeartRateExporter( fun setDetailedSamples(detailed: Boolean) { scope.launch { mutex.withLock { - if (_detailedSamples.value == detailed) { + if (_state.value.detailedSamples == detailed) { requestedDetailedSamples = null return@withLock } @@ -211,7 +200,7 @@ class HealthConnectHeartRateExporter( scheduledFlush?.cancel() scheduledFlush = null if (hasPendingSamplesLocked() && - (!_enabled.value || !flushLocked(forcePartialInterval = true)) + (!_state.value.enabled || !flushLocked(forcePartialInterval = true)) ) { return@withLock } @@ -224,18 +213,20 @@ class HealthConnectHeartRateExporter( scope.launch { mutex.withLock { sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } - _enabled.value = false - _status.value = HealthConnectExportStatus.PERMISSION_DENIED + updateState( + enabled = false, + status = HealthConnectExportStatus.PERMISSION_DENIED + ) } } } fun enqueue(sample: HeartRateSample, deviceModel: String) { - if (!_enabled.value) return + if (!_state.value.enabled) return scope.launch { mutex.withLock { - if (!_enabled.value) return@withLock + if (!_state.value.enabled) return@withLock val id = clientRecordId(sample) pendingSamples.putIfAbsent( @@ -283,9 +274,9 @@ class HealthConnectHeartRateExporter( applyRequestedDetailLocked() return true } - if (!_enabled.value) return false + if (!_state.value.enabled) return false - while (_enabled.value && hasPendingSamplesLocked()) { + while (_state.value.enabled && hasPendingSamplesLocked()) { val record = getOrCreatePendingRecordLocked( forcePartialInterval || requestedDetailedSamples != null ) @@ -297,14 +288,16 @@ class HealthConnectHeartRateExporter( try { getClient().insertRecords(listOf(toRecord(record))) completePendingRecordLocked(record) - _status.value = HealthConnectExportStatus.ENABLED + updateState(status = HealthConnectExportStatus.ENABLED) } catch (error: CancellationException) { throw error } catch (error: SecurityException) { Log.w(TAG, "Health Connect permission was revoked", error) sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) } - _enabled.value = false - _status.value = HealthConnectExportStatus.PERMISSION_REQUIRED + updateState( + enabled = false, + status = HealthConnectExportStatus.PERMISSION_REQUIRED + ) return false } catch (error: IOException) { handleRetryableWriteFailureLocked( @@ -333,7 +326,7 @@ class HealthConnectHeartRateExporter( private fun handleRetryableWriteFailureLocked(message: String, error: Exception) { Log.w(TAG, message, error) - _status.value = HealthConnectExportStatus.ERROR + updateState(status = HealthConnectExportStatus.ERROR) scheduleFlushLocked(RETRY_INTERVAL_MILLIS) } @@ -343,7 +336,7 @@ class HealthConnectHeartRateExporter( intervalWindowStartMillis = null sharedPreferences.edit { putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) } - _detailedSamples.value = detailed + updateState(detailedSamples = detailed) requestedDetailedSamples = null } @@ -477,7 +470,7 @@ class HealthConnectHeartRateExporter( } } - private fun exportIntervalMillis(): Long = if (_detailedSamples.value) { + private fun exportIntervalMillis(): Long = if (_state.value.detailedSamples) { SECOND_INTERVAL_MILLIS } else { MINUTE_INTERVAL_MILLIS @@ -534,31 +527,6 @@ class HealthConnectHeartRateExporter( else -> HealthConnectExportStatus.UNAVAILABLE } - private suspend fun disabledStatus(): HealthConnectExportStatus { - return when (HealthConnectClient.getSdkStatus(appContext)) { - HealthConnectClient.SDK_AVAILABLE -> { - val permissionGranted = try { - hasWritePermission(getClient()) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - Log.w(TAG, "Unable to query Health Connect permissions", error) - return HealthConnectExportStatus.ERROR - } - if (permissionGranted) { - HealthConnectExportStatus.READY - } else { - HealthConnectExportStatus.PERMISSION_REQUIRED - } - } - - HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> - HealthConnectExportStatus.UPDATE_REQUIRED - - else -> HealthConnectExportStatus.UNAVAILABLE - } - } - private fun clientRecordId(sample: HeartRateSample): String = "librepods-heart-rate-v1-${sample.receivedAtMillis}-${sample.sequence}-${sample.bpm}" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt index 299811006..9cbe88fbd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt @@ -30,43 +30,155 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.services.HeartRateMonitoringState import me.kavishdevar.librepods.services.HeartRateMonitoringStatus @Composable fun HeartRateCard( - monitoringEnabled: Boolean, - monitoringStatus: HeartRateMonitoringStatus, - latestSample: HeartRateSample?, - heartRateSamples: List, + state: HeartRateMonitoringState, onMonitoringChanged: (Boolean) -> Unit, onReconnectAacp: () -> Unit, onOpenDetails: () -> Unit, modifier: Modifier = Modifier ) { val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable( - sample = latestSample, - monitoringStatus = monitoringStatus + sample = state.latestSample, + monitoringStatus = state.status ) - val displayedBpm = latestSample + val displayedBpm = state.latestSample ?.takeIf { sampleIsDisplayable } ?.bpm ?.toString() ?: EM_DASH - val graphValues = remember(heartRateSamples) { - normalizedRecentHeartRates(heartRateSamples) + val graphValues = remember(state.samples) { + normalizedRecentHeartRates(state.samples) } - val canReconnectAacp = monitoringStatus == HeartRateMonitoringStatus.COULDNT_START + val canReconnectAacp = state.status == HeartRateMonitoringStatus.COULDNT_START + when (LocalDesignSystem.current) { + DesignSystem.Material -> MaterialHeartRateCard( + displayedBpm = displayedBpm, + graphValues = graphValues, + state = state, + canReconnectAacp = canReconnectAacp, + onMonitoringChanged = onMonitoringChanged, + onReconnectAacp = onReconnectAacp, + onOpenDetails = onOpenDetails, + modifier = modifier + ) + + DesignSystem.Apple -> AppleHeartRateCard( + displayedBpm = displayedBpm, + graphValues = graphValues, + state = state, + canReconnectAacp = canReconnectAacp, + onMonitoringChanged = onMonitoringChanged, + onReconnectAacp = onReconnectAacp, + onOpenDetails = onOpenDetails, + modifier = modifier + ) + } +} + +@Composable +private fun MaterialHeartRateCard( + displayedBpm: String, + graphValues: List, + state: HeartRateMonitoringState, + canReconnectAacp: Boolean, + onMonitoringChanged: (Boolean) -> Unit, + onReconnectAacp: () -> Unit, + onOpenDetails: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onOpenDetails), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HeartRateMiniGraph( + values = graphValues, + width = MATERIAL_GRAPH_WIDTH, + height = MATERIAL_GRAPH_HEIGHT + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "Heart rate", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + HeartRateStatusChip( + status = state.status, + onRetry = onReconnectAacp.takeIf { canReconnectAacp }, + compact = true + ) + } + + if (!canReconnectAacp) { + Spacer(modifier = Modifier.width(10.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = displayedBpm, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Switch( + checked = state.enabled, + onCheckedChange = onMonitoringChanged, + modifier = Modifier.scale(MATERIAL_SWITCH_SCALE) + ) + } + } +} + +@Composable +private fun AppleHeartRateCard( + displayedBpm: String, + graphValues: List, + state: HeartRateMonitoringState, + canReconnectAacp: Boolean, + onMonitoringChanged: (Boolean) -> Unit, + onReconnectAacp: () -> Unit, + onOpenDetails: () -> Unit, + modifier: Modifier = Modifier +) { Card( modifier = modifier .fillMaxWidth() @@ -93,15 +205,10 @@ fun HeartRateCard( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - HeartRateStatusChip( - status = monitoringStatus, - onRetry = onReconnectAacp.takeIf { canReconnectAacp } - ) - } + HeartRateStatusChip( + status = state.status, + onRetry = onReconnectAacp.takeIf { canReconnectAacp } + ) } if (!canReconnectAacp) { @@ -121,17 +228,10 @@ fun HeartRateCard( Spacer(modifier = Modifier.width(14.dp)) } - when (LocalDesignSystem.current) { - DesignSystem.Material -> Switch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged - ) - - DesignSystem.Apple -> StyledSwitch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged - ) - } + StyledSwitch( + checked = state.enabled, + onCheckedChange = onMonitoringChanged + ) } } } @@ -139,15 +239,17 @@ fun HeartRateCard( @Composable private fun HeartRateMiniGraph( values: List, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + width: Dp = GRAPH_WIDTH, + height: Dp = GRAPH_HEIGHT ) { val graphColor = MaterialTheme.colorScheme.primary val guideColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) Canvas( modifier = modifier - .width(GRAPH_WIDTH) - .height(GRAPH_HEIGHT) + .width(width) + .height(height) ) { val horizontalPadding = 2.dp.toPx() val verticalPadding = 4.dp.toPx() @@ -261,6 +363,9 @@ private fun normalizedRecentHeartRates(samples: List): List Unit)? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + compact: Boolean = false ) { val containerColor = when (status) { HeartRateMonitoringStatus.LIVE -> MaterialTheme.colorScheme.primaryContainer @@ -54,8 +55,15 @@ fun HeartRateStatusChip( ) { Text( text = if (onRetry == null) status.label else "${status.label} · Retry", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + style = if (compact) { + MaterialTheme.typography.labelMedium + } else { + MaterialTheme.typography.labelLarge + }, + modifier = Modifier.padding( + horizontal = if (compact) 10.dp else 12.dp, + vertical = if (compact) 4.dp else 7.dp + ) ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 0a4c69487..110864942 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -344,10 +344,7 @@ fun AirPodsSettingsScreen( } item(key = "heart_rate") { HeartRateCard( - monitoringEnabled = state.heartRateMonitoringEnabled, - monitoringStatus = state.heartRateMonitoringStatus, - latestSample = state.heartRateSamples.lastOrNull(), - heartRateSamples = state.heartRateSamples, + state = state.heartRate, onMonitoringChanged = setHeartRateMonitoringEnabled, onReconnectAacp = reconnectAacpForHeartRate, onOpenDetails = navigateToHeartRateTest diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 2b0f4773b..99d007b39 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke @@ -55,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.health.connect.client.PermissionController import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip @@ -64,13 +66,12 @@ import me.kavishdevar.librepods.presentation.components.rememberHeartRateSampleI import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.services.HeartRateMonitoringState import me.kavishdevar.librepods.services.HeartRateMonitoringStatus import java.text.DateFormat import java.util.Date import kotlin.math.ceil import kotlin.math.floor -import kotlin.math.max -import kotlin.math.round @Composable fun HeartRateTestScreen(viewModel: AirPodsViewModel) { @@ -97,10 +98,11 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { } val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp - val latestSample = state.heartRateSamples.lastOrNull() + val heartRate = state.heartRate + val healthConnect = state.healthConnect val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable( - sample = latestSample, - monitoringStatus = state.heartRateMonitoringStatus + sample = heartRate.latestSample, + monitoringStatus = heartRate.status ) Column( modifier = Modifier @@ -112,10 +114,8 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(topPadding)) HeartRateSummaryCard( - monitoringEnabled = state.heartRateMonitoringEnabled, - latestSample = latestSample, + state = heartRate, sampleIsDisplayable = sampleIsDisplayable, - monitoringStatus = state.heartRateMonitoringStatus, onReconnectAacp = viewModel::reconnectAacpForHeartRate, onMonitoringChanged = viewModel::setHeartRateMonitoringEnabled ) @@ -123,16 +123,14 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(16.dp)) HealthConnectControls( - status = state.healthConnectExportStatus, - exportEnabled = state.healthConnectExportEnabled, - detailedSamples = state.healthConnectDetailedSamples, + state = healthConnect, onExportChanged = { enabled -> when { !enabled -> viewModel.setHealthConnectExportEnabled(false) - state.healthConnectExportStatus.canEnableExport -> + healthConnect.status.canEnableExport -> viewModel.setHealthConnectExportEnabled(true) - state.healthConnectExportStatus.requiresPermissionRequest -> + healthConnect.status.requiresPermissionRequest -> healthConnectPermissionLauncher.launch( HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS ) @@ -151,13 +149,13 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { ) Text( - text = formatGraphSummary(state.heartRateSamples), + text = formatGraphSummary(heartRate.samples), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) ) - HeartRateGraph(samples = state.heartRateSamples) + HeartRateGraph(samples = heartRate.samples) Spacer(modifier = Modifier.height(bottomPadding)) } @@ -165,86 +163,127 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { @Composable private fun HeartRateSummaryCard( - monitoringEnabled: Boolean, - latestSample: HeartRateSample?, + state: HeartRateMonitoringState, sampleIsDisplayable: Boolean, - monitoringStatus: HeartRateMonitoringStatus, onReconnectAacp: () -> Unit, onMonitoringChanged: (Boolean) -> Unit ) { + val materialDesign = LocalDesignSystem.current == DesignSystem.Material + Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(if (materialDesign) 24.dp else 28.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + verticalArrangement = Arrangement.spacedBy(if (materialDesign) 12.dp else 14.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - Column { - Text( - text = latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "BPM", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Column(horizontalAlignment = Alignment.End) { - Box(modifier = Modifier.padding(end = 4.dp, bottom = 8.dp)) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> Switch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold ) - - DesignSystem.Apple -> StyledSwitch( - checked = monitoringEnabled, - onCheckedChange = onMonitoringChanged + Text( + text = "BPM", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + Switch( + checked = state.enabled, + onCheckedChange = onMonitoringChanged, + modifier = Modifier.scale(MATERIAL_SWITCH_SCALE) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatLastReading(state.latestSample), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + HeartRateStatusChip( + status = state.status, + onRetry = onReconnectAacp.takeIf { + state.status == HeartRateMonitoringStatus.COULDNT_START + }, + compact = true + ) } - HeartRateStatusChip( - status = monitoringStatus, - onRetry = onReconnectAacp.takeIf { - monitoringStatus == HeartRateMonitoringStatus.COULDNT_START + } + + DesignSystem.Apple -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column { + Text( + text = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + Column(horizontalAlignment = Alignment.End) { + Box(modifier = Modifier.padding(end = 4.dp, bottom = 8.dp)) { + StyledSwitch( + checked = state.enabled, + onCheckedChange = onMonitoringChanged + ) + } + HeartRateStatusChip( + status = state.status, + onRetry = onReconnectAacp.takeIf { + state.status == HeartRateMonitoringStatus.COULDNT_START + } + ) + } + } + + Text( + text = formatLastReading(state.latestSample), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - - Text( - text = formatLastReading(latestSample), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } } @Composable private fun HealthConnectControls( - status: HealthConnectExportStatus, - exportEnabled: Boolean, - detailedSamples: Boolean, + state: HealthConnectExportState, onExportChanged: (Boolean) -> Unit, onDetailedSamplesChanged: (Boolean) -> Unit ) { - val available = status.isAvailable + val available = state.status.isAvailable StyledToggle( title = "Health Connect", label = "Save heart-rate samples", - description = healthConnectDescription(status, detailedSamples), - checked = exportEnabled, + description = healthConnectDescription(state.status, state.detailedSamples), + checked = state.enabled, enabled = available, onCheckedChange = onExportChanged ) @@ -254,12 +293,12 @@ private fun HealthConnectControls( StyledToggle( title = null, label = "Detailed samples", - description = if (detailedSamples) { + description = if (state.detailedSamples) { "Save one BPM record every second." } else { "Save one average BPM record every minute." }, - checked = detailedSamples, + checked = state.detailedSamples, enabled = available, onCheckedChange = onDetailedSamplesChanged ) @@ -475,146 +514,46 @@ private fun sampleX( private fun calculateHeartRateChartScale(bpms: List): HeartRateChartScale { if (bpms.isEmpty()) { - return createHeartRateChartScale( + return HeartRateChartScale( minBpm = CHART_DEFAULT_MIN_BPM, - maxBpm = CHART_DEFAULT_MAX_BPM + maxBpm = CHART_DEFAULT_MAX_BPM, + gridLines = listOf(60f, 70f, 80f, 90f, 100f) ) } - val dataMin = bpms.minOrNull()!! - val dataMax = bpms.maxOrNull()!! - val dataRange = dataMax - dataMin - val margin = max(CHART_MIN_MARGIN_BPM, dataRange * CHART_MARGIN_FRACTION) - val requiredSpan = dataRange + margin * 2f - - val initialBounds = if (requiredSpan <= CHART_MIN_SPAN_BPM) { - val center = (dataMin + dataMax) / 2f - val roundedCenter = roundToIncrement(center, CHART_NARROW_CENTER_INCREMENT_BPM) - val halfSpan = CHART_MIN_SPAN_BPM / 2f - roundedCenter - halfSpan to roundedCenter + halfSpan - } else { - val boundIncrement = if (requiredSpan <= CHART_FINE_BOUND_THRESHOLD_BPM) { - CHART_FINE_BOUND_INCREMENT_BPM - } else { - CHART_COARSE_BOUND_INCREMENT_BPM - } - floorToIncrement(dataMin - margin, boundIncrement) to - ceilToIncrement(dataMax + margin, boundIncrement) - } - - val constrainedBounds = constrainHeartRateBounds( - minBpm = initialBounds.first, - maxBpm = initialBounds.second, - dataMin = dataMin, - dataMax = dataMax - ) - - return createHeartRateChartScale( - minBpm = constrainedBounds.first, - maxBpm = constrainedBounds.second - ) -} - -private fun constrainHeartRateBounds( - minBpm: Float, - maxBpm: Float, - dataMin: Float, - dataMax: Float -): Pair { - val preferredBounds = fitBoundsWithinLimits( - minBpm = minBpm, - maxBpm = maxBpm, - dataMin = dataMin, - dataMax = dataMax, - limitMin = CHART_SAFETY_MIN_BPM, - limitMax = CHART_SAFETY_MAX_BPM - ) - return fitBoundsWithinLimits( - minBpm = preferredBounds.first, - maxBpm = preferredBounds.second, - dataMin = dataMin, - dataMax = dataMax, - limitMin = CHART_OUTER_MIN_BPM, - limitMax = CHART_OUTER_MAX_BPM - ) -} - -private fun fitBoundsWithinLimits( - minBpm: Float, - maxBpm: Float, - dataMin: Float, - dataMax: Float, - limitMin: Float, - limitMax: Float -): Pair { - val safetyInset = CHART_MIN_MARGIN_BPM - if (dataMin < limitMin + safetyInset || dataMax > limitMax - safetyInset) { - return minBpm to maxBpm - } - - val span = maxBpm - minBpm - val limitSpan = limitMax - limitMin - if (span >= limitSpan) { - return limitMin to limitMax + val dataMin = bpms.minOrNull() ?: CHART_DEFAULT_MIN_BPM + val dataMax = bpms.maxOrNull() ?: CHART_DEFAULT_MAX_BPM + val paddedMin = dataMin - CHART_MARGIN_BPM + val paddedMax = dataMax + CHART_MARGIN_BPM + val requestedSpan = maxOf(paddedMax - paddedMin, CHART_MIN_SPAN_BPM) + val center = (paddedMin + paddedMax) / 2f + val roughMin = center - requestedSpan / 2f + val roughMax = center + requestedSpan / 2f + val tickStep = niceTickStep(requestedSpan / CHART_TARGET_GRID_INTERVALS) + + var minBpm = floor(roughMin / tickStep) * tickStep + var maxBpm = ceil(roughMax / tickStep) * tickStep + if (minBpm < CHART_OUTER_MIN_BPM) { + maxBpm -= minBpm - CHART_OUTER_MIN_BPM + minBpm = CHART_OUTER_MIN_BPM } - - var adjustedMin = minBpm - var adjustedMax = maxBpm - if (adjustedMin < limitMin) { - val shift = limitMin - adjustedMin - adjustedMin += shift - adjustedMax += shift - } - if (adjustedMax > limitMax) { - val shift = adjustedMax - limitMax - adjustedMin -= shift - adjustedMax -= shift - } - return adjustedMin to adjustedMax -} - -private fun createHeartRateChartScale( - minBpm: Float, - maxBpm: Float -): HeartRateChartScale { - val span = (maxBpm - minBpm).coerceAtLeast(CHART_MIN_SPAN_BPM) - val adjustedMax = minBpm + span - val tickStep = calculateHeartRateTickStep(span) - val intervalCount = floor(span / tickStep).toInt() - val gridLines = (0..intervalCount).map { index -> - minBpm + index * tickStep - } - - return HeartRateChartScale( - minBpm = minBpm, - maxBpm = adjustedMax, - gridLines = gridLines - ) -} - -private fun calculateHeartRateTickStep(spanBpm: Float): Float { - val rawStep = spanBpm / CHART_TARGET_GRID_INTERVALS - val increment = if (rawStep <= CHART_FINE_TICK_THRESHOLD_BPM) { - CHART_FINE_TICK_INCREMENT_BPM - } else { - CHART_COARSE_TICK_INCREMENT_BPM + if (maxBpm > CHART_OUTER_MAX_BPM) { + minBpm -= maxBpm - CHART_OUTER_MAX_BPM + maxBpm = CHART_OUTER_MAX_BPM } - var step = max(increment, roundToIncrement(rawStep, increment)) - while (floor(spanBpm / step).toInt() + 1 > CHART_MAX_GRID_LINES) { - step += increment + val gridLines = buildList { + var value = minBpm + while (value <= maxBpm + 0.01f) { + add(value) + value += tickStep + } } - return step + return HeartRateChartScale(minBpm, maxBpm, gridLines) } -private fun roundToIncrement(value: Float, increment: Float): Float = - round(value / increment) * increment - -private fun floorToIncrement(value: Float, increment: Float): Float = - floor(value / increment) * increment - -private fun ceilToIncrement(value: Float, increment: Float): Float = - ceil(value / increment) * increment +private fun niceTickStep(rawStep: Float): Float = + CHART_TICK_STEPS.firstOrNull { it >= rawStep } ?: CHART_TICK_STEPS.last() private fun formatLastReading(sample: HeartRateSample?): String { if (sample == null) return "Last reading: $EM_DASH" @@ -624,24 +563,15 @@ private fun formatLastReading(sample: HeartRateSample?): String { } private const val EM_DASH = "—" +private const val MATERIAL_SWITCH_SCALE = 0.82f private const val CHART_DEFAULT_MIN_BPM = 60f private const val CHART_DEFAULT_MAX_BPM = 100f private const val CHART_MIN_SPAN_BPM = 40f -private const val CHART_MIN_MARGIN_BPM = 5f -private const val CHART_MARGIN_FRACTION = 0.10f -private const val CHART_NARROW_CENTER_INCREMENT_BPM = 5f -private const val CHART_FINE_BOUND_THRESHOLD_BPM = 80f -private const val CHART_FINE_BOUND_INCREMENT_BPM = 5f -private const val CHART_COARSE_BOUND_INCREMENT_BPM = 10f -private const val CHART_SAFETY_MIN_BPM = 20f -private const val CHART_SAFETY_MAX_BPM = 240f +private const val CHART_MARGIN_BPM = 5f private const val CHART_OUTER_MIN_BPM = 0f private const val CHART_OUTER_MAX_BPM = 260f private const val CHART_TARGET_GRID_INTERVALS = 5f -private const val CHART_FINE_TICK_THRESHOLD_BPM = 25f -private const val CHART_FINE_TICK_INCREMENT_BPM = 5f -private const val CHART_COARSE_TICK_INCREMENT_BPM = 10f -private const val CHART_MAX_GRID_LINES = 7 +private val CHART_TICK_STEPS = listOf(5f, 10f, 20f, 25f, 50f) private val CHART_AXIS_WIDTH = 42.dp private val CHART_AXIS_LABEL_GAP = 8.dp private val CHART_TOP_INSET = 20.dp diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 6ebb905f5..2722df062 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.kavishdevar.librepods.BuildConfig @@ -42,7 +43,6 @@ import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsNotifications @@ -54,8 +54,10 @@ import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider +import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.HeartRateMonitoringState import me.kavishdevar.librepods.services.HeartRateMonitoringStatus @Suppress("ArrayInDataClass") @@ -83,14 +85,8 @@ data class AirPodsUiState( val headTrackingActive: Boolean = false, val headGesturesEnabled: Boolean = true, - val heartRateMonitoringEnabled: Boolean = false, - val heartRateStreaming: Boolean = false, - val heartRateMonitoringStatus: HeartRateMonitoringStatus = - HeartRateMonitoringStatus.OFF, - val heartRateSamples: List = emptyList(), - val healthConnectExportEnabled: Boolean = false, - val healthConnectExportStatus: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, - val healthConnectDetailedSamples: Boolean = false, + val heartRate: HeartRateMonitoringState = HeartRateMonitoringState(), + val healthConnect: HealthConnectExportState = HealthConnectExportState(), val eqData: FloatArray = floatArrayOf(), @@ -474,38 +470,10 @@ class AirPodsViewModel( private fun observeHeartRate() { viewModelScope.launch { - service.heartRateMonitoringEnabled.collect { enabled -> - _uiState.update { it.copy(heartRateMonitoringEnabled = enabled) } - } - } - viewModelScope.launch { - service.heartRateStreaming.collect { streaming -> - _uiState.update { it.copy(heartRateStreaming = streaming) } - } - } - viewModelScope.launch { - service.heartRateMonitoringStatus.collect { status -> - _uiState.update { it.copy(heartRateMonitoringStatus = status) } - } - } - viewModelScope.launch { - service.heartRateSamples.collect { samples -> - _uiState.update { it.copy(heartRateSamples = samples) } - } - } - viewModelScope.launch { - service.healthConnectExportEnabled.collect { enabled -> - _uiState.update { it.copy(healthConnectExportEnabled = enabled) } - } - } - viewModelScope.launch { - service.healthConnectExportStatus.collect { status -> - _uiState.update { it.copy(healthConnectExportStatus = status) } - } - } - viewModelScope.launch { - service.healthConnectDetailedSamples.collect { detailed -> - _uiState.update { it.copy(healthConnectDetailedSamples = detailed) } + combine(service.heartRateState, service.healthConnectState) { heartRate, export -> + heartRate to export + }.collect { (heartRate, export) -> + _uiState.update { it.copy(heartRate = heartRate, healthConnect = export) } } } } @@ -516,13 +484,8 @@ class AirPodsViewModel( _uiState.update { it.copy( isLocallyConnected = service.isAacpTransportHealthy(), - heartRateMonitoringEnabled = service.heartRateMonitoringEnabled.value, - heartRateStreaming = service.heartRateStreaming.value, - heartRateMonitoringStatus = service.heartRateMonitoringStatus.value, - heartRateSamples = service.heartRateSamples.value, - healthConnectExportEnabled = service.healthConnectExportEnabled.value, - healthConnectExportStatus = service.healthConnectExportStatus.value, - healthConnectDetailedSamples = service.healthConnectDetailedSamples.value, + heartRate = service.heartRateState.value, + healthConnect = service.healthConnectState.value, battery = service.getBattery(), ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, controlStates = controlRepo.getMap() @@ -705,13 +668,14 @@ class AirPodsViewModel( if (isDemoMode) { _uiState.update { it.copy( - heartRateMonitoringEnabled = enabled, - heartRateStreaming = enabled && it.isLocallyConnected, - heartRateMonitoringStatus = when { - !enabled -> HeartRateMonitoringStatus.OFF - it.isLocallyConnected -> HeartRateMonitoringStatus.LIVE - else -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS - } + heartRate = it.heartRate.copy( + enabled = enabled, + status = when { + !enabled -> HeartRateMonitoringStatus.OFF + it.isLocallyConnected -> HeartRateMonitoringStatus.LIVE + else -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } + ) ) } return @@ -734,12 +698,14 @@ class AirPodsViewModel( if (isDemoMode) { _uiState.update { it.copy( - healthConnectExportEnabled = enabled, - healthConnectExportStatus = if (enabled) { - HealthConnectExportStatus.ENABLED - } else { - HealthConnectExportStatus.READY - } + healthConnect = it.healthConnect.copy( + enabled = enabled, + status = if (enabled) { + HealthConnectExportStatus.ENABLED + } else { + HealthConnectExportStatus.READY + } + ) ) } return @@ -750,7 +716,11 @@ class AirPodsViewModel( fun setHealthConnectDetailedSamples(detailed: Boolean) { if (!isReady) return if (isDemoMode) { - _uiState.update { it.copy(healthConnectDetailedSamples = detailed) } + _uiState.update { + it.copy( + healthConnect = it.healthConnect.copy(detailedSamples = detailed) + ) + } return } service.setHealthConnectDetailedSamples(detailed) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 9ff90a63f..4ab072618 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -107,7 +107,7 @@ import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.isHeadTrackingData -import me.kavishdevar.librepods.health.HealthConnectExportStatus +import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.overlays.IslandType import me.kavishdevar.librepods.presentation.overlays.IslandWindow @@ -242,7 +242,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val inMemoryLogs = mutableSetOf() private val heartRateScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val heartRateLock = Any() private val transportRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val transportRecoveryLock = Any() private val aacpConnectLock = Any() @@ -253,47 +252,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var aacpTransportResponsive = false @Volatile private var lastAacpPacketElapsedRealtime = 0L - private var heartRateStartJob: Job? = null - private var heartRateSessionRequested = false - private var heartRateStartCommandSent = false - private var lastValidHeartRateSampleElapsedRealtime: Long? = null - private var heartRateSamplesToDiscardAfterRefresh = 0 - private var activeHeartRateRefreshReason: HeartRateRefreshReason? = null - private var heartRateRefreshAttemptCount = 0 - private var heartRateRefreshAttemptStartedAt: Long? = null - private var heartRateRefreshStartedAtElapsedRealtime: Long? = null - - private enum class HeartRateRefreshReason(val diagnosticName: String) { - FIRST_SAMPLE_TIMEOUT("first-sample-timeout"), - STREAM_STALLED("stream-stalled") - } - - private data class HeartRateRefreshCompletion( - val reason: HeartRateRefreshReason, - val attempt: Int - ) - - private val _heartRateMonitoringEnabled = MutableStateFlow(false) - val heartRateMonitoringEnabled: StateFlow get() = _heartRateMonitoringEnabled - - private val _heartRateStreaming = MutableStateFlow(false) - val heartRateStreaming: StateFlow get() = _heartRateStreaming - - private val _heartRateMonitoringStatus = - MutableStateFlow(HeartRateMonitoringStatus.OFF) - val heartRateMonitoringStatus: StateFlow - get() = _heartRateMonitoringStatus - private val _heartRateSamples = MutableStateFlow>(emptyList()) - val heartRateSamples: StateFlow> get() = _heartRateSamples + private lateinit var heartRateMonitor: HeartRateMonitor + val heartRateState: StateFlow + get() = heartRateMonitor.state private lateinit var heartRateExporter: HealthConnectHeartRateExporter - val healthConnectExportEnabled: StateFlow - get() = heartRateExporter.enabled - val healthConnectExportStatus: StateFlow - get() = heartRateExporter.status - val healthConnectDetailedSamples: StateFlow - get() = heartRateExporter.detailedSamples + val healthConnectState: StateFlow + get() = heartRateExporter.state private var handleIncomingCallOnceConnected = false @@ -301,13 +267,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList companion object { private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" - private const val MAX_HEART_RATE_SAMPLES = 60 - private const val HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS = 8_000L - private const val HEART_RATE_RECONNECT_TIMEOUT_MILLIS = 15_000L private const val HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS = 3_000L - private const val HEART_RATE_STALL_TIMEOUT_MILLIS = 2_000L - private const val HEART_RATE_WATCHDOG_INTERVAL_MILLIS = 1_000L - private const val HEART_RATE_REFRESH_SAMPLES_TO_DISCARD = 4 private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L @@ -315,7 +275,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "me.kavishdevar.librepods.extra.AACP_TRANSPORT_FAILURE" private val AACP_RECONNECT_BACKOFF_MILLIS = longArrayOf(750L, 1_500L, 3_000L, 5_000L, 10_000L) - private val HEART_RATE_RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) init { System.loadLibrary("bluetooth_socket") @@ -458,15 +417,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList _packetLogsFlow.value = inMemoryLogs.toSet() sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - _heartRateMonitoringEnabled.value = sharedPreferences.getBoolean( - HEART_RATE_MONITORING_PREFERENCE, - false - ) - _heartRateMonitoringStatus.value = if (_heartRateMonitoringEnabled.value) { - HeartRateMonitoringStatus.WAITING_FOR_AIRPODS - } else { - HeartRateMonitoringStatus.OFF - } heartRateExporter = HealthConnectHeartRateExporter( context = applicationContext, sharedPreferences = sharedPreferences, @@ -476,6 +426,38 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList initializeConfig() aacpManager = AACPManager() + heartRateMonitor = HeartRateMonitor( + scope = heartRateScope, + initiallyEnabled = sharedPreferences.getBoolean( + HEART_RATE_MONITORING_PREFERENCE, + false + ), + isTransportReady = ::isAacpTransportHealthy, + beforeFirstStart = { + if (isHeadTrackingActive) { + stopHeadTracking() + delay(220) + } + }, + sendConnectService0 = aacpManager::sendHeartRateConnectService0, + sendCapabilitiesService0 = aacpManager::sendHeartRateCapabilitiesService0, + sendConnectService4 = aacpManager::sendHeartRateConnectService4, + sendCapabilitiesService4 = aacpManager::sendHeartRateCapabilitiesService4, + enableHeartRate = { + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, + true + ) + }, + sendStart = aacpManager::sendHeartRateStartFrame, + sendStop = { aacpManager.sendHeartRateStopFrame() }, + onPublishedSample = { sample -> + heartRateExporter.enqueue( + sample = sample, + deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName } + ) + } + ) initializeAACPManagerCallback() attManager = ATTManagerv2() @@ -1188,50 +1170,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onHeartRateReceived(sample: HeartRateSample) { - var completedRefresh: HeartRateRefreshCompletion? = null - val shouldPublish = synchronized(heartRateLock) { - if (!_heartRateMonitoringEnabled.value || - BluetoothConnectionManager.aacpSocket?.isConnected != true - ) { - false - } else if (!heartRateStartCommandSent) { - false - } else { - val receivedAt = SystemClock.elapsedRealtime() - if (consumeHeartRateWarmupSampleLocked()) { - false - } else { - lastValidHeartRateSampleElapsedRealtime = receivedAt - val refreshAttemptStartedAt = heartRateRefreshAttemptStartedAt - val refreshReason = activeHeartRateRefreshReason - if (refreshReason != null && refreshAttemptStartedAt != null && - receivedAt >= refreshAttemptStartedAt - ) { - completedRefresh = HeartRateRefreshCompletion( - reason = refreshReason, - attempt = heartRateRefreshAttemptCount - ) - clearActiveHeartRateRefreshLocked() - } - true - } - } - } - if (!shouldPublish) return - - completedRefresh?.let { refresh -> - Log.i( - TAG, - "RTBuddy heart-rate refresh succeeded " + - "reason=${refresh.reason.diagnosticName} attempt=${refresh.attempt}" - ) - } - _heartRateSamples.value = - (_heartRateSamples.value + sample).takeLast(MAX_HEART_RATE_SAMPLES) - heartRateExporter.enqueue( - sample = sample, - deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName } - ) + heartRateMonitor.onValidatedSample(sample) } override fun onProximityKeysReceived(proximityKeys: ByteArray) { @@ -2983,7 +2922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) if (!handleIncomingCallOnceConnected) { - if (!_heartRateMonitoringEnabled.value) startHeadTracking() + if (!heartRateState.value.enabled) startHeadTracking() } else { handleIncomingCall() } @@ -2995,7 +2934,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) - if (!handleIncomingCallOnceConnected && !_heartRateMonitoringEnabled.value) { + if (!handleIncomingCallOnceConnected && !heartRateState.value.enabled) { stopHeadTracking() } }, 5000) @@ -3706,379 +3645,28 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setHeartRateMonitoringEnabled(enabled: Boolean) { - val wasEnabled = _heartRateMonitoringEnabled.value sharedPreferences.edit { putBoolean(HEART_RATE_MONITORING_PREFERENCE, enabled) } - _heartRateMonitoringEnabled.value = enabled - - if (enabled) { - if (!wasEnabled) { - _heartRateMonitoringStatus.value = if (isAacpTransportHealthy()) { - HeartRateMonitoringStatus.STARTING - } else { - HeartRateMonitoringStatus.WAITING_FOR_AIRPODS - } - } - startHeartRateMonitoringIfEnabled() - } else { - if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync() - stopHeartRateMonitoring(forceStop = wasEnabled) - } + if (!enabled && ::heartRateExporter.isInitialized) heartRateExporter.flushAsync() + heartRateMonitor.setEnabled(enabled) } private fun startHeartRateMonitoringIfEnabled() { - if (!_heartRateMonitoringEnabled.value) { - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.OFF - return - } - if (!isAacpTransportHealthy()) { - _heartRateStreaming.value = false - _heartRateMonitoringStatus.value = - HeartRateMonitoringStatus.WAITING_FOR_AIRPODS - return - } - - synchronized(heartRateLock) { - if (heartRateStartJob?.isActive == true) return - - _heartRateStreaming.value = false - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.STARTING - val job = heartRateScope.launch(start = CoroutineStart.LAZY) { - runHeartRateMonitoringWatchdog() - } - heartRateStartJob = job - job.start() - } - } - - private suspend fun runHeartRateMonitoringWatchdog() { - val currentJob = coroutineContext[Job] - try { - if (isHeadTrackingActive) { - stopHeadTracking() - delay(220) - } - - while (canContinueHeartRateMonitoring()) { - synchronized(heartRateLock) { - _heartRateMonitoringStatus.value = - if (activeHeartRateRefreshReason == null) { - HeartRateMonitoringStatus.STARTING - } else { - HeartRateMonitoringStatus.RECONNECTING - } - } - val attemptStartedAt = startHeartRateStreamAttempt() - if (!canContinueHeartRateMonitoring()) return - - val failure = if (attemptStartedAt == null) { - val newlyRequestedReason = synchronized(heartRateLock) { - val reason = beginHeartRateRefreshLocked( - HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT - ) - stopHeartRateSessionLocked() - reason - } - newlyRequestedReason?.let(::logHeartRateRefreshRequested) - HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT - } else { - awaitHeartRateStreamFailure(attemptStartedAt) ?: return - } - - Log.d(TAG, "RTBuddy heart-rate stream event=${failure.name.lowercase()}") - if (!waitForNextHeartRateRefreshAttempt()) return - } - } finally { - synchronized(heartRateLock) { - if (heartRateStartJob === currentJob) { - stopHeartRateSessionLocked() - clearActiveHeartRateRefreshLocked() - heartRateStartJob = null - } - } - } - } - - private suspend fun waitForNextHeartRateRefreshAttempt(): Boolean { - var refreshReason: HeartRateRefreshReason? = null - var refreshAttempt = 0 - var backoffMillis = 0L - var retriesExhausted = false - - synchronized(heartRateLock) { - val activeReason = activeHeartRateRefreshReason - if (activeReason != null) { - refreshReason = activeReason - val refreshStartedAt = heartRateRefreshStartedAtElapsedRealtime - val reconnectTimedOut = refreshStartedAt != null && - SystemClock.elapsedRealtime() - refreshStartedAt >= - HEART_RATE_RECONNECT_TIMEOUT_MILLIS - if (reconnectTimedOut) { - retriesExhausted = true - clearActiveHeartRateRefreshLocked() - } else { - val backoffIndex = heartRateRefreshAttemptCount - .coerceAtMost(HEART_RATE_RETRY_BACKOFF_MILLIS.lastIndex) - backoffMillis = HEART_RATE_RETRY_BACKOFF_MILLIS[backoffIndex] - heartRateRefreshAttemptCount++ - refreshAttempt = heartRateRefreshAttemptCount - heartRateRefreshAttemptStartedAt = null - } - } - } - - val reason = refreshReason ?: return canContinueHeartRateMonitoring() - if (retriesExhausted) { - Log.w( - TAG, - "RTBuddy heart-rate refresh failed reason=${reason.diagnosticName} " + - "attempts=${HEART_RATE_RETRY_BACKOFF_MILLIS.size}" - ) - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.COULDNT_START - Log.i(TAG, "Waiting for a manual AACP reconnect after heart-rate retries failed") - return false - } - - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.RECONNECTING - Log.w( - TAG, - "RTBuddy heart-rate refresh attempt=$refreshAttempt " + - "reason=${reason.diagnosticName} backoff=${backoffMillis}ms" - ) - delay(backoffMillis) - - val reconnectTimedOut = synchronized(heartRateLock) { - val refreshStartedAt = heartRateRefreshStartedAtElapsedRealtime - val timedOut = activeHeartRateRefreshReason != null && - refreshStartedAt != null && - SystemClock.elapsedRealtime() - refreshStartedAt >= - HEART_RATE_RECONNECT_TIMEOUT_MILLIS - if (timedOut) clearActiveHeartRateRefreshLocked() - timedOut - } - if (reconnectTimedOut) { - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.COULDNT_START - Log.w(TAG, "RTBuddy heart-rate reconnect window expired after 15 seconds") - return false - } - return canContinueHeartRateMonitoring() - } - - private suspend fun startHeartRateStreamAttempt(): Long? { - if (!initializeHeartRateAacpSession()) return null - - val enabledSent = synchronized(heartRateLock) { - if (!canContinueHeartRateMonitoring()) { - false - } else { - val sent = aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, - true - ) - if (sent) heartRateSessionRequested = true - sent - } - } - if (!enabledSent) return null - - delay(120) - - return synchronized(heartRateLock) { - if (!canContinueHeartRateMonitoring()) { - null - } else { - _heartRateStreaming.value = false - val attemptStartedAt = SystemClock.elapsedRealtime() - val started = aacpManager.sendHeartRateStartFrame() - heartRateStartCommandSent = started - if (started) { - heartRateSamplesToDiscardAfterRefresh = - HEART_RATE_REFRESH_SAMPLES_TO_DISCARD - if (activeHeartRateRefreshReason != null) { - heartRateRefreshAttemptStartedAt = attemptStartedAt - } - } - Log.d(TAG, "RTBuddy heart-rate start sent=$started") - if (started) attemptStartedAt else null - } - } - } - - private suspend fun awaitHeartRateStreamFailure( - attemptStartedAt: Long - ): HeartRateRefreshReason? { - val firstSampleTimeoutMillis = synchronized(heartRateLock) { - val reconnectStartedAt = heartRateRefreshStartedAtElapsedRealtime - .takeIf { activeHeartRateRefreshReason != null } - if (reconnectStartedAt == null) { - HEART_RATE_FIRST_SAMPLE_TIMEOUT_MILLIS - } else { - (reconnectStartedAt + HEART_RATE_RECONNECT_TIMEOUT_MILLIS - attemptStartedAt) - .coerceAtLeast(0L) - } - } - while (canContinueHeartRateMonitoring()) { - delay(HEART_RATE_WATCHDOG_INTERVAL_MILLIS) - val now = SystemClock.elapsedRealtime() - var newlyRequestedReason: HeartRateRefreshReason? = null - val failure = synchronized(heartRateLock) { - if (!canContinueHeartRateMonitoring()) { - null - } else { - val lastSampleAt = lastValidHeartRateSampleElapsedRealtime - when { - lastSampleAt != null && lastSampleAt >= attemptStartedAt && - now - lastSampleAt >= HEART_RATE_STALL_TIMEOUT_MILLIS -> { - newlyRequestedReason = beginHeartRateRefreshLocked( - HeartRateRefreshReason.STREAM_STALLED - ) - stopHeartRateSessionLocked() - HeartRateRefreshReason.STREAM_STALLED - } - - (lastSampleAt == null || lastSampleAt < attemptStartedAt) && - now - attemptStartedAt >= firstSampleTimeoutMillis -> { - newlyRequestedReason = beginHeartRateRefreshLocked( - HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT - ) - stopHeartRateSessionLocked() - HeartRateRefreshReason.FIRST_SAMPLE_TIMEOUT - } - - else -> null - } - } - } - newlyRequestedReason?.let(::logHeartRateRefreshRequested) - if (failure != null) return failure - } - return null - } - - /** - * Updates warm-up and streaming state for one validated heart-rate sample. - * Returns true when the sample belongs to the warm-up discard window. - */ - private fun consumeHeartRateWarmupSampleLocked(): Boolean { - if (heartRateSamplesToDiscardAfterRefresh > 0) { - heartRateSamplesToDiscardAfterRefresh-- - _heartRateStreaming.value = false - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.CALIBRATING - return true - } - - _heartRateStreaming.value = - heartRateStartCommandSent && heartRateStartJob?.isActive == true - _heartRateMonitoringStatus.value = if (_heartRateStreaming.value) { - HeartRateMonitoringStatus.LIVE - } else { - HeartRateMonitoringStatus.STARTING - } - return false - } - - private fun beginHeartRateRefreshLocked( - reason: HeartRateRefreshReason - ): HeartRateRefreshReason? { - if (activeHeartRateRefreshReason != null) return null - activeHeartRateRefreshReason = reason - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.RECONNECTING - heartRateRefreshAttemptCount = 0 - heartRateRefreshAttemptStartedAt = null - heartRateRefreshStartedAtElapsedRealtime = SystemClock.elapsedRealtime() - heartRateSamplesToDiscardAfterRefresh = HEART_RATE_REFRESH_SAMPLES_TO_DISCARD - return reason - } - - private fun clearActiveHeartRateRefreshLocked() { - activeHeartRateRefreshReason = null - heartRateRefreshAttemptCount = 0 - heartRateRefreshAttemptStartedAt = null - heartRateRefreshStartedAtElapsedRealtime = null - heartRateSamplesToDiscardAfterRefresh = 0 - } - - private fun logHeartRateRefreshRequested(reason: HeartRateRefreshReason) { - Log.i( - TAG, - "RTBuddy heart-rate refresh requested reason=${reason.diagnosticName} " + - "transport=healthy" - ) - } - - private fun canContinueHeartRateMonitoring(): Boolean = - _heartRateMonitoringEnabled.value && - isAacpTransportHealthy() - - private suspend fun initializeHeartRateAacpSession(): Boolean { - if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService0() }) { - return false - } - - delay(180) - if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService0() }) { - return false - } - delay(220) - if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService4() }) { - return false - } - delay(180) - if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService4() }) { - return false - } - delay(220) - Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized") - return canContinueHeartRateMonitoring() - } - - private fun sendHeartRateSessionFrameIfActive(sendFrame: () -> Boolean): Boolean = - synchronized(heartRateLock) { - canContinueHeartRateMonitoring() && sendFrame() - } - - private fun stopHeartRateSessionLocked( - forceStop: Boolean = false, - sendStopFrame: Boolean = true - ) { - val shouldStop = - forceStop || heartRateSessionRequested || heartRateStartCommandSent - heartRateSessionRequested = false - heartRateStartCommandSent = false - _heartRateStreaming.value = false - - if (sendStopFrame && shouldStop && - BluetoothConnectionManager.aacpSocket?.isConnected == true - ) { - aacpManager.sendHeartRateStopFrame() - } + if (::heartRateMonitor.isInitialized) heartRateMonitor.startIfPossible() } private fun stopHeartRateMonitoring( forceStop: Boolean = false, sendStopFrame: Boolean = true ) { - synchronized(heartRateLock) { - val jobWasActive = heartRateStartJob?.isActive == true - heartRateStartJob?.cancel() - heartRateStartJob = null - lastValidHeartRateSampleElapsedRealtime = null - stopHeartRateSessionLocked( - forceStop = forceStop || jobWasActive, - sendStopFrame = sendStopFrame - ) - clearActiveHeartRateRefreshLocked() - _heartRateMonitoringStatus.value = if (_heartRateMonitoringEnabled.value) { - HeartRateMonitoringStatus.WAITING_FOR_AIRPODS - } else { - HeartRateMonitoringStatus.OFF - } + if (::heartRateMonitor.isInitialized) { + heartRateMonitor.stop(forceStop = forceStop, sendStopFrame = sendStopFrame) } } fun reconnectAacpForHeartRate() { - if (!_heartRateMonitoringEnabled.value) return + if (!heartRateState.value.enabled) return val reconnectDevice = device ?: run { - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + stopHeartRateMonitoring(sendStopFrame = false) return } @@ -4086,7 +3674,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList source = "heart-rate-manual-reconnect", suppressFutureReconnects = false ) - _heartRateMonitoringStatus.value = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS transportRecoveryScope.launch { stopHeartRateMonitoring(forceStop = true) BluetoothConnectionManager.aacpSocket?.let { socket -> @@ -4101,7 +3688,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "before rebuilding AACP after heart-rate reset" ) delay(HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS) - if (!_heartRateMonitoringEnabled.value) return@launch + if (!heartRateState.value.enabled) return@launch + val adapter = getSystemService(BluetoothManager::class.java).adapter Log.i(TAG, "Starting manual AACP reconnect for heart-rate monitoring") connectToSocket(adapter, reconnectDevice, manual = true) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt new file mode 100644 index 000000000..2e2ac403a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt @@ -0,0 +1,357 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.services + +import android.os.SystemClock +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import me.kavishdevar.librepods.bluetooth.HeartRateSample + +/** + * Owns the heart-rate stream lifecycle and its user-visible state. + * + * A single coroutine performs startup, warm-up, live sample collection, and in-stream retries. + * RTBuddy callbacks only enqueue validated samples; they do not mutate status or run watchdogs. + */ +internal class HeartRateMonitor( + private val scope: CoroutineScope, + initiallyEnabled: Boolean, + private val isTransportReady: () -> Boolean, + private val beforeFirstStart: suspend () -> Unit, + private val sendConnectService0: () -> Boolean, + private val sendCapabilitiesService0: () -> Boolean, + private val sendConnectService4: () -> Boolean, + private val sendCapabilitiesService4: () -> Boolean, + private val enableHeartRate: () -> Boolean, + private val sendStart: () -> Boolean, + private val sendStop: () -> Unit, + private val onPublishedSample: (HeartRateSample) -> Unit +) { + private enum class RefreshReason(val diagnosticName: String) { + FIRST_SAMPLE_TIMEOUT("first-sample-timeout"), + STREAM_STALLED("stream-stalled") + } + + private data class RefreshWindow( + val reason: RefreshReason, + val deadlineElapsedRealtime: Long, + var attempts: Int = 0 + ) + + private val lock = Any() + private val incomingSamples = Channel(Channel.UNLIMITED) + private var monitoringJob: Job? = null + private var sessionNeedsStop = false + private var acceptingSamples = false + + private val _state = MutableStateFlow( + HeartRateMonitoringState( + enabled = initiallyEnabled, + status = if (initiallyEnabled) { + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } else { + HeartRateMonitoringStatus.OFF + } + ) + ) + val state: StateFlow = _state + + fun setEnabled(enabled: Boolean) { + val wasEnabled = state.value.enabled + val transportReady = isTransportReady() + updateState { + it.copy( + enabled = enabled, + status = when { + !enabled -> HeartRateMonitoringStatus.OFF + !transportReady -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + !wasEnabled -> HeartRateMonitoringStatus.STARTING + else -> it.status + } + ) + } + + if (enabled) startIfPossible() else stop(forceStop = wasEnabled) + } + + fun startIfPossible() { + val currentState = state.value + if (!currentState.enabled) { + updateStatus(HeartRateMonitoringStatus.OFF) + return + } + if (!isTransportReady()) { + updateStatus(HeartRateMonitoringStatus.WAITING_FOR_AIRPODS) + return + } + + val job = synchronized(lock) { + if (monitoringJob?.isActive == true) return + + updateStatus(HeartRateMonitoringStatus.STARTING) + scope.launch(start = CoroutineStart.LAZY) { runMonitoringLoop() } + .also { monitoringJob = it } + } + job.start() + } + + fun onValidatedSample(sample: HeartRateSample) { + val accepted = synchronized(lock) { + state.value.enabled && acceptingSamples && isTransportReady() + } + if (accepted) incomingSamples.trySend(sample) + } + + fun stop(forceStop: Boolean = false, sendStopFrame: Boolean = true) { + synchronized(lock) { + val jobWasActive = monitoringJob?.isActive == true + monitoringJob?.cancel() + monitoringJob = null + stopSessionLocked(forceStop || jobWasActive, sendStopFrame) + drainIncomingSamples() + } + updateStatus( + if (state.value.enabled) { + HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + } else { + HeartRateMonitoringStatus.OFF + } + ) + } + + private suspend fun runMonitoringLoop() { + val currentJob = kotlinx.coroutines.currentCoroutineContext()[Job] + var refreshWindow: RefreshWindow? = null + + try { + beforeFirstStart() + + while (canRun()) { + updateStatus( + if (refreshWindow == null) { + HeartRateMonitoringStatus.STARTING + } else { + HeartRateMonitoringStatus.RECONNECTING + } + ) + + val attemptStartedAt = startStreamAttempt() + if (!canRun()) return + + val failure = if (attemptStartedAt == null) { + RefreshReason.FIRST_SAMPLE_TIMEOUT + } else { + awaitStreamFailure( + attemptStartedAt = attemptStartedAt, + refreshDeadline = refreshWindow?.deadlineElapsedRealtime, + onStreamLive = { refreshWindow = null } + ) ?: return + } + + synchronized(lock) { stopSessionLocked() } + val window = refreshWindow ?: RefreshWindow( + reason = failure, + deadlineElapsedRealtime = + SystemClock.elapsedRealtime() + RECONNECT_WINDOW_MILLIS + ).also { + refreshWindow = it + Log.i( + TAG, + "RTBuddy heart-rate refresh requested " + + "reason=${it.reason.diagnosticName} transport=healthy" + ) + } + + if (!waitForRetry(window)) { + updateStatus(HeartRateMonitoringStatus.COULDNT_START) + Log.w( + TAG, + "RTBuddy heart-rate refresh failed " + + "reason=${window.reason.diagnosticName} attempts=${window.attempts}" + ) + Log.i(TAG, "Waiting for a manual AACP reconnect after heart-rate retries failed") + return + } + } + } finally { + synchronized(lock) { + if (monitoringJob === currentJob) { + stopSessionLocked() + monitoringJob = null + } + } + } + } + + private suspend fun startStreamAttempt(): Long? { + drainIncomingSamples() + if (!initializeAacpSession()) return null + val enabled = synchronized(lock) { + canRun() && enableHeartRate().also { sent -> + if (sent) sessionNeedsStop = true + } + } + if (!enabled) return null + + delay(START_COMMAND_DELAY_MILLIS) + + return synchronized(lock) { + if (!canRun()) { + null + } else { + val startedAt = SystemClock.elapsedRealtime() + val started = sendStart() + acceptingSamples = started + sessionNeedsStop = sessionNeedsStop || started + Log.d(TAG, "RTBuddy heart-rate start sent=$started") + startedAt.takeIf { started } + } + } + } + + private suspend fun initializeAacpSession(): Boolean { + val frames = listOf( + sendConnectService0 to 180L, + sendCapabilitiesService0 to 220L, + sendConnectService4 to 180L, + sendCapabilitiesService4 to 220L + ) + + for ((sendFrame, delayAfter) in frames) { + if (!sendIfRunning(sendFrame)) return false + delay(delayAfter) + } + + Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized") + return canRun() + } + + private suspend fun awaitStreamFailure( + attemptStartedAt: Long, + refreshDeadline: Long?, + onStreamLive: () -> Unit + ): RefreshReason? { + var warmupSamplesRemaining = WARMUP_SAMPLE_COUNT + var live = false + val firstSampleDeadline = refreshDeadline + ?: (attemptStartedAt + FIRST_SAMPLE_TIMEOUT_MILLIS) + + while (canRun()) { + val timeout = if (live) { + STALL_TIMEOUT_MILLIS + } else { + (firstSampleDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(0L) + } + if (timeout == 0L) return RefreshReason.FIRST_SAMPLE_TIMEOUT + + val sample = withTimeoutOrNull(timeout) { incomingSamples.receive() } + ?: return if (live) { + RefreshReason.STREAM_STALLED + } else { + RefreshReason.FIRST_SAMPLE_TIMEOUT + } + + if (!canRun()) return null + if (warmupSamplesRemaining > 0) { + warmupSamplesRemaining-- + updateStatus(HeartRateMonitoringStatus.CALIBRATING) + continue + } + + publish(sample) + if (!live) { + if (refreshDeadline != null) { + Log.i(TAG, "RTBuddy heart-rate refresh succeeded") + } + onStreamLive() + } + live = true + updateStatus(HeartRateMonitoringStatus.LIVE) + } + return null + } + + private suspend fun waitForRetry(window: RefreshWindow): Boolean { + val remaining = window.deadlineElapsedRealtime - SystemClock.elapsedRealtime() + if (remaining <= 0L) return false + + val backoff = RETRY_BACKOFF_MILLIS[ + window.attempts.coerceAtMost(RETRY_BACKOFF_MILLIS.lastIndex) + ] + window.attempts++ + updateStatus(HeartRateMonitoringStatus.RECONNECTING) + Log.w( + TAG, + "RTBuddy heart-rate refresh attempt=${window.attempts} " + + "reason=${window.reason.diagnosticName} backoff=${backoff}ms" + ) + delay(minOf(backoff, remaining)) + return canRun() && SystemClock.elapsedRealtime() < window.deadlineElapsedRealtime + } + + private fun publish(sample: HeartRateSample) { + updateState { current -> + current.copy(samples = (current.samples + sample).takeLast(MAX_SAMPLES)) + } + onPublishedSample(sample) + } + + private fun sendIfRunning(sendFrame: () -> Boolean): Boolean = synchronized(lock) { + canRun() && sendFrame() + } + + private fun stopSessionLocked( + forceStop: Boolean = false, + sendStopFrame: Boolean = true + ) { + val shouldStop = forceStop || sessionNeedsStop || acceptingSamples + sessionNeedsStop = false + acceptingSamples = false + if (sendStopFrame && shouldStop && isTransportReady()) sendStop() + } + + private fun canRun(): Boolean = state.value.enabled && isTransportReady() + + private fun updateStatus(status: HeartRateMonitoringStatus) { + updateState { it.copy(status = status) } + } + + private inline fun updateState( + transform: (HeartRateMonitoringState) -> HeartRateMonitoringState + ) { + synchronized(lock) { + _state.value = transform(_state.value) + } + } + + private fun drainIncomingSamples() { + while (incomingSamples.tryReceive().isSuccess) Unit + } + + private companion object { + const val TAG = "HeartRateMonitor" + const val MAX_SAMPLES = 60 + const val FIRST_SAMPLE_TIMEOUT_MILLIS = 8_000L + const val RECONNECT_WINDOW_MILLIS = 15_000L + const val STALL_TIMEOUT_MILLIS = 2_000L + const val START_COMMAND_DELAY_MILLIS = 120L + const val WARMUP_SAMPLE_COUNT = 4 + val RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt index b5051f8c2..ac593b6c9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt @@ -10,6 +10,8 @@ package me.kavishdevar.librepods.services +import me.kavishdevar.librepods.bluetooth.HeartRateSample + enum class HeartRateMonitoringStatus { OFF, WAITING_FOR_AIRPODS, @@ -19,3 +21,12 @@ enum class HeartRateMonitoringStatus { RECONNECTING, COULDNT_START } + +data class HeartRateMonitoringState( + val enabled: Boolean = false, + val status: HeartRateMonitoringStatus = HeartRateMonitoringStatus.OFF, + val samples: List = emptyList() +) { + val latestSample: HeartRateSample? + get() = samples.lastOrNull() +} From 813e5d6847768561608a7150e849b8e30c834b4a Mon Sep 17 00:00:00 2001 From: thibauP Date: Sat, 8 Aug 2026 13:28:29 +0200 Subject: [PATCH 12/15] Solved launching bug present in LibrePods, and improved heart rate connecting when switching devices. --- .../librepods/services/AirPodsService.kt | 147 ++++++++++++++---- .../librepods/services/HeartRateMonitor.kt | 63 ++++---- 2 files changed, 152 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 4ab072618..4fcb9f1d0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -250,6 +250,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var aacpReconnectSuppressed = false private var aacpConnectionGeneration = 0L private var aacpTransportResponsive = false + private var heartRateAutomaticAacpRecoveryAttempted = false @Volatile private var lastAacpPacketElapsedRealtime = 0L @@ -267,7 +268,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList companion object { private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" - private const val HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS = 3_000L + private const val HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS = 3_000L private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L @@ -451,6 +452,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }, sendStart = aacpManager::sendHeartRateStartFrame, sendStop = { aacpManager.sendHeartRateStopFrame() }, + requestTransportRecovery = ::requestAutomaticAacpRecoveryForHeartRate, onPublishedSample = { sample -> heartRateExporter.enqueue( sample = sample, @@ -783,6 +785,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList false ) if (!isLocalTransportFailure) { + synchronized(transportRecoveryLock) { + heartRateAutomaticAacpRecoveryAttempted = false + } suppressAacpReconnect("physical-disconnect-broadcast") clearAacpTransport( source = "physical-disconnect-broadcast", @@ -2510,13 +2515,32 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { - if (bluetoothDevice.uuids?.contains(uuid) == true) { - val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } else { + // A raw ACL connection can arrive before BR/EDR authentication/encryption has + // settled. Opening the private AACP L2CAP channel at that exact moment can race + // the platform security transaction and disconnect the whole ACL. Wait for the + // per-device A2DP profile to report CONNECTED instead. + if (bluetoothDevice.uuids?.contains(uuid) != true) { bluetoothDevice.fetchUuidsWithSdp() + } else { + Log.d(TAG, "ACL connected; deferring AACP until A2DP is connected") + } + } else if ("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" == action) { + val state = intent.getIntExtra( + BluetoothProfile.EXTRA_STATE, + BluetoothProfile.STATE_DISCONNECTED + ) + if (state == BluetoothProfile.STATE_CONNECTED) { + val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) + ?.getString("mac_address", "") ?: "" + val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac + val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true + if (matchedByUuid || matchedByMac) { + val connectionIntent = + Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) + connectionIntent.putExtra("name", name) + connectionIntent.putExtra("device", bluetoothDevice) + context?.sendBroadcast(connectionIntent) + } } } else if ("android.bluetooth.device.action.UUID" == action) { val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) @@ -2810,17 +2834,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList try { try { connectSocketWithTimeout(socket, "AACP") - val xposedRemotePref = XposedRemotePrefProvider.create() - attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - attSocket?.let { connectSocketWithTimeout(it, "ATT") } + // AACP is the primary transport. Install it as soon as it connects so an optional + // ATT-over-BR/EDR failure cannot tear down or restart an otherwise healthy AACP link. socketInstalled = synchronized(transportRecoveryLock) { if (aacpReconnectSuppressed || connectionGeneration != aacpConnectionGeneration || @@ -2829,25 +2845,70 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList false } else { BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket + BluetoothConnectionManager.attSocket = null true } } if (!socketInstalled) { closeSocketQuietly(socket, "superseded AACP socket") - closeSocketQuietly(attSocket, "superseded ATT socket") Log.i(TAG, "Discarding superseded AACP connection attempt") return } + val xposedRemotePref = XposedRemotePrefProvider.create() + if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { + try { + val candidateAttSocket = createBluetoothSocket( + adapter, + device, + ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), + 31 + ) + attSocket = candidateAttSocket + connectSocketWithTimeout(candidateAttSocket, "ATT") + + val attInstalled = synchronized(transportRecoveryLock) { + if (BluetoothConnectionManager.aacpSocket === socket && + connectionGeneration == aacpConnectionGeneration && + !aacpReconnectSuppressed + ) { + BluetoothConnectionManager.attSocket = candidateAttSocket + true + } else { + false + } + } + if (!attInstalled) { + closeSocketQuietly(candidateAttSocket, "superseded ATT socket") + attSocket = null + } + } catch (e: Exception) { + Log.w(TAG, "Optional ATT socket unavailable; keeping AACP connected: ${e.message}") + closeSocketQuietly(attSocket, "failed optional ATT socket") + attSocket = null + } + } + this@AirPodsService.device = device startAacpLivenessWatchdog(socket, device) if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) + try { + attManager.startReader() + attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) + attManager.readCharacteristic(ATTHandles.TRANSPARENCY) + attManager.readCharacteristic(ATTHandles.HEARING_AID) + } catch (e: Exception) { + Log.w(TAG, "Optional ATT initialization failed; keeping AACP connected: ${e.message}") + synchronized(transportRecoveryLock) { + if (BluetoothConnectionManager.attSocket === attSocket) { + BluetoothConnectionManager.attSocket = null + } + } + closeSocketQuietly(attSocket, "failed optional ATT socket") + attSocket = null + if (::attManager.isInitialized) attManager.disconnected() + } } // Create AirPodsInstance from stored config if available @@ -3664,36 +3725,60 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun reconnectAacpForHeartRate() { - if (!heartRateState.value.enabled) return + rebuildAacpForHeartRate(source = "heart-rate-manual-reconnect") + } + + private fun requestAutomaticAacpRecoveryForHeartRate(): Boolean { + val claimed = synchronized(transportRecoveryLock) { + if (heartRateAutomaticAacpRecoveryAttempted) { + false + } else { + heartRateAutomaticAacpRecoveryAttempted = true + true + } + } + if (!claimed) return false + + val started = rebuildAacpForHeartRate(source = "heart-rate-auto-reconnect") + if (!started) { + Log.w(TAG, "Automatic AACP rebuild could not be started for heart-rate monitoring") + } + return started + } + + private fun rebuildAacpForHeartRate(source: String): Boolean { + if (!heartRateState.value.enabled) return false val reconnectDevice = device ?: run { stopHeartRateMonitoring(sendStopFrame = false) - return + return false } cancelAacpReconnect( - source = "heart-rate-manual-reconnect", + source = source, suppressFutureReconnects = false ) transportRecoveryScope.launch { stopHeartRateMonitoring(forceStop = true) BluetoothConnectionManager.aacpSocket?.let { socket -> clearAacpTransport( - source = "heart-rate-manual-reconnect", + source = source, expectedSocket = socket ) } + heartRateMonitor.markReconnecting() Log.i( TAG, - "Waiting ${HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS}ms " + - "before rebuilding AACP after heart-rate reset" + "Waiting ${HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS}ms " + + "before rebuilding AACP after heart-rate reset source=$source" ) - delay(HEART_RATE_MANUAL_RECONNECT_QUIET_PERIOD_MILLIS) + delay(HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS) if (!heartRateState.value.enabled) return@launch val adapter = getSystemService(BluetoothManager::class.java).adapter - Log.i(TAG, "Starting manual AACP reconnect for heart-rate monitoring") + Log.i(TAG, "Starting AACP reconnect for heart-rate monitoring source=$source") connectToSocket(adapter, reconnectDevice, manual = true) } + return true } private fun handleHeartRateDisconnected() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt index 2e2ac403a..e8b080dd0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt @@ -41,6 +41,7 @@ internal class HeartRateMonitor( private val enableHeartRate: () -> Boolean, private val sendStart: () -> Boolean, private val sendStop: () -> Unit, + private val requestTransportRecovery: () -> Boolean, private val onPublishedSample: (HeartRateSample) -> Unit ) { private enum class RefreshReason(val diagnosticName: String) { @@ -50,7 +51,7 @@ internal class HeartRateMonitor( private data class RefreshWindow( val reason: RefreshReason, - val deadlineElapsedRealtime: Long, + var deadlineElapsedRealtime: Long, var attempts: Int = 0 ) @@ -118,6 +119,10 @@ internal class HeartRateMonitor( if (accepted) incomingSamples.trySend(sample) } + fun markReconnecting() { + if (state.value.enabled) updateStatus(HeartRateMonitoringStatus.RECONNECTING) + } + fun stop(forceStop: Boolean = false, sendStopFrame: Boolean = true) { synchronized(lock) { val jobWasActive = monitoringJob?.isActive == true @@ -153,6 +158,10 @@ internal class HeartRateMonitor( val attemptStartedAt = startStreamAttempt() if (!canRun()) return + if (attemptStartedAt != null) { + refreshWindow?.deadlineElapsedRealtime = + attemptStartedAt + FIRST_SAMPLE_TIMEOUT_MILLIS + } val failure = if (attemptStartedAt == null) { RefreshReason.FIRST_SAMPLE_TIMEOUT @@ -160,7 +169,7 @@ internal class HeartRateMonitor( awaitStreamFailure( attemptStartedAt = attemptStartedAt, refreshDeadline = refreshWindow?.deadlineElapsedRealtime, - onStreamLive = { refreshWindow = null } + onStreamStarted = { refreshWindow = null } ) ?: return } @@ -179,13 +188,18 @@ internal class HeartRateMonitor( } if (!waitForRetry(window)) { - updateStatus(HeartRateMonitoringStatus.COULDNT_START) Log.w( TAG, "RTBuddy heart-rate refresh failed " + "reason=${window.reason.diagnosticName} attempts=${window.attempts}" ) - Log.i(TAG, "Waiting for a manual AACP reconnect after heart-rate retries failed") + updateStatus(HeartRateMonitoringStatus.RECONNECTING) + if (requestTransportRecovery()) { + Log.i(TAG, "Requesting one automatic AACP rebuild for heart-rate recovery") + } else { + updateStatus(HeartRateMonitoringStatus.COULDNT_START) + Log.i(TAG, "Automatic AACP rebuild unavailable; waiting for manual Retry") + } return } } @@ -245,15 +259,15 @@ internal class HeartRateMonitor( private suspend fun awaitStreamFailure( attemptStartedAt: Long, refreshDeadline: Long?, - onStreamLive: () -> Unit + onStreamStarted: () -> Unit ): RefreshReason? { var warmupSamplesRemaining = WARMUP_SAMPLE_COUNT - var live = false + var streamStarted = false val firstSampleDeadline = refreshDeadline ?: (attemptStartedAt + FIRST_SAMPLE_TIMEOUT_MILLIS) while (canRun()) { - val timeout = if (live) { + val timeout = if (streamStarted) { STALL_TIMEOUT_MILLIS } else { (firstSampleDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(0L) @@ -261,13 +275,20 @@ internal class HeartRateMonitor( if (timeout == 0L) return RefreshReason.FIRST_SAMPLE_TIMEOUT val sample = withTimeoutOrNull(timeout) { incomingSamples.receive() } - ?: return if (live) { + ?: return if (streamStarted) { RefreshReason.STREAM_STALLED } else { RefreshReason.FIRST_SAMPLE_TIMEOUT } if (!canRun()) return null + if (!streamStarted) { + streamStarted = true + if (refreshDeadline != null) { + Log.i(TAG, "RTBuddy heart-rate reconnect succeeded") + } + onStreamStarted() + } if (warmupSamplesRemaining > 0) { warmupSamplesRemaining-- updateStatus(HeartRateMonitoringStatus.CALIBRATING) @@ -275,34 +296,22 @@ internal class HeartRateMonitor( } publish(sample) - if (!live) { - if (refreshDeadline != null) { - Log.i(TAG, "RTBuddy heart-rate refresh succeeded") - } - onStreamLive() - } - live = true updateStatus(HeartRateMonitoringStatus.LIVE) } return null } - private suspend fun waitForRetry(window: RefreshWindow): Boolean { - val remaining = window.deadlineElapsedRealtime - SystemClock.elapsedRealtime() - if (remaining <= 0L) return false + private fun waitForRetry(window: RefreshWindow): Boolean { + if (window.attempts >= MAX_RECONNECT_ATTEMPTS) return false - val backoff = RETRY_BACKOFF_MILLIS[ - window.attempts.coerceAtMost(RETRY_BACKOFF_MILLIS.lastIndex) - ] window.attempts++ updateStatus(HeartRateMonitoringStatus.RECONNECTING) Log.w( TAG, - "RTBuddy heart-rate refresh attempt=${window.attempts} " + - "reason=${window.reason.diagnosticName} backoff=${backoff}ms" + "RTBuddy heart-rate reconnect attempt=${window.attempts} " + + "reason=${window.reason.diagnosticName} timeout=${FIRST_SAMPLE_TIMEOUT_MILLIS}ms" ) - delay(minOf(backoff, remaining)) - return canRun() && SystemClock.elapsedRealtime() < window.deadlineElapsedRealtime + return canRun() } private fun publish(sample: HeartRateSample) { @@ -348,10 +357,10 @@ internal class HeartRateMonitor( const val TAG = "HeartRateMonitor" const val MAX_SAMPLES = 60 const val FIRST_SAMPLE_TIMEOUT_MILLIS = 8_000L - const val RECONNECT_WINDOW_MILLIS = 15_000L + const val RECONNECT_WINDOW_MILLIS = FIRST_SAMPLE_TIMEOUT_MILLIS const val STALL_TIMEOUT_MILLIS = 2_000L const val START_COMMAND_DELAY_MILLIS = 120L const val WARMUP_SAMPLE_COUNT = 4 - val RETRY_BACKOFF_MILLIS = longArrayOf(500L, 1_000L, 2_000L) + const val MAX_RECONNECT_ATTEMPTS = 1 } } From a573241ffac9766031b7c61b676ade8c338bc019 Mon Sep 17 00:00:00 2001 From: thibauP Date: Sat, 8 Aug 2026 23:08:08 +0200 Subject: [PATCH 13/15] detection for AirPods being worn. --- .../components/HeartRateStatusChip.kt | 1 + .../librepods/services/AirPodsService.kt | 75 ++++++++++++++++++- .../librepods/services/HeartRateMonitor.kt | 39 +++++++++- .../services/HeartRateMonitoringStatus.kt | 1 + 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt index d7c082e42..2f055f57c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt @@ -104,6 +104,7 @@ private val HeartRateMonitoringStatus.label: String get() = when (this) { HeartRateMonitoringStatus.OFF -> "Off" HeartRateMonitoringStatus.WAITING_FOR_AIRPODS -> "Waiting for AirPods" + HeartRateMonitoringStatus.WAITING_TO_BE_WORN -> "Waiting to be worn" HeartRateMonitoringStatus.STARTING -> "Starting" HeartRateMonitoringStatus.CALIBRATING -> "Calibrating" HeartRateMonitoringStatus.LIVE -> "Live" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 4fcb9f1d0..ce98a77a4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -252,6 +252,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var aacpTransportResponsive = false private var heartRateAutomaticAacpRecoveryAttempted = false @Volatile + private var heartRateAirPodsWorn: Boolean? = null + @Volatile private var lastAacpPacketElapsedRealtime = 0L private lateinit var heartRateMonitor: HeartRateMonitor @@ -320,6 +322,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { Log.d(TAG, "New address detected") + updateHeartRateWearState( + leftInEar = device.isLeftInEar, + rightInEar = device.isRightInEar, + source = "ble-initial" + ) } override fun onLidStateChanged( @@ -358,6 +365,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean ) { Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar") + updateHeartRateWearState( + leftInEar = leftInEar, + rightInEar = rightInEar, + source = "ble" + ) // In BLE-only mode, ear detection is purely based on BLE data if (config.bleOnlyMode) { @@ -434,6 +446,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList false ), isTransportReady = ::isAacpTransportHealthy, + isAirPodsWorn = ::areAirPodsWornForHeartRate, beforeFirstStart = { if (isHeadTrackingActive) { stopHeadTracking() @@ -787,6 +800,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!isLocalTransportFailure) { synchronized(transportRecoveryLock) { heartRateAutomaticAacpRecoveryAttempted = false + heartRateAirPodsWorn = null } suppressAacpReconnect("physical-disconnect-broadcast") clearAacpTransport( @@ -1345,9 +1359,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList listOf(it[0] == 0x00.toByte(), it[1] == 0x00.toByte()) } - // Do not proactively restart the heart-rate session when the ear status changes. The - // AirPods can keep the active session running on the remaining/primary bud; the watchdog - // below still refreshes it if samples actually stop arriving. + newInEarData?.let { currentInEarData -> + updateHeartRateWearState( + leftInEar = currentInEarData[0], + rightInEar = currentInEarData[1], + source = "aacp" + ) + } if (config.earDetectionEnabled) { val currentData = data ?: return @@ -3715,6 +3733,40 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (::heartRateMonitor.isInitialized) heartRateMonitor.startIfPossible() } + private fun areAirPodsWornForHeartRate(): Boolean = heartRateAirPodsWorn != false + + private fun updateHeartRateWearState( + leftInEar: Boolean, + rightInEar: Boolean, + source: String + ) { + // A single AirPod can keep providing heart-rate samples. Only pause once both are out. + val isWorn = leftInEar || rightInEar + val previousState = synchronized(transportRecoveryLock) { + heartRateAirPodsWorn.also { + heartRateAirPodsWorn = isWorn + if (isWorn && it != true) { + heartRateAutomaticAacpRecoveryAttempted = false + } + } + } + if (previousState == isWorn) return + + Log.i( + TAG, + "Heart-rate wear state changed worn=$isWorn source=$source; " + + if (isWorn) "resuming when transport is ready" else "pausing retries" + ) + if (!::heartRateMonitor.isInitialized) return + + heartRateMonitor.onWearStateChanged(isWorn) + if (isWorn && previousState == false && heartRateState.value.enabled && + !isAacpTransportHealthy() && device != null + ) { + rebuildAacpForHeartRate(source = "heart-rate-wear-resume") + } + } + private fun stopHeartRateMonitoring( forceStop: Boolean = false, sendStopFrame: Boolean = true @@ -3729,6 +3781,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private fun requestAutomaticAacpRecoveryForHeartRate(): Boolean { + if (!areAirPodsWornForHeartRate()) { + heartRateMonitor.onWearStateChanged(false) + return false + } val claimed = synchronized(transportRecoveryLock) { if (heartRateAutomaticAacpRecoveryAttempted) { false @@ -3748,6 +3804,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun rebuildAacpForHeartRate(source: String): Boolean { if (!heartRateState.value.enabled) return false + if (!areAirPodsWornForHeartRate()) { + heartRateMonitor.onWearStateChanged(false) + Log.i(TAG, "Deferring AACP rebuild source=$source until an AirPod is in ear") + return false + } val reconnectDevice = device ?: run { stopHeartRateMonitoring(sendStopFrame = false) return false @@ -3758,6 +3819,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList suppressFutureReconnects = false ) transportRecoveryScope.launch { + if (!heartRateState.value.enabled || !areAirPodsWornForHeartRate()) return@launch stopHeartRateMonitoring(forceStop = true) BluetoothConnectionManager.aacpSocket?.let { socket -> clearAacpTransport( @@ -3772,7 +3834,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "before rebuilding AACP after heart-rate reset source=$source" ) delay(HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS) - if (!heartRateState.value.enabled) return@launch + if (!heartRateState.value.enabled || !areAirPodsWornForHeartRate()) { + if (!areAirPodsWornForHeartRate()) { + heartRateMonitor.onWearStateChanged(false) + } + return@launch + } val adapter = getSystemService(BluetoothManager::class.java).adapter Log.i(TAG, "Starting AACP reconnect for heart-rate monitoring source=$source") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt index e8b080dd0..a79c9c8ea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt @@ -33,6 +33,7 @@ internal class HeartRateMonitor( private val scope: CoroutineScope, initiallyEnabled: Boolean, private val isTransportReady: () -> Boolean, + private val isAirPodsWorn: () -> Boolean, private val beforeFirstStart: suspend () -> Unit, private val sendConnectService0: () -> Boolean, private val sendCapabilitiesService0: () -> Boolean, @@ -76,12 +77,14 @@ internal class HeartRateMonitor( fun setEnabled(enabled: Boolean) { val wasEnabled = state.value.enabled val transportReady = isTransportReady() + val airPodsWorn = isAirPodsWorn() updateState { it.copy( enabled = enabled, status = when { !enabled -> HeartRateMonitoringStatus.OFF !transportReady -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + !airPodsWorn -> HeartRateMonitoringStatus.WAITING_TO_BE_WORN !wasEnabled -> HeartRateMonitoringStatus.STARTING else -> it.status } @@ -101,6 +104,10 @@ internal class HeartRateMonitor( updateStatus(HeartRateMonitoringStatus.WAITING_FOR_AIRPODS) return } + if (!isAirPodsWorn()) { + updateStatus(HeartRateMonitoringStatus.WAITING_TO_BE_WORN) + return + } val job = synchronized(lock) { if (monitoringJob?.isActive == true) return @@ -123,7 +130,31 @@ internal class HeartRateMonitor( if (state.value.enabled) updateStatus(HeartRateMonitoringStatus.RECONNECTING) } + fun onWearStateChanged(isWorn: Boolean) { + if (isWorn) { + startIfPossible() + } else { + stopAndUpdateStatus( + forceStop = false, + sendStopFrame = true, + enabledStatus = HeartRateMonitoringStatus.WAITING_TO_BE_WORN + ) + } + } + fun stop(forceStop: Boolean = false, sendStopFrame: Boolean = true) { + stopAndUpdateStatus( + forceStop = forceStop, + sendStopFrame = sendStopFrame, + enabledStatus = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + ) + } + + private fun stopAndUpdateStatus( + forceStop: Boolean, + sendStopFrame: Boolean, + enabledStatus: HeartRateMonitoringStatus + ) { synchronized(lock) { val jobWasActive = monitoringJob?.isActive == true monitoringJob?.cancel() @@ -133,7 +164,7 @@ internal class HeartRateMonitor( } updateStatus( if (state.value.enabled) { - HeartRateMonitoringStatus.WAITING_FOR_AIRPODS + enabledStatus } else { HeartRateMonitoringStatus.OFF } @@ -174,6 +205,7 @@ internal class HeartRateMonitor( } synchronized(lock) { stopSessionLocked() } + if (!canRun()) return val window = refreshWindow ?: RefreshWindow( reason = failure, deadlineElapsedRealtime = @@ -196,6 +228,8 @@ internal class HeartRateMonitor( updateStatus(HeartRateMonitoringStatus.RECONNECTING) if (requestTransportRecovery()) { Log.i(TAG, "Requesting one automatic AACP rebuild for heart-rate recovery") + } else if (!canRun()) { + return } else { updateStatus(HeartRateMonitoringStatus.COULDNT_START) Log.i(TAG, "Automatic AACP rebuild unavailable; waiting for manual Retry") @@ -335,7 +369,8 @@ internal class HeartRateMonitor( if (sendStopFrame && shouldStop && isTransportReady()) sendStop() } - private fun canRun(): Boolean = state.value.enabled && isTransportReady() + private fun canRun(): Boolean = + state.value.enabled && isTransportReady() && isAirPodsWorn() private fun updateStatus(status: HeartRateMonitoringStatus) { updateState { it.copy(status = status) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt index ac593b6c9..f74da4f89 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt @@ -15,6 +15,7 @@ import me.kavishdevar.librepods.bluetooth.HeartRateSample enum class HeartRateMonitoringStatus { OFF, WAITING_FOR_AIRPODS, + WAITING_TO_BE_WORN, STARTING, CALIBRATING, LIVE, From 5e2098690f1128f497511db78723f961c5bfe503 Mon Sep 17 00:00:00 2001 From: thibauP Date: Wed, 12 Aug 2026 12:00:16 +0200 Subject: [PATCH 14/15] iOS 27 support. --- .../librepods/bluetooth/AACPManager.kt | 63 ++-- .../librepods/bluetooth/RtBuddyHeartRate.kt | 327 +++++++++++++++++- .../librepods/services/AirPodsService.kt | 1 + .../librepods/services/HeartRateMonitor.kt | 2 + 4 files changed, 360 insertions(+), 33 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index 86ef37018..36fd784e0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -20,7 +20,9 @@ package me.kavishdevar.librepods.bluetooth +import android.os.SystemClock import android.util.Log +import kotlinx.coroutines.delay import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.CustomEq import java.nio.ByteBuffer @@ -77,22 +79,6 @@ class AACPManager { private val HEART_RATE_CAPABILITIES_SERVICE_4 = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00) - // Verified RTBuddy SensorDataWX HEARTRATE(19) service-setting frames from the legacy probe. - // These arrays intentionally omit HEADER_BYTES because sendDataPacket() adds it. - private val HEART_RATE_START_1S = byteArrayOf( - 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, - 0x08, 0xE3.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10, - 0x02, 0x1A, 0x05, 0x01, 0x40, 0x42, 0x0F, 0x00 - ) - - private val HEART_RATE_STOP = byteArrayOf( - 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, - 0x08, 0xED.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10, - 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 - ) - private val HEART_RATE_START_PACKET = HEADER_BYTES + HEART_RATE_START_1S - private val HEART_RATE_STOP_PACKET = HEADER_BYTES + HEART_RATE_STOP - private const val HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS = 10_000L private const val HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD = 10 private const val HEART_RATE_DIAGNOSTIC_COUNT_LIMIT = 1_000 @@ -317,6 +303,7 @@ class AACPManager { private var callback: PacketCallback? = null private val heartRateDecoder = RtBuddyHeartRateDecoder() + private val heartRateControlSession = RtBuddyHeartRateControlSession(heartRateDecoder) private val heartRateDiagnosticLock = Any() private var heartRateAcceptedSampleLogged = false private var heartRateDiagnosticWindowStartedAtMillis = 0L @@ -350,9 +337,45 @@ class AACPManager { return sendPacket(createDataPacket(data)) } - fun sendHeartRateStartFrame(): Boolean = sendDataPacket(HEART_RATE_START_1S) + fun sendHeartRateStartFrame(): Boolean = sendHeartRateControlFrame(start = true) + + fun sendHeartRateStopFrame(): Boolean = sendHeartRateControlFrame(start = false) - fun sendHeartRateStopFrame(): Boolean = sendDataPacket(HEART_RATE_STOP) + suspend fun awaitHeartRateServiceResolution(timeoutMillis: Long = 1_500L): Boolean { + return waitForHeartRateServiceResolution( + decoder = heartRateDecoder, + timeoutMillis = timeoutMillis, + elapsedRealtimeMillis = SystemClock::elapsedRealtime, + pause = { delay(it) } + ) + } + + private fun sendHeartRateControlFrame(start: Boolean): Boolean { + val result = if (start) { + heartRateControlSession.sendStart(::sendPacket) + } else { + heartRateControlSession.sendStop(::sendPacket) + } + if (!result.attempted) { + Log.w( + TAG, + if (start) { + "HeartRateService unavailable; refusing to target a non-heart service" + } else { + "No active RTBuddy heart-rate stream; skipping stop control" + } + ) + return false + } + Log.d( + TAG, + "Sending RTBuddy heart-rate ${if (start) "start" else "stop"} " + + "service=${result.serviceId} " + + "source=${if (result.discoveredFromMetadata) "metadata" else "legacy"} " + + "sent=${result.sent}" + ) + return result.sent + } fun sendHeartRateConnectService0(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_0) @@ -1416,12 +1439,12 @@ class AACPManager { } private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean = - packet.contentEquals(HEART_RATE_START_PACKET) || - packet.contentEquals(HEART_RATE_STOP_PACKET) + RtBuddyHeartRateControlFrames.isControlFrame(packet) fun disconnected() { Log.d(TAG, "Disconnected, clearing state") heartRateDecoder.reset() + heartRateControlSession.reset() resetHeartRateDiagnostics() controlCommandStatusList.clear() controlCommandListeners.clear() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt index 8b3a85eed..e76cfc0e6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt @@ -37,20 +37,66 @@ internal data class HeartRateDecodeResult( get() = rejectionReasons.values.sum() } +internal data class HeartRateServiceResolution( + val serviceId: Int?, + val discoveredFromMetadata: Boolean +) + +internal suspend fun waitForHeartRateServiceResolution( + decoder: RtBuddyHeartRateDecoder, + timeoutMillis: Long, + elapsedRealtimeMillis: () -> Long, + pause: suspend (Long) -> Unit +): Boolean { + val deadline = elapsedRealtimeMillis() + timeoutMillis.coerceAtLeast(0L) + while (true) { + val resolution = decoder.heartRateServiceResolution() + if (resolution.discoveredFromMetadata) return true + if (elapsedRealtimeMillis() >= deadline) return resolution.serviceId != null + pause(25L) + } +} + /** * Reassembles RTBuddy frames and extracts the verified HEARTRATE SensorDataWX payload. * * The parser deliberately keeps the protocol checks that prevent control/startup frames from being - * interpreted as BPM: live log type, service 19, exact 18-byte payload, known status trailer, and - * the validated physiological range. Length-delimited wrappers are traversed only to the same - * bounded depth as the observed firmware variants. + * interpreted as BPM: live log type, the metadata-advertised HeartRateService, exact 18-byte + * payload, known status trailer, and the validated physiological range. Service 19 remains a + * legacy fallback only when metadata has not assigned it to another accessory service. */ -internal class RtBuddyHeartRateDecoder { +internal class RtBuddyHeartRateDecoder( + private val wallClockMillis: () -> Long = System::currentTimeMillis, + private val elapsedRealtimeMillis: () -> Long = SystemClock::elapsedRealtime +) { private var carry = ByteArray(0) + private var discoveredHeartRateServiceId: Int? = null + private val explicitlyNonHeartRateServiceIds = mutableSetOf() @Synchronized fun reset() { carry = ByteArray(0) + discoveredHeartRateServiceId = null + explicitlyNonHeartRateServiceIds.clear() + } + + /** The service to target for this connection, or null when metadata rules out the fallback. */ + @Synchronized + fun heartRateServiceIdForControl(): Int? = + discoveredHeartRateServiceId + ?: LEGACY_HEART_RATE_SERVICE.takeUnless(explicitlyNonHeartRateServiceIds::contains) + + @Synchronized + fun discoveredHeartRateServiceId(): Int? = discoveredHeartRateServiceId + + @Synchronized + fun heartRateServiceResolution(): HeartRateServiceResolution { + val discovered = discoveredHeartRateServiceId + return HeartRateServiceResolution( + serviceId = discovered + ?: LEGACY_HEART_RATE_SERVICE.takeUnless(explicitlyNonHeartRateServiceIds::contains), + discoveredFromMetadata = discovered != null + ) } @Synchronized @@ -113,10 +159,12 @@ internal class RtBuddyHeartRateDecoder { val frame = data.copyOfRange(frameOffset, frameOffset + frameLength) val classification = classifyFrame(frame) - if (classification.related) { - relatedFrameCount++ - classification.rejectionReason?.let { rejectionReasons.increment(it) } - classification.sample?.let(samples::add) + if (classification.related || classification.consumed) { + if (classification.related) { + relatedFrameCount++ + classification.rejectionReason?.let { rejectionReasons.increment(it) } + classification.sample?.let(samples::add) + } suppressRawLogging = true } else { passthroughPackets += frame @@ -141,6 +189,8 @@ internal class RtBuddyHeartRateDecoder { frame.size ) ?: return FrameClassification() + val metadataRecords = updateServiceMetadata(frame, topLevel) + val sequence = topLevel.firstVarint(FIELD_SEQUENCE)?.toInt() ?: -1 val logType = topLevel.firstVarint(FIELD_LOG_TYPE)?.toInt() ?: -1 val commands = mutableListOf() @@ -148,6 +198,7 @@ internal class RtBuddyHeartRateDecoder { topLevel.fields.forEach { field -> if (field.wireType == WIRE_LENGTH_DELIMITED && field.number in SENSOR_DATA_COMMAND_FIELDS && + MetadataRecord(field.valueStart, field.valueEnd) !in metadataRecords && commands.size < MAX_COMMANDS_PER_FRAME ) { collectHeartRateCommands( @@ -160,7 +211,9 @@ internal class RtBuddyHeartRateDecoder { } } - if (commands.isEmpty()) return FrameClassification() + if (commands.isEmpty()) { + return FrameClassification(consumed = metadataRecords.isNotEmpty()) + } if (logType !in LIVE_SENSOR_DATA_LOG_TYPES) { return FrameClassification( related = true, @@ -184,12 +237,63 @@ internal class RtBuddyHeartRateDecoder { sample = HeartRateSample( bpm = acceptedPayload.unsignedByteAt(HEART_RATE_BPM_OFFSET), sequence = sequence, - receivedAtMillis = System.currentTimeMillis(), - receivedAtElapsedRealtime = SystemClock.elapsedRealtime() + receivedAtMillis = wallClockMillis(), + receivedAtElapsedRealtime = elapsedRealtimeMillis() ) ) } + private fun updateServiceMetadata( + data: ByteArray, + topLevel: ProtoMessage + ): Set { + val metadataRecords = mutableSetOf() + topLevel.fields.forEach { field -> + if (field.wireType != WIRE_LENGTH_DELIMITED) return@forEach + val serviceRecord = parseProtoMessage(data, field.valueStart, field.valueEnd) + ?: return@forEach + val serviceId = serviceRecord.firstVarint(FIELD_SERVICE)?.toInt() + ?: return@forEach + val metadataFields = serviceRecord.fields.filter { + it.number == FIELD_SERVICE_METADATA && it.wireType == WIRE_LENGTH_DELIMITED + } + if (metadataFields.isEmpty()) return@forEach + + val identifiesHeartRate = metadataFields.any { metadata -> + data.containsBytes( + needle = HEART_RATE_SERVICE_MARKER, + start = metadata.valueStart, + end = metadata.valueEnd + ) + } + val identifiesHostLibHid = metadataFields.any { metadata -> + data.containsBytes( + needle = HOST_LIB_HID_MARKER, + start = metadata.valueStart, + end = metadata.valueEnd + ) + } + + if (identifiesHeartRate || identifiesHostLibHid) { + metadataRecords += MetadataRecord(field.valueStart, field.valueEnd) + } + when { + // A service explicitly named HostLibHID must never be targeted as heart rate. + identifiesHostLibHid -> { + explicitlyNonHeartRateServiceIds += serviceId + if (discoveredHeartRateServiceId == serviceId) { + discoveredHeartRateServiceId = null + } + } + + identifiesHeartRate && serviceId !in explicitlyNonHeartRateServiceIds -> { + discoveredHeartRateServiceId = serviceId + } + } + } + return metadataRecords + } + private fun collectHeartRateCommands( data: ByteArray, start: Int, @@ -201,7 +305,7 @@ internal class RtBuddyHeartRateDecoder { val message = parseProtoMessage(data, start, end) ?: return val service = message.firstVarint(FIELD_SERVICE)?.toInt() - if (service == HEART_RATE_SERVICE) { + if (service != null && isHeartRateService(service)) { val payloads = mutableListOf() message.fields.forEach { field -> if (field.number == FIELD_COMMAND_PAYLOAD && @@ -235,6 +339,13 @@ internal class RtBuddyHeartRateDecoder { } } + private fun isHeartRateService(serviceId: Int): Boolean { + val discovered = discoveredHeartRateServiceId + if (discovered != null) return serviceId == discovered + if (serviceId in explicitlyNonHeartRateServiceIds) return false + return serviceId == LEGACY_HEART_RATE_SERVICE + } + private fun collectPayloadCandidates( data: ByteArray, start: Int, @@ -369,6 +480,8 @@ internal class RtBuddyHeartRateDecoder { private data class HeartRateCommand(val payloadCandidates: List) + private data class MetadataRecord(val start: Int, val end: Int) + private data class ProtoMessage(val fields: List) { fun firstVarint(fieldNumber: Int): Long? = fields.firstOrNull { it.number == fieldNumber && it.wireType == WIRE_VARINT @@ -387,6 +500,7 @@ internal class RtBuddyHeartRateDecoder { private data class FrameClassification( val related: Boolean = false, + val consumed: Boolean = false, val sample: HeartRateSample? = null, val rejectionReason: HeartRateRejectionReason? = null ) @@ -401,7 +515,9 @@ internal class RtBuddyHeartRateDecoder { val LIVE_SENSOR_DATA_LOG_TYPES = setOf(1, 3) val KNOWN_HEART_RATE_STATUS_TAILS = arrayOf( byteArrayOf(0x10, 0x00, 0x00), + byteArrayOf(0x10, 0x00, 0x80.toByte()), byteArrayOf(0x20, 0x00, 0x00), + byteArrayOf(0x20, 0x80.toByte(), 0x00), byteArrayOf(0x20, 0x02, 0x80.toByte()), byteArrayOf(0x20, 0x82.toByte(), 0x80.toByte()) ) @@ -409,8 +525,9 @@ internal class RtBuddyHeartRateDecoder { const val FIELD_SEQUENCE = 1 const val FIELD_LOG_TYPE = 2 const val FIELD_SERVICE = 1 + const val FIELD_SERVICE_METADATA = 2 const val FIELD_COMMAND_PAYLOAD = 3 - const val HEART_RATE_SERVICE = 19 + const val LEGACY_HEART_RATE_SERVICE = 19 const val HEART_RATE_PAYLOAD_LENGTH = 18 const val HEART_RATE_BPM_OFFSET = 1 const val HEART_RATE_STATUS_TAIL_OFFSET = 15 @@ -437,6 +554,180 @@ internal class RtBuddyHeartRateDecoder { 0x17, 0x00, 0x00, 0x00, 0x10, 0x00 ) + + val HEART_RATE_SERVICE_MARKER = "HeartRateService".encodeToByteArray() + val HOST_LIB_HID_MARKER = "HostLibHID".encodeToByteArray() + } +} + +/** Builds RTBuddy heart-rate controls with a per-connection service and monotonic sequence. */ +internal class RtBuddyHeartRateControlFrames( + private val initialSequence: Int = LEGACY_INITIAL_SEQUENCE +) { + private var nextSequence = initialSequence + + @Synchronized + fun reset() { + nextSequence = initialSequence + } + + @Synchronized + fun start(serviceId: Int): ByteArray = + buildFrame(serviceId, takeSequence(), HEART_RATE_INTERVAL_MICROS) + + @Synchronized + fun stop(serviceId: Int): ByteArray = + buildFrame(serviceId, takeSequence(), 0) + + private fun takeSequence(): Int { + val sequence = nextSequence + nextSequence = if (sequence == Int.MAX_VALUE) 0 else sequence + 1 + return sequence + } + + companion object { + private const val LEGACY_INITIAL_SEQUENCE = 9_059 + private const val HEART_RATE_INTERVAL_MICROS = 1_000_000 + + private val RTBUDDY_CONTROL_PREFIX = byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x17, 0x00, + 0x00, 0x00, 0x10, 0x00 + ) + + internal fun buildFrame(serviceId: Int, sequence: Int, intervalMicros: Int): ByteArray { + require(serviceId in 1..4_096) { "Invalid RTBuddy service ID: $serviceId" } + require(sequence >= 0) { "RTBuddy sequence must be non-negative" } + require(intervalMicros >= 0) { "Heart-rate interval must be non-negative" } + + val setting = byteArrayOf(0x01) + intervalMicros.toLittleEndian32() + val command = + protoVarintField(1, serviceId) + + protoVarintField(2, 2) + + protoBytesField(3, setting) + val body = + protoVarintField(1, sequence) + + protoBytesField(8, command) + require(body.size <= 0xFFFF) { "RTBuddy control body is too large" } + + return RTBUDDY_CONTROL_PREFIX + body.size.toLittleEndian16() + body + } + + internal fun isControlFrame(packet: ByteArray): Boolean { + if (packet.size < RTBUDDY_CONTROL_PREFIX.size + 2 + 7) return false + if (!RTBUDDY_CONTROL_PREFIX.indices.all { + packet[it] == RTBUDDY_CONTROL_PREFIX[it] + } + ) return false + return packet[packet.lastIndex - 6] == 0x1A.toByte() && + packet[packet.lastIndex - 5] == 0x05.toByte() && + packet[packet.lastIndex - 4] == 0x01.toByte() + } + + private fun protoVarintField(fieldNumber: Int, value: Int): ByteArray = + encodeVarint((fieldNumber shl 3).toLong()) + encodeVarint(value.toLong()) + + private fun protoBytesField(fieldNumber: Int, value: ByteArray): ByteArray = + encodeVarint(((fieldNumber shl 3) or 2).toLong()) + + encodeVarint(value.size.toLong()) + + value + + private fun encodeVarint(value: Long): ByteArray { + require(value >= 0) { "Varints must be non-negative" } + var remaining = value + val result = ArrayList(10) + do { + var byte = (remaining and 0x7F).toInt() + remaining = remaining ushr 7 + if (remaining != 0L) byte = byte or 0x80 + result += byte.toByte() + } while (remaining != 0L) + return result.toByteArray() + } + + private fun Int.toLittleEndian16(): ByteArray = byteArrayOf( + and(0xFF).toByte(), + ushr(8).and(0xFF).toByte() + ) + + private fun Int.toLittleEndian32(): ByteArray = byteArrayOf( + and(0xFF).toByte(), + ushr(8).and(0xFF).toByte(), + ushr(16).and(0xFF).toByte(), + ushr(24).and(0xFF).toByte() + ) + } +} + +internal data class HeartRateControlSendResult( + val attempted: Boolean, + val sent: Boolean, + val serviceId: Int? = null, + val discoveredFromMetadata: Boolean = false +) + +/** + * Owns the heart-rate service pin and control sequence for one AACP connection. + * + * A stop is only emitted after a start was successfully written. Failed stops retain the pin so a + * retry cannot be redirected by metadata that arrived after the stream began. + */ +internal class RtBuddyHeartRateControlSession( + private val decoder: RtBuddyHeartRateDecoder, + private val frames: RtBuddyHeartRateControlFrames = RtBuddyHeartRateControlFrames() +) { + private var activeServiceId: Int? = null + private var activeServiceWasDiscovered = false + + @Synchronized + fun sendStart(sender: (ByteArray) -> Boolean): HeartRateControlSendResult = + sendControl(start = true, sender = sender) + + @Synchronized + fun sendStop(sender: (ByteArray) -> Boolean): HeartRateControlSendResult = + sendControl(start = false, sender = sender) + + @Synchronized + fun reset() { + activeServiceId = null + activeServiceWasDiscovered = false + frames.reset() + } + + private fun sendControl( + start: Boolean, + sender: (ByteArray) -> Boolean + ): HeartRateControlSendResult { + val resolution = decoder.heartRateServiceResolution() + val serviceId = if (start) { + activeServiceId ?: resolution.serviceId?.also { + activeServiceId = it + activeServiceWasDiscovered = resolution.discoveredFromMetadata + } + } else { + activeServiceId + } ?: return HeartRateControlSendResult(attempted = false, sent = false) + + val packet = if (start) frames.start(serviceId) else frames.stop(serviceId) + val discoveredFromMetadata = activeServiceWasDiscovered + val sent = sender(packet) + when { + start && !sent && activeServiceId == serviceId -> { + activeServiceId = null + activeServiceWasDiscovered = false + } + + !start && sent && activeServiceId == serviceId -> { + activeServiceId = null + activeServiceWasDiscovered = false + } + } + return HeartRateControlSendResult( + attempted = true, + sent = sent, + serviceId = serviceId, + discoveredFromMetadata = discoveredFromMetadata + ) } } @@ -466,3 +757,13 @@ private fun ByteArray.longestSuffixMatchingPrefix( } return 0 } + +private fun ByteArray.containsBytes(needle: ByteArray, start: Int, end: Int): Boolean { + if (needle.isEmpty()) return true + if (start < 0 || end > size || start > end || end - start < needle.size) return false + val lastStart = end - needle.size + for (candidate in start..lastStart) { + if (needle.indices.all { this[candidate + it] == needle[it] }) return true + } + return false +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index ce98a77a4..24908679d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -457,6 +457,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendCapabilitiesService0 = aacpManager::sendHeartRateCapabilitiesService0, sendConnectService4 = aacpManager::sendHeartRateConnectService4, sendCapabilitiesService4 = aacpManager::sendHeartRateCapabilitiesService4, + awaitHeartRateService = { aacpManager.awaitHeartRateServiceResolution() }, enableHeartRate = { aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt index a79c9c8ea..8ed1fe603 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt @@ -39,6 +39,7 @@ internal class HeartRateMonitor( private val sendCapabilitiesService0: () -> Boolean, private val sendConnectService4: () -> Boolean, private val sendCapabilitiesService4: () -> Boolean, + private val awaitHeartRateService: suspend () -> Boolean, private val enableHeartRate: () -> Boolean, private val sendStart: () -> Boolean, private val sendStop: () -> Unit, @@ -250,6 +251,7 @@ internal class HeartRateMonitor( private suspend fun startStreamAttempt(): Long? { drainIncomingSamples() if (!initializeAacpSession()) return null + if (!awaitHeartRateService()) return null val enabled = synchronized(lock) { canRun() && enableHeartRate().also { sent -> if (sent) sessionNeedsStop = true From 3613d59a6595dff3fc3dc626bfdf12a971a7208f Mon Sep 17 00:00:00 2001 From: thibauP Date: Wed, 12 Aug 2026 16:13:31 +0200 Subject: [PATCH 15/15] Implemented RSSI, workouts with the heart rate data, and added an option to send out the heart rate data as an Android BLE server --- android/app/build.gradle.kts | 5 + android/app/src/main/AndroidManifest.xml | 8 +- .../librepods/LibrePodsApplication.kt | 30 + .../librepods/bluetooth/BLEManager.kt | 160 ++++- .../bluetooth/HeartRateBlePeripheral.kt | 548 +++++++++++++++ .../bluetooth/HeartRateMeasurementEncoder.kt | 17 + .../librepods/data/workout/HeartRateZones.kt | 69 ++ .../librepods/data/workout/WorkoutDao.kt | 165 +++++ .../librepods/data/workout/WorkoutDatabase.kt | 33 + .../librepods/data/workout/WorkoutEntities.kt | 77 +++ .../data/workout/WorkoutPreferences.kt | 38 ++ .../data/workout/WorkoutRepository.kt | 293 ++++++++ .../export/workout/FitActivityEncoder.kt | 210 ++++++ .../export/workout/WorkoutCsvEncoder.kt | 58 ++ .../export/workout/WorkoutFileExporter.kt | 78 +++ .../librepods/finder/NearbyAirPodsFinder.kt | 142 ++++ .../librepods/finder/NearbyFinderSignal.kt | 183 +++++ .../workout/WorkoutHealthConnectExporter.kt | 94 +++ .../presentation/navigation/AppNavGraph.kt | 51 +- .../presentation/navigation/NavigationRoot.kt | 5 + .../presentation/navigation/Screen.kt | 15 + .../screens/AirPodsSettingsScreen.kt | 14 +- .../presentation/screens/AppSettingsScreen.kt | 2 +- .../screens/HeartRateTestScreen.kt | 197 +++++- .../screens/MicrophoneSettingsScreen.kt | 4 +- .../screens/NearbyAirPodsFinderScreen.kt | 286 ++++++++ .../presentation/screens/WorkoutScreens.kt | 642 ++++++++++++++++++ .../viewmodel/AirPodsViewModel.kt | 41 ++ .../librepods/services/AirPodsService.kt | 95 +++ android/app/src/main/res/xml/file_paths.xml | 1 + .../HeartRateMeasurementEncoderTest.kt | 31 + .../data/workout/HeartRateZonesTest.kt | 37 + .../data/workout/WorkoutRepositoryTest.kt | 194 ++++++ .../export/workout/FitActivityEncoderTest.kt | 111 +++ .../export/workout/WorkoutCsvEncoderTest.kt | 76 +++ .../finder/RssiSignalProcessorTest.kt | 81 +++ android/build.gradle.kts | 1 + android/gradle/libs.versions.toml | 8 + 38 files changed, 4027 insertions(+), 73 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e3ca5223b..915d121ea 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.ksp) // alias(libs.plugins.hilt) id("kotlin-parcelize") } @@ -135,6 +136,9 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.health.connect.client) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) implementation(libs.androidx.activity.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) @@ -164,6 +168,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigationevent) + testImplementation(libs.junit) } aboutLibraries { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 56c76b448..8837a6abd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,10 +27,9 @@ android:name="android.permission.INTERACT_ACROSS_USERS" tools:ignore="ProtectedPermissions" /> - + + + @@ -42,6 +41,7 @@ + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt index d6968038c..b2a692ead 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -4,8 +4,17 @@ import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.room.Room import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedServiceHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import me.kavishdevar.librepods.data.workout.RoomWorkoutLocalStore +import me.kavishdevar.librepods.data.workout.WorkoutDatabase +import me.kavishdevar.librepods.data.workout.WorkoutPreferences +import me.kavishdevar.librepods.data.workout.WorkoutRepository +import me.kavishdevar.librepods.health.workout.AndroidWorkoutHealthConnectExporter import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.utils.XposedServiceHolder @@ -13,12 +22,33 @@ import me.kavishdevar.librepods.utils.XposedState class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { + private val workoutScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val workoutPreferences: WorkoutPreferences by lazy { + WorkoutPreferences(getSharedPreferences("settings", MODE_PRIVATE)) + } + + val workoutRepository: WorkoutRepository by lazy { + val database = Room.databaseBuilder( + applicationContext, + WorkoutDatabase::class.java, + "workouts.db", + ).build() + WorkoutRepository( + localStore = RoomWorkoutLocalStore(database), + healthConnectExporter = AndroidWorkoutHealthConnectExporter(this), + maxHeartRateProvider = { workoutPreferences.maxHeartRateBpm }, + scope = workoutScope, + ) + } + override fun onCreate() { XposedServiceHelper.registerListener(this) BillingManager.provider = BillingProviderFactory.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) super.onCreate() + workoutRepository.retryPendingHealthConnectExports() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt index 52fa05512..7b3e345e2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt @@ -29,6 +29,7 @@ import android.content.Context import android.content.SharedPreferences import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.util.Log import me.kavishdevar.librepods.utils.BluetoothCryptography import javax.crypto.Cipher @@ -71,11 +72,13 @@ class BLEManager(private val context: Context) { fun onLidStateChanged(lidOpen: Boolean) fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) fun onBatteryChanged(device: AirPodsStatus) + fun onVerifiedRssi(rssi: Int) + fun onScanError(errorCode: Int) fun onDeviceDisappeared() } private var mBluetoothLeScanner: BluetoothLeScanner? = null - private var mScanCallback: ScanCallback? = null + @Volatile private var mScanCallback: ScanCallback? = null private var airPodsStatusListener: AirPodsStatusListener? = null private val deviceStatusMap = mutableMapOf() private val verifiedAddresses = mutableSetOf() @@ -83,6 +86,11 @@ class BLEManager(private val context: Context) { private var currentGlobalLidState: Boolean? = null private var lastBroadcastTime: Long = 0 private val processedAddresses = mutableSetOf() + @Volatile private var finderScanMode = false + @Volatile private var finderScanStartedAt = 0L + @Volatile private var lastFinderVerifiedRssiAt = 0L + @Volatile private var lastFinderScanRestartAt = 0L + @Volatile private var scanGeneration = 0L private val lastValidCaseBatteryMap = mutableMapOf() private val modelNames = mapOf( @@ -119,21 +127,59 @@ class BLEManager(private val context: Context) { } } + /** + * OxygenOS occasionally leaves an unfiltered BLE scan registered but stops delivering its + * callbacks. Finder mode is short-lived, so recover that state automatically instead of + * forcing the user to leave and re-enter the screen. + */ + private val finderScanWatchdogRunnable = object : Runnable { + override fun run() { + if (!finderScanMode) return + val now = SystemClock.elapsedRealtime() + val lastSignal = lastFinderVerifiedRssiAt.takeIf { it > 0L } ?: finderScanStartedAt + val stalled = now - lastSignal >= FINDER_SCAN_STALL_TIMEOUT_MS + val canRestart = now - lastFinderScanRestartAt >= FINDER_SCAN_RESTART_COOLDOWN_MS + if (stalled && canRestart) { + lastFinderScanRestartAt = now + Log.w(TAG, "Finder BLE scan stalled; restarting scanner") + if (!startScanning(scanAllAdvertisementsForFinder = true)) { + airPodsStatusListener?.onScanError(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) + } + return + } + cleanupHandler.postDelayed(this, FINDER_WATCHDOG_INTERVAL_MS) + } + } + fun setAirPodsStatusListener(listener: AirPodsStatusListener) { airPodsStatusListener = listener } @SuppressLint("MissingPermission") - fun startScanning() { + @Synchronized + fun startScanning( + scanAllAdvertisementsForFinder: Boolean = false, + resetFinderWatchdog: Boolean = false, + ): Boolean { + val generation = ++scanGeneration try { Log.d(TAG, "Starting BLE scanner") + finderScanMode = scanAllAdvertisementsForFinder + if (scanAllAdvertisementsForFinder) { + finderScanStartedAt = SystemClock.elapsedRealtime() + lastFinderVerifiedRssiAt = 0L + if (resetFinderWatchdog) lastFinderScanRestartAt = 0L + } else { + cleanupHandler.removeCallbacks(finderScanWatchdogRunnable) + } val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val btAdapter = btManager.adapter if (btAdapter == null) { Log.d(TAG, "No Bluetooth adapter available") - return + finderScanMode = false + return false } if (mBluetoothLeScanner != null && mScanCallback != null) { @@ -143,38 +189,58 @@ class BLEManager(private val context: Context) { if (!btAdapter.isEnabled) { Log.d(TAG, "Bluetooth is disabled") - return + finderScanMode = false + return false } mBluetoothLeScanner = btAdapter.bluetoothLeScanner + val scanner = mBluetoothLeScanner + if (scanner == null) { + Log.d(TAG, "No Bluetooth LE scanner available") + finderScanMode = false + return false + } val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) - .setReportDelay(500L) + // Finder needs each RSSI sample immediately. Some vendor stacks (including + // OxygenOS) fail to flush an unfiltered offloaded batch while the app is also + // using BLE peripheral mode, leaving the UI on "Waiting for signal" forever. + .setReportDelay(if (scanAllAdvertisementsForFinder) 0L else 500L) .build() - val manufacturerData = ByteArray(27) - val manufacturerDataMask = ByteArray(27) - - manufacturerData[0] = 7 - manufacturerData[1] = 25 - - manufacturerDataMask[0] = -1 - manufacturerDataMask[1] = -1 - - val scanFilter = ScanFilter.Builder() - .setManufacturerData(76, manufacturerData, manufacturerDataMask) - .build() + val scanFilters = if (scanAllAdvertisementsForFinder) { + // Some vendor Bluetooth stacks interpret an empty manufacturer-data filter as + // "match an empty payload". Finder therefore uses a genuinely unfiltered + // foreground scan and applies Apple company-ID + RPA ownership checks in-process. + null + } else { + val manufacturerData = ByteArray(27).apply { + this[0] = 0x07 + this[1] = 0x19 + } + val manufacturerDataMask = ByteArray(27).apply { + this[0] = -1 + this[1] = -1 + } + listOf( + ScanFilter.Builder() + .setManufacturerData(76, manufacturerData, manufacturerDataMask) + .build() + ) + } - mScanCallback = object : ScanCallback() { + lateinit var callback: ScanCallback + callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { - processScanResult(result) + if (isCurrentScan(callback, generation)) processScanResult(result) } override fun onBatchScanResults(results: List) { + if (!isCurrentScan(callback, generation)) return processedAddresses.clear() for (result in results) { processScanResult(result) @@ -182,22 +248,47 @@ class BLEManager(private val context: Context) { } override fun onScanFailed(errorCode: Int) { + if (!isCurrentScan(callback, generation)) return Log.e(TAG, "BLE scan failed with error code: $errorCode") + mScanCallback = null + finderScanMode = false + cleanupHandler.removeCallbacks(finderScanWatchdogRunnable) + airPodsStatusListener?.onScanError(errorCode) } } - mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback) + mScanCallback = callback + processedAddresses.clear() + scanner.startScan(scanFilters, scanSettings, callback) Log.d(TAG, "BLE scanner started successfully") + cleanupHandler.removeCallbacks(cleanupRunnable) cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS) + if (scanAllAdvertisementsForFinder) { + cleanupHandler.removeCallbacks(finderScanWatchdogRunnable) + cleanupHandler.postDelayed(finderScanWatchdogRunnable, FINDER_WATCHDOG_INTERVAL_MS) + } + return true } catch (t: Throwable) { Log.e(TAG, "Error starting BLE scanner", t) + if (scanGeneration == generation) { + mScanCallback = null + finderScanMode = false + cleanupHandler.removeCallbacks(finderScanWatchdogRunnable) + if (scanAllAdvertisementsForFinder) { + airPodsStatusListener?.onScanError(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) + } + } + return false } } @SuppressLint("MissingPermission") - fun stopScanning() { + @Synchronized + fun stopScanning(): Boolean { + ++scanGeneration try { + finderScanMode = false if (mBluetoothLeScanner != null && mScanCallback != null) { Log.d(TAG, "Stopping BLE scanner") mBluetoothLeScanner?.stopScan(mScanCallback) @@ -205,11 +296,17 @@ class BLEManager(private val context: Context) { } cleanupHandler.removeCallbacks(cleanupRunnable) + cleanupHandler.removeCallbacks(finderScanWatchdogRunnable) + return true } catch (t: Throwable) { Log.e(TAG, "Error stopping BLE scanner", t) + return false } } + private fun isCurrentScan(callback: ScanCallback, generation: Long): Boolean = + scanGeneration == generation && mScanCallback === callback + @OptIn(ExperimentalEncodingApi::class) private fun getEncryptionKeyFromPreferences(): ByteArray? { val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) @@ -254,12 +351,7 @@ class BLEManager(private val context: Context) { val scanRecord = result.scanRecord ?: return val address = result.device.address - if (processedAddresses.contains(address)) { - return - } - val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return - if (manufacturerData.size <= 20) return if (!verifiedAddresses.contains(address)) { val irk = getIrkFromPreferences() @@ -270,6 +362,19 @@ class BLEManager(private val context: Context) { Log.d(TAG, "RPA verified and added to trusted list: $address") } + // RSSI is useful on every verified advertisement. Keep the existing status-message + // de-duplication below so finder updates do not increase unrelated status callbacks. + if (finderScanMode) lastFinderVerifiedRssiAt = SystemClock.elapsedRealtime() + airPodsStatusListener?.onVerifiedRssi(result.rssi) + if (processedAddresses.contains(address)) return + + // RSSI finding can use any advertisement from the verified rotating address. The + // battery/lid parser below is specific to Apple's legacy 0x07/0x19 payload. + if (manufacturerData.size <= 20 || + manufacturerData[0] != 0x07.toByte() || + manufacturerData[1] != 0x19.toByte() + ) return + processedAddresses.add(address) lastBroadcastTime = System.currentTimeMillis() @@ -491,6 +596,9 @@ class BLEManager(private val context: Context) { companion object { private const val TAG = "AirPodsBLE" + private const val FINDER_WATCHDOG_INTERVAL_MS = 3_000L + private const val FINDER_SCAN_STALL_TIMEOUT_MS = 8_000L + private const val FINDER_SCAN_RESTART_COOLDOWN_MS = 15_000L private const val CLEANUP_INTERVAL_MS = 10000L private const val STALE_DEVICE_TIMEOUT_MS = 15000L private const val LID_CLOSE_TIMEOUT_MS = 2500L diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt new file mode 100644 index 000000000..1265faa30 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt @@ -0,0 +1,548 @@ +package me.kavishdevar.librepods.bluetooth + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.content.pm.PackageManager +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +enum class HeartRateBlePeripheralStatus { + DISABLED, + STARTING, + ADVERTISING, + PERMISSION_REQUIRED, + BLUETOOTH_OFF, + UNSUPPORTED, + ERROR +} + +data class HeartRateBlePeripheralState( + val enabled: Boolean = false, + val status: HeartRateBlePeripheralStatus = HeartRateBlePeripheralStatus.DISABLED, + val connectedDeviceCount: Int = 0, + val subscribedDeviceCount: Int = 0, + val lastError: String? = null +) + +/** + * Opt-in Bluetooth SIG Heart Rate Service peripheral. + * + * The caller is responsible for forwarding only validated samples. This class never reads the + * AirPods transport directly and does not start heart-rate monitoring on its own. + */ +class HeartRateBlePeripheral(private val context: Context) { + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private val lock = Any() + private val connectedDevices = mutableSetOf() + private val subscribedDevices = mutableSetOf() + private val notificationInFlight = mutableSetOf() + + private var gattServer: BluetoothGattServer? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var advertising = false + private var starting = false + private var requestedEnabled = false + + private val heartRateMeasurement = BluetoothGattCharacteristic( + HEART_RATE_MEASUREMENT_UUID, + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + 0 + ).apply { + addDescriptor( + BluetoothGattDescriptor( + CLIENT_CHARACTERISTIC_CONFIGURATION_UUID, + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE + ) + ) + } + + private val bodySensorLocation = BluetoothGattCharacteristic( + BODY_SENSOR_LOCATION_UUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ + ) + + private val heartRateService = BluetoothGattService( + HEART_RATE_SERVICE_UUID, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ).apply { + addCharacteristic(heartRateMeasurement) + addCharacteristic(bodySensorLocation) + } + + private val _state = MutableStateFlow(HeartRateBlePeripheralState()) + val state: StateFlow = _state + + private val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + synchronized(lock) { + if (!requestedEnabled) return + starting = false + advertising = true + publishStateLocked(HeartRateBlePeripheralStatus.ADVERTISING, null) + } + } + + override fun onStartFailure(errorCode: Int) { + synchronized(lock) { + advertising = false + publishStateLocked( + HeartRateBlePeripheralStatus.ERROR, + "BLE advertising failed: ${advertiseErrorName(errorCode)}" + ) + } + stopResources(keepRequestedEnabled = true) + } + } + + private val gattCallback = object : BluetoothGattServerCallback() { + override fun onServiceAdded(status: Int, service: BluetoothGattService) { + if (service.uuid != HEART_RATE_SERVICE_UUID) return + if (status != BluetoothGatt.GATT_SUCCESS) { + synchronized(lock) { + publishStateLocked( + HeartRateBlePeripheralStatus.ERROR, + "Could not add Heart Rate Service (GATT status $status)" + ) + } + stopResources(keepRequestedEnabled = true) + return + } + startAdvertisingAfterServiceAdded() + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + synchronized(lock) { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> connectedDevices.add(device) + BluetoothProfile.STATE_DISCONNECTED -> { + connectedDevices.remove(device) + subscribedDevices.remove(device) + notificationInFlight.remove(device) + } + } + publishStateLocked() + } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic + ) { + when (characteristic.uuid) { + BODY_SENSOR_LOCATION_UUID -> sendReadResponse( + device = device, + requestId = requestId, + offset = offset, + fullValue = byteArrayOf(BODY_SENSOR_LOCATION_EAR_LOBE) + ) + else -> sendResponse( + device, + requestId, + BluetoothGatt.GATT_READ_NOT_PERMITTED, + 0, + null + ) + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + if (responseNeeded) { + sendResponse( + device, + requestId, + BluetoothGatt.GATT_WRITE_NOT_PERMITTED, + 0, + null + ) + } + } + + override fun onDescriptorReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + descriptor: BluetoothGattDescriptor + ) { + if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIGURATION_UUID || + descriptor.characteristic.uuid != HEART_RATE_MEASUREMENT_UUID + ) { + sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null) + return + } + val value = synchronized(lock) { + if (subscribedDevices.contains(device)) { + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } else { + BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } + } + sendReadResponse(device, requestId, offset, value) + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + val isHeartRateCccd = descriptor.uuid == CLIENT_CHARACTERISTIC_CONFIGURATION_UUID && + descriptor.characteristic.uuid == HEART_RATE_MEASUREMENT_UUID + val status = when { + !isHeartRateCccd -> BluetoothGatt.GATT_WRITE_NOT_PERMITTED + preparedWrite -> BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED + offset != 0 -> BluetoothGatt.GATT_INVALID_OFFSET + value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> { + synchronized(lock) { + subscribedDevices.add(device) + publishStateLocked() + } + BluetoothGatt.GATT_SUCCESS + } + value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> { + synchronized(lock) { + subscribedDevices.remove(device) + notificationInFlight.remove(device) + publishStateLocked() + } + BluetoothGatt.GATT_SUCCESS + } + else -> BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED + } + if (responseNeeded) sendResponse(device, requestId, status, 0, null) + } + + override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) { + sendResponse(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED, 0, null) + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + synchronized(lock) { + notificationInFlight.remove(device) + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "Heart-rate notification failed for ${device.address}: $status") + } + } + } + } + + @SuppressLint("MissingPermission") + fun setEnabled(enabled: Boolean) { + if (enabled) start() else stop() + } + + @SuppressLint("MissingPermission") + fun start() { + synchronized(lock) { + requestedEnabled = true + if (advertising || starting || gattServer != null) { + publishStateLocked(if (advertising) HeartRateBlePeripheralStatus.ADVERTISING else HeartRateBlePeripheralStatus.STARTING) + return + } + starting = true + publishStateLocked(HeartRateBlePeripheralStatus.STARTING, null) + } + + val missingPermission = when { + context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED -> Manifest.permission.BLUETOOTH_CONNECT + context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADVERTISE) != PackageManager.PERMISSION_GRANTED -> Manifest.permission.BLUETOOTH_ADVERTISE + else -> null + } + if (missingPermission != null) { + synchronized(lock) { + starting = false + publishStateLocked( + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, + "Nearby devices permission is required for BLE heart-rate sharing" + ) + } + return + } + + val adapter = bluetoothManager.adapter + if (adapter == null) { + synchronized(lock) { starting = false; publishStateLocked(HeartRateBlePeripheralStatus.UNSUPPORTED, "Bluetooth is not supported") } + return + } + if (!adapter.isEnabled) { + synchronized(lock) { starting = false; publishStateLocked(HeartRateBlePeripheralStatus.BLUETOOTH_OFF, "Bluetooth is turned off") } + return + } + if (!adapter.isMultipleAdvertisementSupported) { + synchronized(lock) { + starting = false + publishStateLocked( + HeartRateBlePeripheralStatus.UNSUPPORTED, + "This Bluetooth chipset does not support LE advertising" + ) + } + return + } + val leAdvertiser = adapter.bluetoothLeAdvertiser + if (leAdvertiser == null) { + synchronized(lock) { + starting = false + publishStateLocked(HeartRateBlePeripheralStatus.UNSUPPORTED, "BLE advertising is unavailable") + } + return + } + + val server = try { + bluetoothManager.openGattServer(context, gattCallback) + } catch (_: SecurityException) { + synchronized(lock) { + starting = false + publishStateLocked( + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, + "Nearby devices permission was revoked" + ) + } + return + } catch (_: Throwable) { + null + } + if (server == null) { + synchronized(lock) { + starting = false + publishStateLocked(HeartRateBlePeripheralStatus.ERROR, "Could not open a local GATT server") + } + return + } + + synchronized(lock) { + advertiser = leAdvertiser + gattServer = server + publishStateLocked(HeartRateBlePeripheralStatus.STARTING, null) + } + val serviceAdded = try { + server.addService(heartRateService) + } catch (_: SecurityException) { + synchronized(lock) { + publishStateLocked( + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, + "Nearby devices permission was revoked" + ) + } + false + } catch (_: Throwable) { + false + } + if (!serviceAdded) { + if (_state.value.status != HeartRateBlePeripheralStatus.PERMISSION_REQUIRED) { + synchronized(lock) { + publishStateLocked(HeartRateBlePeripheralStatus.ERROR, "Could not register Heart Rate Service") + } + } + stopResources(keepRequestedEnabled = true) + } + } + + @SuppressLint("MissingPermission") + private fun startAdvertisingAfterServiceAdded() { + val localAdvertiser = synchronized(lock) { + if (!requestedEnabled || advertising) return + advertiser + } ?: return + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID)) + .build() + try { + localAdvertiser.startAdvertising(settings, data, advertiseCallback) + } catch (securityException: SecurityException) { + synchronized(lock) { + publishStateLocked(HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, "Bluetooth advertise permission was revoked") + } + stopResources(keepRequestedEnabled = true) + } catch (t: Throwable) { + synchronized(lock) { + publishStateLocked(HeartRateBlePeripheralStatus.ERROR, t.message ?: "BLE advertising could not start") + } + stopResources(keepRequestedEnabled = true) + } + } + + @SuppressLint("MissingPermission") + fun stop() { + stopResources(keepRequestedEnabled = false) + synchronized(lock) { + publishStateLocked(HeartRateBlePeripheralStatus.DISABLED, null) + } + } + + /** Called only with HeartRateMonitor's published/validated samples. */ + fun onValidatedSample(sample: HeartRateSample) { + val value = HeartRateMeasurementEncoder.encodeBpm(sample.bpm) + val targets = synchronized(lock) { + if (!requestedEnabled || !advertising) return + subscribedDevices.filterTo(mutableListOf()) { connectedDevices.contains(it) } + } + targets.forEach { sendNotification(it, value) } + } + + @SuppressLint("MissingPermission") + private fun sendNotification(device: BluetoothDevice, value: ByteArray) { + val server = synchronized(lock) { + if (!requestedEnabled || !subscribedDevices.contains(device)) return + // Heart Rate Measurement is time-sensitive. Do not queue old BPM values while Android + // is still sending a prior notification for this central. + if (notificationInFlight.contains(device)) return + gattServer + } ?: return + + val result = try { + server.notifyCharacteristicChanged(device, heartRateMeasurement, false, value) + } catch (_: SecurityException) { + synchronized(lock) { + publishStateLocked( + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, + "Nearby devices permission was revoked" + ) + } + stopResources(keepRequestedEnabled = true) + return + } catch (_: Throwable) { + BluetoothStatusCodes.ERROR_UNKNOWN + } + synchronized(lock) { + if (result == BluetoothStatusCodes.SUCCESS) { + notificationInFlight.add(device) + } else { + Log.w(TAG, "Could not queue heart-rate notification for ${device.address}: $result") + } + } + } + + @SuppressLint("MissingPermission") + private fun sendReadResponse( + device: BluetoothDevice, + requestId: Int, + offset: Int, + fullValue: ByteArray + ) { + if (offset < 0 || offset > fullValue.size) { + sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, 0, null) + return + } + sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + fullValue.copyOfRange(offset, fullValue.size) + ) + } + + @SuppressLint("MissingPermission") + private fun sendResponse( + device: BluetoothDevice, + requestId: Int, + status: Int, + offset: Int, + value: ByteArray? + ) { + try { + gattServer?.sendResponse(device, requestId, status, offset, value) + } catch (t: Throwable) { + Log.w(TAG, "Could not send GATT response", t) + } + } + + @SuppressLint("MissingPermission") + private fun stopResources(keepRequestedEnabled: Boolean) { + val localAdvertiser: BluetoothLeAdvertiser? + val localServer: BluetoothGattServer? + synchronized(lock) { + requestedEnabled = keepRequestedEnabled + starting = false + advertising = false + localAdvertiser = advertiser + localServer = gattServer + advertiser = null + gattServer = null + connectedDevices.clear() + subscribedDevices.clear() + notificationInFlight.clear() + } + try { + localAdvertiser?.stopAdvertising(advertiseCallback) + } catch (_: Throwable) { + } + try { + localServer?.clearServices() + } catch (_: Throwable) { + } + try { + localServer?.close() + } catch (_: Throwable) { + } + } + + private fun publishStateLocked( + status: HeartRateBlePeripheralStatus = _state.value.status, + error: String? = _state.value.lastError + ) { + _state.value = HeartRateBlePeripheralState( + enabled = requestedEnabled, + status = status, + connectedDeviceCount = connectedDevices.size, + subscribedDeviceCount = subscribedDevices.size, + lastError = error + ) + } + + private fun advertiseErrorName(code: Int): String = when (code) { + AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "already started" + AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "data too large" + AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "feature unsupported" + AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "internal error" + AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "too many advertisers" + else -> "error $code" + } + + companion object { + private const val TAG = "HeartRateBlePeripheral" + val HEART_RATE_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") + val HEART_RATE_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") + val BODY_SENSOR_LOCATION_UUID: UUID = UUID.fromString("00002a38-0000-1000-8000-00805f9b34fb") + val CLIENT_CHARACTERISTIC_CONFIGURATION_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + const val BODY_SENSOR_LOCATION_EAR_LOBE: Byte = 0x05 + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt new file mode 100644 index 000000000..b89df5784 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt @@ -0,0 +1,17 @@ +package me.kavishdevar.librepods.bluetooth + +/** Encodes the Bluetooth SIG Heart Rate Measurement characteristic (0x2A37). */ +object HeartRateMeasurementEncoder { + fun encodeBpm(bpm: Int): ByteArray { + require(bpm in 0..0xFFFF) { "Heart rate must fit an unsigned 16-bit value" } + return if (bpm <= 0xFF) { + byteArrayOf(0x00, bpm.toByte()) + } else { + byteArrayOf( + 0x01, // Flags: Heart Rate Value Format = UINT16; all optional fields absent. + (bpm and 0xFF).toByte(), + ((bpm ushr 8) and 0xFF).toByte() + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt new file mode 100644 index 000000000..f7259e69e --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt @@ -0,0 +1,69 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +data class HeartRateZone( + val label: String, + val minimumPercent: Int?, + val maximumPercentExclusive: Int?, + val sampleCount: Int, +) + +object HeartRateZones { + const val DEFAULT_MAX_HEART_RATE_BPM = 190 + const val MIN_CONFIGURABLE_MAX_HEART_RATE_BPM = 120 + const val MAX_CONFIGURABLE_MAX_HEART_RATE_BPM = 240 + + fun normalizedMaxHeartRate(value: Int): Int = value.coerceIn( + MIN_CONFIGURABLE_MAX_HEART_RATE_BPM, + MAX_CONFIGURABLE_MAX_HEART_RATE_BPM, + ) + + /** + * Deterministic five-zone model using percentage of the configured max HR. + * Recovery=50-59%, Endurance=60-69%, Tempo=70-79%, Threshold=80-89%, Peak>=90%; + * below 50% is separate. + * Distribution is by recorded sample count, not inferred time between samples. + */ + fun zoneIndex(bpm: Int, maxHeartRateBpm: Int): Int { + val maxHr = normalizedMaxHeartRate(maxHeartRateBpm) + val percentage = bpm.toLong() * 100L / maxHr.toLong() + return when { + percentage < 50L -> 0 + percentage < 60L -> 1 + percentage < 70L -> 2 + percentage < 80L -> 3 + percentage < 90L -> 4 + else -> 5 + } + } + + fun distribution(samples: List, maxHeartRateBpm: Int): List { + val counts = IntArray(6) + samples.forEach { counts[zoneIndex(it.bpm, maxHeartRateBpm)]++ } + return listOf( + HeartRateZone("Below target", null, 50, counts[0]), + HeartRateZone("Recovery", 50, 60, counts[1]), + HeartRateZone("Endurance", 60, 70, counts[2]), + HeartRateZone("Tempo", 70, 80, counts[3]), + HeartRateZone("Threshold", 80, 90, counts[4]), + HeartRateZone("Peak", 90, null, counts[5]), + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt new file mode 100644 index 000000000..321e124fc --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt @@ -0,0 +1,165 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** Aggregate row used directly by UI-facing repository Flows. */ +data class WorkoutSessionSummaryRow( + val id: String, + val startTimeEpochMillis: Long, + val endTimeEpochMillis: Long?, + val maxHeartRateBpm: Int, + val healthConnectExportState: String, + val healthConnectExportMessage: String?, + val sampleCount: Long, + val latestBpm: Int?, + val minBpm: Int?, + val avgBpm: Double?, + val maxBpm: Int?, +) + +@Dao +interface WorkoutDao { + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertSession(session: WorkoutSessionEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSample(sample: WorkoutSampleEntity): Long + + @Query("SELECT * FROM workout_sessions WHERE endTimeEpochMillis IS NULL ORDER BY startTimeEpochMillis DESC LIMIT 1") + suspend fun getActiveSession(): WorkoutSessionEntity? + + @Query("SELECT * FROM workout_sessions WHERE id = :sessionId LIMIT 1") + suspend fun getSession(sessionId: String): WorkoutSessionEntity? + + @Query("DELETE FROM workout_sessions WHERE id = :sessionId") + suspend fun deleteSession(sessionId: String): Int + + @Query("SELECT * FROM workout_sessions WHERE endTimeEpochMillis IS NOT NULL AND healthConnectExportState = 'PENDING' ORDER BY startTimeEpochMillis") + suspend fun getPendingHealthConnectSessions(): List + + @Query("SELECT * FROM workout_samples WHERE sessionId = :sessionId ORDER BY timestampEpochMillis, id") + suspend fun getSamples(sessionId: String): List + + @Query("SELECT * FROM workout_samples WHERE sessionId = :sessionId ORDER BY timestampEpochMillis, id") + fun observeSamples(sessionId: String): Flow> + + @Query( + """ + SELECT hr.* FROM workout_samples hr + INNER JOIN workout_sessions s ON s.id = hr.sessionId + WHERE s.endTimeEpochMillis IS NULL + ORDER BY hr.timestampEpochMillis, hr.id + """ + ) + fun observeActiveSamples(): Flow> + + @Query( + """ + UPDATE workout_sessions + SET endTimeEpochMillis = :endTimeEpochMillis, + endZoneOffsetSeconds = :endZoneOffsetSeconds, + healthConnectExportState = :exportState, + healthConnectExportMessage = NULL + WHERE id = :sessionId AND endTimeEpochMillis IS NULL + """ + ) + suspend fun closeSessionIfActive( + sessionId: String, + endTimeEpochMillis: Long, + endZoneOffsetSeconds: Int, + exportState: String, + ): Int + + @Query( + """ + UPDATE workout_sessions + SET healthConnectExportState = :exportState, + healthConnectRecordId = :recordId, + healthConnectExportMessage = :message + WHERE id = :sessionId + """ + ) + suspend fun updateHealthConnectExport( + sessionId: String, + exportState: String, + recordId: String?, + message: String?, + ) + + @Query(ACTIVE_SUMMARY_QUERY) + fun observeActiveSummary(): Flow + + @Query(FINISHED_SUMMARIES_QUERY) + fun observeFinishedSummaries(): Flow> + + @Query(SESSION_SUMMARY_QUERY) + fun observeSummary(sessionId: String): Flow + + companion object { + private const val SUMMARY_COLUMNS = """ + s.id AS id, + s.startTimeEpochMillis AS startTimeEpochMillis, + s.endTimeEpochMillis AS endTimeEpochMillis, + s.maxHeartRateBpm AS maxHeartRateBpm, + s.healthConnectExportState AS healthConnectExportState, + s.healthConnectExportMessage AS healthConnectExportMessage, + COUNT(hr.id) AS sampleCount, + (SELECT latest.bpm FROM workout_samples latest + WHERE latest.sessionId = s.id + ORDER BY latest.timestampEpochMillis DESC, latest.id DESC LIMIT 1) AS latestBpm, + MIN(hr.bpm) AS minBpm, + AVG(hr.bpm) AS avgBpm, + MAX(hr.bpm) AS maxBpm + """ + + const val ACTIVE_SUMMARY_QUERY = """ + SELECT $SUMMARY_COLUMNS + FROM workout_sessions s + LEFT JOIN workout_samples hr ON hr.sessionId = s.id + WHERE s.endTimeEpochMillis IS NULL + GROUP BY s.id + ORDER BY s.startTimeEpochMillis DESC + LIMIT 1 + """ + + const val FINISHED_SUMMARIES_QUERY = """ + SELECT $SUMMARY_COLUMNS + FROM workout_sessions s + LEFT JOIN workout_samples hr ON hr.sessionId = s.id + WHERE s.endTimeEpochMillis IS NOT NULL + GROUP BY s.id + ORDER BY s.startTimeEpochMillis DESC + """ + + const val SESSION_SUMMARY_QUERY = """ + SELECT $SUMMARY_COLUMNS + FROM workout_sessions s + LEFT JOIN workout_samples hr ON hr.sessionId = s.id + WHERE s.id = :sessionId + GROUP BY s.id + LIMIT 1 + """ + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt new file mode 100644 index 000000000..0b0ee051d --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt @@ -0,0 +1,33 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [WorkoutSessionEntity::class, WorkoutSampleEntity::class], + version = 1, + // This database is new and unreleased in this snapshot. There is no shipped schema to migrate + // from yet; enable schema export and commit v1 before the first release containing workouts. + exportSchema = false, +) +abstract class WorkoutDatabase : RoomDatabase() { + abstract fun workoutDao(): WorkoutDao +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt new file mode 100644 index 000000000..ce51efd3f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt @@ -0,0 +1,77 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "workout_sessions", + indices = [ + Index(value = ["startTimeEpochMillis"]), + Index(value = ["endTimeEpochMillis"]), + ], +) +data class WorkoutSessionEntity( + @PrimaryKey val id: String, + val startTimeEpochMillis: Long, + val startZoneOffsetSeconds: Int, + val endTimeEpochMillis: Long? = null, + val endZoneOffsetSeconds: Int? = null, + val maxHeartRateBpm: Int, + val healthConnectClientRecordId: String, + val healthConnectRecordId: String? = null, + val healthConnectExportState: String = HealthConnectSessionExportState.NOT_FINISHED.name, + val healthConnectExportMessage: String? = null, +) + +@Entity( + tableName = "workout_samples", + foreignKeys = [ + ForeignKey( + entity = WorkoutSessionEntity::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["sessionId"]), + Index(value = ["sessionId", "timestampEpochMillis"]), + Index(value = ["sessionId", "timestampEpochMillis", "sequence"], unique = true), + ], +) +data class WorkoutSampleEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val sessionId: String, + val timestampEpochMillis: Long, + val sequence: Int, + val bpm: Int, +) + +enum class HealthConnectSessionExportState { + NOT_FINISHED, + PENDING, + EXPORTED, + PERMISSION_REQUIRED, + UNAVAILABLE, + ERROR, +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt new file mode 100644 index 000000000..8231e50ae --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt @@ -0,0 +1,38 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import android.content.SharedPreferences +import androidx.core.content.edit + +class WorkoutPreferences(private val sharedPreferences: SharedPreferences) { + var maxHeartRateBpm: Int + get() = HeartRateZones.normalizedMaxHeartRate( + sharedPreferences.getInt(KEY_MAX_HEART_RATE, HeartRateZones.DEFAULT_MAX_HEART_RATE_BPM) + ) + set(value) { + sharedPreferences.edit { + putInt(KEY_MAX_HEART_RATE, HeartRateZones.normalizedMaxHeartRate(value)) + } + } + + companion object { + private const val KEY_MAX_HEART_RATE = "workout_max_heart_rate_bpm" + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt new file mode 100644 index 000000000..07d296983 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt @@ -0,0 +1,293 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import androidx.room.withTransaction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExportResult +import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExporter +import java.time.Instant +import java.time.ZoneId +import java.util.UUID + +interface WorkoutLocalStore { + fun observeActiveSummary(): Flow + fun observeFinishedSummaries(): Flow> + fun observeSummary(sessionId: String): Flow + fun observeSamples(sessionId: String): Flow> + fun observeActiveSamples(): Flow> + suspend fun getActiveSession(): WorkoutSessionEntity? + suspend fun getSession(sessionId: String): WorkoutSessionEntity? + suspend fun getPendingHealthConnectSessions(): List + suspend fun getSamples(sessionId: String): List + suspend fun createSession(session: WorkoutSessionEntity) + suspend fun addSample(sample: WorkoutSampleEntity) + suspend fun deleteSession(sessionId: String): Boolean = false + suspend fun finishLocally(sessionId: String, endMillis: Long, endOffsetSeconds: Int): WorkoutSessionEntity? + suspend fun updateHealthConnectExport( + sessionId: String, + state: HealthConnectSessionExportState, + recordId: String?, + message: String?, + ) +} + +class RoomWorkoutLocalStore(private val database: WorkoutDatabase) : WorkoutLocalStore { + private val dao = database.workoutDao() + + override fun observeActiveSummary() = dao.observeActiveSummary() + override fun observeFinishedSummaries() = dao.observeFinishedSummaries() + override fun observeSummary(sessionId: String) = dao.observeSummary(sessionId) + override fun observeSamples(sessionId: String) = dao.observeSamples(sessionId) + override fun observeActiveSamples() = dao.observeActiveSamples() + override suspend fun getActiveSession() = dao.getActiveSession() + override suspend fun getSession(sessionId: String) = dao.getSession(sessionId) + override suspend fun getPendingHealthConnectSessions() = dao.getPendingHealthConnectSessions() + override suspend fun getSamples(sessionId: String) = dao.getSamples(sessionId) + override suspend fun createSession(session: WorkoutSessionEntity) = dao.insertSession(session) + override suspend fun addSample(sample: WorkoutSampleEntity) { dao.insertSample(sample) } + override suspend fun deleteSession(sessionId: String): Boolean = dao.deleteSession(sessionId) > 0 + + override suspend fun finishLocally( + sessionId: String, + endMillis: Long, + endOffsetSeconds: Int, + ): WorkoutSessionEntity? = database.withTransaction { + val current = dao.getSession(sessionId) ?: return@withTransaction null + if (current.endTimeEpochMillis == null) { + dao.closeSessionIfActive( + sessionId, + endMillis, + endOffsetSeconds, + HealthConnectSessionExportState.PENDING.name, + ) + } + dao.getSession(sessionId) + } + + override suspend fun updateHealthConnectExport( + sessionId: String, + state: HealthConnectSessionExportState, + recordId: String?, + message: String?, + ) = dao.updateHealthConnectExport(sessionId, state.name, recordId, message) +} + +data class WorkoutSummary( + val id: String, + val startTimeEpochMillis: Long, + val endTimeEpochMillis: Long?, + val maxHeartRateBpm: Int, + val sampleCount: Long, + val latestBpm: Int?, + val minBpm: Int?, + val avgBpm: Double?, + val maxBpm: Int?, + val healthConnectExportState: HealthConnectSessionExportState, + val healthConnectExportMessage: String?, +) + +data class WorkoutDetail( + val summary: WorkoutSummary, + val samples: List, + val zones: List, +) + +sealed interface FinishWorkoutResult { + data object NoActiveWorkout : FinishWorkoutResult + data class Finished(val sessionId: String, val healthConnectState: HealthConnectSessionExportState) : FinishWorkoutResult +} + +class WorkoutRepository( + private val localStore: WorkoutLocalStore, + private val healthConnectExporter: WorkoutHealthConnectExporter, + private val maxHeartRateProvider: () -> Int, + private val scope: CoroutineScope, + private val nowMillis: () -> Long = System::currentTimeMillis, + private val newId: () -> String = { UUID.randomUUID().toString() }, + private val zoneOffsetSecondsAt: (Long) -> Int = { millis -> + val instant = Instant.ofEpochMilli(millis) + ZoneId.systemDefault().rules.getOffset(instant).totalSeconds + }, +) { + private val localMutationMutex = Mutex() + private val healthExportMutex = Mutex() + + val activeWorkout: Flow = combine( + localStore.observeActiveSummary(), + localStore.observeActiveSamples(), + ) { row, samples -> row?.let { detail(it, samples) } } + + val history: Flow> = localStore.observeFinishedSummaries().map { rows -> + rows.map(::summary) + } + + fun workout(sessionId: String): Flow = combine( + localStore.observeSummary(sessionId), + localStore.observeSamples(sessionId), + ) { row, samples -> row?.let { detail(it, samples) } } + + suspend fun startWorkout(): String = localMutationMutex.withLock { + localStore.getActiveSession()?.id ?: run { + val start = nowMillis() + val id = newId() + localStore.createSession( + WorkoutSessionEntity( + id = id, + startTimeEpochMillis = start, + startZoneOffsetSeconds = zoneOffsetSecondsAt(start), + maxHeartRateBpm = maxHeartRateProvider(), + healthConnectClientRecordId = "librepods-workout:$id", + ) + ) + id + } + } + + /** Called only from HeartRateMonitor's already-validated/published stream. */ + fun recordValidatedSample(sample: HeartRateSample) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + localMutationMutex.withLock { + val session = localStore.getActiveSession() ?: return@withLock + localStore.addSample( + WorkoutSampleEntity( + sessionId = session.id, + timestampEpochMillis = sample.receivedAtMillis, + sequence = sample.sequence, + bpm = sample.bpm, + ) + ) + } + } + } + + suspend fun finishWorkout(sessionId: String? = null): FinishWorkoutResult { + val finished = localMutationMutex.withLock { + val id = sessionId ?: localStore.getActiveSession()?.id + ?: return FinishWorkoutResult.NoActiveWorkout + val end = nowMillis() + localStore.finishLocally(id, end, zoneOffsetSecondsAt(end)) + ?: return FinishWorkoutResult.NoActiveWorkout + } + val state = exportFinishedSession(finished) + return FinishWorkoutResult.Finished(finished.id, state) + } + + suspend fun deleteWorkout(sessionId: String): Boolean = localMutationMutex.withLock { + localStore.deleteSession(sessionId) + } + + suspend fun retryHealthConnectExport(sessionId: String): HealthConnectSessionExportState { + val session = localStore.getSession(sessionId) ?: return HealthConnectSessionExportState.ERROR + if (session.endTimeEpochMillis == null) return HealthConnectSessionExportState.NOT_FINISHED + return exportFinishedSession(session) + } + + /** Resumes only commits that were interrupted after the local finish transaction. */ + fun retryPendingHealthConnectExports() { + scope.launch { + localStore.getPendingHealthConnectSessions().forEach { session -> + exportFinishedSession(session) + } + } + } + + suspend fun snapshot(sessionId: String): WorkoutDetail? { + val session = localStore.getSession(sessionId) ?: return null + val samples = localStore.getSamples(sessionId) + val row = WorkoutSessionSummaryRow( + id = session.id, + startTimeEpochMillis = session.startTimeEpochMillis, + endTimeEpochMillis = session.endTimeEpochMillis, + maxHeartRateBpm = session.maxHeartRateBpm, + healthConnectExportState = session.healthConnectExportState, + healthConnectExportMessage = session.healthConnectExportMessage, + sampleCount = samples.size.toLong(), + latestBpm = samples.maxByOrNull { it.timestampEpochMillis }?.bpm, + minBpm = samples.minOfOrNull { it.bpm }, + avgBpm = samples.takeIf { it.isNotEmpty() }?.map { it.bpm }?.average(), + maxBpm = samples.maxOfOrNull { it.bpm }, + ) + return detail(row, samples) + } + + private suspend fun exportFinishedSession(session: WorkoutSessionEntity): HealthConnectSessionExportState = + healthExportMutex.withLock { + val latest = localStore.getSession(session.id) ?: session + if (latest.healthConnectExportState == HealthConnectSessionExportState.EXPORTED.name) { + return@withLock HealthConnectSessionExportState.EXPORTED + } + val result = healthConnectExporter.export(latest) + val (state, recordId, message) = when (result) { + is WorkoutHealthConnectExportResult.Exported -> Triple( + HealthConnectSessionExportState.EXPORTED, + result.recordId ?: latest.healthConnectRecordId, + null, + ) + WorkoutHealthConnectExportResult.PermissionRequired -> Triple( + HealthConnectSessionExportState.PERMISSION_REQUIRED, + latest.healthConnectRecordId, + "Exercise write permission is required", + ) + WorkoutHealthConnectExportResult.Unavailable -> Triple( + HealthConnectSessionExportState.UNAVAILABLE, + latest.healthConnectRecordId, + "Health Connect is unavailable", + ) + is WorkoutHealthConnectExportResult.Failed -> Triple( + HealthConnectSessionExportState.ERROR, + latest.healthConnectRecordId, + result.message, + ) + } + localStore.updateHealthConnectExport(latest.id, state, recordId, message) + state + } + + private fun summary(row: WorkoutSessionSummaryRow) = WorkoutSummary( + id = row.id, + startTimeEpochMillis = row.startTimeEpochMillis, + endTimeEpochMillis = row.endTimeEpochMillis, + maxHeartRateBpm = row.maxHeartRateBpm, + sampleCount = row.sampleCount, + latestBpm = row.latestBpm, + minBpm = row.minBpm, + avgBpm = row.avgBpm, + maxBpm = row.maxBpm, + healthConnectExportState = runCatching { + HealthConnectSessionExportState.valueOf(row.healthConnectExportState) + }.getOrDefault(HealthConnectSessionExportState.ERROR), + healthConnectExportMessage = row.healthConnectExportMessage, + ) + + private fun detail(row: WorkoutSessionSummaryRow, samples: List): WorkoutDetail = + WorkoutDetail( + summary = summary(row), + samples = samples, + zones = HeartRateZones.distribution(samples, row.maxHeartRateBpm), + ) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt new file mode 100644 index 000000000..9979f4083 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt @@ -0,0 +1,210 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.export.workout + +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt + +/** Minimal FIT Activity encoder for HR-only LibrePods sessions. */ +object FitActivityEncoder { + private const val HEADER_SIZE = 14 + private const val PROTOCOL_VERSION = 0x20 // FIT protocol 2.0 + // Matches the current public Garmin C SDK profile used to validate these definitions (21.213). + private const val PROFILE_VERSION = 21_213 + private const val FIT_EPOCH_UNIX_SECONDS = 631065600L + + fun encode(workout: WorkoutDetail): ByteArray { + val summary = workout.summary + val startMillis = summary.startTimeEpochMillis + val endMillis = requireNotNull(summary.endTimeEpochMillis) { + "FIT export requires a finished workout" + } + val data = ByteArrayOutputStream() + + definition(data, 0, 0, listOf( + Field(0, 1, BaseType.ENUM), + Field(1, 2, BaseType.UINT16), + Field(4, 4, BaseType.UINT32), + )) + dataMessage(data, 0) { + u8(4) // file type: activity + u16(255) // manufacturer: development + u32(fitTimestamp(startMillis)) + } + + definition(data, 1, 20, listOf( + Field(253, 4, BaseType.UINT32), + Field(3, 1, BaseType.UINT8), + )) + val orderedSamples = workout.samples.sortedBy { it.timestampEpochMillis } + if (orderedSamples.isEmpty()) { + // Required Record message without fabricating a heart-rate value. 0xFF is invalid uint8. + dataMessage(data, 1) { + u32(fitTimestamp(startMillis)) + u8(0xFF) + } + } else { + orderedSamples.forEach { sample -> + dataMessage(data, 1) { + u32(fitTimestamp(sample.timestampEpochMillis)) + u8(sample.bpm.coerceIn(0, 255)) + } + } + } + + val elapsedMs = (endMillis - startMillis).coerceAtLeast(0L) + val avgHr = summary.avgBpm?.roundToInt()?.coerceIn(0, 255) ?: 0xFF + val maxHr = summary.maxBpm?.coerceIn(0, 255) ?: 0xFF + + // Garmin Activity files require a Lap message even for an unsplit single-lap activity. + definition(data, 2, 19, listOf( + Field(253, 4, BaseType.UINT32), + Field(2, 4, BaseType.UINT32), + Field(7, 4, BaseType.UINT32), + Field(8, 4, BaseType.UINT32), + Field(15, 1, BaseType.UINT8), + Field(16, 1, BaseType.UINT8), + )) + dataMessage(data, 2) { + u32(fitTimestamp(endMillis)) + u32(fitTimestamp(startMillis)) + u32(elapsedMs) + u32(elapsedMs) + u8(avgHr) + u8(maxHr) + } + + definition(data, 3, 18, listOf( + Field(253, 4, BaseType.UINT32), + Field(2, 4, BaseType.UINT32), + Field(7, 4, BaseType.UINT32), + Field(8, 4, BaseType.UINT32), + Field(5, 1, BaseType.ENUM), + Field(16, 1, BaseType.UINT8), + Field(17, 1, BaseType.UINT8), + )) + dataMessage(data, 3) { + u32(fitTimestamp(endMillis)) + u32(fitTimestamp(startMillis)) + u32(elapsedMs) + u32(elapsedMs) + u8(0) // sport: generic + u8(avgHr) + u8(maxHr) + } + + definition(data, 4, 34, listOf( + Field(253, 4, BaseType.UINT32), + Field(0, 4, BaseType.UINT32), + Field(1, 2, BaseType.UINT16), + Field(2, 1, BaseType.ENUM), + )) + dataMessage(data, 4) { + u32(fitTimestamp(endMillis)) + u32(elapsedMs) + u16(1) + u8(0) // activity type: manual + } + + val dataBytes = data.toByteArray() + val header = ByteArrayOutputStream().apply { + u8(HEADER_SIZE) + u8(PROTOCOL_VERSION) + u16(PROFILE_VERSION) + u32(dataBytes.size.toLong()) + write(byteArrayOf('.'.code.toByte(), 'F'.code.toByte(), 'I'.code.toByte(), 'T'.code.toByte())) + }.toByteArray() + val fileCrc = FitCrc.compute(dataBytes) + return ByteArrayOutputStream().apply { + write(header) + u16(FitCrc.compute(header)) + write(dataBytes) + u16(fileCrc) + }.toByteArray() + } + + private fun fitTimestamp(unixMillis: Long): Long = + (unixMillis / 1000L - FIT_EPOCH_UNIX_SECONDS).coerceAtLeast(0L) + + private data class Field(val number: Int, val size: Int, val baseType: Int) + private object BaseType { + const val ENUM = 0x00 + const val UINT8 = 0x02 + const val UINT16 = 0x84 + const val UINT32 = 0x86 + } + + private fun definition(out: ByteArrayOutputStream, local: Int, global: Int, fields: List) { + out.u8(0x40 or (local and 0x0F)) + out.u8(0) + out.u8(0) // little endian architecture + out.u16(global) + out.u8(fields.size) + fields.forEach { field -> + out.u8(field.number) + out.u8(field.size) + out.u8(field.baseType) + } + } + + private inline fun dataMessage( + out: ByteArrayOutputStream, + local: Int, + body: ByteArrayOutputStream.() -> Unit, + ) { + out.u8(local and 0x0F) + out.body() + } + + private fun ByteArrayOutputStream.u8(value: Int) = write(value and 0xFF) + private fun ByteArrayOutputStream.u16(value: Int) { + u8(value) + u8(value ushr 8) + } + private fun ByteArrayOutputStream.u32(value: Long) { + u8(value.toInt()) + u8((value ushr 8).toInt()) + u8((value ushr 16).toInt()) + u8((value ushr 24).toInt()) + } +} + +object FitCrc { + private val table = intArrayOf( + 0x0000, 0xCC01, 0xD801, 0x1400, + 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, + 0x5000, 0x9C01, 0x8801, 0x4400, + ) + + fun compute(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size - offset): Int { + var crc = 0 + for (index in offset until offset + length) { + val value = bytes[index].toInt() and 0xFF + var tmp = table[crc and 0x0F] + crc = (crc ushr 4) and 0x0FFF + crc = crc xor tmp xor table[value and 0x0F] + tmp = table[crc and 0x0F] + crc = (crc ushr 4) and 0x0FFF + crc = crc xor tmp xor table[(value ushr 4) and 0x0F] + } + return crc and 0xFFFF + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt new file mode 100644 index 000000000..b693bbdc6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt @@ -0,0 +1,58 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.export.workout + +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import java.time.Instant + +object WorkoutCsvEncoder { + fun encode(workout: WorkoutDetail): String = buildString { + appendLine("session_id,session_start,session_end,max_hr_bpm,sample_timestamp,bpm,sequence") + val summary = workout.summary + val start = Instant.ofEpochMilli(summary.startTimeEpochMillis).toString() + val end = summary.endTimeEpochMillis?.let { Instant.ofEpochMilli(it).toString() }.orEmpty() + val sessionPrefix = buildString { + append(csvEscape(summary.id)); append(',') + append(csvEscape(start)); append(',') + append(csvEscape(end)); append(',') + append(summary.maxHeartRateBpm) + } + if (workout.samples.isEmpty()) { + append(sessionPrefix); append(",,,\n") + } else { + workout.samples.forEach { sample -> + append(sessionPrefix); append(',') + append(csvEscape(Instant.ofEpochMilli(sample.timestampEpochMillis).toString())); append(',') + append(sample.bpm); append(',') + append(sample.sequence); append('\n') + } + } + } + + internal fun csvEscape(value: String): String { + if (value.none { it == ',' || it == '"' || it == '\r' || it == '\n' }) return value + return buildString(value.length + 2) { + append('"') + value.forEach { char -> + if (char == '"') append("\"\"") else append(char) + } + append('"') + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt new file mode 100644 index 000000000..3a2428dc8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt @@ -0,0 +1,78 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.export.workout + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object WorkoutFileExporter { + data class ExportedFile(val file: File, val mimeType: String) + + fun exportCsv(context: Context, workout: WorkoutDetail): ExportedFile { + val file = exportFile(context, workout, "csv") + file.writeText(WorkoutCsvEncoder.encode(workout), Charsets.UTF_8) + return ExportedFile(file, "text/csv") + } + + fun exportFit(context: Context, workout: WorkoutDetail): ExportedFile { + val file = exportFile(context, workout, "fit") + file.writeBytes(FitActivityEncoder.encode(workout)) + return ExportedFile(file, "application/octet-stream") + } + + fun share(context: Context, exported: ExportedFile) { + val uri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.provider", + exported.file, + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = exported.mimeType + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newRawUri(exported.file.name, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Export workout")) + } + + internal fun sanitizeFilename(value: String): String = value + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('_', '.') + .take(96) + .ifBlank { "workout" } + + private fun exportFile(context: Context, workout: WorkoutDetail, extension: String): File { + val dir = File(context.cacheDir, "workout-exports").apply { mkdirs() } + val instant = Instant.ofEpochMilli(workout.summary.startTimeEpochMillis) + val timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + .withZone(ZoneId.systemDefault()) + .format(instant) + val idSuffix = sanitizeFilename(workout.summary.id).take(12) + val name = sanitizeFilename("LibrePods-workout-$timestamp-$idSuffix.${extension.lowercase()}") + return File(dir, name) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt new file mode 100644 index 000000000..3e0c0c7c2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt @@ -0,0 +1,142 @@ +package me.kavishdevar.librepods.finder + +import android.Manifest +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +enum class NearbyFinderStatus { + STOPPED, + WAITING_FOR_SIGNAL, + ACTIVE, + PERMISSION_REQUIRED, + BLUETOOTH_OFF, + NO_SELECTED_DEVICE, + ERROR +} + +data class NearbyFinderState( + val running: Boolean = false, + val status: NearbyFinderStatus = NearbyFinderStatus.STOPPED, + val signal: FinderSignalSnapshot = FinderSignalSnapshot(), + val errorMessage: String? = null +) + +/** + * Finder coordinator. It deliberately does not own a scanner: LibrePods already performs a + * verified, AirPods-specific BLE scan, so this consumes those RSSI callbacks only while active. + */ +class NearbyAirPodsFinder( + private val context: Context, + private val scope: CoroutineScope, + private val hasSelectedDevice: () -> Boolean, + private val processor: RssiSignalProcessor = RssiSignalProcessor() +) { + private val _state = MutableStateFlow(NearbyFinderState()) + val state: StateFlow = _state + private var tickerJob: Job? = null + + fun start(): Boolean { + if (_state.value.running) return true + when { + context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED -> { + _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED) + return false + } + context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED -> { + _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED) + return false + } + context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED -> { + _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED) + return false + } + context.getSystemService(BluetoothManager::class.java).adapter?.isEnabled != true -> { + _state.value = NearbyFinderState(status = NearbyFinderStatus.BLUETOOTH_OFF) + return false + } + !hasSelectedDevice() -> { + _state.value = NearbyFinderState(status = NearbyFinderStatus.NO_SELECTED_DEVICE) + return false + } + } + + processor.reset() + _state.value = NearbyFinderState( + running = true, + status = NearbyFinderStatus.WAITING_FOR_SIGNAL, + signal = processor.snapshot(SystemClock.elapsedRealtime()) + ) + tickerJob = scope.launch { + while (true) { + delay(250L) + val snapshot = processor.snapshot(SystemClock.elapsedRealtime()) + val previous = _state.value + if (!previous.running) return@launch + _state.value = previous.copy( + status = if (snapshot.proximity == ProximityBucket.SIGNAL_LOST) { + NearbyFinderStatus.WAITING_FOR_SIGNAL + } else { + NearbyFinderStatus.ACTIVE + }, + signal = snapshot + ) + } + } + return true + } + + fun stop() { + tickerJob?.cancel() + tickerJob = null + processor.reset() + _state.value = NearbyFinderState() + } + + fun onVerifiedScanRssi(rssi: Int) { + if (!_state.value.running) return + val snapshot = processor.addSample( + rssi = rssi, + elapsedRealtime = SystemClock.elapsedRealtime() + ) + // Publish the first fix immediately, then let the 250 ms ticker pace UI changes. Updating + // Compose for every BLE advertisement makes normal radio noise look like rapid movement. + if (_state.value.signal.rawRssi == null) { + _state.value = _state.value.copy( + status = NearbyFinderStatus.ACTIVE, + signal = snapshot, + errorMessage = null + ) + } + } + + fun onScanError(errorCode: Int) { + val previous = _state.value + if (!previous.running) return + tickerJob?.cancel() + tickerJob = null + _state.value = previous.copy( + running = false, + status = NearbyFinderStatus.ERROR, + signal = processor.snapshot(SystemClock.elapsedRealtime()), + errorMessage = "AirPods BLE scan failed (code $errorCode)." + ) + } + + fun refreshPrerequisites(): Boolean { + if (_state.value.status == NearbyFinderStatus.PERMISSION_REQUIRED || + _state.value.status == NearbyFinderStatus.BLUETOOTH_OFF || + _state.value.status == NearbyFinderStatus.NO_SELECTED_DEVICE + ) { + return start() + } + return false + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt new file mode 100644 index 000000000..74cbc2487 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt @@ -0,0 +1,183 @@ +package me.kavishdevar.librepods.finder + +import java.util.ArrayDeque +import kotlin.math.pow +import kotlin.math.round + +enum class ProximityBucket(val label: String) { + VERY_CLOSE("Very close"), + CLOSE("Close"), + NEARBY("Nearby"), + FAR("Far"), + SIGNAL_LOST("Signal lost") +} + +enum class SignalTrend(val label: String) { + GETTING_CLOSER("Getting closer"), + STABLE("Stable"), + GETTING_FARTHER("Getting farther") +} + +data class FinderSignalSnapshot( + val rawRssi: Int? = null, + val smoothedRssi: Double? = null, + val sampleAgeMillis: Long? = null, + val stale: Boolean = true, + val approximateDistanceMeters: Double? = null, + val proximity: ProximityBucket = ProximityBucket.SIGNAL_LOST, + val trend: SignalTrend = SignalTrend.STABLE, +) + +/** + * Small stateful RSSI filter tuned for a finder UI, not radio metrology. + * + * RSSI is strongly affected by body blocking, orientation and multipath. The distance estimate is + * intentionally coarse and should only be presented as approximate. + */ +class RssiSignalProcessor( + private val emaAlpha: Double = 0.23, + private val staleAfterMillis: Long = 3_000L, + private val lostAfterMillis: Long = 8_000L, + private val hysteresisDb: Double = 3.5, + private val calibratedRssiAtOneMeter: Double = -59.0, + private val pathLossExponent: Double = 2.2, + private val minTrendSamples: Int = 6, + private val minTrendSpanMillis: Long = 1_750L, + private val trendThresholdDb: Double = 2.5, + private val medianWindowSize: Int = 7 +) { + private data class HistoryPoint(val elapsedRealtime: Long, val rssi: Double) + + private val history = ArrayDeque() + private val rawWindow = ArrayDeque() + private var rawRssi: Int? = null + private var smoothedRssi: Double? = null + private var lastSampleElapsedRealtime: Long? = null + private var bucket = ProximityBucket.SIGNAL_LOST + + fun reset() { + history.clear() + rawWindow.clear() + rawRssi = null + smoothedRssi = null + lastSampleElapsedRealtime = null + bucket = ProximityBucket.SIGNAL_LOST + } + + fun addSample(rssi: Int, elapsedRealtime: Long): FinderSignalSnapshot { + if (rssi !in -127..20) return snapshot(elapsedRealtime) + + rawRssi = rssi + rawWindow.addLast(rssi) + while (rawWindow.size > medianWindowSize.coerceAtLeast(1)) rawWindow.removeFirst() + val sortedWindow = rawWindow.sorted() + val robustRssi = if (sortedWindow.size % 2 == 1) { + sortedWindow[sortedWindow.size / 2].toDouble() + } else { + val upper = sortedWindow.size / 2 + (sortedWindow[upper - 1] + sortedWindow[upper]) / 2.0 + } + smoothedRssi = smoothedRssi?.let { previous -> + (emaAlpha * robustRssi) + ((1.0 - emaAlpha) * previous) + } ?: robustRssi + lastSampleElapsedRealtime = elapsedRealtime + + val filtered = smoothedRssi!! + // BLE advertisements can arrive dozens of times per second. Trend history is deliberately + // time-sampled so a burst from one bud cannot dominate several seconds of movement. + if (history.lastOrNull()?.elapsedRealtime?.let { elapsedRealtime - it >= 250L } != false) { + history.addLast(HistoryPoint(elapsedRealtime, filtered)) + while (history.size > 24 || + (history.firstOrNull()?.elapsedRealtime ?: elapsedRealtime) < elapsedRealtime - 12_000L + ) { + history.removeFirst() + } + } + + bucket = nextBucket(bucket, filtered) + return snapshot(elapsedRealtime) + } + + fun snapshot(nowElapsedRealtime: Long): FinderSignalSnapshot { + val last = lastSampleElapsedRealtime + val age = last?.let { (nowElapsedRealtime - it).coerceAtLeast(0L) } + val isLost = age == null || age >= lostAfterMillis + val currentBucket = if (isLost) ProximityBucket.SIGNAL_LOST else bucket + return FinderSignalSnapshot( + rawRssi = rawRssi, + smoothedRssi = smoothedRssi, + sampleAgeMillis = age, + stale = age == null || age >= staleAfterMillis, + approximateDistanceMeters = smoothedRssi + ?.takeUnless { isLost } + ?.let(::coarseDistanceEstimateMeters), + proximity = currentBucket, + trend = if (isLost) SignalTrend.STABLE else calculateTrend(), + ) + } + + private fun calculateTrend(): SignalTrend { + if (history.size < minTrendSamples) return SignalTrend.STABLE + val points = history.toList() + if (points.last().elapsedRealtime - points.first().elapsedRealtime < minTrendSpanMillis) { + return SignalTrend.STABLE + } + val split = points.size / 2 + val older = points.take(split).map { it.rssi }.average() + val newer = points.takeLast(split).map { it.rssi }.average() + val delta = newer - older + return when { + delta >= trendThresholdDb -> SignalTrend.GETTING_CLOSER + delta <= -trendThresholdDb -> SignalTrend.GETTING_FARTHER + else -> SignalTrend.STABLE + } + } + + private fun nextBucket(previous: ProximityBucket, rssi: Double): ProximityBucket { + val candidate = bucketWithoutHysteresis(rssi) + if (previous == ProximityBucket.SIGNAL_LOST) return candidate + if (candidate == previous) return previous + + return when (previous) { + ProximityBucket.VERY_CLOSE -> + if (rssi < VERY_CLOSE_THRESHOLD - hysteresisDb) candidate else previous + ProximityBucket.CLOSE -> when { + candidate == ProximityBucket.VERY_CLOSE && rssi >= VERY_CLOSE_THRESHOLD + hysteresisDb -> candidate + candidate.ordinal > previous.ordinal && rssi < CLOSE_THRESHOLD - hysteresisDb -> candidate + else -> previous + } + ProximityBucket.NEARBY -> when { + candidate.ordinal < previous.ordinal && rssi >= CLOSE_THRESHOLD + hysteresisDb -> candidate + candidate == ProximityBucket.FAR && rssi < NEARBY_THRESHOLD - hysteresisDb -> candidate + else -> previous + } + ProximityBucket.FAR -> + if (candidate.ordinal < previous.ordinal && rssi >= NEARBY_THRESHOLD + hysteresisDb) candidate else previous + ProximityBucket.SIGNAL_LOST -> candidate + } + } + + private fun bucketWithoutHysteresis(rssi: Double): ProximityBucket = when { + rssi >= VERY_CLOSE_THRESHOLD -> ProximityBucket.VERY_CLOSE + rssi >= CLOSE_THRESHOLD -> ProximityBucket.CLOSE + rssi >= NEARBY_THRESHOLD -> ProximityBucket.NEARBY + else -> ProximityBucket.FAR + } + + private fun coarseDistanceEstimateMeters(rssi: Double): Double { + val raw = 10.0.pow((calibratedRssiAtOneMeter - rssi) / (10.0 * pathLossExponent)) + .coerceIn(0.1, 50.0) + return when { + raw < 1.0 -> maxOf(0.5, round(raw * 2.0) / 2.0) + raw < 5.0 -> round(raw * 2.0) / 2.0 + raw < 15.0 -> round(raw) + else -> round(raw / 5.0) * 5.0 + } + } + + companion object { + private const val VERY_CLOSE_THRESHOLD = -48.0 + private const val CLOSE_THRESHOLD = -58.0 + private const val NEARBY_THRESHOLD = -68.0 + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt new file mode 100644 index 000000000..f1a59a389 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt @@ -0,0 +1,94 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.health.workout + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import me.kavishdevar.librepods.data.workout.WorkoutSessionEntity +import java.time.Instant +import java.time.ZoneOffset + +sealed interface WorkoutHealthConnectExportResult { + data class Exported(val recordId: String?) : WorkoutHealthConnectExportResult + data object PermissionRequired : WorkoutHealthConnectExportResult + data object Unavailable : WorkoutHealthConnectExportResult + data class Failed(val message: String) : WorkoutHealthConnectExportResult +} + +interface WorkoutHealthConnectExporter { + suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult +} + +class AndroidWorkoutHealthConnectExporter(context: Context) : WorkoutHealthConnectExporter { + private val appContext = context.applicationContext + private var client: HealthConnectClient? = null + + override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult { + val endMillis = session.endTimeEpochMillis ?: return WorkoutHealthConnectExportResult.Failed( + "Workout has not been finished" + ) + return try { + if (HealthConnectClient.getSdkStatus(appContext) != HealthConnectClient.SDK_AVAILABLE) { + return WorkoutHealthConnectExportResult.Unavailable + } + val healthClient = client ?: HealthConnectClient.getOrCreate(appContext).also { client = it } + if (WRITE_EXERCISE_PERMISSION !in healthClient.permissionController.getGrantedPermissions()) { + return WorkoutHealthConnectExportResult.PermissionRequired + } + + val record = ExerciseSessionRecord( + startTime = Instant.ofEpochMilli(session.startTimeEpochMillis), + startZoneOffset = ZoneOffset.ofTotalSeconds(session.startZoneOffsetSeconds), + endTime = Instant.ofEpochMilli(endMillis), + endZoneOffset = ZoneOffset.ofTotalSeconds( + session.endZoneOffsetSeconds ?: session.startZoneOffsetSeconds + ), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, + title = "LibrePods workout", + metadata = Metadata.activelyRecorded( + device = Device( + type = Device.TYPE_UNKNOWN, + manufacturer = "Apple", + model = "AirPods", + ), + clientRecordId = session.healthConnectClientRecordId, + clientRecordVersion = 0L, + ), + ) + val response = healthClient.insertRecords(listOf(record)) + WorkoutHealthConnectExportResult.Exported(response.recordIdsList.firstOrNull()) + } catch (security: SecurityException) { + WorkoutHealthConnectExportResult.PermissionRequired + } catch (error: Exception) { + WorkoutHealthConnectExportResult.Failed( + error.message ?: error.javaClass.simpleName + ) + } + } + + companion object { + val WRITE_EXERCISE_PERMISSION: String = + HealthPermission.getWritePermission(ExerciseSessionRecord::class) + val REQUIRED_PERMISSIONS: Set = setOf(WRITE_EXERCISE_PERMISSION) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index cc8f19a1d..0c59ffcf7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -31,6 +31,7 @@ import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen import me.kavishdevar.librepods.presentation.screens.LoadingScreen import me.kavishdevar.librepods.presentation.screens.LongPress import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute +import me.kavishdevar.librepods.presentation.screens.NearbyAirPodsFinderScreen import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.presentation.screens.PurchaseScreen import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen @@ -39,6 +40,10 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute import me.kavishdevar.librepods.presentation.screens.VersionScreen +import me.kavishdevar.librepods.presentation.screens.WorkoutDetailScreen +import me.kavishdevar.librepods.presentation.screens.WorkoutHistoryScreen +import me.kavishdevar.librepods.presentation.screens.WorkoutScreen +import me.kavishdevar.librepods.presentation.screens.WorkoutSettingsScreen import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -113,6 +118,7 @@ fun AppNavGraph( navigateToCallControlScreen = { navigate(Screen.CallControl(it)) }, navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) }, navigateToHeartRateTest = { navigate(Screen.HeartRateTest) }, + navigateToNearbyFinder = { navigate(Screen.NearbyFinder) }, ) } @@ -130,7 +136,7 @@ fun AppNavGraph( navigateToPurchase = ::navigateToPurchase, navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, - navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } + navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }, ) } @@ -148,7 +154,48 @@ fun AppNavGraph( Screen.HeartRateTest -> NavEntry(screen) { if (!airPodsViewModel.isReady) LoadingScreen() - HeartRateTestScreen(airPodsViewModel) + HeartRateTestScreen( + viewModel = airPodsViewModel, + navigateToWorkout = { navigate(Screen.Workout) }, + ) + } + + Screen.Workout -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + WorkoutScreen( + viewModel = airPodsViewModel, + navigateToHistory = { navigate(Screen.WorkoutHistory) }, + navigateToSettings = { navigate(Screen.WorkoutSettings) }, + ) + } + + Screen.WorkoutHistory -> + NavEntry(screen) { + WorkoutHistoryScreen { sessionId -> + navigate(Screen.WorkoutDetail(sessionId)) + } + } + + is Screen.WorkoutDetail -> + NavEntry(screen) { + WorkoutDetailScreen( + sessionId = screen.sessionId, + onDeleted = { + if (backStack.size > 1) backStack.removeAt(backStack.lastIndex) + }, + ) + } + + Screen.WorkoutSettings -> + NavEntry(screen) { + WorkoutSettingsScreen() + } + + Screen.NearbyFinder -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + NearbyAirPodsFinderScreen(airPodsViewModel) } Screen.Accessibility -> diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index 8471644a4..2e3cf7ecc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -60,6 +60,11 @@ fun NavigationRoot( Screen.Equalizer -> stringResource(R.string.equalizer) Screen.HeadTracking -> stringResource(R.string.head_tracking) Screen.HeartRateTest -> "Heart rate" + Screen.Workout -> "Workout" + Screen.WorkoutHistory -> "Workout history" + is Screen.WorkoutDetail -> "Workout details" + Screen.WorkoutSettings -> "Workout zones" + Screen.NearbyFinder -> "Find Nearby" Screen.HearingAid -> stringResource(R.string.hearing_aid) Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) Screen.HearingProtection -> stringResource(R.string.hearing_protection) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt index 70e0ff2c6..906f32da5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -31,6 +31,21 @@ sealed interface Screen: NavKey { @Serializable data object HeartRateTest: Screen + @Serializable + data object Workout: Screen + + @Serializable + data object WorkoutHistory: Screen + + @Serializable + data class WorkoutDetail(val sessionId: String): Screen + + @Serializable + data object WorkoutSettings: Screen + + @Serializable + data object NearbyFinder: Screen + @Serializable data object Accessibility: Screen diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 110864942..bb64e2674 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -146,7 +146,8 @@ fun AirPodsSettingsRoute( navigateToTroubleshooting: () -> Unit, navigateToCallControlScreen: (action: String) -> Unit, navigateToMicrophoneSettings: () -> Unit, - navigateToHeartRateTest: () -> Unit + navigateToHeartRateTest: () -> Unit, + navigateToNearbyFinder: () -> Unit ) { val state by viewModel.uiState.collectAsState() @@ -193,6 +194,7 @@ fun AirPodsSettingsRoute( navigateToCallControlScreen = navigateToCallControlScreen, navigateToMicrophoneSettings = navigateToMicrophoneSettings, navigateToHeartRateTest = navigateToHeartRateTest, + navigateToNearbyFinder = navigateToNearbyFinder, setHeartRateMonitoringEnabled = viewModel::setHeartRateMonitoringEnabled, reconnectAacpForHeartRate = viewModel::reconnectAacpForHeartRate, @@ -239,6 +241,7 @@ fun AirPodsSettingsScreen( navigateToCallControlScreen: (action: String) -> Unit, navigateToMicrophoneSettings: () -> Unit, navigateToHeartRateTest: () -> Unit, + navigateToNearbyFinder: () -> Unit, setHeartRateMonitoringEnabled: (Boolean) -> Unit, reconnectAacpForHeartRate: () -> Unit, @@ -336,6 +339,13 @@ fun AirPodsSettingsScreen( onClick = navigateToRename, ) } + item(key = "spacer_nearby_finder") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "nearby_finder") { + StyledListItem( + name = "Find Nearby", + onClick = navigateToNearbyFinder + ) + } val hasHeartRateCapability = state.instance?.model?.capabilities?.contains(Capability.HRM) == true if (hasHeartRateCapability) { @@ -992,6 +1002,7 @@ fun AirPodsSettingsScreenPreviewApple() { navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, navigateToHeartRateTest = {}, + navigateToNearbyFinder = {}, setHeartRateMonitoringEnabled = {}, reconnectAacpForHeartRate = {}, @@ -1043,6 +1054,7 @@ fun AirPodsSettingsScreenPreviewMaterial() { navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, navigateToHeartRateTest = {}, + navigateToNearbyFinder = {}, setHeartRateMonitoringEnabled = {}, reconnectAacpForHeartRate = {}, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 06436561d..0b662834d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -105,7 +105,7 @@ fun AppSettingsScreen( navigateToPurchase: () -> Unit, navigateToTroubleshooting: () -> Unit, navigateToOpenSourceLicenses: () -> Unit, - navigateToReleaseNotesScreen: () -> Unit + navigateToReleaseNotesScreen: () -> Unit, ) { val context = LocalContext.current val scrollState = rememberScrollState() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 99d007b39..92b237d6c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -10,9 +10,12 @@ package me.kavishdevar.librepods.presentation.screens +import android.Manifest +import android.content.pm.PackageManager import android.graphics.Paint import android.graphics.Typeface import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -34,13 +37,16 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -49,12 +55,15 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.health.connect.client.PermissionController +import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState +import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralStatus import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus @@ -72,10 +81,13 @@ import java.text.DateFormat import java.util.Date import kotlin.math.ceil import kotlin.math.floor +import kotlinx.coroutines.delay @Composable -fun HeartRateTestScreen(viewModel: AirPodsViewModel) { +fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Unit) { val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + var graphNowMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } val healthConnectPermissionLauncher = rememberLauncherForActivityResult( PermissionController.createRequestPermissionResultContract() ) { grantedPermissions: Set -> @@ -85,9 +97,40 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { viewModel.markHealthConnectPermissionDenied() } } + val blePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + viewModel.refreshHeartRateBlePeripheral() + } + + fun enableOrRetryBlePeripheral() { + val permissions = arrayOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + viewModel.setHeartRateBlePeripheralEnabled(true) + val missing = permissions.filter { + context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) { + blePermissionLauncher.launch(missing.toTypedArray()) + } else { + viewModel.refreshHeartRateBlePeripheral() + } + } LaunchedEffect(Unit) { viewModel.refreshHealthConnectExportState() + viewModel.refreshHeartRateBlePeripheral() + } + + // Keep the live chart's right edge moving while the stream is quiet. This makes a + // lost signal visible as blank time instead of leaving the last sample at the edge. + LaunchedEffect(Unit) { + while (true) { + graphNowMillis = System.currentTimeMillis() + delay(LIVE_HEART_RATE_GRAPH_TICK_MILLIS) + } } val materialDesign = LocalDesignSystem.current == DesignSystem.Material @@ -120,6 +163,15 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { onMonitoringChanged = viewModel::setHeartRateMonitoringEnabled ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = navigateToWorkout, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Workouts & session history") + } + Spacer(modifier = Modifier.height(16.dp)) HealthConnectControls( @@ -141,6 +193,17 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(12.dp)) + BleHeartRatePeripheralControls( + state = state.heartRateBlePeripheral, + onEnabledChanged = { enabled -> + if (enabled) enableOrRetryBlePeripheral() + else viewModel.setHeartRateBlePeripheralEnabled(false) + }, + onRetry = ::enableOrRetryBlePeripheral + ) + + Spacer(modifier = Modifier.height(12.dp)) + Text( text = "Recent samples", style = MaterialTheme.typography.titleMedium, @@ -155,7 +218,7 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel) { modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) ) - HeartRateGraph(samples = heartRate.samples) + HeartRateGraph(samples = heartRate.samples, nowMillis = graphNowMillis) Spacer(modifier = Modifier.height(bottomPadding)) } @@ -169,6 +232,10 @@ private fun HeartRateSummaryCard( onMonitoringChanged: (Boolean) -> Unit ) { val materialDesign = LocalDesignSystem.current == DesignSystem.Material + val displayBpm = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH + val reconnectAction = onReconnectAacp.takeIf { + state.status == HeartRateMonitoringStatus.COULDNT_START + } Card( modifier = Modifier.fillMaxWidth(), @@ -188,7 +255,7 @@ private fun HeartRateSummaryCard( ) { Column { Text( - text = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, + text = displayBpm, style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold ) @@ -218,9 +285,7 @@ private fun HeartRateSummaryCard( ) HeartRateStatusChip( status = state.status, - onRetry = onReconnectAacp.takeIf { - state.status == HeartRateMonitoringStatus.COULDNT_START - }, + onRetry = reconnectAction, compact = true ) } @@ -234,7 +299,7 @@ private fun HeartRateSummaryCard( ) { Column { Text( - text = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH, + text = displayBpm, style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold ) @@ -253,9 +318,7 @@ private fun HeartRateSummaryCard( } HeartRateStatusChip( status = state.status, - onRetry = onReconnectAacp.takeIf { - state.status == HeartRateMonitoringStatus.COULDNT_START - } + onRetry = reconnectAction ) } } @@ -304,6 +367,52 @@ private fun HealthConnectControls( ) } +@Composable +private fun BleHeartRatePeripheralControls( + state: HeartRateBlePeripheralState, + onEnabledChanged: (Boolean) -> Unit, + onRetry: () -> Unit +) { + StyledToggle( + title = "Bluetooth heart-rate sharing", + label = "Share as a BLE heart-rate sensor", + description = blePeripheralDescription(state), + checked = state.enabled, + onCheckedChange = onEnabledChanged + ) + + if (state.enabled && state.status in setOf( + HeartRateBlePeripheralStatus.ERROR, + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, + HeartRateBlePeripheralStatus.BLUETOOTH_OFF + ) + ) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (state.status == HeartRateBlePeripheralStatus.PERMISSION_REQUIRED) "Allow & retry" else "Retry") + } + } +} + +private fun blePeripheralDescription(state: HeartRateBlePeripheralState): String { + val privacy = "Off by default. Shares only validated LibrePods heart-rate samples while enabled." + return when (state.status) { + HeartRateBlePeripheralStatus.DISABLED -> privacy + HeartRateBlePeripheralStatus.STARTING -> "Starting the standard Heart Rate Service (0x180D). $privacy" + HeartRateBlePeripheralStatus.ADVERTISING -> + "Advertising · ${state.connectedDeviceCount} connected · ${state.subscribedDeviceCount} subscribed. $privacy" + HeartRateBlePeripheralStatus.PERMISSION_REQUIRED -> + "Nearby devices permission is required to advertise and accept GATT connections. $privacy" + HeartRateBlePeripheralStatus.BLUETOOTH_OFF -> "Bluetooth is off. $privacy" + HeartRateBlePeripheralStatus.UNSUPPORTED -> + "BLE peripheral advertising is not supported by this adapter. $privacy" + HeartRateBlePeripheralStatus.ERROR -> + "${state.lastError ?: "BLE heart-rate sharing failed."} $privacy" + } +} + private val HealthConnectExportStatus.isAvailable: Boolean get() = this != HealthConnectExportStatus.UNAVAILABLE && this != HealthConnectExportStatus.UPDATE_REQUIRED @@ -347,9 +456,23 @@ private fun healthConnectDescription( } @Composable -private fun HeartRateGraph(samples: List) { - val chartScale = remember(samples) { - calculateHeartRateChartScale(samples.map { it.bpm.toFloat() }) +private fun HeartRateGraph(samples: List, nowMillis: Long) { + val orderedSamples = remember(samples) { + samples.sortedBy { it.receivedAtMillis } + } + val minTime = maxOf( + orderedSamples.firstOrNull()?.receivedAtMillis ?: nowMillis, + nowMillis - LIVE_HEART_RATE_GRAPH_WINDOW_MILLIS, + ) + val maxTime = maxOf( + orderedSamples.lastOrNull()?.receivedAtMillis ?: nowMillis, + nowMillis, + ).coerceAtLeast(minTime + 1L) + val visibleSamples = orderedSamples.filter { + it.receivedAtMillis in minTime..maxTime + } + val chartScale = remember(visibleSamples) { + calculateHeartRateChartScale(visibleSamples.map { it.bpm.toFloat() }) } val lineColor = MaterialTheme.colorScheme.primary val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) @@ -431,23 +554,31 @@ private fun HeartRateGraph(samples: List) { ) } - if (samples.isNotEmpty()) { + if (visibleSamples.isNotEmpty()) { val path = Path() - samples.forEachIndexed { index, sample -> - val x = sampleX( - index = index, - sampleCount = samples.size, - plotLeft = plotLeft, - plotWidth = plotWidth - ) + var previousTimestamp: Long? = null + visibleSamples.forEachIndexed { index, sample -> + val x = plotLeft + + (sample.receivedAtMillis - minTime).toFloat() / + (maxTime - minTime).toFloat() * plotWidth val y = chartScale.bpmY( bpm = sample.bpm.toFloat(), plotBottom = plotBottom, plotHeight = plotHeight ) - if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) - if (index == samples.lastIndex) { + val previous = previousTimestamp + if ( + previous == null || + sample.receivedAtMillis - previous > LIVE_HEART_RATE_GRAPH_GAP_MILLIS + ) { + // Do not invent a slope through a period with no validated data. + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + previousTimestamp = sample.receivedAtMillis + if (index == visibleSamples.lastIndex) { drawCircle( color = pointColor, radius = 4.dp.toPx(), @@ -455,7 +586,7 @@ private fun HeartRateGraph(samples: List) { ) } } - if (samples.size > 1) { + if (visibleSamples.size > 1) { drawPath( path = path, color = lineColor, @@ -465,9 +596,9 @@ private fun HeartRateGraph(samples: List) { } } - if (samples.isEmpty()) { + if (visibleSamples.isEmpty()) { Text( - text = "Waiting for validated heart-rate samples", + text = "Waiting for recent validated heart-rate samples", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -501,17 +632,6 @@ private data class HeartRateChartScale( } } -private fun sampleX( - index: Int, - sampleCount: Int, - plotLeft: Float, - plotWidth: Float -): Float = if (sampleCount == 1) { - plotLeft + plotWidth / 2f -} else { - plotLeft + index.toFloat() / (sampleCount - 1).toFloat() * plotWidth -} - private fun calculateHeartRateChartScale(bpms: List): HeartRateChartScale { if (bpms.isEmpty()) { return HeartRateChartScale( @@ -571,6 +691,9 @@ private const val CHART_MARGIN_BPM = 5f private const val CHART_OUTER_MIN_BPM = 0f private const val CHART_OUTER_MAX_BPM = 260f private const val CHART_TARGET_GRID_INTERVALS = 5f +private const val LIVE_HEART_RATE_GRAPH_GAP_MILLIS = 4_000L +private const val LIVE_HEART_RATE_GRAPH_WINDOW_MILLIS = 60_000L +private const val LIVE_HEART_RATE_GRAPH_TICK_MILLIS = 1_000L private val CHART_TICK_STEPS = listOf(5f, 10f, 20f, 25f, 50f) private val CHART_AXIS_WIDTH = 42.dp private val CHART_AXIS_LABEL_GAP = 8.dp diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt index 91bf97502..af763a7d6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt @@ -52,7 +52,7 @@ fun MicrophoneSettingsRoute( bottomPadding = bottomPadding, onMicrophoneSettingsChanged = { viewModel.setControlCommandInt(id, it) - } + }, ) } } @@ -62,7 +62,7 @@ fun MicrophoneSettingsScreen( selectedMode: Int, topPadding: Dp = 16.dp, bottomPadding: Dp = 16.dp, - onMicrophoneSettingsChanged: (Int) -> Unit + onMicrophoneSettingsChanged: (Int) -> Unit, ) { val scrollState = rememberScrollState() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt new file mode 100644 index 000000000..bc751d149 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt @@ -0,0 +1,286 @@ +package me.kavishdevar.librepods.presentation.screens + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.finder.NearbyFinderState +import me.kavishdevar.librepods.finder.NearbyFinderStatus +import me.kavishdevar.librepods.finder.ProximityBucket +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import kotlin.math.roundToInt + +@Composable +fun NearbyAirPodsFinderScreen(viewModel: AirPodsViewModel) { + val uiState by viewModel.uiState.collectAsState() + val finder = uiState.nearbyFinder + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + viewModel.refreshNearbyFinderPrerequisites() + } + + DisposableEffect(Unit) { + onDispose { viewModel.stopNearbyFinder() } + } + + val materialDesign = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (materialDesign) { + 16.dp + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + } + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 20.dp + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(topPadding)) + Text( + text = "Find your AirPods", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Move slowly while the signal settles. Distance is only an estimate.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, start = 20.dp, end = 20.dp) + ) + + Spacer(modifier = Modifier.height(28.dp)) + FinderIndicator(finder) + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = finder.signal.proximity.label, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = statusLine(finder), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + finder.signal.approximateDistanceMeters?.let { distance -> + Text( + text = "Approx. ${formatDistance(distance)}", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + FinderControls( + finder = finder, + onStart = viewModel::startNearbyFinder, + onStop = viewModel::stopNearbyFinder, + onRequestPermissions = { + permissionLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) + } + ) + + Spacer(modifier = Modifier.height(18.dp)) + SignalDebugCard(finder) + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + +@Composable +private fun FinderIndicator(state: NearbyFinderState) { + val proximityLevel = when (state.signal.proximity) { + ProximityBucket.VERY_CLOSE -> 1f + ProximityBucket.CLOSE -> 0.82f + ProximityBucket.NEARBY -> 0.62f + ProximityBucket.FAR -> 0.42f + ProximityBucket.SIGNAL_LOST -> 0.25f + } + val level by animateFloatAsState(targetValue = proximityLevel, animationSpec = tween(450), label = "finder-level") + val infinite = rememberInfiniteTransition(label = "finder-pulse") + val pulse by infinite.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1800), RepeatMode.Restart), + label = "finder-pulse-phase" + ) + val primary = MaterialTheme.colorScheme.primary + val muted = MaterialTheme.colorScheme.onSurfaceVariant + + Box(modifier = Modifier.size(260.dp), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + val base = size.minDimension / 2f + repeat(3) { index -> + val phase = (pulse + index / 3f) % 1f + val radius = base * (0.34f + (0.58f * phase)) + val alpha = (1f - phase) * 0.16f * level + drawCircle(color = primary.copy(alpha = alpha), radius = radius) + } + drawCircle( + color = if (state.signal.proximity == ProximityBucket.SIGNAL_LOST) muted.copy(alpha = 0.14f) else primary.copy(alpha = 0.16f), + radius = base * (0.28f + 0.08f * level) + ) + drawCircle( + color = if (state.signal.proximity == ProximityBucket.SIGNAL_LOST) muted.copy(alpha = 0.55f) else primary, + radius = base * (0.12f + 0.035f * level) + ) + } + } +} + +@Composable +private fun FinderControls( + finder: NearbyFinderState, + onStart: () -> Unit, + onStop: () -> Unit, + onRequestPermissions: () -> Unit +) { + when (finder.status) { + NearbyFinderStatus.PERMISSION_REQUIRED -> Button( + onClick = onRequestPermissions, + modifier = Modifier.fillMaxWidth() + ) { Text("Allow Nearby devices & location") } + + NearbyFinderStatus.BLUETOOTH_OFF -> { + Text( + "Turn on Bluetooth, then try again.", + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(10.dp)) + Button(onClick = onStart, modifier = Modifier.fillMaxWidth()) { Text("Try again") } + } + + NearbyFinderStatus.NO_SELECTED_DEVICE -> { + Text( + "Connect or select your AirPods first.", + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + else -> if (finder.running) { + OutlinedButton(onClick = onStop, modifier = Modifier.fillMaxWidth()) { Text("Stop finding") } + } else { + Button(onClick = onStart, modifier = Modifier.fillMaxWidth()) { Text("Start finding") } + } + } +} + +@Composable +private fun SignalDebugCard(state: NearbyFinderState) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(9.dp) + ) { + Text("Signal details", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + DebugRow("Raw RSSI", state.signal.rawRssi?.let { "$it dBm" } ?: "—") + DebugRow("Smoothed", state.signal.smoothedRssi?.let { "${it.roundToInt()} dBm" } ?: "—") + DebugRow("Age", state.signal.sampleAgeMillis?.let(::formatAge) ?: "No sample") + if (state.signal.stale && state.signal.rawRssi != null) { + Text( + "Signal is stale; hold position or move back into range.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + state.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun DebugRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontWeight = FontWeight.Medium) + } +} + +private fun statusLine(state: NearbyFinderState): String = when (state.status) { + NearbyFinderStatus.STOPPED -> "Ready" + NearbyFinderStatus.WAITING_FOR_SIGNAL -> if (state.signal.rawRssi == null) { + "Waiting for an AirPods proximity broadcast…" + } else { + "Signal lost — move back into range" + } + NearbyFinderStatus.ACTIVE -> state.signal.trend.label + NearbyFinderStatus.PERMISSION_REQUIRED -> "Nearby devices and location permission required" + NearbyFinderStatus.BLUETOOTH_OFF -> "Bluetooth is off" + NearbyFinderStatus.NO_SELECTED_DEVICE -> "No AirPods selected" + NearbyFinderStatus.ERROR -> state.errorMessage ?: "Finder unavailable" +} + +private fun formatDistance(meters: Double): String = if (meters < 1.0) { + "<1 m" +} else { + if (meters % 1.0 == 0.0) "${meters.roundToInt()} m" else "$meters m" +} + +private fun formatAge(ageMillis: Long): String = when { + ageMillis < 1_000L -> "<1 s" + ageMillis < 10_000L -> "${ageMillis / 1_000L} s" + else -> "${ageMillis / 1_000L} s (stale)" +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt new file mode 100644 index 000000000..aea03bbf2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt @@ -0,0 +1,642 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.presentation.screens + +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.health.connect.client.PermissionController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.kavishdevar.librepods.LibrePodsApplication +import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState +import me.kavishdevar.librepods.data.workout.HeartRateZone +import me.kavishdevar.librepods.data.workout.HeartRateZones +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity +import me.kavishdevar.librepods.data.workout.WorkoutSummary +import me.kavishdevar.librepods.export.workout.WorkoutFileExporter +import me.kavishdevar.librepods.health.workout.AndroidWorkoutHealthConnectExporter +import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.services.HeartRateMonitoringStatus +import java.text.DateFormat +import java.util.Date +import kotlin.math.ceil +import kotlin.math.roundToInt + +@Composable +fun WorkoutScreen( + viewModel: AirPodsViewModel, + navigateToHistory: () -> Unit, + navigateToSettings: () -> Unit, +) { + val context = LocalContext.current + val app = context.applicationContext as LibrePodsApplication + val repository = app.workoutRepository + val workout by repository.activeWorkout.collectAsState(initial = null) + val state by viewModel.uiState.collectAsState() + val scope = rememberCoroutineScope() + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + + LaunchedEffect(workout?.summary?.id) { + while (workout != null) { + now = System.currentTimeMillis() + delay(1_000L) + } + } + + WorkoutPage { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = navigateToHistory, modifier = Modifier.weight(1f)) { + Text("History") + } + OutlinedButton(onClick = navigateToSettings, modifier = Modifier.weight(1f)) { + Text("Zone settings") + } + } + + Spacer(Modifier.height(16.dp)) + + if (workout == null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("No active workout", style = MaterialTheme.typography.titleLarge) + Text( + "Starting creates the local session immediately. Heart-rate monitoring is enabled so validated AirPods samples can be persisted to it.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HeartRateStatusChip( + status = state.heartRate.status, + onRetry = if (state.heartRate.status == HeartRateMonitoringStatus.COULDNT_START) + viewModel::reconnectAacpForHeartRate else null, + compact = true, + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + repository.startWorkout() + viewModel.setHeartRateMonitoringEnabled(true) + } + }, + ) { Text("Start workout") } + } + } + } else { + val detail = workout!! + WorkoutSummaryCard(detail, now) + Spacer(Modifier.height(12.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Heart-rate source", fontWeight = FontWeight.SemiBold) + HeartRateStatusChip( + status = state.heartRate.status, + onRetry = if (state.heartRate.status == HeartRateMonitoringStatus.COULDNT_START) + viewModel::reconnectAacpForHeartRate else null, + compact = true, + ) + } + if (detail.samples.isEmpty()) { + Text( + "No validated samples yet. The workout is still saved locally even while AirPods are disconnected.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + WorkoutChartCard( + samples = detail.samples, + startTimeMillis = detail.summary.startTimeEpochMillis, + endTimeMillis = now, + ) + Spacer(Modifier.height(12.dp)) + ZoneCard(detail.zones, detail.summary.maxHeartRateBpm) + Spacer(Modifier.height(16.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { scope.launch { repository.finishWorkout(detail.summary.id) } }, + ) { Text("Finish workout") } + } + } +} + +@Composable +fun WorkoutHistoryScreen(navigateToDetail: (String) -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as LibrePodsApplication + val repository = app.workoutRepository + val history by repository.history.collectAsState(initial = emptyList()) + val scope = rememberCoroutineScope() + var workoutPendingDelete by remember { mutableStateOf(null) } + + fun exportWorkout(sessionId: String, format: WorkoutExportFormat) { + scope.launch { + val workout = repository.snapshot(sessionId) + if (workout == null) { + Toast.makeText(context, "Workout no longer exists", Toast.LENGTH_SHORT).show() + } else { + exportAndShare(context, workout, format) + } + } + } + + WorkoutPage { + if (history.isEmpty()) { + Text( + "Finished workouts will appear here.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + history.forEach { session -> + HistoryCard( + session = session, + onClick = { navigateToDetail(session.id) }, + onExportCsv = { exportWorkout(session.id, WorkoutExportFormat.CSV) }, + onExportFit = { exportWorkout(session.id, WorkoutExportFormat.FIT) }, + onDelete = { workoutPendingDelete = session }, + ) + Spacer(Modifier.height(10.dp)) + } + } + workoutPendingDelete?.let { session -> + DeleteWorkoutDialog( + onDismissRequest = { workoutPendingDelete = null }, + onConfirm = { + workoutPendingDelete = null + scope.launch { repository.deleteWorkout(session.id) } + }, + ) + } +} + +@Composable +fun WorkoutDetailScreen(sessionId: String, onDeleted: () -> Unit = {}) { + val context = LocalContext.current + val app = context.applicationContext as LibrePodsApplication + val repository = app.workoutRepository + val detail by repository.workout(sessionId).collectAsState(initial = null) + val scope = rememberCoroutineScope() + var showDeleteConfirmation by remember { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted -> + if (AndroidWorkoutHealthConnectExporter.WRITE_EXERCISE_PERMISSION in granted) { + scope.launch { repository.retryHealthConnectExport(sessionId) } + } + } + + WorkoutPage { + val workout = detail + if (workout == null) { + Text("Workout not found.", color = MaterialTheme.colorScheme.onSurfaceVariant) + return@WorkoutPage + } + WorkoutSummaryCard(workout, workout.summary.endTimeEpochMillis ?: System.currentTimeMillis()) + Spacer(Modifier.height(12.dp)) + WorkoutChartCard( + samples = workout.samples, + startTimeMillis = workout.summary.startTimeEpochMillis, + endTimeMillis = workout.summary.endTimeEpochMillis, + ) + Spacer(Modifier.height(12.dp)) + ZoneCard(workout.zones, workout.summary.maxHeartRateBpm) + Spacer(Modifier.height(12.dp)) + HealthConnectSessionCard( + workout = workout, + onRequestPermission = { + permissionLauncher.launch(AndroidWorkoutHealthConnectExporter.REQUIRED_PERMISSIONS) + }, + onRetry = { scope.launch { repository.retryHealthConnectExport(sessionId) } }, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { scope.launch { exportAndShare(context, workout, WorkoutExportFormat.CSV) } }, + ) { Text("Export CSV") } + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { scope.launch { exportAndShare(context, workout, WorkoutExportFormat.FIT) } }, + ) { Text("Export FIT") } + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { showDeleteConfirmation = true }, + ) { Text("Delete workout") } + if (showDeleteConfirmation) { + DeleteWorkoutDialog( + onDismissRequest = { showDeleteConfirmation = false }, + onConfirm = { + showDeleteConfirmation = false + scope.launch { + if (repository.deleteWorkout(sessionId)) onDeleted() + } + }, + ) + } + } +} + +@Composable +fun WorkoutSettingsScreen() { + val app = LocalContext.current.applicationContext as LibrePodsApplication + val preferences = app.workoutPreferences + val keyboardController = LocalSoftwareKeyboardController.current + var text by remember { mutableStateOf(preferences.maxHeartRateBpm.toString()) } + var savedValue by remember { mutableStateOf(preferences.maxHeartRateBpm) } + + WorkoutPage { + Text("Heart-rate zones", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = text, + onValueChange = { value -> text = value.filter(Char::isDigit).take(3) }, + label = { Text("Maximum heart rate (BPM)") }, + supportingText = { Text("Allowed ${HeartRateZones.MIN_CONFIGURABLE_MAX_HEART_RATE_BPM}–${HeartRateZones.MAX_CONFIGURABLE_MAX_HEART_RATE_BPM} BPM") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + val value = text.toIntOrNull() ?: HeartRateZones.DEFAULT_MAX_HEART_RATE_BPM + preferences.maxHeartRateBpm = value + savedValue = preferences.maxHeartRateBpm + text = savedValue.toString() + keyboardController?.hide() + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Save for new workouts") } + Text("Current saved max HR: $savedValue BPM", fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun WorkoutPage(content: @Composable ColumnScope.() -> Unit) { + val material = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (material) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(topPadding)) + content() + Spacer(Modifier.height(bottomPadding)) + } +} + +@Composable +private fun WorkoutSummaryCard(detail: WorkoutDetail, nowMillis: Long) { + val summary = detail.summary + val end = summary.endTimeEpochMillis ?: nowMillis + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text(summary.latestBpm?.toString() ?: "—", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold) + Text("Latest BPM", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column(horizontalAlignment = Alignment.End) { + Text(formatDuration((end - summary.startTimeEpochMillis).coerceAtLeast(0L)), style = MaterialTheme.typography.titleLarge) + Text("Duration", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Stat("Min", summary.minBpm?.toString() ?: "—") + Stat("Avg", summary.avgBpm?.roundToInt()?.toString() ?: "—") + Stat("Max", summary.maxBpm?.toString() ?: "—") + Stat("Samples", summary.sampleCount.toString()) + } + } + } +} + +@Composable +private fun Stat(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun WorkoutChartCard( + samples: List, + startTimeMillis: Long? = null, + endTimeMillis: Long? = null, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(16.dp)) { + Text("Heart rate", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + if (samples.size < 2) { + Box(Modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) { + Text("The graph appears after two validated samples.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + HeartRateCanvas( + samples = samples, + startTimeMillis = startTimeMillis, + endTimeMillis = endTimeMillis, + modifier = Modifier.fillMaxWidth().height(180.dp), + ) + } + } + } +} + +@Composable +private fun HeartRateCanvas( + samples: List, + startTimeMillis: Long? = null, + endTimeMillis: Long? = null, + modifier: Modifier = Modifier, +) { + val lineColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + Canvas(modifier = modifier) { + val ordered = samples.sortedBy { it.timestampEpochMillis } + val minTime = minOf( + ordered.first().timestampEpochMillis, + startTimeMillis ?: ordered.first().timestampEpochMillis, + ) + val maxTime = maxOf( + ordered.last().timestampEpochMillis, + endTimeMillis ?: ordered.last().timestampEpochMillis, + ).coerceAtLeast(minTime + 1L) + val minBpm = (ordered.minOf { it.bpm } - 5).coerceAtLeast(20) + val maxBpm = (ordered.maxOf { it.bpm } + 5).coerceAtLeast(minBpm + 1) + repeat(4) { row -> + val y = size.height * row / 3f + drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f) + } + val path = Path() + var previousTimestamp: Long? = null + ordered.forEach { sample -> + val x = ((sample.timestampEpochMillis - minTime).toFloat() / (maxTime - minTime).toFloat()) * size.width + val y = size.height - ((sample.bpm - minBpm).toFloat() / (maxBpm - minBpm).toFloat()) * size.height + val previous = previousTimestamp + if (previous == null || sample.timestampEpochMillis - previous > HEART_RATE_GRAPH_GAP_MILLIS) { + // A long interval means no validated HR data was written. Start a new segment + // after the gap instead of inventing a slope across the disconnection. + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + previousTimestamp = sample.timestampEpochMillis + } + drawPath(path, lineColor, style = Stroke(width = 4f)) + } +} + +private const val HEART_RATE_GRAPH_GAP_MILLIS = 4_000L + +@Composable +private fun ZoneCard(zones: List, maxHeartRateBpm: Int) { + val total = zones.sumOf { it.sampleCount }.coerceAtLeast(1) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Text("Heart-rate zones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("Max HR $maxHeartRateBpm BPM", color = MaterialTheme.colorScheme.onSurfaceVariant) + zones.forEach { zone -> + val range = zoneBpmRange(zone, maxHeartRateBpm) + val sharePercent = (zone.sampleCount.toDouble() / total.toDouble() * 100.0).roundToInt() + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${zone.label} $range") + Text("$sharePercent%", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LinearProgressIndicator( + progress = { zone.sampleCount.toFloat() / total.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun HistoryCard( + session: WorkoutSummary, + onClick: () -> Unit, + onExportCsv: () -> Unit, + onExportFit: () -> Unit, + onDelete: () -> Unit, +) { + val duration = (session.endTimeEpochMillis ?: session.startTimeEpochMillis) - session.startTimeEpochMillis + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(session.startTimeEpochMillis)), fontWeight = FontWeight.SemiBold) + Text("${formatDuration(duration)} • ${session.sampleCount} samples • avg ${session.avgBpm?.roundToInt() ?: "—"} BPM") + Text("Health Connect: ${healthStateLabel(session.healthConnectExportState)}", color = MaterialTheme.colorScheme.onSurfaceVariant) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(modifier = Modifier.weight(1f), onClick = onExportCsv) { Text("CSV") } + OutlinedButton(modifier = Modifier.weight(1f), onClick = onExportFit) { Text("FIT") } + } + TextButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("Delete workout") } + } + } +} + +private fun zoneBpmRange(zone: HeartRateZone, maxHeartRateBpm: Int): String { + fun firstBpmAtPercent(percent: Int): Int = ceil(maxHeartRateBpm * percent / 100.0).toInt() + return when { + zone.minimumPercent == null -> "<${firstBpmAtPercent(zone.maximumPercentExclusive!!)} BPM" + zone.maximumPercentExclusive == null -> "≥${firstBpmAtPercent(zone.minimumPercent)} BPM" + else -> { + val start = firstBpmAtPercent(zone.minimumPercent) + val end = firstBpmAtPercent(zone.maximumPercentExclusive) - 1 + "$start–$end BPM" + } + } +} + +@Composable +private fun DeleteWorkoutDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text("Delete workout?") }, + text = { Text("This removes the workout from LibrePods history. Health Connect data is unchanged.") }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } }, + dismissButton = { TextButton(onClick = onDismissRequest) { Text("Cancel") } }, + ) +} + +@Composable +private fun HealthConnectSessionCard( + workout: WorkoutDetail, + onRequestPermission: () -> Unit, + onRetry: () -> Unit, +) { + val state = workout.summary.healthConnectExportState + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Health Connect session", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(healthStateLabel(state), color = MaterialTheme.colorScheme.onSurfaceVariant) + workout.summary.healthConnectExportMessage?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + when (state) { + HealthConnectSessionExportState.PERMISSION_REQUIRED -> OutlinedButton(onClick = onRequestPermission) { Text("Grant permission and retry") } + HealthConnectSessionExportState.UNAVAILABLE, + HealthConnectSessionExportState.ERROR, + HealthConnectSessionExportState.PENDING -> OutlinedButton(onClick = onRetry) { Text("Retry Health Connect export") } + else -> Unit + } + } + } +} + +private enum class WorkoutExportFormat { CSV, FIT } + +private suspend fun exportAndShare( + context: Context, + workout: WorkoutDetail, + format: WorkoutExportFormat, +) { + try { + val exported = withContext(Dispatchers.IO) { + when (format) { + WorkoutExportFormat.CSV -> WorkoutFileExporter.exportCsv(context, workout) + WorkoutExportFormat.FIT -> WorkoutFileExporter.exportFit(context, workout) + } + } + WorkoutFileExporter.share(context, exported) + } catch (error: Exception) { + Toast.makeText( + context, + "${format.name} export failed: ${error.message ?: error.javaClass.simpleName}", + Toast.LENGTH_LONG, + ).show() + } +} + +private fun formatDuration(milliseconds: Long): String { + val totalSeconds = milliseconds.coerceAtLeast(0L) / 1000L + val hours = totalSeconds / 3600L + val minutes = totalSeconds % 3600L / 60L + val seconds = totalSeconds % 60L + return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%02d:%02d".format(minutes, seconds) +} + +private fun healthStateLabel(state: HealthConnectSessionExportState): String = when (state) { + HealthConnectSessionExportState.NOT_FINISHED -> "Not finished" + HealthConnectSessionExportState.PENDING -> "Pending export" + HealthConnectSessionExportState.EXPORTED -> "Exported" + HealthConnectSessionExportState.PERMISSION_REQUIRED -> "Exercise permission required; local workout is safe" + HealthConnectSessionExportState.UNAVAILABLE -> "Unavailable; local workout is safe" + HealthConnectSessionExportState.ERROR -> "Export failed; local workout is safe" +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 2722df062..144f4c95f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -56,6 +56,8 @@ import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus +import me.kavishdevar.librepods.finder.NearbyFinderState +import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.HeartRateMonitoringState import me.kavishdevar.librepods.services.HeartRateMonitoringStatus @@ -87,6 +89,8 @@ data class AirPodsUiState( val heartRate: HeartRateMonitoringState = HeartRateMonitoringState(), val healthConnect: HealthConnectExportState = HealthConnectExportState(), + val nearbyFinder: NearbyFinderState = NearbyFinderState(), + val heartRateBlePeripheral: HeartRateBlePeripheralState = HeartRateBlePeripheralState(), val eqData: FloatArray = floatArrayOf(), @@ -476,6 +480,16 @@ class AirPodsViewModel( _uiState.update { it.copy(heartRate = heartRate, healthConnect = export) } } } + viewModelScope.launch { + service.nearbyFinderState.collect { finder -> + _uiState.update { it.copy(nearbyFinder = finder) } + } + } + viewModelScope.launch { + service.heartRateBlePeripheralState.collect { peripheral -> + _uiState.update { it.copy(heartRateBlePeripheral = peripheral) } + } + } } fun loadCurrentStatus() { @@ -486,6 +500,8 @@ class AirPodsViewModel( isLocallyConnected = service.isAacpTransportHealthy(), heartRate = service.heartRateState.value, healthConnect = service.healthConnectState.value, + nearbyFinder = service.nearbyFinderState.value, + heartRateBlePeripheral = service.heartRateBlePeripheralState.value, battery = service.getBattery(), ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, controlStates = controlRepo.getMap() @@ -663,6 +679,31 @@ class AirPodsViewModel( _uiState.update { it.copy(headTrackingActive = false) } } + fun startNearbyFinder() { + if (!isReady || isDemoMode) return + service.startNearbyFinder() + } + + fun stopNearbyFinder() { + if (!isReady || isDemoMode) return + service.stopNearbyFinder() + } + + fun refreshNearbyFinderPrerequisites() { + if (!isReady || isDemoMode) return + service.refreshNearbyFinderPrerequisites() + } + + fun setHeartRateBlePeripheralEnabled(enabled: Boolean) { + if (!isReady || isDemoMode) return + service.setHeartRateBlePeripheralEnabled(enabled) + } + + fun refreshHeartRateBlePeripheral() { + if (!isReady || isDemoMode) return + service.refreshHeartRateBlePeripheral() + } + fun setHeartRateMonitoringEnabled(enabled: Boolean) { if (!isReady) return if (isDemoMode) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 24908679d..e562bcc6c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -20,6 +20,7 @@ package me.kavishdevar.librepods.services +import me.kavishdevar.librepods.LibrePodsApplication //import me.kavishdevar.librepods.utils.CrossDevice //import me.kavishdevar.librepods.utils.CrossDevicePackets import android.Manifest @@ -95,6 +96,8 @@ import me.kavishdevar.librepods.bluetooth.ATTManagerv2 import me.kavishdevar.librepods.bluetooth.BLEManager import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheral +import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState import me.kavishdevar.librepods.bluetooth.createBluetoothSocket import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels @@ -108,6 +111,8 @@ import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.isHeadTrackingData import me.kavishdevar.librepods.health.HealthConnectExportState +import me.kavishdevar.librepods.finder.NearbyAirPodsFinder +import me.kavishdevar.librepods.finder.NearbyFinderState import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.overlays.IslandType import me.kavishdevar.librepods.presentation.overlays.IslandWindow @@ -264,12 +269,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val healthConnectState: StateFlow get() = heartRateExporter.state + private lateinit var heartRateBlePeripheral: HeartRateBlePeripheral + val heartRateBlePeripheralState: StateFlow + get() = heartRateBlePeripheral.state + + private lateinit var nearbyFinder: NearbyAirPodsFinder + val nearbyFinderState: StateFlow + get() = nearbyFinder.state + private var handleIncomingCallOnceConnected = false lateinit var bleManager: BLEManager companion object { private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled" + private const val HEART_RATE_BLE_PERIPHERAL_PREFERENCE = "heart_rate_ble_peripheral_enabled" private const val HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS = 3_000L private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L @@ -398,6 +412,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "Battery changed") } + override fun onVerifiedRssi(rssi: Int) { + if (::nearbyFinder.isInitialized) nearbyFinder.onVerifiedScanRssi(rssi) + } + + override fun onScanError(errorCode: Int) { + if (::nearbyFinder.isInitialized) nearbyFinder.onScanError(errorCode) + } + override fun onDeviceDisappeared() { Log.d(TAG, "All disappeared") updateNotificationContent( @@ -438,6 +460,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList heartRateExporter.refresh() initializeConfig() + heartRateBlePeripheral = HeartRateBlePeripheral(applicationContext) + nearbyFinder = NearbyAirPodsFinder( + context = applicationContext, + scope = heartRateScope, + hasSelectedDevice = { device != null || macAddress.isNotBlank() } + ) + aacpManager = AACPManager() heartRateMonitor = HeartRateMonitor( scope = heartRateScope, @@ -468,10 +497,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendStop = { aacpManager.sendHeartRateStopFrame() }, requestTransportRecovery = ::requestAutomaticAacpRecoveryForHeartRate, onPublishedSample = { sample -> + heartRateBlePeripheral.onValidatedSample(sample) heartRateExporter.enqueue( sample = sample, deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName } ) + (application as LibrePodsApplication).workoutRepository.recordValidatedSample(sample) } ) initializeAACPManagerCallback() @@ -906,6 +937,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CoroutineScope(Dispatchers.IO).launch { bleManager.startScanning() } + if (sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false)) { + heartRateBlePeripheral.start() + } } @Suppress("unused") @@ -3696,6 +3730,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { telephonyManager.unregisterTelephonyCallback(phoneStateListener) } + if (::nearbyFinder.isInitialized) nearbyFinder.stop() + if (::heartRateBlePeripheral.isInitialized) heartRateBlePeripheral.stop() stopHeartRateMonitoring() if (::heartRateExporter.isInitialized) { runBlocking { heartRateExporter.closeAndFlush() } @@ -3724,6 +3760,65 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (::heartRateExporter.isInitialized) heartRateExporter.markPermissionDenied() } + fun startNearbyFinder() { + if (!::nearbyFinder.isInitialized || !nearbyFinder.start()) return + prepareNearbyFinderScan() + if (!::bleManager.isInitialized || + !bleManager.startScanning( + scanAllAdvertisementsForFinder = true, + resetFinderWatchdog = true, + ) + ) { + nearbyFinder.onScanError(-1) + } + } + + fun stopNearbyFinder() { + if (::nearbyFinder.isInitialized) nearbyFinder.stop() + if (::bleManager.isInitialized) bleManager.startScanning() + if (::heartRateBlePeripheral.isInitialized && + sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false) + ) { + heartRateBlePeripheral.start() + } + } + + fun refreshNearbyFinderPrerequisites() { + if (!::nearbyFinder.isInitialized || !nearbyFinder.refreshPrerequisites()) return + prepareNearbyFinderScan() + if (!::bleManager.isInitialized || + !bleManager.startScanning( + scanAllAdvertisementsForFinder = true, + resetFinderWatchdog = true, + ) + ) { + nearbyFinder.onScanError(-1) + } + } + + private fun prepareNearbyFinderScan() { + // A few Android Bluetooth chipsets do not scan reliably while the same app owns a + // connectable advertiser. Finder takes priority; restore the opt-in HR peripheral when + // the user leaves this screen. + if (::heartRateBlePeripheral.isInitialized && + sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false) + ) { + heartRateBlePeripheral.stop() + } + } + + fun setHeartRateBlePeripheralEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, enabled) } + if (::heartRateBlePeripheral.isInitialized) heartRateBlePeripheral.setEnabled(enabled) + } + + fun refreshHeartRateBlePeripheral() { + if (!::heartRateBlePeripheral.isInitialized) return + heartRateBlePeripheral.setEnabled( + sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false) + ) + } + fun setHeartRateMonitoringEnabled(enabled: Boolean) { sharedPreferences.edit { putBoolean(HEART_RATE_MONITORING_PREFERENCE, enabled) } if (!enabled && ::heartRateExporter.isInitialized) heartRateExporter.flushAsync() diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index 4558e635d..001873109 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt new file mode 100644 index 000000000..8b8f2e7f4 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt @@ -0,0 +1,31 @@ +package me.kavishdevar.librepods.bluetooth + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class HeartRateMeasurementEncoderTest { + @Test + fun bpmAtOrBelow255UsesUint8Format() { + assertArrayEquals( + byteArrayOf(0x00, 72), + HeartRateMeasurementEncoder.encodeBpm(72) + ) + assertArrayEquals( + byteArrayOf(0x00, 0xff.toByte()), + HeartRateMeasurementEncoder.encodeBpm(255) + ) + } + + @Test + fun bpmAbove255UsesUint16LittleEndianFormat() { + assertArrayEquals( + byteArrayOf(0x01, 0x2c, 0x01), + HeartRateMeasurementEncoder.encodeBpm(300) + ) + } + + @Test(expected = IllegalArgumentException::class) + fun bpmOutsideUint16IsRejected() { + HeartRateMeasurementEncoder.encodeBpm(65_536) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt new file mode 100644 index 000000000..6897323c7 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt @@ -0,0 +1,37 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeartRateZonesTest { + @Test + fun boundariesAreDeterministicPercentagesOfConfiguredMax() { + val max = 200 + assertEquals(0, HeartRateZones.zoneIndex(99, max)) + assertEquals(1, HeartRateZones.zoneIndex(100, max)) + assertEquals(1, HeartRateZones.zoneIndex(119, max)) + assertEquals(2, HeartRateZones.zoneIndex(120, max)) + assertEquals(3, HeartRateZones.zoneIndex(140, max)) + assertEquals(4, HeartRateZones.zoneIndex(160, max)) + assertEquals(5, HeartRateZones.zoneIndex(180, max)) + assertEquals(5, HeartRateZones.zoneIndex(205, max)) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt new file mode 100644 index 000000000..d7c51af1a --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt @@ -0,0 +1,194 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.data.workout + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import me.kavishdevar.librepods.bluetooth.HeartRateSample +import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExportResult +import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExporter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class WorkoutRepositoryTest { + @Test + fun finishIsIdempotentAndDoesNotMoveEndTimeOrDuplicateHealthConnectExport() = runBlocking { + var now = 1_000L + val store = FakeStore() + val exporter = FakeExporter(WorkoutHealthConnectExportResult.Exported("hc-record")) + val repository = WorkoutRepository( + localStore = store, + healthConnectExporter = exporter, + maxHeartRateProvider = { 190 }, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + nowMillis = { now }, + newId = { "session-1" }, + zoneOffsetSecondsAt = { 0 }, + ) + + assertEquals("session-1", repository.startWorkout()) + now = 5_000L + repository.finishWorkout() + now = 9_000L + repository.finishWorkout("session-1") + + val stored = store.getSession("session-1") + assertNotNull(stored) + assertEquals(5_000L, stored!!.endTimeEpochMillis) + assertEquals(HealthConnectSessionExportState.EXPORTED.name, stored.healthConnectExportState) + assertEquals("hc-record", stored.healthConnectRecordId) + assertEquals(1, exporter.calls) + } + + @Test + fun permissionFailureKeepsFinishedLocalSessionAndRetryUsesSameIdentity() = runBlocking { + var now = 10_000L + val store = FakeStore() + val exporter = SequencedExporter( + mutableListOf( + WorkoutHealthConnectExportResult.PermissionRequired, + WorkoutHealthConnectExportResult.Exported("hc-2"), + ) + ) + val repository = WorkoutRepository( + localStore = store, + healthConnectExporter = exporter, + maxHeartRateProvider = { 185 }, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + nowMillis = { now }, + newId = { "stable-id" }, + zoneOffsetSecondsAt = { 3600 }, + ) + + repository.startWorkout() + now = 20_000L + repository.finishWorkout() + val afterDenied = store.getSession("stable-id")!! + assertEquals(20_000L, afterDenied.endTimeEpochMillis) + assertEquals(HealthConnectSessionExportState.PERMISSION_REQUIRED.name, afterDenied.healthConnectExportState) + + now = 30_000L + repository.retryHealthConnectExport("stable-id") + val afterRetry = store.getSession("stable-id")!! + assertEquals(20_000L, afterRetry.endTimeEpochMillis) + assertEquals("librepods-workout:stable-id", afterRetry.healthConnectClientRecordId) + assertEquals(HealthConnectSessionExportState.EXPORTED.name, afterRetry.healthConnectExportState) + assertEquals(2, exporter.calls) + } + + + @Test + fun validatedSamplePublishedBeforeFinishIsPersistedToThatSession() = runBlocking { + var now = 1_000L + val store = FakeStore() + val repository = WorkoutRepository( + localStore = store, + healthConnectExporter = FakeExporter(WorkoutHealthConnectExportResult.Exported("hc")), + maxHeartRateProvider = { 190 }, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + nowMillis = { now }, + newId = { "sample-session" }, + zoneOffsetSecondsAt = { 0 }, + ) + + repository.startWorkout() + repository.recordValidatedSample( + HeartRateSample( + bpm = 137, + sequence = 42, + receivedAtMillis = 1_500L, + receivedAtElapsedRealtime = 500L, + ) + ) + now = 2_000L + repository.finishWorkout() + + val samples = store.getSamples("sample-session") + assertEquals(1, samples.size) + assertEquals(137, samples.single().bpm) + assertEquals(42, samples.single().sequence) + } + + private class FakeExporter(private val result: WorkoutHealthConnectExportResult) : WorkoutHealthConnectExporter { + var calls = 0 + override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult { + calls++ + return result + } + } + + private class SequencedExporter( + private val results: MutableList + ) : WorkoutHealthConnectExporter { + var calls = 0 + override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult { + calls++ + return results.removeAt(0) + } + } + + private class FakeStore : WorkoutLocalStore { + private val sessions = linkedMapOf() + private val samples = mutableListOf() + + override fun observeActiveSummary(): Flow = flowOf(null) + override fun observeFinishedSummaries(): Flow> = flowOf(emptyList()) + override fun observeSummary(sessionId: String): Flow = flowOf(null) + override fun observeSamples(sessionId: String): Flow> = flowOf(emptyList()) + override fun observeActiveSamples(): Flow> = flowOf(emptyList()) + override suspend fun getActiveSession(): WorkoutSessionEntity? = sessions.values.lastOrNull { it.endTimeEpochMillis == null } + override suspend fun getSession(sessionId: String): WorkoutSessionEntity? = sessions[sessionId] + override suspend fun getPendingHealthConnectSessions(): List = + sessions.values.filter { it.healthConnectExportState == HealthConnectSessionExportState.PENDING.name } + override suspend fun getSamples(sessionId: String): List = samples.filter { it.sessionId == sessionId } + override suspend fun createSession(session: WorkoutSessionEntity) { sessions[session.id] = session } + override suspend fun addSample(sample: WorkoutSampleEntity) { samples += sample } + + override suspend fun finishLocally(sessionId: String, endMillis: Long, endOffsetSeconds: Int): WorkoutSessionEntity? { + val current = sessions[sessionId] ?: return null + if (current.endTimeEpochMillis == null) { + sessions[sessionId] = current.copy( + endTimeEpochMillis = endMillis, + endZoneOffsetSeconds = endOffsetSeconds, + healthConnectExportState = HealthConnectSessionExportState.PENDING.name, + ) + } + return sessions[sessionId] + } + + override suspend fun updateHealthConnectExport( + sessionId: String, + state: HealthConnectSessionExportState, + recordId: String?, + message: String?, + ) { + val current = sessions[sessionId] ?: return + sessions[sessionId] = current.copy( + healthConnectExportState = state.name, + healthConnectRecordId = recordId, + healthConnectExportMessage = message, + ) + } + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt new file mode 100644 index 000000000..dd2e3d415 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt @@ -0,0 +1,111 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.export.workout + +import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState +import me.kavishdevar.librepods.data.workout.HeartRateZones +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity +import me.kavishdevar.librepods.data.workout.WorkoutSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FitActivityEncoderTest { + @Test + fun headerAndDataCrcAreSelfConsistent() { + val bytes = FitActivityEncoder.encode(detail()) + assertEquals(14, bytes[0].toInt() and 0xFF) + assertEquals(".FIT", String(bytes, 8, 4, Charsets.US_ASCII)) + + val dataSize = le32(bytes, 4).toInt() + assertEquals(bytes.size - 14 - 2, dataSize) + + val storedHeaderCrc = le16(bytes, 12) + assertEquals(FitCrc.compute(bytes, 0, 12), storedHeaderCrc) + assertEquals(0, FitCrc.compute(bytes, 0, 14)) + + val storedFileCrc = le16(bytes, 14 + dataSize) + assertEquals(FitCrc.compute(bytes, 14, dataSize), storedFileCrc) + assertEquals(FitCrc.compute(bytes, 0, 14 + dataSize), storedFileCrc) + assertEquals(setOf(0, 18, 19, 20, 34), globalMessageDefinitions(bytes, dataSize)) + assertTrue(dataSize > 0) + } + + private fun globalMessageDefinitions(bytes: ByteArray, dataSize: Int): Set { + val globals = linkedSetOf() + val localSizes = IntArray(16) + var offset = 14 + val dataEnd = 14 + dataSize + while (offset < dataEnd) { + val header = bytes[offset++].toInt() and 0xFF + val local = header and 0x0F + if ((header and 0x40) != 0) { + offset++ // reserved + val architecture = bytes[offset++].toInt() and 0xFF + require(architecture == 0) + val global = le16(bytes, offset) + offset += 2 + globals += global + val fieldCount = bytes[offset++].toInt() and 0xFF + var messageSize = 0 + repeat(fieldCount) { + offset++ // field number + messageSize += bytes[offset++].toInt() and 0xFF + offset++ // base type + } + localSizes[local] = messageSize + } else { + offset += localSizes[local] + } + } + return globals + } + + private fun le16(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) + + private fun le32(bytes: ByteArray, offset: Int): Long = + (bytes[offset].toLong() and 0xFF) or + ((bytes[offset + 1].toLong() and 0xFF) shl 8) or + ((bytes[offset + 2].toLong() and 0xFF) shl 16) or + ((bytes[offset + 3].toLong() and 0xFF) shl 24) + + private fun detail(): WorkoutDetail { + val start = 1_704_067_200_000L + val samples = listOf( + WorkoutSampleEntity(id = 1, sessionId = "fit", timestampEpochMillis = start + 1_000, sequence = 1, bpm = 120), + WorkoutSampleEntity(id = 2, sessionId = "fit", timestampEpochMillis = start + 2_000, sequence = 2, bpm = 128), + ) + val summary = WorkoutSummary( + id = "fit", + startTimeEpochMillis = start, + endTimeEpochMillis = start + 60_000, + maxHeartRateBpm = 190, + sampleCount = samples.size.toLong(), + latestBpm = 128, + minBpm = 120, + avgBpm = 124.0, + maxBpm = 128, + healthConnectExportState = HealthConnectSessionExportState.EXPORTED, + healthConnectExportMessage = null, + ) + return WorkoutDetail(summary, samples, HeartRateZones.distribution(samples, 190)) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt new file mode 100644 index 000000000..0c4b99357 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt @@ -0,0 +1,76 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.export.workout + +import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState +import me.kavishdevar.librepods.data.workout.HeartRateZones +import me.kavishdevar.librepods.data.workout.WorkoutDetail +import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity +import me.kavishdevar.librepods.data.workout.WorkoutSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutCsvEncoderTest { + @Test + fun escapingDoublesQuotesAndWrapsSpecialFields() { + assertEquals("\"a,\"\"b\"\"\n\"", WorkoutCsvEncoder.csvEscape("a,\"b\"\n")) + } + + @Test + fun timestampsAreIso8601UtcInstants() { + val workout = detail(id = "session,\"quoted\"") + val csv = WorkoutCsvEncoder.encode(workout) + assertTrue(csv.contains("2024-01-01T00:00:00Z")) + assertTrue(csv.contains("2024-01-01T00:00:01Z")) + assertTrue(csv.contains("\"session,\"\"quoted\"\"\"")) + } + + + @Test + fun emptyWorkoutStillExportsOneSessionMetadataRow() { + val workout = detail(id = "empty").copy(samples = emptyList()) + val csv = WorkoutCsvEncoder.encode(workout) + val lines = csv.trimEnd().lines() + assertEquals(2, lines.size) + assertTrue(lines[1].startsWith("empty,2024-01-01T00:00:00Z,")) + assertTrue(lines[1].endsWith(",190,,,")) + } + + private fun detail(id: String): WorkoutDetail { + val start = 1_704_067_200_000L + val samples = listOf( + WorkoutSampleEntity(id = 1, sessionId = id, timestampEpochMillis = start + 1_000L, sequence = 7, bpm = 123) + ) + val summary = WorkoutSummary( + id = id, + startTimeEpochMillis = start, + endTimeEpochMillis = start + 10_000L, + maxHeartRateBpm = 190, + sampleCount = 1, + latestBpm = 123, + minBpm = 123, + avgBpm = 123.0, + maxBpm = 123, + healthConnectExportState = HealthConnectSessionExportState.EXPORTED, + healthConnectExportMessage = null, + ) + return WorkoutDetail(summary, samples, HeartRateZones.distribution(samples, 190)) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt new file mode 100644 index 000000000..0d451bea3 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt @@ -0,0 +1,81 @@ +package me.kavishdevar.librepods.finder + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RssiSignalProcessorTest { + @Test + fun emaDampensSingleStrongSpike() { + val processor = RssiSignalProcessor(medianWindowSize = 1) + processor.addSample(-60, 0L) + processor.addSample(-60, 500L) + val snapshot = processor.addSample(-30, 1_000L) + + val smoothed = requireNotNull(snapshot.smoothedRssi) + assertTrue(smoothed < -45.0) + assertTrue(smoothed > -60.0) + assertEquals(-30, snapshot.rawRssi) + } + + @Test + fun bucketHysteresisPreventsBoundaryFlapping() { + val processor = RssiSignalProcessor( + emaAlpha = 1.0, + hysteresisDb = 3.0, + medianWindowSize = 1 + ) + + assertEquals( + ProximityBucket.CLOSE, + processor.addSample(-57, 0L).proximity + ) + assertEquals( + ProximityBucket.CLOSE, + processor.addSample(-59, 500L).proximity + ) + assertEquals( + ProximityBucket.NEARBY, + processor.addSample(-62, 1_000L).proximity + ) + } + + @Test + fun signalBecomesStaleThenLost() { + val processor = RssiSignalProcessor(emaAlpha = 1.0, medianWindowSize = 1) + processor.addSample(-55, 1_000L) + + val stale = processor.snapshot(4_000L) + assertTrue(stale.stale) + assertEquals(ProximityBucket.CLOSE, stale.proximity) + + val lost = processor.snapshot(9_000L) + assertTrue(lost.stale) + assertEquals(ProximityBucket.SIGNAL_LOST, lost.proximity) + assertEquals(null, lost.approximateDistanceMeters) + } + + @Test + fun trendNeedsHistoryAndDetectsCloserFartherAndStable() { + fun trendFor(values: List): SignalTrend { + val processor = RssiSignalProcessor( + emaAlpha = 1.0, + minTrendSamples = 5, + minTrendSpanMillis = 2_000L, + trendThresholdDb = 2.0, + medianWindowSize = 1 + ) + values.forEachIndexed { index, rssi -> + processor.addSample( + rssi, + index * 500L, + ) + } + return processor.snapshot(2_000L).trend + } + + assertEquals(SignalTrend.GETTING_CLOSER, trendFor(listOf(-70, -68, -66, -64, -62))) + assertEquals(SignalTrend.GETTING_FARTHER, trendFor(listOf(-62, -64, -66, -68, -70))) + assertEquals(SignalTrend.STABLE, trendFor(listOf(-65, -64, -65, -64, -65))) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 45682a060..d756f7216 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -3,5 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.aboutLibraries) apply false + alias(libs.plugins.ksp) apply false // alias(libs.plugins.hilt) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 622ddea0a..dfd24fb97 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -18,6 +18,9 @@ backdrop = "2.0.0-alpha03" billing = "8.3.0" hilt = "2.59.2" healthConnect = "1.1.0" +room = "2.8.4" +ksp = "2.3.11" +junit = "4.13.2" xposed = "101.0.0" lifecycleProcess = "2.10.0" play = "2.0.2" @@ -54,6 +57,10 @@ backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backd billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" } hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } androidx-health-connect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +junit = { group = "junit", name = "junit", version.ref = "junit" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } @@ -72,3 +79,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }