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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- 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/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt
index ac6d356b7..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 {
@@ -62,6 +63,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 +264,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 +310,7 @@ class AACPManager {
}
private var callback: PacketCallback? = null
+ private val heartRateDecoder = RtBuddyHeartRateDecoder()
fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
@@ -306,6 +337,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 +440,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 +1197,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 {
@@ -1159,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}")
@@ -1269,8 +1332,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..88bfb7bb0
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt
@@ -0,0 +1,505 @@
+/*
+ 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 {
+ 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 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,
+ 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 fieldValue = readLengthDelimited(data, index, end) ?: return false
+ if (field in HEART_RATE_SERVICE_REFERENCE_FIELDS &&
+ parseReferencedService(
+ data,
+ fieldValue.startIndex,
+ fieldValue.endIndex
+ ) == HEART_RATE_SERVICE
+ ) {
+ return true
+ }
+ index = fieldValue.endIndex
+ }
+
+ 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 fieldValue = readLengthDelimited(data, index, end) ?: return null
+
+ when (field) {
+ 5, 8, 9, 12 -> parseReferencedService(
+ data,
+ fieldValue.startIndex,
+ fieldValue.endIndex
+ )
+ ?.let(referencedServices::add)
+
+ 7 -> {
+ val command = parseCommand(
+ data,
+ fieldValue.startIndex,
+ fieldValue.endIndex
+ )
+ if (command != null) {
+ commands += command
+ if (command.service >= 0) referencedServices += command.service
+ } else {
+ parseReferencedService(
+ data,
+ fieldValue.startIndex,
+ fieldValue.endIndex
+ )
+ ?.let(referencedServices::add)
+ }
+ }
+ }
+ index = fieldValue.endIndex
+ }
+
+ 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 fieldValue = readLengthDelimited(data, index, end) ?: return null
+ if (field == 3) {
+ if (payload != null) {
+ duplicatePayload = true
+ } else {
+ payload = data.copyOfRange(
+ fieldValue.startIndex,
+ fieldValue.endIndex
+ )
+ }
+ }
+ index = fieldValue.endIndex
+ }
+
+ 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 fieldValue = readLengthDelimited(data, index, end) ?: return null
+ index = fieldValue.endIndex
+ }
+
+ 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 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,
+ 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 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
+ 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.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/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt
new file mode 100644
index 000000000..f5c8b4710
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt
@@ -0,0 +1,657 @@
+/*
+ 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.health
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.core.content.edit
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.metadata.Device
+import androidx.health.connect.client.records.metadata.Metadata
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import java.io.IOException
+import java.security.MessageDigest
+import java.time.Instant
+import java.time.ZoneId
+
+/** User-visible Health Connect state for the optional heart-rate export. */
+enum class HealthConnectExportStatus {
+ UNAVAILABLE,
+ UPDATE_REQUIRED,
+ PERMISSION_REQUIRED,
+ PERMISSION_DENIED,
+ READY,
+ ENABLED,
+ ERROR
+}
+
+/**
+ * Buffers validated AirPods heart-rate samples and writes them to Health Connect.
+ *
+ * Each batch is assigned a stable client record ID derived from its ordered sample contents and
+ * device metadata. Retrying a failed batch therefore remains idempotent even if Health Connect
+ * accepted the record before returning an error.
+ */
+class HealthConnectHeartRateExporter(
+ context: Context,
+ private val sharedPreferences: SharedPreferences,
+ private val scope: CoroutineScope
+) {
+ private enum class BatchDetail {
+ MINUTE_AVERAGE,
+ DETAILED
+ }
+
+ private data class PendingSample(
+ val id: String,
+ val sample: HeartRateSample,
+ val deviceModel: String
+ )
+
+ private data class PendingBatch(
+ val samples: List,
+ 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 minuteWindowStartMillis: 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 {
+ hasWritePermission(client)
+ } 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 {
+ hasWritePermission(getClient())
+ } 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 && minuteWindowStartMillis == null) {
+ minuteWindowStartMillis = sample.receivedAtMillis
+ }
+ trimBufferLocked()
+
+ if (pendingBatch != null) {
+ false
+ } else if (_detailedSamples.value) {
+ if (bufferedSampleCountLocked() >= MAX_BATCH_SIZE) {
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ true
+ } else {
+ scheduleFlushLocked(FLUSH_INTERVAL_MILLIS)
+ false
+ }
+ } else if (hasCompletedMinuteWindowLocked()) {
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ true
+ } else {
+ scheduleMinuteFlushLocked()
+ 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) {
+ handleRetryableWriteFailureLocked(
+ "Health Connect write failed; keeping batch for retry",
+ error
+ )
+ return false
+ } catch (error: IllegalStateException) {
+ handleRetryableWriteFailureLocked(
+ "Health Connect is temporarily unavailable",
+ error
+ )
+ return false
+ } catch (error: RuntimeException) {
+ handleRetryableWriteFailureLocked(
+ "Unexpected Health Connect write failure",
+ error
+ )
+ return false
+ }
+ }
+
+ applyRequestedDetailLocked()
+ 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
+
+ minuteWindowStartMillis = 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 {
+ scheduleMinuteFlushLocked()
+ }
+ }
+
+ private fun scheduleMinuteFlushLocked() {
+ val windowStart = ensureMinuteWindowStartLocked() ?: return
+ val windowEnd = windowStart + MINUTE_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 {
+ createMinuteAverageBatchLocked(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 createMinuteAverageBatchLocked(forcePartialMinute: Boolean): PendingBatch? {
+ val orderedSamples = pendingSamples.values.sortedWith(PENDING_SAMPLE_COMPARATOR)
+ if (orderedSamples.isEmpty()) return null
+
+ var windowStart = ensureMinuteWindowStartLocked() ?: return null
+ val earliestTimestamp = orderedSamples.first().sample.receivedAtMillis
+ var windowEnd = windowStart + MINUTE_WINDOW_MILLIS
+ while (earliestTimestamp >= windowEnd) {
+ windowStart = windowEnd
+ windowEnd = windowStart + MINUTE_WINDOW_MILLIS
+ minuteWindowStartMillis = 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) {
+ minuteWindowStartMillis = 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 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 ensureMinuteWindowStartLocked(): Long? {
+ minuteWindowStartMillis?.let { return it }
+ return pendingSamples.values.minOfOrNull { it.sample.receivedAtMillis }?.also {
+ minuteWindowStartMillis = 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 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
+ 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 {
+ hasWritePermission(getClient())
+ } 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 MINUTE_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..86b66816c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt
@@ -0,0 +1,266 @@
+/*
+ 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.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
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.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
+import me.kavishdevar.librepods.presentation.theme.DesignSystem
+import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
+
+@Composable
+fun HeartRateCard(
+ monitoringEnabled: Boolean,
+ streaming: Boolean,
+ connected: Boolean,
+ latestSample: HeartRateSample?,
+ heartRateSamples: List,
+ onMonitoringChanged: (Boolean) -> Unit,
+ onOpenDetails: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val status = heartRateStatus(monitoringEnabled, connected, streaming)
+ val displayedBpm = latestSample
+ ?.takeIf { streaming }
+ ?.bpm
+ ?.toString()
+ ?: EM_DASH
+ val graphValues = remember(heartRateSamples) {
+ normalizedRecentHeartRates(heartRateSamples)
+ }
+
+ 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
+ ) {
+ HeartRateMiniGraph(values = graphValues)
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ 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 = displayedBpm,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "BPM",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ when (LocalDesignSystem.current) {
+ DesignSystem.Material -> Switch(
+ checked = monitoringEnabled,
+ onCheckedChange = onMonitoringChanged
+ )
+
+ DesignSystem.Apple -> StyledSwitch(
+ checked = monitoringEnabled,
+ onCheckedChange = onMonitoringChanged
+ )
+ }
+ }
+ }
+}
+
+@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/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt
index 14479eb57..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
@@ -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
@@ -111,6 +112,7 @@ fun AppNavGraph(
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
+ 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..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,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"
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/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
index 9583cceab..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
@@ -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,20 @@ 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 hasHearingAidCapability =
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
@@ -966,6 +988,9 @@ fun AirPodsSettingsScreenPreviewApple() {
navigateToTroubleshooting = {},
navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {},
+ navigateToHeartRateTest = {},
+
+ setHeartRateMonitoringEnabled = {},
activateDemoMode = {},
reconnectFromSavedMac = {}
@@ -1013,6 +1038,9 @@ fun AirPodsSettingsScreenPreviewMaterial() {
navigateToTroubleshooting = {},
navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {},
+ navigateToHeartRateTest = {},
+
+ setHeartRateMonitoringEnabled = {},
activateDemoMode = {},
reconnectFromSavedMac = {}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt
new file mode 100644
index 000000000..0bd2ffde2
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt
@@ -0,0 +1,610 @@
+/*
+ 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 android.graphics.Paint
+import android.graphics.Typeface
+import androidx.activity.compose.rememberLauncherForActivityResult
+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.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
+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
+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) {
+ 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
+ } else {
+ WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
+ }
+ val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp
+
+ val latestSample = state.heartRateSamples.lastOrNull()
+ val monitoringStatus = monitoringStatus(
+ enabled = state.heartRateMonitoringEnabled,
+ connected = state.isLocallyConnected,
+ streaming = state.heartRateStreaming
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(topPadding))
+
+ HeartRateSummaryCard(
+ latestSample = latestSample,
+ connected = state.isLocallyConnected,
+ monitoringStatus = monitoringStatus
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ HealthConnectControls(
+ status = state.healthConnectExportStatus,
+ exportEnabled = state.healthConnectExportEnabled,
+ detailedSamples = state.healthConnectDetailedSamples,
+ onExportChanged = { enabled ->
+ if (!enabled) {
+ viewModel.setHealthConnectExportEnabled(false)
+ } else {
+ when (state.healthConnectExportStatus) {
+ HealthConnectExportStatus.READY,
+ HealthConnectExportStatus.ENABLED ->
+ viewModel.setHealthConnectExportEnabled(true)
+
+ HealthConnectExportStatus.PERMISSION_REQUIRED,
+ HealthConnectExportStatus.PERMISSION_DENIED,
+ HealthConnectExportStatus.ERROR ->
+ healthConnectPermissionLauncher.launch(
+ HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS
+ )
+
+ HealthConnectExportStatus.UNAVAILABLE,
+ HealthConnectExportStatus.UPDATE_REQUIRED -> Unit
+ }
+ }
+ },
+ onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples
+ )
+
+ Spacer(modifier = Modifier.height(12.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)
+
+ Spacer(modifier = Modifier.height(bottomPadding))
+ }
+}
+
+@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
+ .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 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
+
+ drawLine(
+ color = gridColor,
+ 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) {
+ plotLeft + plotWidth / 2f
+ } else {
+ plotLeft +
+ index.toFloat() / (samples.size - 1).toFloat() * plotWidth
+ }
+ val normalized = (
+ (sample.bpm.toFloat() - chartScale.minBpm) /
+ chartScale.spanBpm
+ ).coerceIn(0f, 1f)
+ val y = plotBottom - normalized * plotHeight
+
+ if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
+ if (index == samples.lastIndex) {
+ drawCircle(
+ color = pointColor,
+ radius = 4.dp.toPx(),
+ center = 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,
+ 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/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 8c99178d6..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
@@ -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
@@ -54,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")
@@ -81,6 +83,13 @@ data class AirPodsUiState(
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
+ 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(),
val automaticEarDetectionEnabled: Boolean = true,
@@ -210,6 +219,7 @@ class AirPodsViewModel(
loadInstance()
loadSharedPreferences()
observeAACP()
+ observeHeartRate()
loadCurrentStatus()
loadEq()
loadATT()
@@ -460,12 +470,51 @@ 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) }
+ }
+ }
+ 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() {
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,
+ 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()
@@ -625,6 +674,7 @@ class AirPodsViewModel(
}
fun reconnectFromSavedMac() {
+ if (!::service.isInitialized) return
service.reconnectFromSavedMac()
}
@@ -642,6 +692,58 @@ 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 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 0cf08c11d..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
@@ -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,8 +72,12 @@ 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
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -89,6 +94,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
@@ -101,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
@@ -132,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"
@@ -231,11 +240,54 @@ 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 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
+ private var lastValidHeartRateSampleElapsedRealtime: Long? = null
+
+ private enum class HeartRateStreamFailure {
+ FIRST_SAMPLE_TIMEOUT,
+ STREAM_STALLED
+ }
+
+ 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 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
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 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 {
System.loadLibrary("bluetooth_socket")
}
@@ -377,6 +429,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
_packetLogsFlow.value = inMemoryLogs.toSet()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
+ _heartRateMonitoringEnabled.value = sharedPreferences.getBoolean(
+ HEART_RATE_MONITORING_PREFERENCE,
+ false
+ )
+ heartRateExporter = HealthConnectHeartRateExporter(
+ context = applicationContext,
+ sharedPreferences = sharedPreferences,
+ scope = heartRateScope
+ )
+ heartRateExporter.refresh()
initializeConfig()
aacpManager = AACPManager()
@@ -664,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 {
@@ -692,13 +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)
- aacpManager.disconnected()
- BluetoothConnectionManager.aacpSocket = null
- BluetoothConnectionManager.attSocket = null
}
}
}
@@ -1080,6 +1156,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
+ override fun onHeartRateReceived(sample: HeartRateSample) {
+ 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) {
val keys = aacpManager.parseProximityKeysResponse(proximityKeys)
Log.d("AirPodsParser", "Proximity keys: $keys")
@@ -2756,13 +2855,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 +2877,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
})
setupStemActions()
+ startHeartRateMonitoringIfEnabled()
while (socket.isConnected) {
try {
@@ -2785,7 +2891,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,42 +2898,63 @@ 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")
}
} else if (bytesRead == -1) {
Log.d("AirPodsService", "socket closed (bytesRead = -1)")
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
- setPackage(packageName)
- })
- 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)
- })
- 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
- 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) {
+ 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}")
@@ -2841,7 +2967,139 @@ 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
Log.d(TAG, "Disconnected from AirPods, showing island.")
@@ -2874,6 +3132,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun disconnectAirPods() {
if (BluetoothConnectionManager.aacpSocket == null) return
+ stopHeartRateMonitoring()
try {
BluetoothConnectionManager.aacpSocket?.close()
} catch(e: Exception) {
@@ -3139,11 +3398,253 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.unregisterTelephonyCallback(phoneStateListener)
}
+ stopHeartRateMonitoring()
+ if (::heartRateExporter.isInitialized) {
+ runBlocking { heartRateExporter.closeAndFlush() }
+ }
+ heartRateScope.cancel()
+ suppressAacpReconnect("service-destroyed")
+ transportRecoveryScope.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) }
+ _heartRateMonitoringEnabled.value = enabled
+
+ if (enabled) {
+ if (!wasEnabled) _heartRateSamples.value = emptyList()
+ startHeartRateMonitoringIfEnabled()
+ } else {
+ if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync()
+ stopHeartRateMonitoring(forceStop = wasEnabled)
+ }
+ }
+
+ private fun startHeartRateMonitoringIfEnabled() {
+ if (!_heartRateMonitoringEnabled.value) return
+ if (BluetoothConnectionManager.aacpSocket?.isConnected != true) {
+ _heartRateStreaming.value = false
+ return
+ }
+
+ synchronized(heartRateLock) {
+ if (heartRateStartJob?.isActive == true) return
+
+ _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 failure = if (attemptStartedAt == null) {
+ synchronized(heartRateLock) {
+ stopHeartRateSessionLocked()
+ }
+ HeartRateStreamFailure.FIRST_SAMPLE_TIMEOUT
+ } else {
+ awaitHeartRateStreamFailure(attemptStartedAt) ?: return
+ }
+
+ if (failure == HeartRateStreamFailure.STREAM_STALLED) {
+ consecutiveRecoveryAttempts = 0
+ }
+
+ if (consecutiveRecoveryAttempts >= HEART_RATE_RETRY_BACKOFF_MILLIS.size) {
+ Log.w(TAG, "RTBuddy heart-rate recovery retries exhausted")
+ return
+ }
+
+ val backoffMillis =
+ HEART_RATE_RETRY_BACKOFF_MILLIS[consecutiveRecoveryAttempts]
+ consecutiveRecoveryAttempts++
+ Log.w(
+ TAG,
+ "RTBuddy heart-rate ${failure.name.lowercase()} recovery " +
+ "attempt=$consecutiveRecoveryAttempts backoff=${backoffMillis}ms"
+ )
+ delay(backoffMillis)
+ }
+ } finally {
+ synchronized(heartRateLock) {
+ if (heartRateStartJob === currentJob) {
+ stopHeartRateSessionLocked()
+ heartRateStartJob = null
+ }
+ }
+ }
+ }
+
+ 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 {
+ if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService0() }) {
+ return false
+ }
+
+ delay(180)
+ if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService0() }) {
+ return false
+ }
+ delay(220)
+ if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateConnectService4() }) {
+ return false
+ }
+ delay(180)
+ if (!sendHeartRateSessionFrameIfActive { aacpManager.sendHeartRateCapabilitiesService4() }) {
+ return false
+ }
+ delay(220)
+ Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized")
+ return canContinueHeartRateMonitoring()
+ }
+
+ private fun sendHeartRateSessionFrameIfActive(sendFrame: () -> Boolean): Boolean =
+ synchronized(heartRateLock) {
+ canContinueHeartRateMonitoring() && sendFrame()
+ }
+
+ private fun stopHeartRateSessionLocked(
+ forceStop: Boolean = false,
+ sendStopFrame: Boolean = true
+ ) {
+ val shouldStop =
+ forceStop || heartRateSessionRequested || heartRateStartCommandSent
+ heartRateSessionRequested = false
+ heartRateStartCommandSent = false
+ _heartRateStreaming.value = false
+
+ if (sendStopFrame && shouldStop &&
+ BluetoothConnectionManager.aacpSocket?.isConnected == true
+ ) {
+ aacpManager.sendHeartRateStopFrame()
+ }
+ }
+
+ private fun stopHeartRateMonitoring(
+ forceStop: Boolean = false,
+ sendStopFrame: Boolean = true
+ ) {
+ synchronized(heartRateLock) {
+ val jobWasActive = heartRateStartJob?.isActive == true
+ heartRateStartJob?.cancel()
+ heartRateStartJob = null
+ lastValidHeartRateSampleElapsedRealtime = null
+ stopHeartRateSessionLocked(
+ forceStop = forceStop || jobWasActive,
+ sendStopFrame = sendStopFrame
+ )
+ }
+ }
+
+ private fun handleHeartRateDisconnected() {
+ if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync()
+ stopHeartRateMonitoring(sendStopFrame = false)
+ }
+
var isHeadTrackingActive = false
fun startHeadTracking() {
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" }