From f1b45fb3936b195677c132e1c682de7a3810d764 Mon Sep 17 00:00:00 2001 From: Thibau Pauwels Date: Mon, 3 Aug 2026 15:41:34 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 {