diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9b20a00ba..915d121ea 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
+ alias(libs.plugins.ksp)
// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
@@ -86,6 +87,11 @@ android {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "false")
}
+ create("coexist") {
+ dimension = "env"
+ applicationIdSuffix = ".hearttest"
+ buildConfigField("Boolean", "PLAY_BUILD", "false")
+ }
create("play") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "true")
@@ -129,6 +135,10 @@ 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.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
@@ -158,6 +168,7 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigationevent)
+ testImplementation(libs.junit)
}
aboutLibraries {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0474dfd88..8837a6abd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,10 +27,9 @@
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
-
+
+
+
@@ -41,6 +40,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
.onCreate()
+ workoutRepository.retryPendingHealthConnectExports()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
index 2e7b49a98..883e7358f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
@@ -31,7 +31,6 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
-import android.content.SharedPreferences
import android.os.Bundle
import android.os.IBinder
import android.util.Log
@@ -40,8 +39,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -55,6 +54,7 @@ import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -80,23 +80,11 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
- val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
+ val appSettingsViewModel: AppSettingsViewModel = viewModel()
+ val appSettingsState = appSettingsViewModel.uiState.collectAsState()
- val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
- when (key) {
- "m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
- }
- }
-
- DisposableEffect(Unit) {
- sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
- onDispose {
- sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
- }
- }
LibrePodsTheme(
- m3eEnabled = m3eEnabled.value
+ m3eEnabled = appSettingsState.value.m3eEnabled
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt
index ac6d356b7..36fd784e0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt
@@ -20,7 +20,9 @@
package me.kavishdevar.librepods.bluetooth
+import android.os.SystemClock
import android.util.Log
+import kotlinx.coroutines.delay
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.CustomEq
import java.nio.ByteBuffer
@@ -34,6 +36,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 +65,24 @@ 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)
+
+ private const val HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS = 10_000L
+ private const val HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD = 10
+ private const val HEART_RATE_DIAGNOSTIC_COUNT_LIMIT = 1_000
+
data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers, val value: ByteArray
) {
@@ -235,6 +256,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 +302,15 @@ class AACPManager {
}
private var callback: PacketCallback? = null
+ private val heartRateDecoder = RtBuddyHeartRateDecoder()
+ private val heartRateControlSession = RtBuddyHeartRateControlSession(heartRateDecoder)
+ private val heartRateDiagnosticLock = Any()
+ private var heartRateAcceptedSampleLogged = false
+ private var heartRateDiagnosticWindowStartedAtMillis = 0L
+ private var heartRateDiagnosticRelatedFrames = 0
+ private var heartRateDiagnosticRejectedFrames = 0
+ private val heartRateDiagnosticRejectionReasons =
+ mutableMapOf()
fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
@@ -306,6 +337,54 @@ class AACPManager {
return sendPacket(createDataPacket(data))
}
+ fun sendHeartRateStartFrame(): Boolean = sendHeartRateControlFrame(start = true)
+
+ fun sendHeartRateStopFrame(): Boolean = sendHeartRateControlFrame(start = false)
+
+ suspend fun awaitHeartRateServiceResolution(timeoutMillis: Long = 1_500L): Boolean {
+ return waitForHeartRateServiceResolution(
+ decoder = heartRateDecoder,
+ timeoutMillis = timeoutMillis,
+ elapsedRealtimeMillis = SystemClock::elapsedRealtime,
+ pause = { delay(it) }
+ )
+ }
+
+ private fun sendHeartRateControlFrame(start: Boolean): Boolean {
+ val result = if (start) {
+ heartRateControlSession.sendStart(::sendPacket)
+ } else {
+ heartRateControlSession.sendStop(::sendPacket)
+ }
+ if (!result.attempted) {
+ Log.w(
+ TAG,
+ if (start) {
+ "HeartRateService unavailable; refusing to target a non-heart service"
+ } else {
+ "No active RTBuddy heart-rate stream; skipping stop control"
+ }
+ )
+ return false
+ }
+ Log.d(
+ TAG,
+ "Sending RTBuddy heart-rate ${if (start) "start" else "stop"} " +
+ "service=${result.serviceId} " +
+ "source=${if (result.discoveredFromMetadata) "metadata" else "legacy"} " +
+ "sent=${result.sent}"
+ )
+ return result.sent
+ }
+
+ fun sendHeartRateConnectService0(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_0)
+
+ 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 +476,93 @@ class AACPManager {
return opcode + data
}
+ fun receivePacket(packet: ByteArray): Boolean {
+ val heartRateResult = heartRateDecoder.feed(packet)
+ recordHeartRateDecodeDiagnostics(heartRateResult)
+ heartRateResult.samples.forEach { callback?.onHeartRateReceived(it) }
+ heartRateResult.passthroughPackets.forEach(::receiveStandardPacket)
+ return heartRateResult.suppressRawLogging
+ }
+
+ private fun recordHeartRateDecodeDiagnostics(result: HeartRateDecodeResult) {
+ if (result.relatedFrameCount == 0) return
+
+ var acceptedFirstSample = false
+ var rejectionSummary: String? = null
+ synchronized(heartRateDiagnosticLock) {
+ if (result.samples.isNotEmpty() && !heartRateAcceptedSampleLogged) {
+ heartRateAcceptedSampleLogged = true
+ acceptedFirstSample = true
+ }
+ if (result.rejectedFrameCount > 0) {
+ val now = System.currentTimeMillis()
+ if (heartRateDiagnosticWindowStartedAtMillis == 0L) {
+ heartRateDiagnosticWindowStartedAtMillis = now
+ }
+ heartRateDiagnosticRelatedFrames = boundedDiagnosticCount(
+ heartRateDiagnosticRelatedFrames,
+ result.relatedFrameCount
+ )
+ heartRateDiagnosticRejectedFrames = boundedDiagnosticCount(
+ heartRateDiagnosticRejectedFrames,
+ result.rejectedFrameCount
+ )
+ result.rejectionReasons.forEach { (reason, count) ->
+ heartRateDiagnosticRejectionReasons.incrementBounded(reason, count)
+ }
+
+ val shouldLog =
+ now - heartRateDiagnosticWindowStartedAtMillis >=
+ HEART_RATE_DIAGNOSTIC_LOG_INTERVAL_MILLIS ||
+ heartRateDiagnosticRejectedFrames >=
+ HEART_RATE_DIAGNOSTIC_REJECTION_THRESHOLD
+ if (shouldLog) {
+ val reasons = heartRateDiagnosticRejectionReasons.entries
+ .sortedBy { it.key.name }
+ .joinToString(",") { (reason, count) ->
+ "${reason.name.lowercase()}=$count"
+ }
+ rejectionSummary =
+ "RTBuddy heart-rate decode window " +
+ "frames=$heartRateDiagnosticRelatedFrames " +
+ "rejected=$heartRateDiagnosticRejectedFrames reasons=$reasons; " +
+ "raw frame data suppressed"
+ clearHeartRateDiagnosticWindowLocked()
+ }
+ }
+ }
+
+ if (acceptedFirstSample) {
+ Log.d(TAG, "Validated first RTBuddy heart-rate sample for this AACP connection")
+ }
+ rejectionSummary?.let { Log.w(TAG, it) }
+ }
+
+ private fun boundedDiagnosticCount(current: Int, increment: Int): Int =
+ (current.toLong() + increment)
+ .coerceAtMost(HEART_RATE_DIAGNOSTIC_COUNT_LIMIT.toLong())
+ .toInt()
+
+ private fun MutableMap.incrementBounded(key: K, increment: Int) {
+ this[key] = boundedDiagnosticCount(getOrDefault(key, 0), increment)
+ }
+
+ private fun clearHeartRateDiagnosticWindowLocked() {
+ heartRateDiagnosticWindowStartedAtMillis = 0L
+ heartRateDiagnosticRelatedFrames = 0
+ heartRateDiagnosticRejectedFrames = 0
+ heartRateDiagnosticRejectionReasons.clear()
+ }
+
+ private fun resetHeartRateDiagnostics() {
+ synchronized(heartRateDiagnosticLock) {
+ heartRateAcceptedSampleLogged = false
+ clearHeartRateDiagnosticWindowLocked()
+ }
+ }
+
@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 +1303,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 +1327,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 +1438,14 @@ class AACPManager {
)
}
+ private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean =
+ RtBuddyHeartRateControlFrames.isControlFrame(packet)
+
fun disconnected() {
Log.d(TAG, "Disconnected, clearing state")
+ heartRateDecoder.reset()
+ heartRateControlSession.reset()
+ resetHeartRateDiagnostics()
controlCommandStatusList.clear()
controlCommandListeners.clear()
owns = false
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
index 753c4d3d7..588f6d66d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
@@ -65,9 +65,17 @@ class ATTManagerv2 {
}
fun stopReader() {
+ val thread = readerThread
readerRunning.set(false)
- readerThread?.interrupt()
- readerThread = null
+ thread?.interrupt()
+ if (thread != null && thread !== Thread.currentThread()) {
+ try {
+ thread.join(500L)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ if (readerThread === thread) readerThread = null
}
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
@@ -140,6 +148,7 @@ class ATTManagerv2 {
fun disconnected() {
characteristicList.clear()
+ responseQueues.clear()
stopReader()
val socket = BluetoothConnectionManager.attSocket?: return
try {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt
index 52fa05512..7b3e345e2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt
@@ -29,6 +29,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
+import android.os.SystemClock
import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher
@@ -71,11 +72,13 @@ class BLEManager(private val context: Context) {
fun onLidStateChanged(lidOpen: Boolean)
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
fun onBatteryChanged(device: AirPodsStatus)
+ fun onVerifiedRssi(rssi: Int)
+ fun onScanError(errorCode: Int)
fun onDeviceDisappeared()
}
private var mBluetoothLeScanner: BluetoothLeScanner? = null
- private var mScanCallback: ScanCallback? = null
+ @Volatile private var mScanCallback: ScanCallback? = null
private var airPodsStatusListener: AirPodsStatusListener? = null
private val deviceStatusMap = mutableMapOf()
private val verifiedAddresses = mutableSetOf()
@@ -83,6 +86,11 @@ class BLEManager(private val context: Context) {
private var currentGlobalLidState: Boolean? = null
private var lastBroadcastTime: Long = 0
private val processedAddresses = mutableSetOf()
+ @Volatile private var finderScanMode = false
+ @Volatile private var finderScanStartedAt = 0L
+ @Volatile private var lastFinderVerifiedRssiAt = 0L
+ @Volatile private var lastFinderScanRestartAt = 0L
+ @Volatile private var scanGeneration = 0L
private val lastValidCaseBatteryMap = mutableMapOf()
private val modelNames = mapOf(
@@ -119,21 +127,59 @@ class BLEManager(private val context: Context) {
}
}
+ /**
+ * OxygenOS occasionally leaves an unfiltered BLE scan registered but stops delivering its
+ * callbacks. Finder mode is short-lived, so recover that state automatically instead of
+ * forcing the user to leave and re-enter the screen.
+ */
+ private val finderScanWatchdogRunnable = object : Runnable {
+ override fun run() {
+ if (!finderScanMode) return
+ val now = SystemClock.elapsedRealtime()
+ val lastSignal = lastFinderVerifiedRssiAt.takeIf { it > 0L } ?: finderScanStartedAt
+ val stalled = now - lastSignal >= FINDER_SCAN_STALL_TIMEOUT_MS
+ val canRestart = now - lastFinderScanRestartAt >= FINDER_SCAN_RESTART_COOLDOWN_MS
+ if (stalled && canRestart) {
+ lastFinderScanRestartAt = now
+ Log.w(TAG, "Finder BLE scan stalled; restarting scanner")
+ if (!startScanning(scanAllAdvertisementsForFinder = true)) {
+ airPodsStatusListener?.onScanError(ScanCallback.SCAN_FAILED_INTERNAL_ERROR)
+ }
+ return
+ }
+ cleanupHandler.postDelayed(this, FINDER_WATCHDOG_INTERVAL_MS)
+ }
+ }
+
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
airPodsStatusListener = listener
}
@SuppressLint("MissingPermission")
- fun startScanning() {
+ @Synchronized
+ fun startScanning(
+ scanAllAdvertisementsForFinder: Boolean = false,
+ resetFinderWatchdog: Boolean = false,
+ ): Boolean {
+ val generation = ++scanGeneration
try {
Log.d(TAG, "Starting BLE scanner")
+ finderScanMode = scanAllAdvertisementsForFinder
+ if (scanAllAdvertisementsForFinder) {
+ finderScanStartedAt = SystemClock.elapsedRealtime()
+ lastFinderVerifiedRssiAt = 0L
+ if (resetFinderWatchdog) lastFinderScanRestartAt = 0L
+ } else {
+ cleanupHandler.removeCallbacks(finderScanWatchdogRunnable)
+ }
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
if (btAdapter == null) {
Log.d(TAG, "No Bluetooth adapter available")
- return
+ finderScanMode = false
+ return false
}
if (mBluetoothLeScanner != null && mScanCallback != null) {
@@ -143,38 +189,58 @@ class BLEManager(private val context: Context) {
if (!btAdapter.isEnabled) {
Log.d(TAG, "Bluetooth is disabled")
- return
+ finderScanMode = false
+ return false
}
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
+ val scanner = mBluetoothLeScanner
+ if (scanner == null) {
+ Log.d(TAG, "No Bluetooth LE scanner available")
+ finderScanMode = false
+ return false
+ }
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
- .setReportDelay(500L)
+ // Finder needs each RSSI sample immediately. Some vendor stacks (including
+ // OxygenOS) fail to flush an unfiltered offloaded batch while the app is also
+ // using BLE peripheral mode, leaving the UI on "Waiting for signal" forever.
+ .setReportDelay(if (scanAllAdvertisementsForFinder) 0L else 500L)
.build()
- val manufacturerData = ByteArray(27)
- val manufacturerDataMask = ByteArray(27)
-
- manufacturerData[0] = 7
- manufacturerData[1] = 25
-
- manufacturerDataMask[0] = -1
- manufacturerDataMask[1] = -1
-
- val scanFilter = ScanFilter.Builder()
- .setManufacturerData(76, manufacturerData, manufacturerDataMask)
- .build()
+ val scanFilters = if (scanAllAdvertisementsForFinder) {
+ // Some vendor Bluetooth stacks interpret an empty manufacturer-data filter as
+ // "match an empty payload". Finder therefore uses a genuinely unfiltered
+ // foreground scan and applies Apple company-ID + RPA ownership checks in-process.
+ null
+ } else {
+ val manufacturerData = ByteArray(27).apply {
+ this[0] = 0x07
+ this[1] = 0x19
+ }
+ val manufacturerDataMask = ByteArray(27).apply {
+ this[0] = -1
+ this[1] = -1
+ }
+ listOf(
+ ScanFilter.Builder()
+ .setManufacturerData(76, manufacturerData, manufacturerDataMask)
+ .build()
+ )
+ }
- mScanCallback = object : ScanCallback() {
+ lateinit var callback: ScanCallback
+ callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
- processScanResult(result)
+ if (isCurrentScan(callback, generation)) processScanResult(result)
}
override fun onBatchScanResults(results: List) {
+ if (!isCurrentScan(callback, generation)) return
processedAddresses.clear()
for (result in results) {
processScanResult(result)
@@ -182,22 +248,47 @@ class BLEManager(private val context: Context) {
}
override fun onScanFailed(errorCode: Int) {
+ if (!isCurrentScan(callback, generation)) return
Log.e(TAG, "BLE scan failed with error code: $errorCode")
+ mScanCallback = null
+ finderScanMode = false
+ cleanupHandler.removeCallbacks(finderScanWatchdogRunnable)
+ airPodsStatusListener?.onScanError(errorCode)
}
}
- mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
+ mScanCallback = callback
+ processedAddresses.clear()
+ scanner.startScan(scanFilters, scanSettings, callback)
Log.d(TAG, "BLE scanner started successfully")
+ cleanupHandler.removeCallbacks(cleanupRunnable)
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
+ if (scanAllAdvertisementsForFinder) {
+ cleanupHandler.removeCallbacks(finderScanWatchdogRunnable)
+ cleanupHandler.postDelayed(finderScanWatchdogRunnable, FINDER_WATCHDOG_INTERVAL_MS)
+ }
+ return true
} catch (t: Throwable) {
Log.e(TAG, "Error starting BLE scanner", t)
+ if (scanGeneration == generation) {
+ mScanCallback = null
+ finderScanMode = false
+ cleanupHandler.removeCallbacks(finderScanWatchdogRunnable)
+ if (scanAllAdvertisementsForFinder) {
+ airPodsStatusListener?.onScanError(ScanCallback.SCAN_FAILED_INTERNAL_ERROR)
+ }
+ }
+ return false
}
}
@SuppressLint("MissingPermission")
- fun stopScanning() {
+ @Synchronized
+ fun stopScanning(): Boolean {
+ ++scanGeneration
try {
+ finderScanMode = false
if (mBluetoothLeScanner != null && mScanCallback != null) {
Log.d(TAG, "Stopping BLE scanner")
mBluetoothLeScanner?.stopScan(mScanCallback)
@@ -205,11 +296,17 @@ class BLEManager(private val context: Context) {
}
cleanupHandler.removeCallbacks(cleanupRunnable)
+ cleanupHandler.removeCallbacks(finderScanWatchdogRunnable)
+ return true
} catch (t: Throwable) {
Log.e(TAG, "Error stopping BLE scanner", t)
+ return false
}
}
+ private fun isCurrentScan(callback: ScanCallback, generation: Long): Boolean =
+ scanGeneration == generation && mScanCallback === callback
+
@OptIn(ExperimentalEncodingApi::class)
private fun getEncryptionKeyFromPreferences(): ByteArray? {
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
@@ -254,12 +351,7 @@ class BLEManager(private val context: Context) {
val scanRecord = result.scanRecord ?: return
val address = result.device.address
- if (processedAddresses.contains(address)) {
- return
- }
-
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
- if (manufacturerData.size <= 20) return
if (!verifiedAddresses.contains(address)) {
val irk = getIrkFromPreferences()
@@ -270,6 +362,19 @@ class BLEManager(private val context: Context) {
Log.d(TAG, "RPA verified and added to trusted list: $address")
}
+ // RSSI is useful on every verified advertisement. Keep the existing status-message
+ // de-duplication below so finder updates do not increase unrelated status callbacks.
+ if (finderScanMode) lastFinderVerifiedRssiAt = SystemClock.elapsedRealtime()
+ airPodsStatusListener?.onVerifiedRssi(result.rssi)
+ if (processedAddresses.contains(address)) return
+
+ // RSSI finding can use any advertisement from the verified rotating address. The
+ // battery/lid parser below is specific to Apple's legacy 0x07/0x19 payload.
+ if (manufacturerData.size <= 20 ||
+ manufacturerData[0] != 0x07.toByte() ||
+ manufacturerData[1] != 0x19.toByte()
+ ) return
+
processedAddresses.add(address)
lastBroadcastTime = System.currentTimeMillis()
@@ -491,6 +596,9 @@ class BLEManager(private val context: Context) {
companion object {
private const val TAG = "AirPodsBLE"
+ private const val FINDER_WATCHDOG_INTERVAL_MS = 3_000L
+ private const val FINDER_SCAN_STALL_TIMEOUT_MS = 8_000L
+ private const val FINDER_SCAN_RESTART_COOLDOWN_MS = 15_000L
private const val CLEANUP_INTERVAL_MS = 10000L
private const val STALE_DEVICE_TIMEOUT_MS = 15000L
private const val LID_CLOSE_TIMEOUT_MS = 2500L
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt
new file mode 100644
index 000000000..1265faa30
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateBlePeripheral.kt
@@ -0,0 +1,548 @@
+package me.kavishdevar.librepods.bluetooth
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattServer
+import android.bluetooth.BluetoothGattServerCallback
+import android.bluetooth.BluetoothGattService
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.bluetooth.BluetoothStatusCodes
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.BluetoothLeAdvertiser
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.ParcelUuid
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.util.UUID
+
+enum class HeartRateBlePeripheralStatus {
+ DISABLED,
+ STARTING,
+ ADVERTISING,
+ PERMISSION_REQUIRED,
+ BLUETOOTH_OFF,
+ UNSUPPORTED,
+ ERROR
+}
+
+data class HeartRateBlePeripheralState(
+ val enabled: Boolean = false,
+ val status: HeartRateBlePeripheralStatus = HeartRateBlePeripheralStatus.DISABLED,
+ val connectedDeviceCount: Int = 0,
+ val subscribedDeviceCount: Int = 0,
+ val lastError: String? = null
+)
+
+/**
+ * Opt-in Bluetooth SIG Heart Rate Service peripheral.
+ *
+ * The caller is responsible for forwarding only validated samples. This class never reads the
+ * AirPods transport directly and does not start heart-rate monitoring on its own.
+ */
+class HeartRateBlePeripheral(private val context: Context) {
+ private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
+ private val lock = Any()
+ private val connectedDevices = mutableSetOf()
+ private val subscribedDevices = mutableSetOf()
+ private val notificationInFlight = mutableSetOf()
+
+ private var gattServer: BluetoothGattServer? = null
+ private var advertiser: BluetoothLeAdvertiser? = null
+ private var advertising = false
+ private var starting = false
+ private var requestedEnabled = false
+
+ private val heartRateMeasurement = BluetoothGattCharacteristic(
+ HEART_RATE_MEASUREMENT_UUID,
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY,
+ 0
+ ).apply {
+ addDescriptor(
+ BluetoothGattDescriptor(
+ CLIENT_CHARACTERISTIC_CONFIGURATION_UUID,
+ BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
+ )
+ )
+ }
+
+ private val bodySensorLocation = BluetoothGattCharacteristic(
+ BODY_SENSOR_LOCATION_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ
+ )
+
+ private val heartRateService = BluetoothGattService(
+ HEART_RATE_SERVICE_UUID,
+ BluetoothGattService.SERVICE_TYPE_PRIMARY
+ ).apply {
+ addCharacteristic(heartRateMeasurement)
+ addCharacteristic(bodySensorLocation)
+ }
+
+ private val _state = MutableStateFlow(HeartRateBlePeripheralState())
+ val state: StateFlow = _state
+
+ private val advertiseCallback = object : AdvertiseCallback() {
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
+ synchronized(lock) {
+ if (!requestedEnabled) return
+ starting = false
+ advertising = true
+ publishStateLocked(HeartRateBlePeripheralStatus.ADVERTISING, null)
+ }
+ }
+
+ override fun onStartFailure(errorCode: Int) {
+ synchronized(lock) {
+ advertising = false
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.ERROR,
+ "BLE advertising failed: ${advertiseErrorName(errorCode)}"
+ )
+ }
+ stopResources(keepRequestedEnabled = true)
+ }
+ }
+
+ private val gattCallback = object : BluetoothGattServerCallback() {
+ override fun onServiceAdded(status: Int, service: BluetoothGattService) {
+ if (service.uuid != HEART_RATE_SERVICE_UUID) return
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ synchronized(lock) {
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.ERROR,
+ "Could not add Heart Rate Service (GATT status $status)"
+ )
+ }
+ stopResources(keepRequestedEnabled = true)
+ return
+ }
+ startAdvertisingAfterServiceAdded()
+ }
+
+ override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
+ synchronized(lock) {
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTED -> connectedDevices.add(device)
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ connectedDevices.remove(device)
+ subscribedDevices.remove(device)
+ notificationInFlight.remove(device)
+ }
+ }
+ publishStateLocked()
+ }
+ }
+
+ override fun onCharacteristicReadRequest(
+ device: BluetoothDevice,
+ requestId: Int,
+ offset: Int,
+ characteristic: BluetoothGattCharacteristic
+ ) {
+ when (characteristic.uuid) {
+ BODY_SENSOR_LOCATION_UUID -> sendReadResponse(
+ device = device,
+ requestId = requestId,
+ offset = offset,
+ fullValue = byteArrayOf(BODY_SENSOR_LOCATION_EAR_LOBE)
+ )
+ else -> sendResponse(
+ device,
+ requestId,
+ BluetoothGatt.GATT_READ_NOT_PERMITTED,
+ 0,
+ null
+ )
+ }
+ }
+
+ override fun onCharacteristicWriteRequest(
+ device: BluetoothDevice,
+ requestId: Int,
+ characteristic: BluetoothGattCharacteristic,
+ preparedWrite: Boolean,
+ responseNeeded: Boolean,
+ offset: Int,
+ value: ByteArray
+ ) {
+ if (responseNeeded) {
+ sendResponse(
+ device,
+ requestId,
+ BluetoothGatt.GATT_WRITE_NOT_PERMITTED,
+ 0,
+ null
+ )
+ }
+ }
+
+ override fun onDescriptorReadRequest(
+ device: BluetoothDevice,
+ requestId: Int,
+ offset: Int,
+ descriptor: BluetoothGattDescriptor
+ ) {
+ if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIGURATION_UUID ||
+ descriptor.characteristic.uuid != HEART_RATE_MEASUREMENT_UUID
+ ) {
+ sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null)
+ return
+ }
+ val value = synchronized(lock) {
+ if (subscribedDevices.contains(device)) {
+ BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ } else {
+ BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
+ }
+ }
+ sendReadResponse(device, requestId, offset, value)
+ }
+
+ override fun onDescriptorWriteRequest(
+ device: BluetoothDevice,
+ requestId: Int,
+ descriptor: BluetoothGattDescriptor,
+ preparedWrite: Boolean,
+ responseNeeded: Boolean,
+ offset: Int,
+ value: ByteArray
+ ) {
+ val isHeartRateCccd = descriptor.uuid == CLIENT_CHARACTERISTIC_CONFIGURATION_UUID &&
+ descriptor.characteristic.uuid == HEART_RATE_MEASUREMENT_UUID
+ val status = when {
+ !isHeartRateCccd -> BluetoothGatt.GATT_WRITE_NOT_PERMITTED
+ preparedWrite -> BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED
+ offset != 0 -> BluetoothGatt.GATT_INVALID_OFFSET
+ value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> {
+ synchronized(lock) {
+ subscribedDevices.add(device)
+ publishStateLocked()
+ }
+ BluetoothGatt.GATT_SUCCESS
+ }
+ value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> {
+ synchronized(lock) {
+ subscribedDevices.remove(device)
+ notificationInFlight.remove(device)
+ publishStateLocked()
+ }
+ BluetoothGatt.GATT_SUCCESS
+ }
+ else -> BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED
+ }
+ if (responseNeeded) sendResponse(device, requestId, status, 0, null)
+ }
+
+ override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
+ sendResponse(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED, 0, null)
+ }
+
+ override fun onNotificationSent(device: BluetoothDevice, status: Int) {
+ synchronized(lock) {
+ notificationInFlight.remove(device)
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Log.w(TAG, "Heart-rate notification failed for ${device.address}: $status")
+ }
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun setEnabled(enabled: Boolean) {
+ if (enabled) start() else stop()
+ }
+
+ @SuppressLint("MissingPermission")
+ fun start() {
+ synchronized(lock) {
+ requestedEnabled = true
+ if (advertising || starting || gattServer != null) {
+ publishStateLocked(if (advertising) HeartRateBlePeripheralStatus.ADVERTISING else HeartRateBlePeripheralStatus.STARTING)
+ return
+ }
+ starting = true
+ publishStateLocked(HeartRateBlePeripheralStatus.STARTING, null)
+ }
+
+ val missingPermission = when {
+ context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED -> Manifest.permission.BLUETOOTH_CONNECT
+ context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADVERTISE) != PackageManager.PERMISSION_GRANTED -> Manifest.permission.BLUETOOTH_ADVERTISE
+ else -> null
+ }
+ if (missingPermission != null) {
+ synchronized(lock) {
+ starting = false
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED,
+ "Nearby devices permission is required for BLE heart-rate sharing"
+ )
+ }
+ return
+ }
+
+ val adapter = bluetoothManager.adapter
+ if (adapter == null) {
+ synchronized(lock) { starting = false; publishStateLocked(HeartRateBlePeripheralStatus.UNSUPPORTED, "Bluetooth is not supported") }
+ return
+ }
+ if (!adapter.isEnabled) {
+ synchronized(lock) { starting = false; publishStateLocked(HeartRateBlePeripheralStatus.BLUETOOTH_OFF, "Bluetooth is turned off") }
+ return
+ }
+ if (!adapter.isMultipleAdvertisementSupported) {
+ synchronized(lock) {
+ starting = false
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.UNSUPPORTED,
+ "This Bluetooth chipset does not support LE advertising"
+ )
+ }
+ return
+ }
+ val leAdvertiser = adapter.bluetoothLeAdvertiser
+ if (leAdvertiser == null) {
+ synchronized(lock) {
+ starting = false
+ publishStateLocked(HeartRateBlePeripheralStatus.UNSUPPORTED, "BLE advertising is unavailable")
+ }
+ return
+ }
+
+ val server = try {
+ bluetoothManager.openGattServer(context, gattCallback)
+ } catch (_: SecurityException) {
+ synchronized(lock) {
+ starting = false
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED,
+ "Nearby devices permission was revoked"
+ )
+ }
+ return
+ } catch (_: Throwable) {
+ null
+ }
+ if (server == null) {
+ synchronized(lock) {
+ starting = false
+ publishStateLocked(HeartRateBlePeripheralStatus.ERROR, "Could not open a local GATT server")
+ }
+ return
+ }
+
+ synchronized(lock) {
+ advertiser = leAdvertiser
+ gattServer = server
+ publishStateLocked(HeartRateBlePeripheralStatus.STARTING, null)
+ }
+ val serviceAdded = try {
+ server.addService(heartRateService)
+ } catch (_: SecurityException) {
+ synchronized(lock) {
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED,
+ "Nearby devices permission was revoked"
+ )
+ }
+ false
+ } catch (_: Throwable) {
+ false
+ }
+ if (!serviceAdded) {
+ if (_state.value.status != HeartRateBlePeripheralStatus.PERMISSION_REQUIRED) {
+ synchronized(lock) {
+ publishStateLocked(HeartRateBlePeripheralStatus.ERROR, "Could not register Heart Rate Service")
+ }
+ }
+ stopResources(keepRequestedEnabled = true)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun startAdvertisingAfterServiceAdded() {
+ val localAdvertiser = synchronized(lock) {
+ if (!requestedEnabled || advertising) return
+ advertiser
+ } ?: return
+ val settings = AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+ .setConnectable(true)
+ .setTimeout(0)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
+ .build()
+ val data = AdvertiseData.Builder()
+ .setIncludeDeviceName(false)
+ .addServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
+ .build()
+ try {
+ localAdvertiser.startAdvertising(settings, data, advertiseCallback)
+ } catch (securityException: SecurityException) {
+ synchronized(lock) {
+ publishStateLocked(HeartRateBlePeripheralStatus.PERMISSION_REQUIRED, "Bluetooth advertise permission was revoked")
+ }
+ stopResources(keepRequestedEnabled = true)
+ } catch (t: Throwable) {
+ synchronized(lock) {
+ publishStateLocked(HeartRateBlePeripheralStatus.ERROR, t.message ?: "BLE advertising could not start")
+ }
+ stopResources(keepRequestedEnabled = true)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun stop() {
+ stopResources(keepRequestedEnabled = false)
+ synchronized(lock) {
+ publishStateLocked(HeartRateBlePeripheralStatus.DISABLED, null)
+ }
+ }
+
+ /** Called only with HeartRateMonitor's published/validated samples. */
+ fun onValidatedSample(sample: HeartRateSample) {
+ val value = HeartRateMeasurementEncoder.encodeBpm(sample.bpm)
+ val targets = synchronized(lock) {
+ if (!requestedEnabled || !advertising) return
+ subscribedDevices.filterTo(mutableListOf()) { connectedDevices.contains(it) }
+ }
+ targets.forEach { sendNotification(it, value) }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun sendNotification(device: BluetoothDevice, value: ByteArray) {
+ val server = synchronized(lock) {
+ if (!requestedEnabled || !subscribedDevices.contains(device)) return
+ // Heart Rate Measurement is time-sensitive. Do not queue old BPM values while Android
+ // is still sending a prior notification for this central.
+ if (notificationInFlight.contains(device)) return
+ gattServer
+ } ?: return
+
+ val result = try {
+ server.notifyCharacteristicChanged(device, heartRateMeasurement, false, value)
+ } catch (_: SecurityException) {
+ synchronized(lock) {
+ publishStateLocked(
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED,
+ "Nearby devices permission was revoked"
+ )
+ }
+ stopResources(keepRequestedEnabled = true)
+ return
+ } catch (_: Throwable) {
+ BluetoothStatusCodes.ERROR_UNKNOWN
+ }
+ synchronized(lock) {
+ if (result == BluetoothStatusCodes.SUCCESS) {
+ notificationInFlight.add(device)
+ } else {
+ Log.w(TAG, "Could not queue heart-rate notification for ${device.address}: $result")
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun sendReadResponse(
+ device: BluetoothDevice,
+ requestId: Int,
+ offset: Int,
+ fullValue: ByteArray
+ ) {
+ if (offset < 0 || offset > fullValue.size) {
+ sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, 0, null)
+ return
+ }
+ sendResponse(
+ device,
+ requestId,
+ BluetoothGatt.GATT_SUCCESS,
+ offset,
+ fullValue.copyOfRange(offset, fullValue.size)
+ )
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun sendResponse(
+ device: BluetoothDevice,
+ requestId: Int,
+ status: Int,
+ offset: Int,
+ value: ByteArray?
+ ) {
+ try {
+ gattServer?.sendResponse(device, requestId, status, offset, value)
+ } catch (t: Throwable) {
+ Log.w(TAG, "Could not send GATT response", t)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun stopResources(keepRequestedEnabled: Boolean) {
+ val localAdvertiser: BluetoothLeAdvertiser?
+ val localServer: BluetoothGattServer?
+ synchronized(lock) {
+ requestedEnabled = keepRequestedEnabled
+ starting = false
+ advertising = false
+ localAdvertiser = advertiser
+ localServer = gattServer
+ advertiser = null
+ gattServer = null
+ connectedDevices.clear()
+ subscribedDevices.clear()
+ notificationInFlight.clear()
+ }
+ try {
+ localAdvertiser?.stopAdvertising(advertiseCallback)
+ } catch (_: Throwable) {
+ }
+ try {
+ localServer?.clearServices()
+ } catch (_: Throwable) {
+ }
+ try {
+ localServer?.close()
+ } catch (_: Throwable) {
+ }
+ }
+
+ private fun publishStateLocked(
+ status: HeartRateBlePeripheralStatus = _state.value.status,
+ error: String? = _state.value.lastError
+ ) {
+ _state.value = HeartRateBlePeripheralState(
+ enabled = requestedEnabled,
+ status = status,
+ connectedDeviceCount = connectedDevices.size,
+ subscribedDeviceCount = subscribedDevices.size,
+ lastError = error
+ )
+ }
+
+ private fun advertiseErrorName(code: Int): String = when (code) {
+ AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "already started"
+ AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "data too large"
+ AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "feature unsupported"
+ AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "internal error"
+ AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "too many advertisers"
+ else -> "error $code"
+ }
+
+ companion object {
+ private const val TAG = "HeartRateBlePeripheral"
+ val HEART_RATE_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
+ val HEART_RATE_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
+ val BODY_SENSOR_LOCATION_UUID: UUID = UUID.fromString("00002a38-0000-1000-8000-00805f9b34fb")
+ val CLIENT_CHARACTERISTIC_CONFIGURATION_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ const val BODY_SENSOR_LOCATION_EAR_LOBE: Byte = 0x05
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt
new file mode 100644
index 000000000..b89df5784
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoder.kt
@@ -0,0 +1,17 @@
+package me.kavishdevar.librepods.bluetooth
+
+/** Encodes the Bluetooth SIG Heart Rate Measurement characteristic (0x2A37). */
+object HeartRateMeasurementEncoder {
+ fun encodeBpm(bpm: Int): ByteArray {
+ require(bpm in 0..0xFFFF) { "Heart rate must fit an unsigned 16-bit value" }
+ return if (bpm <= 0xFF) {
+ byteArrayOf(0x00, bpm.toByte())
+ } else {
+ byteArrayOf(
+ 0x01, // Flags: Heart Rate Value Format = UINT16; all optional fields absent.
+ (bpm and 0xFF).toByte(),
+ ((bpm ushr 8) and 0xFF).toByte()
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt
new file mode 100644
index 000000000..e76cfc0e6
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/RtBuddyHeartRate.kt
@@ -0,0 +1,769 @@
+/*
+ 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
+
+import android.os.SystemClock
+
+/** A validated heart-rate sample decoded from an RTBuddy SensorDataWX frame. */
+data class HeartRateSample(
+ val bpm: Int,
+ val sequence: Int,
+ val receivedAtMillis: Long,
+ val receivedAtElapsedRealtime: Long = SystemClock.elapsedRealtime()
+)
+
+internal enum class HeartRateRejectionReason {
+ UNSUPPORTED_LOG_TYPE,
+ MISSING_HEART_RATE_PAYLOAD,
+ UNRECOGNIZED_HEART_RATE_PAYLOAD
+}
+
+internal data class HeartRateDecodeResult(
+ val samples: List = emptyList(),
+ val relatedFrameCount: Int = 0,
+ val rejectionReasons: Map = emptyMap(),
+ val suppressRawLogging: Boolean = false,
+ val passthroughPackets: List = emptyList()
+) {
+ val rejectedFrameCount: Int
+ get() = rejectionReasons.values.sum()
+}
+
+internal data class HeartRateServiceResolution(
+ val serviceId: Int?,
+ val discoveredFromMetadata: Boolean
+)
+
+internal suspend fun waitForHeartRateServiceResolution(
+ decoder: RtBuddyHeartRateDecoder,
+ timeoutMillis: Long,
+ elapsedRealtimeMillis: () -> Long,
+ pause: suspend (Long) -> Unit
+): Boolean {
+ val deadline = elapsedRealtimeMillis() + timeoutMillis.coerceAtLeast(0L)
+ while (true) {
+ val resolution = decoder.heartRateServiceResolution()
+ if (resolution.discoveredFromMetadata) return true
+ if (elapsedRealtimeMillis() >= deadline) return resolution.serviceId != null
+ pause(25L)
+ }
+}
+
+/**
+ * Reassembles RTBuddy frames and extracts the verified HEARTRATE SensorDataWX payload.
+ *
+ * The parser deliberately keeps the protocol checks that prevent control/startup frames from being
+ * interpreted as BPM: live log type, the metadata-advertised HeartRateService, exact 18-byte
+ * payload, known status trailer, and the validated physiological range. Service 19 remains a
+ * legacy fallback only when metadata has not assigned it to another accessory service.
+ */
+internal class RtBuddyHeartRateDecoder(
+ private val wallClockMillis: () -> Long = System::currentTimeMillis,
+ private val elapsedRealtimeMillis: () -> Long = SystemClock::elapsedRealtime
+) {
+ private var carry = ByteArray(0)
+ private var discoveredHeartRateServiceId: Int? = null
+ private val explicitlyNonHeartRateServiceIds = mutableSetOf()
+
+ @Synchronized
+ fun reset() {
+ carry = ByteArray(0)
+ discoveredHeartRateServiceId = null
+ explicitlyNonHeartRateServiceIds.clear()
+ }
+
+ /** The service to target for this connection, or null when metadata rules out the fallback. */
+ @Synchronized
+ fun heartRateServiceIdForControl(): Int? =
+ discoveredHeartRateServiceId
+ ?: LEGACY_HEART_RATE_SERVICE.takeUnless(explicitlyNonHeartRateServiceIds::contains)
+
+ @Synchronized
+ fun discoveredHeartRateServiceId(): Int? = discoveredHeartRateServiceId
+
+ @Synchronized
+ fun heartRateServiceResolution(): HeartRateServiceResolution {
+ val discovered = discoveredHeartRateServiceId
+ return HeartRateServiceResolution(
+ serviceId = discovered
+ ?: LEGACY_HEART_RATE_SERVICE.takeUnless(explicitlyNonHeartRateServiceIds::contains),
+ discoveredFromMetadata = discovered != null
+ )
+ }
+
+ @Synchronized
+ fun feed(chunk: ByteArray): HeartRateDecodeResult {
+ if (chunk.isEmpty()) return HeartRateDecodeResult()
+
+ val hadCarry = carry.isNotEmpty()
+ val carryWasSensitive = carry.size >= MIN_SENSITIVE_PREFIX_LENGTH
+ val data = if (carry.isEmpty()) chunk else carry + chunk
+ carry = ByteArray(0)
+
+ val samples = mutableListOf()
+ val passthroughPackets = mutableListOf()
+ val rejectionReasons = mutableMapOf()
+ var relatedFrameCount = 0
+ var suppressRawLogging = carryWasSensitive
+ var cursor = 0
+
+ while (cursor < data.size) {
+ val frameOffset = data.indexOfPrefix(RTBUDDY_FRAME_PREFIX, cursor)
+ if (frameOffset < 0) {
+ val suffixLength = data.longestSuffixMatchingPrefix(
+ prefix = RTBUDDY_FRAME_PREFIX,
+ startIndex = cursor
+ )
+ val passthroughEnd = data.size - suffixLength
+ if (passthroughEnd > cursor) {
+ passthroughPackets += data.copyOfRange(cursor, passthroughEnd)
+ }
+ if (suffixLength > 0) {
+ carry = data.copyOfRange(passthroughEnd, data.size)
+ suppressRawLogging = suppressRawLogging ||
+ suffixLength >= MIN_SENSITIVE_PREFIX_LENGTH
+ }
+ break
+ }
+
+ if (frameOffset > cursor) {
+ passthroughPackets += data.copyOfRange(cursor, frameOffset)
+ }
+ if (data.size - frameOffset < AACP_RTBUDDY_HEADER_LENGTH) {
+ carry = data.copyOfRange(frameOffset, data.size)
+ suppressRawLogging = true
+ break
+ }
+
+ val payloadLength = data.readLe16(frameOffset + 10)
+ if (payloadLength > MAX_RTBUDDY_PAYLOAD_LENGTH) {
+ // The exact SensorDataWX prefix is sensitive, but the declared length is untrusted.
+ suppressRawLogging = true
+ break
+ }
+
+ val frameLength = AACP_RTBUDDY_HEADER_LENGTH + payloadLength
+ if (data.size - frameOffset < frameLength) {
+ carry = data.copyOfRange(frameOffset, data.size)
+ suppressRawLogging = true
+ break
+ }
+
+ val frame = data.copyOfRange(frameOffset, frameOffset + frameLength)
+ val classification = classifyFrame(frame)
+ if (classification.related || classification.consumed) {
+ if (classification.related) {
+ relatedFrameCount++
+ classification.rejectionReason?.let { rejectionReasons.increment(it) }
+ classification.sample?.let(samples::add)
+ }
+ suppressRawLogging = true
+ } else {
+ passthroughPackets += frame
+ if (hadCarry && frameOffset == 0) suppressRawLogging = true
+ }
+ cursor = frameOffset + frameLength
+ }
+
+ return HeartRateDecodeResult(
+ samples = samples,
+ relatedFrameCount = relatedFrameCount,
+ rejectionReasons = rejectionReasons,
+ suppressRawLogging = suppressRawLogging,
+ passthroughPackets = passthroughPackets
+ )
+ }
+
+ private fun classifyFrame(frame: ByteArray): FrameClassification {
+ val topLevel = parseProtoMessage(
+ frame,
+ AACP_RTBUDDY_HEADER_LENGTH,
+ frame.size
+ ) ?: return FrameClassification()
+
+ val metadataRecords = updateServiceMetadata(frame, topLevel)
+
+ val sequence = topLevel.firstVarint(FIELD_SEQUENCE)?.toInt() ?: -1
+ val logType = topLevel.firstVarint(FIELD_LOG_TYPE)?.toInt() ?: -1
+ val commands = mutableListOf()
+
+ topLevel.fields.forEach { field ->
+ if (field.wireType == WIRE_LENGTH_DELIMITED &&
+ field.number in SENSOR_DATA_COMMAND_FIELDS &&
+ MetadataRecord(field.valueStart, field.valueEnd) !in metadataRecords &&
+ commands.size < MAX_COMMANDS_PER_FRAME
+ ) {
+ collectHeartRateCommands(
+ data = frame,
+ start = field.valueStart,
+ end = field.valueEnd,
+ depth = 0,
+ commands = commands
+ )
+ }
+ }
+
+ if (commands.isEmpty()) {
+ return FrameClassification(consumed = metadataRecords.isNotEmpty())
+ }
+ if (logType !in LIVE_SENSOR_DATA_LOG_TYPES) {
+ return FrameClassification(
+ related = true,
+ rejectionReason = HeartRateRejectionReason.UNSUPPORTED_LOG_TYPE
+ )
+ }
+
+ val payloads = commands.flatMap { it.payloadCandidates }
+ val acceptedPayload = payloads.firstOrNull(::isValidHeartRatePayload)
+ ?: return FrameClassification(
+ related = true,
+ rejectionReason = if (payloads.isEmpty()) {
+ HeartRateRejectionReason.MISSING_HEART_RATE_PAYLOAD
+ } else {
+ HeartRateRejectionReason.UNRECOGNIZED_HEART_RATE_PAYLOAD
+ }
+ )
+
+ return FrameClassification(
+ related = true,
+ sample = HeartRateSample(
+ bpm = acceptedPayload.unsignedByteAt(HEART_RATE_BPM_OFFSET),
+ sequence = sequence,
+ receivedAtMillis = wallClockMillis(),
+ receivedAtElapsedRealtime = elapsedRealtimeMillis()
+ )
+ )
+ }
+
+ private fun updateServiceMetadata(
+ data: ByteArray,
+ topLevel: ProtoMessage
+ ): Set {
+ val metadataRecords = mutableSetOf()
+ topLevel.fields.forEach { field ->
+ if (field.wireType != WIRE_LENGTH_DELIMITED) return@forEach
+ val serviceRecord = parseProtoMessage(data, field.valueStart, field.valueEnd)
+ ?: return@forEach
+ val serviceId = serviceRecord.firstVarint(FIELD_SERVICE)?.toInt()
+ ?: return@forEach
+ val metadataFields = serviceRecord.fields.filter {
+ it.number == FIELD_SERVICE_METADATA && it.wireType == WIRE_LENGTH_DELIMITED
+ }
+ if (metadataFields.isEmpty()) return@forEach
+
+ val identifiesHeartRate = metadataFields.any { metadata ->
+ data.containsBytes(
+ needle = HEART_RATE_SERVICE_MARKER,
+ start = metadata.valueStart,
+ end = metadata.valueEnd
+ )
+ }
+ val identifiesHostLibHid = metadataFields.any { metadata ->
+ data.containsBytes(
+ needle = HOST_LIB_HID_MARKER,
+ start = metadata.valueStart,
+ end = metadata.valueEnd
+ )
+ }
+
+ if (identifiesHeartRate || identifiesHostLibHid) {
+ metadataRecords += MetadataRecord(field.valueStart, field.valueEnd)
+ }
+ when {
+ // A service explicitly named HostLibHID must never be targeted as heart rate.
+ identifiesHostLibHid -> {
+ explicitlyNonHeartRateServiceIds += serviceId
+ if (discoveredHeartRateServiceId == serviceId) {
+ discoveredHeartRateServiceId = null
+ }
+ }
+
+ identifiesHeartRate && serviceId !in explicitlyNonHeartRateServiceIds -> {
+ discoveredHeartRateServiceId = serviceId
+ }
+ }
+ }
+ return metadataRecords
+ }
+
+ private fun collectHeartRateCommands(
+ data: ByteArray,
+ start: Int,
+ end: Int,
+ depth: Int,
+ commands: MutableList
+ ) {
+ if (depth > MAX_COMMAND_ENVELOPE_DEPTH || commands.size >= MAX_COMMANDS_PER_FRAME) return
+ val message = parseProtoMessage(data, start, end) ?: return
+ val service = message.firstVarint(FIELD_SERVICE)?.toInt()
+
+ if (service != null && isHeartRateService(service)) {
+ val payloads = mutableListOf()
+ message.fields.forEach { field ->
+ if (field.number == FIELD_COMMAND_PAYLOAD &&
+ field.wireType == WIRE_LENGTH_DELIMITED
+ ) {
+ collectPayloadCandidates(
+ data = data,
+ start = field.valueStart,
+ end = field.valueEnd,
+ depth = 0,
+ candidates = payloads
+ )
+ }
+ }
+ commands += HeartRateCommand(payloads)
+ }
+
+ if (depth == MAX_COMMAND_ENVELOPE_DEPTH) return
+ message.fields.forEach { field ->
+ if (field.wireType == WIRE_LENGTH_DELIMITED &&
+ commands.size < MAX_COMMANDS_PER_FRAME
+ ) {
+ collectHeartRateCommands(
+ data = data,
+ start = field.valueStart,
+ end = field.valueEnd,
+ depth = depth + 1,
+ commands = commands
+ )
+ }
+ }
+ }
+
+ private fun isHeartRateService(serviceId: Int): Boolean {
+ val discovered = discoveredHeartRateServiceId
+ if (discovered != null) return serviceId == discovered
+ if (serviceId in explicitlyNonHeartRateServiceIds) return false
+ return serviceId == LEGACY_HEART_RATE_SERVICE
+ }
+
+ private fun collectPayloadCandidates(
+ data: ByteArray,
+ start: Int,
+ end: Int,
+ depth: Int,
+ candidates: MutableList
+ ) {
+ if (candidates.size >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND) return
+
+ val direct = data.copyOfRange(start, end)
+ if (candidates.none(direct::contentEquals)) candidates += direct
+ if (depth >= MAX_PAYLOAD_WRAPPER_DEPTH) return
+
+ val wrapper = parseProtoMessage(data, start, end) ?: return
+ wrapper.fields.forEach { field ->
+ if (field.wireType == WIRE_LENGTH_DELIMITED &&
+ candidates.size < MAX_PAYLOAD_CANDIDATES_PER_COMMAND
+ ) {
+ collectPayloadCandidates(
+ data = data,
+ start = field.valueStart,
+ end = field.valueEnd,
+ depth = depth + 1,
+ candidates = candidates
+ )
+ }
+ }
+ }
+
+ private fun isValidHeartRatePayload(payload: ByteArray): Boolean {
+ if (payload.size != HEART_RATE_PAYLOAD_LENGTH) return false
+ if (payload.unsignedByteAt(HEART_RATE_BPM_OFFSET) !in MIN_BPM..MAX_BPM) return false
+
+ return KNOWN_HEART_RATE_STATUS_TAILS.any { tail ->
+ tail.indices.all { index ->
+ payload[HEART_RATE_STATUS_TAIL_OFFSET + index] == tail[index]
+ }
+ }
+ }
+
+ private fun parseProtoMessage(data: ByteArray, start: Int, end: Int): ProtoMessage? {
+ if (start < 0 || end < start || end > data.size || end - start > MAX_PROTO_MESSAGE_LENGTH) {
+ return null
+ }
+
+ val fields = mutableListOf()
+ var index = start
+ while (index < end) {
+ if (fields.size >= MAX_PROTO_FIELDS) return null
+ val key = readVarint(data, index, end) ?: return null
+ index = key.nextIndex
+
+ val fieldNumber = key.value ushr 3
+ if (fieldNumber <= 0 || fieldNumber > MAX_PROTO_FIELD_NUMBER) return null
+ val wireType = (key.value and 0x07).toInt()
+
+ when (wireType) {
+ WIRE_VARINT -> {
+ val value = readVarint(data, index, end) ?: return null
+ fields += ProtoField(
+ number = fieldNumber.toInt(),
+ wireType = wireType,
+ varintValue = value.value,
+ valueStart = index,
+ valueEnd = value.nextIndex
+ )
+ index = value.nextIndex
+ }
+
+ WIRE_LENGTH_DELIMITED -> {
+ val length = readVarint(data, index, end) ?: return null
+ if (length.value > Int.MAX_VALUE) return null
+ val valueEnd = length.nextIndex + length.value.toInt()
+ if (valueEnd < length.nextIndex || valueEnd > end) return null
+
+ fields += ProtoField(
+ number = fieldNumber.toInt(),
+ wireType = wireType,
+ valueStart = length.nextIndex,
+ valueEnd = valueEnd
+ )
+ index = valueEnd
+ }
+
+ WIRE_FIXED64 -> {
+ if (end - index < 8) return null
+ fields += ProtoField(
+ number = fieldNumber.toInt(),
+ wireType = wireType,
+ valueStart = index,
+ valueEnd = index + 8
+ )
+ index += 8
+ }
+
+ WIRE_FIXED32 -> {
+ if (end - index < 4) return null
+ fields += ProtoField(
+ number = fieldNumber.toInt(),
+ wireType = wireType,
+ valueStart = index,
+ valueEnd = index + 4
+ )
+ index += 4
+ }
+
+ else -> return null
+ }
+ }
+ return ProtoMessage(fields)
+ }
+
+ 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 ByteArray.unsignedByteAt(index: Int): Int = this[index].toInt().and(0xFF)
+
+ private fun MutableMap.increment(key: K) {
+ this[key] = getOrDefault(key, 0) + 1
+ }
+
+ private data class HeartRateCommand(val payloadCandidates: List)
+
+ private data class MetadataRecord(val start: Int, val end: Int)
+
+ private data class ProtoMessage(val fields: List) {
+ fun firstVarint(fieldNumber: Int): Long? = fields.firstOrNull {
+ it.number == fieldNumber && it.wireType == WIRE_VARINT
+ }?.varintValue
+ }
+
+ private data class ProtoField(
+ val number: Int,
+ val wireType: Int,
+ val varintValue: Long? = null,
+ val valueStart: Int,
+ val valueEnd: Int
+ )
+
+ private data class VarintRead(val value: Long, val nextIndex: Int)
+
+ private data class FrameClassification(
+ val related: Boolean = false,
+ val consumed: Boolean = false,
+ val sample: HeartRateSample? = null,
+ val rejectionReason: HeartRateRejectionReason? = null
+ )
+
+ 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
+
+ // Firmware has emitted live records with both log types and different exact status trailers
+ // depending on whether one or both earbuds participate in the session.
+ val LIVE_SENSOR_DATA_LOG_TYPES = setOf(1, 3)
+ val KNOWN_HEART_RATE_STATUS_TAILS = arrayOf(
+ byteArrayOf(0x10, 0x00, 0x00),
+ byteArrayOf(0x10, 0x00, 0x80.toByte()),
+ byteArrayOf(0x20, 0x00, 0x00),
+ byteArrayOf(0x20, 0x80.toByte(), 0x00),
+ byteArrayOf(0x20, 0x02, 0x80.toByte()),
+ byteArrayOf(0x20, 0x82.toByte(), 0x80.toByte())
+ )
+
+ const val FIELD_SEQUENCE = 1
+ const val FIELD_LOG_TYPE = 2
+ const val FIELD_SERVICE = 1
+ const val FIELD_SERVICE_METADATA = 2
+ const val FIELD_COMMAND_PAYLOAD = 3
+ const val LEGACY_HEART_RATE_SERVICE = 19
+ const val HEART_RATE_PAYLOAD_LENGTH = 18
+ const val HEART_RATE_BPM_OFFSET = 1
+ const val HEART_RATE_STATUS_TAIL_OFFSET = 15
+ const val MIN_BPM = 30
+ const val MAX_BPM = 220
+
+ val SENSOR_DATA_COMMAND_FIELDS = setOf(5, 7, 8, 9, 12)
+ const val MAX_COMMAND_ENVELOPE_DEPTH = 3
+ const val MAX_PAYLOAD_WRAPPER_DEPTH = 3
+ const val MAX_COMMANDS_PER_FRAME = 16
+ const val MAX_PAYLOAD_CANDIDATES_PER_COMMAND = 12
+ const val MAX_PROTO_MESSAGE_LENGTH = MAX_RTBUDDY_PAYLOAD_LENGTH
+ const val MAX_PROTO_FIELDS = 96
+ const val MAX_PROTO_FIELD_NUMBER = 4_096L
+
+ const val 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
+ )
+
+ val HEART_RATE_SERVICE_MARKER = "HeartRateService".encodeToByteArray()
+ val HOST_LIB_HID_MARKER = "HostLibHID".encodeToByteArray()
+ }
+}
+
+/** Builds RTBuddy heart-rate controls with a per-connection service and monotonic sequence. */
+internal class RtBuddyHeartRateControlFrames(
+ private val initialSequence: Int = LEGACY_INITIAL_SEQUENCE
+) {
+ private var nextSequence = initialSequence
+
+ @Synchronized
+ fun reset() {
+ nextSequence = initialSequence
+ }
+
+ @Synchronized
+ fun start(serviceId: Int): ByteArray =
+ buildFrame(serviceId, takeSequence(), HEART_RATE_INTERVAL_MICROS)
+
+ @Synchronized
+ fun stop(serviceId: Int): ByteArray =
+ buildFrame(serviceId, takeSequence(), 0)
+
+ private fun takeSequence(): Int {
+ val sequence = nextSequence
+ nextSequence = if (sequence == Int.MAX_VALUE) 0 else sequence + 1
+ return sequence
+ }
+
+ companion object {
+ private const val LEGACY_INITIAL_SEQUENCE = 9_059
+ private const val HEART_RATE_INTERVAL_MICROS = 1_000_000
+
+ private val RTBUDDY_CONTROL_PREFIX = byteArrayOf(
+ 0x04, 0x00, 0x04, 0x00,
+ 0x17, 0x00,
+ 0x00, 0x00, 0x10, 0x00
+ )
+
+ internal fun buildFrame(serviceId: Int, sequence: Int, intervalMicros: Int): ByteArray {
+ require(serviceId in 1..4_096) { "Invalid RTBuddy service ID: $serviceId" }
+ require(sequence >= 0) { "RTBuddy sequence must be non-negative" }
+ require(intervalMicros >= 0) { "Heart-rate interval must be non-negative" }
+
+ val setting = byteArrayOf(0x01) + intervalMicros.toLittleEndian32()
+ val command =
+ protoVarintField(1, serviceId) +
+ protoVarintField(2, 2) +
+ protoBytesField(3, setting)
+ val body =
+ protoVarintField(1, sequence) +
+ protoBytesField(8, command)
+ require(body.size <= 0xFFFF) { "RTBuddy control body is too large" }
+
+ return RTBUDDY_CONTROL_PREFIX + body.size.toLittleEndian16() + body
+ }
+
+ internal fun isControlFrame(packet: ByteArray): Boolean {
+ if (packet.size < RTBUDDY_CONTROL_PREFIX.size + 2 + 7) return false
+ if (!RTBUDDY_CONTROL_PREFIX.indices.all {
+ packet[it] == RTBUDDY_CONTROL_PREFIX[it]
+ }
+ ) return false
+ return packet[packet.lastIndex - 6] == 0x1A.toByte() &&
+ packet[packet.lastIndex - 5] == 0x05.toByte() &&
+ packet[packet.lastIndex - 4] == 0x01.toByte()
+ }
+
+ private fun protoVarintField(fieldNumber: Int, value: Int): ByteArray =
+ encodeVarint((fieldNumber shl 3).toLong()) + encodeVarint(value.toLong())
+
+ private fun protoBytesField(fieldNumber: Int, value: ByteArray): ByteArray =
+ encodeVarint(((fieldNumber shl 3) or 2).toLong()) +
+ encodeVarint(value.size.toLong()) +
+ value
+
+ private fun encodeVarint(value: Long): ByteArray {
+ require(value >= 0) { "Varints must be non-negative" }
+ var remaining = value
+ val result = ArrayList(10)
+ do {
+ var byte = (remaining and 0x7F).toInt()
+ remaining = remaining ushr 7
+ if (remaining != 0L) byte = byte or 0x80
+ result += byte.toByte()
+ } while (remaining != 0L)
+ return result.toByteArray()
+ }
+
+ private fun Int.toLittleEndian16(): ByteArray = byteArrayOf(
+ and(0xFF).toByte(),
+ ushr(8).and(0xFF).toByte()
+ )
+
+ private fun Int.toLittleEndian32(): ByteArray = byteArrayOf(
+ and(0xFF).toByte(),
+ ushr(8).and(0xFF).toByte(),
+ ushr(16).and(0xFF).toByte(),
+ ushr(24).and(0xFF).toByte()
+ )
+ }
+}
+
+internal data class HeartRateControlSendResult(
+ val attempted: Boolean,
+ val sent: Boolean,
+ val serviceId: Int? = null,
+ val discoveredFromMetadata: Boolean = false
+)
+
+/**
+ * Owns the heart-rate service pin and control sequence for one AACP connection.
+ *
+ * A stop is only emitted after a start was successfully written. Failed stops retain the pin so a
+ * retry cannot be redirected by metadata that arrived after the stream began.
+ */
+internal class RtBuddyHeartRateControlSession(
+ private val decoder: RtBuddyHeartRateDecoder,
+ private val frames: RtBuddyHeartRateControlFrames = RtBuddyHeartRateControlFrames()
+) {
+ private var activeServiceId: Int? = null
+ private var activeServiceWasDiscovered = false
+
+ @Synchronized
+ fun sendStart(sender: (ByteArray) -> Boolean): HeartRateControlSendResult =
+ sendControl(start = true, sender = sender)
+
+ @Synchronized
+ fun sendStop(sender: (ByteArray) -> Boolean): HeartRateControlSendResult =
+ sendControl(start = false, sender = sender)
+
+ @Synchronized
+ fun reset() {
+ activeServiceId = null
+ activeServiceWasDiscovered = false
+ frames.reset()
+ }
+
+ private fun sendControl(
+ start: Boolean,
+ sender: (ByteArray) -> Boolean
+ ): HeartRateControlSendResult {
+ val resolution = decoder.heartRateServiceResolution()
+ val serviceId = if (start) {
+ activeServiceId ?: resolution.serviceId?.also {
+ activeServiceId = it
+ activeServiceWasDiscovered = resolution.discoveredFromMetadata
+ }
+ } else {
+ activeServiceId
+ } ?: return HeartRateControlSendResult(attempted = false, sent = false)
+
+ val packet = if (start) frames.start(serviceId) else frames.stop(serviceId)
+ val discoveredFromMetadata = activeServiceWasDiscovered
+ val sent = sender(packet)
+ when {
+ start && !sent && activeServiceId == serviceId -> {
+ activeServiceId = null
+ activeServiceWasDiscovered = false
+ }
+
+ !start && sent && activeServiceId == serviceId -> {
+ activeServiceId = null
+ activeServiceWasDiscovered = false
+ }
+ }
+ return HeartRateControlSendResult(
+ attempted = true,
+ sent = sent,
+ serviceId = serviceId,
+ discoveredFromMetadata = discoveredFromMetadata
+ )
+ }
+}
+
+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) {
+ if (prefix.indices.all { this[start + it] == prefix[it] }) 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) {
+ val start = size - length
+ if ((0 until length).all { this[start + it] == prefix[it] }) return length
+ }
+ return 0
+}
+
+private fun ByteArray.containsBytes(needle: ByteArray, start: Int, end: Int): Boolean {
+ if (needle.isEmpty()) return true
+ if (start < 0 || end > size || start > end || end - start < needle.size) return false
+ val lastStart = end - needle.size
+ for (candidate in start..lastStart) {
+ if (needle.indices.all { this[candidate + it] == needle[it] }) return true
+ }
+ return false
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt
new file mode 100644
index 000000000..f7259e69e
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/HeartRateZones.kt
@@ -0,0 +1,69 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+data class HeartRateZone(
+ val label: String,
+ val minimumPercent: Int?,
+ val maximumPercentExclusive: Int?,
+ val sampleCount: Int,
+)
+
+object HeartRateZones {
+ const val DEFAULT_MAX_HEART_RATE_BPM = 190
+ const val MIN_CONFIGURABLE_MAX_HEART_RATE_BPM = 120
+ const val MAX_CONFIGURABLE_MAX_HEART_RATE_BPM = 240
+
+ fun normalizedMaxHeartRate(value: Int): Int = value.coerceIn(
+ MIN_CONFIGURABLE_MAX_HEART_RATE_BPM,
+ MAX_CONFIGURABLE_MAX_HEART_RATE_BPM,
+ )
+
+ /**
+ * Deterministic five-zone model using percentage of the configured max HR.
+ * Recovery=50-59%, Endurance=60-69%, Tempo=70-79%, Threshold=80-89%, Peak>=90%;
+ * below 50% is separate.
+ * Distribution is by recorded sample count, not inferred time between samples.
+ */
+ fun zoneIndex(bpm: Int, maxHeartRateBpm: Int): Int {
+ val maxHr = normalizedMaxHeartRate(maxHeartRateBpm)
+ val percentage = bpm.toLong() * 100L / maxHr.toLong()
+ return when {
+ percentage < 50L -> 0
+ percentage < 60L -> 1
+ percentage < 70L -> 2
+ percentage < 80L -> 3
+ percentage < 90L -> 4
+ else -> 5
+ }
+ }
+
+ fun distribution(samples: List, maxHeartRateBpm: Int): List {
+ val counts = IntArray(6)
+ samples.forEach { counts[zoneIndex(it.bpm, maxHeartRateBpm)]++ }
+ return listOf(
+ HeartRateZone("Below target", null, 50, counts[0]),
+ HeartRateZone("Recovery", 50, 60, counts[1]),
+ HeartRateZone("Endurance", 60, 70, counts[2]),
+ HeartRateZone("Tempo", 70, 80, counts[3]),
+ HeartRateZone("Threshold", 80, 90, counts[4]),
+ HeartRateZone("Peak", 90, null, counts[5]),
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt
new file mode 100644
index 000000000..321e124fc
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDao.kt
@@ -0,0 +1,165 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/** Aggregate row used directly by UI-facing repository Flows. */
+data class WorkoutSessionSummaryRow(
+ val id: String,
+ val startTimeEpochMillis: Long,
+ val endTimeEpochMillis: Long?,
+ val maxHeartRateBpm: Int,
+ val healthConnectExportState: String,
+ val healthConnectExportMessage: String?,
+ val sampleCount: Long,
+ val latestBpm: Int?,
+ val minBpm: Int?,
+ val avgBpm: Double?,
+ val maxBpm: Int?,
+)
+
+@Dao
+interface WorkoutDao {
+ @Insert(onConflict = OnConflictStrategy.ABORT)
+ suspend fun insertSession(session: WorkoutSessionEntity)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertSample(sample: WorkoutSampleEntity): Long
+
+ @Query("SELECT * FROM workout_sessions WHERE endTimeEpochMillis IS NULL ORDER BY startTimeEpochMillis DESC LIMIT 1")
+ suspend fun getActiveSession(): WorkoutSessionEntity?
+
+ @Query("SELECT * FROM workout_sessions WHERE id = :sessionId LIMIT 1")
+ suspend fun getSession(sessionId: String): WorkoutSessionEntity?
+
+ @Query("DELETE FROM workout_sessions WHERE id = :sessionId")
+ suspend fun deleteSession(sessionId: String): Int
+
+ @Query("SELECT * FROM workout_sessions WHERE endTimeEpochMillis IS NOT NULL AND healthConnectExportState = 'PENDING' ORDER BY startTimeEpochMillis")
+ suspend fun getPendingHealthConnectSessions(): List
+
+ @Query("SELECT * FROM workout_samples WHERE sessionId = :sessionId ORDER BY timestampEpochMillis, id")
+ suspend fun getSamples(sessionId: String): List
+
+ @Query("SELECT * FROM workout_samples WHERE sessionId = :sessionId ORDER BY timestampEpochMillis, id")
+ fun observeSamples(sessionId: String): Flow>
+
+ @Query(
+ """
+ SELECT hr.* FROM workout_samples hr
+ INNER JOIN workout_sessions s ON s.id = hr.sessionId
+ WHERE s.endTimeEpochMillis IS NULL
+ ORDER BY hr.timestampEpochMillis, hr.id
+ """
+ )
+ fun observeActiveSamples(): Flow>
+
+ @Query(
+ """
+ UPDATE workout_sessions
+ SET endTimeEpochMillis = :endTimeEpochMillis,
+ endZoneOffsetSeconds = :endZoneOffsetSeconds,
+ healthConnectExportState = :exportState,
+ healthConnectExportMessage = NULL
+ WHERE id = :sessionId AND endTimeEpochMillis IS NULL
+ """
+ )
+ suspend fun closeSessionIfActive(
+ sessionId: String,
+ endTimeEpochMillis: Long,
+ endZoneOffsetSeconds: Int,
+ exportState: String,
+ ): Int
+
+ @Query(
+ """
+ UPDATE workout_sessions
+ SET healthConnectExportState = :exportState,
+ healthConnectRecordId = :recordId,
+ healthConnectExportMessage = :message
+ WHERE id = :sessionId
+ """
+ )
+ suspend fun updateHealthConnectExport(
+ sessionId: String,
+ exportState: String,
+ recordId: String?,
+ message: String?,
+ )
+
+ @Query(ACTIVE_SUMMARY_QUERY)
+ fun observeActiveSummary(): Flow
+
+ @Query(FINISHED_SUMMARIES_QUERY)
+ fun observeFinishedSummaries(): Flow>
+
+ @Query(SESSION_SUMMARY_QUERY)
+ fun observeSummary(sessionId: String): Flow
+
+ companion object {
+ private const val SUMMARY_COLUMNS = """
+ s.id AS id,
+ s.startTimeEpochMillis AS startTimeEpochMillis,
+ s.endTimeEpochMillis AS endTimeEpochMillis,
+ s.maxHeartRateBpm AS maxHeartRateBpm,
+ s.healthConnectExportState AS healthConnectExportState,
+ s.healthConnectExportMessage AS healthConnectExportMessage,
+ COUNT(hr.id) AS sampleCount,
+ (SELECT latest.bpm FROM workout_samples latest
+ WHERE latest.sessionId = s.id
+ ORDER BY latest.timestampEpochMillis DESC, latest.id DESC LIMIT 1) AS latestBpm,
+ MIN(hr.bpm) AS minBpm,
+ AVG(hr.bpm) AS avgBpm,
+ MAX(hr.bpm) AS maxBpm
+ """
+
+ const val ACTIVE_SUMMARY_QUERY = """
+ SELECT $SUMMARY_COLUMNS
+ FROM workout_sessions s
+ LEFT JOIN workout_samples hr ON hr.sessionId = s.id
+ WHERE s.endTimeEpochMillis IS NULL
+ GROUP BY s.id
+ ORDER BY s.startTimeEpochMillis DESC
+ LIMIT 1
+ """
+
+ const val FINISHED_SUMMARIES_QUERY = """
+ SELECT $SUMMARY_COLUMNS
+ FROM workout_sessions s
+ LEFT JOIN workout_samples hr ON hr.sessionId = s.id
+ WHERE s.endTimeEpochMillis IS NOT NULL
+ GROUP BY s.id
+ ORDER BY s.startTimeEpochMillis DESC
+ """
+
+ const val SESSION_SUMMARY_QUERY = """
+ SELECT $SUMMARY_COLUMNS
+ FROM workout_sessions s
+ LEFT JOIN workout_samples hr ON hr.sessionId = s.id
+ WHERE s.id = :sessionId
+ GROUP BY s.id
+ LIMIT 1
+ """
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt
new file mode 100644
index 000000000..0b0ee051d
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutDatabase.kt
@@ -0,0 +1,33 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [WorkoutSessionEntity::class, WorkoutSampleEntity::class],
+ version = 1,
+ // This database is new and unreleased in this snapshot. There is no shipped schema to migrate
+ // from yet; enable schema export and commit v1 before the first release containing workouts.
+ exportSchema = false,
+)
+abstract class WorkoutDatabase : RoomDatabase() {
+ abstract fun workoutDao(): WorkoutDao
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt
new file mode 100644
index 000000000..ce51efd3f
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutEntities.kt
@@ -0,0 +1,77 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "workout_sessions",
+ indices = [
+ Index(value = ["startTimeEpochMillis"]),
+ Index(value = ["endTimeEpochMillis"]),
+ ],
+)
+data class WorkoutSessionEntity(
+ @PrimaryKey val id: String,
+ val startTimeEpochMillis: Long,
+ val startZoneOffsetSeconds: Int,
+ val endTimeEpochMillis: Long? = null,
+ val endZoneOffsetSeconds: Int? = null,
+ val maxHeartRateBpm: Int,
+ val healthConnectClientRecordId: String,
+ val healthConnectRecordId: String? = null,
+ val healthConnectExportState: String = HealthConnectSessionExportState.NOT_FINISHED.name,
+ val healthConnectExportMessage: String? = null,
+)
+
+@Entity(
+ tableName = "workout_samples",
+ foreignKeys = [
+ ForeignKey(
+ entity = WorkoutSessionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["sessionId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [
+ Index(value = ["sessionId"]),
+ Index(value = ["sessionId", "timestampEpochMillis"]),
+ Index(value = ["sessionId", "timestampEpochMillis", "sequence"], unique = true),
+ ],
+)
+data class WorkoutSampleEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0L,
+ val sessionId: String,
+ val timestampEpochMillis: Long,
+ val sequence: Int,
+ val bpm: Int,
+)
+
+enum class HealthConnectSessionExportState {
+ NOT_FINISHED,
+ PENDING,
+ EXPORTED,
+ PERMISSION_REQUIRED,
+ UNAVAILABLE,
+ ERROR,
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt
new file mode 100644
index 000000000..8231e50ae
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutPreferences.kt
@@ -0,0 +1,38 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+class WorkoutPreferences(private val sharedPreferences: SharedPreferences) {
+ var maxHeartRateBpm: Int
+ get() = HeartRateZones.normalizedMaxHeartRate(
+ sharedPreferences.getInt(KEY_MAX_HEART_RATE, HeartRateZones.DEFAULT_MAX_HEART_RATE_BPM)
+ )
+ set(value) {
+ sharedPreferences.edit {
+ putInt(KEY_MAX_HEART_RATE, HeartRateZones.normalizedMaxHeartRate(value))
+ }
+ }
+
+ companion object {
+ private const val KEY_MAX_HEART_RATE = "workout_max_heart_rate_bpm"
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt
new file mode 100644
index 000000000..07d296983
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/workout/WorkoutRepository.kt
@@ -0,0 +1,293 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import androidx.room.withTransaction
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExportResult
+import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExporter
+import java.time.Instant
+import java.time.ZoneId
+import java.util.UUID
+
+interface WorkoutLocalStore {
+ fun observeActiveSummary(): Flow
+ fun observeFinishedSummaries(): Flow>
+ fun observeSummary(sessionId: String): Flow
+ fun observeSamples(sessionId: String): Flow>
+ fun observeActiveSamples(): Flow>
+ suspend fun getActiveSession(): WorkoutSessionEntity?
+ suspend fun getSession(sessionId: String): WorkoutSessionEntity?
+ suspend fun getPendingHealthConnectSessions(): List
+ suspend fun getSamples(sessionId: String): List
+ suspend fun createSession(session: WorkoutSessionEntity)
+ suspend fun addSample(sample: WorkoutSampleEntity)
+ suspend fun deleteSession(sessionId: String): Boolean = false
+ suspend fun finishLocally(sessionId: String, endMillis: Long, endOffsetSeconds: Int): WorkoutSessionEntity?
+ suspend fun updateHealthConnectExport(
+ sessionId: String,
+ state: HealthConnectSessionExportState,
+ recordId: String?,
+ message: String?,
+ )
+}
+
+class RoomWorkoutLocalStore(private val database: WorkoutDatabase) : WorkoutLocalStore {
+ private val dao = database.workoutDao()
+
+ override fun observeActiveSummary() = dao.observeActiveSummary()
+ override fun observeFinishedSummaries() = dao.observeFinishedSummaries()
+ override fun observeSummary(sessionId: String) = dao.observeSummary(sessionId)
+ override fun observeSamples(sessionId: String) = dao.observeSamples(sessionId)
+ override fun observeActiveSamples() = dao.observeActiveSamples()
+ override suspend fun getActiveSession() = dao.getActiveSession()
+ override suspend fun getSession(sessionId: String) = dao.getSession(sessionId)
+ override suspend fun getPendingHealthConnectSessions() = dao.getPendingHealthConnectSessions()
+ override suspend fun getSamples(sessionId: String) = dao.getSamples(sessionId)
+ override suspend fun createSession(session: WorkoutSessionEntity) = dao.insertSession(session)
+ override suspend fun addSample(sample: WorkoutSampleEntity) { dao.insertSample(sample) }
+ override suspend fun deleteSession(sessionId: String): Boolean = dao.deleteSession(sessionId) > 0
+
+ override suspend fun finishLocally(
+ sessionId: String,
+ endMillis: Long,
+ endOffsetSeconds: Int,
+ ): WorkoutSessionEntity? = database.withTransaction {
+ val current = dao.getSession(sessionId) ?: return@withTransaction null
+ if (current.endTimeEpochMillis == null) {
+ dao.closeSessionIfActive(
+ sessionId,
+ endMillis,
+ endOffsetSeconds,
+ HealthConnectSessionExportState.PENDING.name,
+ )
+ }
+ dao.getSession(sessionId)
+ }
+
+ override suspend fun updateHealthConnectExport(
+ sessionId: String,
+ state: HealthConnectSessionExportState,
+ recordId: String?,
+ message: String?,
+ ) = dao.updateHealthConnectExport(sessionId, state.name, recordId, message)
+}
+
+data class WorkoutSummary(
+ val id: String,
+ val startTimeEpochMillis: Long,
+ val endTimeEpochMillis: Long?,
+ val maxHeartRateBpm: Int,
+ val sampleCount: Long,
+ val latestBpm: Int?,
+ val minBpm: Int?,
+ val avgBpm: Double?,
+ val maxBpm: Int?,
+ val healthConnectExportState: HealthConnectSessionExportState,
+ val healthConnectExportMessage: String?,
+)
+
+data class WorkoutDetail(
+ val summary: WorkoutSummary,
+ val samples: List,
+ val zones: List,
+)
+
+sealed interface FinishWorkoutResult {
+ data object NoActiveWorkout : FinishWorkoutResult
+ data class Finished(val sessionId: String, val healthConnectState: HealthConnectSessionExportState) : FinishWorkoutResult
+}
+
+class WorkoutRepository(
+ private val localStore: WorkoutLocalStore,
+ private val healthConnectExporter: WorkoutHealthConnectExporter,
+ private val maxHeartRateProvider: () -> Int,
+ private val scope: CoroutineScope,
+ private val nowMillis: () -> Long = System::currentTimeMillis,
+ private val newId: () -> String = { UUID.randomUUID().toString() },
+ private val zoneOffsetSecondsAt: (Long) -> Int = { millis ->
+ val instant = Instant.ofEpochMilli(millis)
+ ZoneId.systemDefault().rules.getOffset(instant).totalSeconds
+ },
+) {
+ private val localMutationMutex = Mutex()
+ private val healthExportMutex = Mutex()
+
+ val activeWorkout: Flow = combine(
+ localStore.observeActiveSummary(),
+ localStore.observeActiveSamples(),
+ ) { row, samples -> row?.let { detail(it, samples) } }
+
+ val history: Flow> = localStore.observeFinishedSummaries().map { rows ->
+ rows.map(::summary)
+ }
+
+ fun workout(sessionId: String): Flow = combine(
+ localStore.observeSummary(sessionId),
+ localStore.observeSamples(sessionId),
+ ) { row, samples -> row?.let { detail(it, samples) } }
+
+ suspend fun startWorkout(): String = localMutationMutex.withLock {
+ localStore.getActiveSession()?.id ?: run {
+ val start = nowMillis()
+ val id = newId()
+ localStore.createSession(
+ WorkoutSessionEntity(
+ id = id,
+ startTimeEpochMillis = start,
+ startZoneOffsetSeconds = zoneOffsetSecondsAt(start),
+ maxHeartRateBpm = maxHeartRateProvider(),
+ healthConnectClientRecordId = "librepods-workout:$id",
+ )
+ )
+ id
+ }
+ }
+
+ /** Called only from HeartRateMonitor's already-validated/published stream. */
+ fun recordValidatedSample(sample: HeartRateSample) {
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ localMutationMutex.withLock {
+ val session = localStore.getActiveSession() ?: return@withLock
+ localStore.addSample(
+ WorkoutSampleEntity(
+ sessionId = session.id,
+ timestampEpochMillis = sample.receivedAtMillis,
+ sequence = sample.sequence,
+ bpm = sample.bpm,
+ )
+ )
+ }
+ }
+ }
+
+ suspend fun finishWorkout(sessionId: String? = null): FinishWorkoutResult {
+ val finished = localMutationMutex.withLock {
+ val id = sessionId ?: localStore.getActiveSession()?.id
+ ?: return FinishWorkoutResult.NoActiveWorkout
+ val end = nowMillis()
+ localStore.finishLocally(id, end, zoneOffsetSecondsAt(end))
+ ?: return FinishWorkoutResult.NoActiveWorkout
+ }
+ val state = exportFinishedSession(finished)
+ return FinishWorkoutResult.Finished(finished.id, state)
+ }
+
+ suspend fun deleteWorkout(sessionId: String): Boolean = localMutationMutex.withLock {
+ localStore.deleteSession(sessionId)
+ }
+
+ suspend fun retryHealthConnectExport(sessionId: String): HealthConnectSessionExportState {
+ val session = localStore.getSession(sessionId) ?: return HealthConnectSessionExportState.ERROR
+ if (session.endTimeEpochMillis == null) return HealthConnectSessionExportState.NOT_FINISHED
+ return exportFinishedSession(session)
+ }
+
+ /** Resumes only commits that were interrupted after the local finish transaction. */
+ fun retryPendingHealthConnectExports() {
+ scope.launch {
+ localStore.getPendingHealthConnectSessions().forEach { session ->
+ exportFinishedSession(session)
+ }
+ }
+ }
+
+ suspend fun snapshot(sessionId: String): WorkoutDetail? {
+ val session = localStore.getSession(sessionId) ?: return null
+ val samples = localStore.getSamples(sessionId)
+ val row = WorkoutSessionSummaryRow(
+ id = session.id,
+ startTimeEpochMillis = session.startTimeEpochMillis,
+ endTimeEpochMillis = session.endTimeEpochMillis,
+ maxHeartRateBpm = session.maxHeartRateBpm,
+ healthConnectExportState = session.healthConnectExportState,
+ healthConnectExportMessage = session.healthConnectExportMessage,
+ sampleCount = samples.size.toLong(),
+ latestBpm = samples.maxByOrNull { it.timestampEpochMillis }?.bpm,
+ minBpm = samples.minOfOrNull { it.bpm },
+ avgBpm = samples.takeIf { it.isNotEmpty() }?.map { it.bpm }?.average(),
+ maxBpm = samples.maxOfOrNull { it.bpm },
+ )
+ return detail(row, samples)
+ }
+
+ private suspend fun exportFinishedSession(session: WorkoutSessionEntity): HealthConnectSessionExportState =
+ healthExportMutex.withLock {
+ val latest = localStore.getSession(session.id) ?: session
+ if (latest.healthConnectExportState == HealthConnectSessionExportState.EXPORTED.name) {
+ return@withLock HealthConnectSessionExportState.EXPORTED
+ }
+ val result = healthConnectExporter.export(latest)
+ val (state, recordId, message) = when (result) {
+ is WorkoutHealthConnectExportResult.Exported -> Triple(
+ HealthConnectSessionExportState.EXPORTED,
+ result.recordId ?: latest.healthConnectRecordId,
+ null,
+ )
+ WorkoutHealthConnectExportResult.PermissionRequired -> Triple(
+ HealthConnectSessionExportState.PERMISSION_REQUIRED,
+ latest.healthConnectRecordId,
+ "Exercise write permission is required",
+ )
+ WorkoutHealthConnectExportResult.Unavailable -> Triple(
+ HealthConnectSessionExportState.UNAVAILABLE,
+ latest.healthConnectRecordId,
+ "Health Connect is unavailable",
+ )
+ is WorkoutHealthConnectExportResult.Failed -> Triple(
+ HealthConnectSessionExportState.ERROR,
+ latest.healthConnectRecordId,
+ result.message,
+ )
+ }
+ localStore.updateHealthConnectExport(latest.id, state, recordId, message)
+ state
+ }
+
+ private fun summary(row: WorkoutSessionSummaryRow) = WorkoutSummary(
+ id = row.id,
+ startTimeEpochMillis = row.startTimeEpochMillis,
+ endTimeEpochMillis = row.endTimeEpochMillis,
+ maxHeartRateBpm = row.maxHeartRateBpm,
+ sampleCount = row.sampleCount,
+ latestBpm = row.latestBpm,
+ minBpm = row.minBpm,
+ avgBpm = row.avgBpm,
+ maxBpm = row.maxBpm,
+ healthConnectExportState = runCatching {
+ HealthConnectSessionExportState.valueOf(row.healthConnectExportState)
+ }.getOrDefault(HealthConnectSessionExportState.ERROR),
+ healthConnectExportMessage = row.healthConnectExportMessage,
+ )
+
+ private fun detail(row: WorkoutSessionSummaryRow, samples: List): WorkoutDetail =
+ WorkoutDetail(
+ summary = summary(row),
+ samples = samples,
+ zones = HeartRateZones.distribution(samples, row.maxHeartRateBpm),
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt
new file mode 100644
index 000000000..9979f4083
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/FitActivityEncoder.kt
@@ -0,0 +1,210 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.export.workout
+
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import java.io.ByteArrayOutputStream
+import kotlin.math.roundToInt
+
+/** Minimal FIT Activity encoder for HR-only LibrePods sessions. */
+object FitActivityEncoder {
+ private const val HEADER_SIZE = 14
+ private const val PROTOCOL_VERSION = 0x20 // FIT protocol 2.0
+ // Matches the current public Garmin C SDK profile used to validate these definitions (21.213).
+ private const val PROFILE_VERSION = 21_213
+ private const val FIT_EPOCH_UNIX_SECONDS = 631065600L
+
+ fun encode(workout: WorkoutDetail): ByteArray {
+ val summary = workout.summary
+ val startMillis = summary.startTimeEpochMillis
+ val endMillis = requireNotNull(summary.endTimeEpochMillis) {
+ "FIT export requires a finished workout"
+ }
+ val data = ByteArrayOutputStream()
+
+ definition(data, 0, 0, listOf(
+ Field(0, 1, BaseType.ENUM),
+ Field(1, 2, BaseType.UINT16),
+ Field(4, 4, BaseType.UINT32),
+ ))
+ dataMessage(data, 0) {
+ u8(4) // file type: activity
+ u16(255) // manufacturer: development
+ u32(fitTimestamp(startMillis))
+ }
+
+ definition(data, 1, 20, listOf(
+ Field(253, 4, BaseType.UINT32),
+ Field(3, 1, BaseType.UINT8),
+ ))
+ val orderedSamples = workout.samples.sortedBy { it.timestampEpochMillis }
+ if (orderedSamples.isEmpty()) {
+ // Required Record message without fabricating a heart-rate value. 0xFF is invalid uint8.
+ dataMessage(data, 1) {
+ u32(fitTimestamp(startMillis))
+ u8(0xFF)
+ }
+ } else {
+ orderedSamples.forEach { sample ->
+ dataMessage(data, 1) {
+ u32(fitTimestamp(sample.timestampEpochMillis))
+ u8(sample.bpm.coerceIn(0, 255))
+ }
+ }
+ }
+
+ val elapsedMs = (endMillis - startMillis).coerceAtLeast(0L)
+ val avgHr = summary.avgBpm?.roundToInt()?.coerceIn(0, 255) ?: 0xFF
+ val maxHr = summary.maxBpm?.coerceIn(0, 255) ?: 0xFF
+
+ // Garmin Activity files require a Lap message even for an unsplit single-lap activity.
+ definition(data, 2, 19, listOf(
+ Field(253, 4, BaseType.UINT32),
+ Field(2, 4, BaseType.UINT32),
+ Field(7, 4, BaseType.UINT32),
+ Field(8, 4, BaseType.UINT32),
+ Field(15, 1, BaseType.UINT8),
+ Field(16, 1, BaseType.UINT8),
+ ))
+ dataMessage(data, 2) {
+ u32(fitTimestamp(endMillis))
+ u32(fitTimestamp(startMillis))
+ u32(elapsedMs)
+ u32(elapsedMs)
+ u8(avgHr)
+ u8(maxHr)
+ }
+
+ definition(data, 3, 18, listOf(
+ Field(253, 4, BaseType.UINT32),
+ Field(2, 4, BaseType.UINT32),
+ Field(7, 4, BaseType.UINT32),
+ Field(8, 4, BaseType.UINT32),
+ Field(5, 1, BaseType.ENUM),
+ Field(16, 1, BaseType.UINT8),
+ Field(17, 1, BaseType.UINT8),
+ ))
+ dataMessage(data, 3) {
+ u32(fitTimestamp(endMillis))
+ u32(fitTimestamp(startMillis))
+ u32(elapsedMs)
+ u32(elapsedMs)
+ u8(0) // sport: generic
+ u8(avgHr)
+ u8(maxHr)
+ }
+
+ definition(data, 4, 34, listOf(
+ Field(253, 4, BaseType.UINT32),
+ Field(0, 4, BaseType.UINT32),
+ Field(1, 2, BaseType.UINT16),
+ Field(2, 1, BaseType.ENUM),
+ ))
+ dataMessage(data, 4) {
+ u32(fitTimestamp(endMillis))
+ u32(elapsedMs)
+ u16(1)
+ u8(0) // activity type: manual
+ }
+
+ val dataBytes = data.toByteArray()
+ val header = ByteArrayOutputStream().apply {
+ u8(HEADER_SIZE)
+ u8(PROTOCOL_VERSION)
+ u16(PROFILE_VERSION)
+ u32(dataBytes.size.toLong())
+ write(byteArrayOf('.'.code.toByte(), 'F'.code.toByte(), 'I'.code.toByte(), 'T'.code.toByte()))
+ }.toByteArray()
+ val fileCrc = FitCrc.compute(dataBytes)
+ return ByteArrayOutputStream().apply {
+ write(header)
+ u16(FitCrc.compute(header))
+ write(dataBytes)
+ u16(fileCrc)
+ }.toByteArray()
+ }
+
+ private fun fitTimestamp(unixMillis: Long): Long =
+ (unixMillis / 1000L - FIT_EPOCH_UNIX_SECONDS).coerceAtLeast(0L)
+
+ private data class Field(val number: Int, val size: Int, val baseType: Int)
+ private object BaseType {
+ const val ENUM = 0x00
+ const val UINT8 = 0x02
+ const val UINT16 = 0x84
+ const val UINT32 = 0x86
+ }
+
+ private fun definition(out: ByteArrayOutputStream, local: Int, global: Int, fields: List) {
+ out.u8(0x40 or (local and 0x0F))
+ out.u8(0)
+ out.u8(0) // little endian architecture
+ out.u16(global)
+ out.u8(fields.size)
+ fields.forEach { field ->
+ out.u8(field.number)
+ out.u8(field.size)
+ out.u8(field.baseType)
+ }
+ }
+
+ private inline fun dataMessage(
+ out: ByteArrayOutputStream,
+ local: Int,
+ body: ByteArrayOutputStream.() -> Unit,
+ ) {
+ out.u8(local and 0x0F)
+ out.body()
+ }
+
+ private fun ByteArrayOutputStream.u8(value: Int) = write(value and 0xFF)
+ private fun ByteArrayOutputStream.u16(value: Int) {
+ u8(value)
+ u8(value ushr 8)
+ }
+ private fun ByteArrayOutputStream.u32(value: Long) {
+ u8(value.toInt())
+ u8((value ushr 8).toInt())
+ u8((value ushr 16).toInt())
+ u8((value ushr 24).toInt())
+ }
+}
+
+object FitCrc {
+ private val table = intArrayOf(
+ 0x0000, 0xCC01, 0xD801, 0x1400,
+ 0xF001, 0x3C00, 0x2800, 0xE401,
+ 0xA001, 0x6C00, 0x7800, 0xB401,
+ 0x5000, 0x9C01, 0x8801, 0x4400,
+ )
+
+ fun compute(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size - offset): Int {
+ var crc = 0
+ for (index in offset until offset + length) {
+ val value = bytes[index].toInt() and 0xFF
+ var tmp = table[crc and 0x0F]
+ crc = (crc ushr 4) and 0x0FFF
+ crc = crc xor tmp xor table[value and 0x0F]
+ tmp = table[crc and 0x0F]
+ crc = (crc ushr 4) and 0x0FFF
+ crc = crc xor tmp xor table[(value ushr 4) and 0x0F]
+ }
+ return crc and 0xFFFF
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt
new file mode 100644
index 000000000..b693bbdc6
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoder.kt
@@ -0,0 +1,58 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.export.workout
+
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import java.time.Instant
+
+object WorkoutCsvEncoder {
+ fun encode(workout: WorkoutDetail): String = buildString {
+ appendLine("session_id,session_start,session_end,max_hr_bpm,sample_timestamp,bpm,sequence")
+ val summary = workout.summary
+ val start = Instant.ofEpochMilli(summary.startTimeEpochMillis).toString()
+ val end = summary.endTimeEpochMillis?.let { Instant.ofEpochMilli(it).toString() }.orEmpty()
+ val sessionPrefix = buildString {
+ append(csvEscape(summary.id)); append(',')
+ append(csvEscape(start)); append(',')
+ append(csvEscape(end)); append(',')
+ append(summary.maxHeartRateBpm)
+ }
+ if (workout.samples.isEmpty()) {
+ append(sessionPrefix); append(",,,\n")
+ } else {
+ workout.samples.forEach { sample ->
+ append(sessionPrefix); append(',')
+ append(csvEscape(Instant.ofEpochMilli(sample.timestampEpochMillis).toString())); append(',')
+ append(sample.bpm); append(',')
+ append(sample.sequence); append('\n')
+ }
+ }
+ }
+
+ internal fun csvEscape(value: String): String {
+ if (value.none { it == ',' || it == '"' || it == '\r' || it == '\n' }) return value
+ return buildString(value.length + 2) {
+ append('"')
+ value.forEach { char ->
+ if (char == '"') append("\"\"") else append(char)
+ }
+ append('"')
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt
new file mode 100644
index 000000000..3a2428dc8
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/export/workout/WorkoutFileExporter.kt
@@ -0,0 +1,78 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.export.workout
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import me.kavishdevar.librepods.BuildConfig
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import java.io.File
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+object WorkoutFileExporter {
+ data class ExportedFile(val file: File, val mimeType: String)
+
+ fun exportCsv(context: Context, workout: WorkoutDetail): ExportedFile {
+ val file = exportFile(context, workout, "csv")
+ file.writeText(WorkoutCsvEncoder.encode(workout), Charsets.UTF_8)
+ return ExportedFile(file, "text/csv")
+ }
+
+ fun exportFit(context: Context, workout: WorkoutDetail): ExportedFile {
+ val file = exportFile(context, workout, "fit")
+ file.writeBytes(FitActivityEncoder.encode(workout))
+ return ExportedFile(file, "application/octet-stream")
+ }
+
+ fun share(context: Context, exported: ExportedFile) {
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${BuildConfig.APPLICATION_ID}.provider",
+ exported.file,
+ )
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = exported.mimeType
+ putExtra(Intent.EXTRA_STREAM, uri)
+ clipData = ClipData.newRawUri(exported.file.name, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Export workout"))
+ }
+
+ internal fun sanitizeFilename(value: String): String = value
+ .replace(Regex("[^A-Za-z0-9._-]+"), "_")
+ .trim('_', '.')
+ .take(96)
+ .ifBlank { "workout" }
+
+ private fun exportFile(context: Context, workout: WorkoutDetail, extension: String): File {
+ val dir = File(context.cacheDir, "workout-exports").apply { mkdirs() }
+ val instant = Instant.ofEpochMilli(workout.summary.startTimeEpochMillis)
+ val timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
+ .withZone(ZoneId.systemDefault())
+ .format(instant)
+ val idSuffix = sanitizeFilename(workout.summary.id).take(12)
+ val name = sanitizeFilename("LibrePods-workout-$timestamp-$idSuffix.${extension.lowercase()}")
+ return File(dir, name)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt
new file mode 100644
index 000000000..3e0c0c7c2
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyAirPodsFinder.kt
@@ -0,0 +1,142 @@
+package me.kavishdevar.librepods.finder
+
+import android.Manifest
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.SystemClock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+enum class NearbyFinderStatus {
+ STOPPED,
+ WAITING_FOR_SIGNAL,
+ ACTIVE,
+ PERMISSION_REQUIRED,
+ BLUETOOTH_OFF,
+ NO_SELECTED_DEVICE,
+ ERROR
+}
+
+data class NearbyFinderState(
+ val running: Boolean = false,
+ val status: NearbyFinderStatus = NearbyFinderStatus.STOPPED,
+ val signal: FinderSignalSnapshot = FinderSignalSnapshot(),
+ val errorMessage: String? = null
+)
+
+/**
+ * Finder coordinator. It deliberately does not own a scanner: LibrePods already performs a
+ * verified, AirPods-specific BLE scan, so this consumes those RSSI callbacks only while active.
+ */
+class NearbyAirPodsFinder(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val hasSelectedDevice: () -> Boolean,
+ private val processor: RssiSignalProcessor = RssiSignalProcessor()
+) {
+ private val _state = MutableStateFlow(NearbyFinderState())
+ val state: StateFlow = _state
+ private var tickerJob: Job? = null
+
+ fun start(): Boolean {
+ if (_state.value.running) return true
+ when {
+ context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED -> {
+ _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED)
+ return false
+ }
+ context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED -> {
+ _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED)
+ return false
+ }
+ context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED -> {
+ _state.value = NearbyFinderState(status = NearbyFinderStatus.PERMISSION_REQUIRED)
+ return false
+ }
+ context.getSystemService(BluetoothManager::class.java).adapter?.isEnabled != true -> {
+ _state.value = NearbyFinderState(status = NearbyFinderStatus.BLUETOOTH_OFF)
+ return false
+ }
+ !hasSelectedDevice() -> {
+ _state.value = NearbyFinderState(status = NearbyFinderStatus.NO_SELECTED_DEVICE)
+ return false
+ }
+ }
+
+ processor.reset()
+ _state.value = NearbyFinderState(
+ running = true,
+ status = NearbyFinderStatus.WAITING_FOR_SIGNAL,
+ signal = processor.snapshot(SystemClock.elapsedRealtime())
+ )
+ tickerJob = scope.launch {
+ while (true) {
+ delay(250L)
+ val snapshot = processor.snapshot(SystemClock.elapsedRealtime())
+ val previous = _state.value
+ if (!previous.running) return@launch
+ _state.value = previous.copy(
+ status = if (snapshot.proximity == ProximityBucket.SIGNAL_LOST) {
+ NearbyFinderStatus.WAITING_FOR_SIGNAL
+ } else {
+ NearbyFinderStatus.ACTIVE
+ },
+ signal = snapshot
+ )
+ }
+ }
+ return true
+ }
+
+ fun stop() {
+ tickerJob?.cancel()
+ tickerJob = null
+ processor.reset()
+ _state.value = NearbyFinderState()
+ }
+
+ fun onVerifiedScanRssi(rssi: Int) {
+ if (!_state.value.running) return
+ val snapshot = processor.addSample(
+ rssi = rssi,
+ elapsedRealtime = SystemClock.elapsedRealtime()
+ )
+ // Publish the first fix immediately, then let the 250 ms ticker pace UI changes. Updating
+ // Compose for every BLE advertisement makes normal radio noise look like rapid movement.
+ if (_state.value.signal.rawRssi == null) {
+ _state.value = _state.value.copy(
+ status = NearbyFinderStatus.ACTIVE,
+ signal = snapshot,
+ errorMessage = null
+ )
+ }
+ }
+
+ fun onScanError(errorCode: Int) {
+ val previous = _state.value
+ if (!previous.running) return
+ tickerJob?.cancel()
+ tickerJob = null
+ _state.value = previous.copy(
+ running = false,
+ status = NearbyFinderStatus.ERROR,
+ signal = processor.snapshot(SystemClock.elapsedRealtime()),
+ errorMessage = "AirPods BLE scan failed (code $errorCode)."
+ )
+ }
+
+ fun refreshPrerequisites(): Boolean {
+ if (_state.value.status == NearbyFinderStatus.PERMISSION_REQUIRED ||
+ _state.value.status == NearbyFinderStatus.BLUETOOTH_OFF ||
+ _state.value.status == NearbyFinderStatus.NO_SELECTED_DEVICE
+ ) {
+ return start()
+ }
+ return false
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt
new file mode 100644
index 000000000..74cbc2487
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/finder/NearbyFinderSignal.kt
@@ -0,0 +1,183 @@
+package me.kavishdevar.librepods.finder
+
+import java.util.ArrayDeque
+import kotlin.math.pow
+import kotlin.math.round
+
+enum class ProximityBucket(val label: String) {
+ VERY_CLOSE("Very close"),
+ CLOSE("Close"),
+ NEARBY("Nearby"),
+ FAR("Far"),
+ SIGNAL_LOST("Signal lost")
+}
+
+enum class SignalTrend(val label: String) {
+ GETTING_CLOSER("Getting closer"),
+ STABLE("Stable"),
+ GETTING_FARTHER("Getting farther")
+}
+
+data class FinderSignalSnapshot(
+ val rawRssi: Int? = null,
+ val smoothedRssi: Double? = null,
+ val sampleAgeMillis: Long? = null,
+ val stale: Boolean = true,
+ val approximateDistanceMeters: Double? = null,
+ val proximity: ProximityBucket = ProximityBucket.SIGNAL_LOST,
+ val trend: SignalTrend = SignalTrend.STABLE,
+)
+
+/**
+ * Small stateful RSSI filter tuned for a finder UI, not radio metrology.
+ *
+ * RSSI is strongly affected by body blocking, orientation and multipath. The distance estimate is
+ * intentionally coarse and should only be presented as approximate.
+ */
+class RssiSignalProcessor(
+ private val emaAlpha: Double = 0.23,
+ private val staleAfterMillis: Long = 3_000L,
+ private val lostAfterMillis: Long = 8_000L,
+ private val hysteresisDb: Double = 3.5,
+ private val calibratedRssiAtOneMeter: Double = -59.0,
+ private val pathLossExponent: Double = 2.2,
+ private val minTrendSamples: Int = 6,
+ private val minTrendSpanMillis: Long = 1_750L,
+ private val trendThresholdDb: Double = 2.5,
+ private val medianWindowSize: Int = 7
+) {
+ private data class HistoryPoint(val elapsedRealtime: Long, val rssi: Double)
+
+ private val history = ArrayDeque()
+ private val rawWindow = ArrayDeque()
+ private var rawRssi: Int? = null
+ private var smoothedRssi: Double? = null
+ private var lastSampleElapsedRealtime: Long? = null
+ private var bucket = ProximityBucket.SIGNAL_LOST
+
+ fun reset() {
+ history.clear()
+ rawWindow.clear()
+ rawRssi = null
+ smoothedRssi = null
+ lastSampleElapsedRealtime = null
+ bucket = ProximityBucket.SIGNAL_LOST
+ }
+
+ fun addSample(rssi: Int, elapsedRealtime: Long): FinderSignalSnapshot {
+ if (rssi !in -127..20) return snapshot(elapsedRealtime)
+
+ rawRssi = rssi
+ rawWindow.addLast(rssi)
+ while (rawWindow.size > medianWindowSize.coerceAtLeast(1)) rawWindow.removeFirst()
+ val sortedWindow = rawWindow.sorted()
+ val robustRssi = if (sortedWindow.size % 2 == 1) {
+ sortedWindow[sortedWindow.size / 2].toDouble()
+ } else {
+ val upper = sortedWindow.size / 2
+ (sortedWindow[upper - 1] + sortedWindow[upper]) / 2.0
+ }
+ smoothedRssi = smoothedRssi?.let { previous ->
+ (emaAlpha * robustRssi) + ((1.0 - emaAlpha) * previous)
+ } ?: robustRssi
+ lastSampleElapsedRealtime = elapsedRealtime
+
+ val filtered = smoothedRssi!!
+ // BLE advertisements can arrive dozens of times per second. Trend history is deliberately
+ // time-sampled so a burst from one bud cannot dominate several seconds of movement.
+ if (history.lastOrNull()?.elapsedRealtime?.let { elapsedRealtime - it >= 250L } != false) {
+ history.addLast(HistoryPoint(elapsedRealtime, filtered))
+ while (history.size > 24 ||
+ (history.firstOrNull()?.elapsedRealtime ?: elapsedRealtime) < elapsedRealtime - 12_000L
+ ) {
+ history.removeFirst()
+ }
+ }
+
+ bucket = nextBucket(bucket, filtered)
+ return snapshot(elapsedRealtime)
+ }
+
+ fun snapshot(nowElapsedRealtime: Long): FinderSignalSnapshot {
+ val last = lastSampleElapsedRealtime
+ val age = last?.let { (nowElapsedRealtime - it).coerceAtLeast(0L) }
+ val isLost = age == null || age >= lostAfterMillis
+ val currentBucket = if (isLost) ProximityBucket.SIGNAL_LOST else bucket
+ return FinderSignalSnapshot(
+ rawRssi = rawRssi,
+ smoothedRssi = smoothedRssi,
+ sampleAgeMillis = age,
+ stale = age == null || age >= staleAfterMillis,
+ approximateDistanceMeters = smoothedRssi
+ ?.takeUnless { isLost }
+ ?.let(::coarseDistanceEstimateMeters),
+ proximity = currentBucket,
+ trend = if (isLost) SignalTrend.STABLE else calculateTrend(),
+ )
+ }
+
+ private fun calculateTrend(): SignalTrend {
+ if (history.size < minTrendSamples) return SignalTrend.STABLE
+ val points = history.toList()
+ if (points.last().elapsedRealtime - points.first().elapsedRealtime < minTrendSpanMillis) {
+ return SignalTrend.STABLE
+ }
+ val split = points.size / 2
+ val older = points.take(split).map { it.rssi }.average()
+ val newer = points.takeLast(split).map { it.rssi }.average()
+ val delta = newer - older
+ return when {
+ delta >= trendThresholdDb -> SignalTrend.GETTING_CLOSER
+ delta <= -trendThresholdDb -> SignalTrend.GETTING_FARTHER
+ else -> SignalTrend.STABLE
+ }
+ }
+
+ private fun nextBucket(previous: ProximityBucket, rssi: Double): ProximityBucket {
+ val candidate = bucketWithoutHysteresis(rssi)
+ if (previous == ProximityBucket.SIGNAL_LOST) return candidate
+ if (candidate == previous) return previous
+
+ return when (previous) {
+ ProximityBucket.VERY_CLOSE ->
+ if (rssi < VERY_CLOSE_THRESHOLD - hysteresisDb) candidate else previous
+ ProximityBucket.CLOSE -> when {
+ candidate == ProximityBucket.VERY_CLOSE && rssi >= VERY_CLOSE_THRESHOLD + hysteresisDb -> candidate
+ candidate.ordinal > previous.ordinal && rssi < CLOSE_THRESHOLD - hysteresisDb -> candidate
+ else -> previous
+ }
+ ProximityBucket.NEARBY -> when {
+ candidate.ordinal < previous.ordinal && rssi >= CLOSE_THRESHOLD + hysteresisDb -> candidate
+ candidate == ProximityBucket.FAR && rssi < NEARBY_THRESHOLD - hysteresisDb -> candidate
+ else -> previous
+ }
+ ProximityBucket.FAR ->
+ if (candidate.ordinal < previous.ordinal && rssi >= NEARBY_THRESHOLD + hysteresisDb) candidate else previous
+ ProximityBucket.SIGNAL_LOST -> candidate
+ }
+ }
+
+ private fun bucketWithoutHysteresis(rssi: Double): ProximityBucket = when {
+ rssi >= VERY_CLOSE_THRESHOLD -> ProximityBucket.VERY_CLOSE
+ rssi >= CLOSE_THRESHOLD -> ProximityBucket.CLOSE
+ rssi >= NEARBY_THRESHOLD -> ProximityBucket.NEARBY
+ else -> ProximityBucket.FAR
+ }
+
+ private fun coarseDistanceEstimateMeters(rssi: Double): Double {
+ val raw = 10.0.pow((calibratedRssiAtOneMeter - rssi) / (10.0 * pathLossExponent))
+ .coerceIn(0.1, 50.0)
+ return when {
+ raw < 1.0 -> maxOf(0.5, round(raw * 2.0) / 2.0)
+ raw < 5.0 -> round(raw * 2.0) / 2.0
+ raw < 15.0 -> round(raw)
+ else -> round(raw / 5.0) * 5.0
+ }
+ }
+
+ companion object {
+ private const val VERY_CLOSE_THRESHOLD = -48.0
+ private const val CLOSE_THRESHOLD = -58.0
+ private const val NEARBY_THRESHOLD = -68.0
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt
new file mode 100644
index 000000000..c257da64f
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt
@@ -0,0 +1,554 @@
+/*
+ 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.CancellationException
+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
+}
+
+data class HealthConnectExportState(
+ val enabled: Boolean = false,
+ val status: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE,
+ val detailedSamples: Boolean = false
+)
+
+/**
+ * Writes validated AirPods heart-rate samples to Health Connect at the selected interval.
+ *
+ * Each record is assigned a stable client record ID derived from its source sample contents and
+ * device metadata. Retrying a failed record therefore remains idempotent even if Health Connect
+ * accepted the record before returning an error.
+ */
+class HealthConnectHeartRateExporter(
+ context: Context,
+ private val sharedPreferences: SharedPreferences,
+ private val scope: CoroutineScope
+) {
+ private data class PendingSample(
+ val id: String,
+ val sample: HeartRateSample,
+ val deviceModel: String
+ )
+
+ private data class PendingRecord(
+ val samples: List,
+ val clientRecordId: String,
+ val startTimeMillis: Long,
+ val endTimeMillis: Long,
+ val partialInterval: Boolean
+ )
+
+ private val appContext = context.applicationContext
+ private val mutex = Mutex()
+ private val pendingSamples = linkedMapOf()
+ private var pendingRecord: PendingRecord? = null
+ private var intervalWindowStartMillis: Long? = null
+ private var requestedDetailedSamples: Boolean? = null
+ private var healthConnectClient: HealthConnectClient? = null
+ private var scheduledFlush: Job? = null
+
+ private val _state = MutableStateFlow(
+ HealthConnectExportState(
+ status = statusForSdk(),
+ detailedSamples = sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false)
+ )
+ )
+ val state: StateFlow get() = _state
+
+ private fun updateState(
+ enabled: Boolean = _state.value.enabled,
+ status: HealthConnectExportStatus = _state.value.status,
+ detailedSamples: Boolean = _state.value.detailedSamples
+ ) {
+ _state.value = HealthConnectExportState(enabled, status, detailedSamples)
+ }
+
+ fun refresh() {
+ scope.launch {
+ refreshInternal()
+ }
+ }
+
+ suspend fun refreshInternal() {
+ mutex.withLock {
+ val requested = sharedPreferences.getBoolean(EXPORT_PREFERENCE, false)
+ _state.value = resolveStateLocked(requested)
+ if (_state.value.enabled && hasPendingSamplesLocked()) {
+ scheduleFlushLocked(0L)
+ }
+ }
+ }
+
+ fun setEnabled(enabled: Boolean) {
+ scope.launch {
+ mutex.withLock {
+ if (!enabled) {
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ flushLocked(forcePartialInterval = true)
+ sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) }
+ _state.value = resolveStateLocked(requested = false)
+ return@withLock
+ }
+
+ val nextState = resolveStateLocked(requested = true)
+ when (nextState.status) {
+ HealthConnectExportStatus.ENABLED ->
+ sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, true) }
+
+ HealthConnectExportStatus.PERMISSION_REQUIRED ->
+ sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) }
+
+ else -> Unit
+ }
+ _state.value = nextState
+ if (nextState.enabled && hasPendingSamplesLocked()) {
+ scheduleFlushLocked(0L)
+ }
+ }
+ }
+ }
+
+ private suspend fun resolveStateLocked(requested: Boolean): HealthConnectExportState {
+ val current = _state.value
+ return when (HealthConnectClient.getSdkStatus(appContext)) {
+ HealthConnectClient.SDK_AVAILABLE -> {
+ val granted = try {
+ hasWritePermission(getClient())
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.w(TAG, "Unable to query Health Connect permissions", error)
+ return current.copy(enabled = false, status = HealthConnectExportStatus.ERROR)
+ }
+ current.copy(
+ enabled = requested && granted,
+ status = when {
+ !granted -> HealthConnectExportStatus.PERMISSION_REQUIRED
+ requested -> HealthConnectExportStatus.ENABLED
+ else -> HealthConnectExportStatus.READY
+ }
+ )
+ }
+
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
+ healthConnectClient = null
+ current.copy(
+ enabled = false,
+ status = HealthConnectExportStatus.UPDATE_REQUIRED
+ )
+ }
+
+ else -> {
+ healthConnectClient = null
+ current.copy(
+ enabled = false,
+ status = HealthConnectExportStatus.UNAVAILABLE
+ )
+ }
+ }
+ }
+
+ fun setDetailedSamples(detailed: Boolean) {
+ scope.launch {
+ mutex.withLock {
+ if (_state.value.detailedSamples == detailed) {
+ requestedDetailedSamples = null
+ return@withLock
+ }
+
+ requestedDetailedSamples = detailed
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ if (hasPendingSamplesLocked() &&
+ (!_state.value.enabled || !flushLocked(forcePartialInterval = true))
+ ) {
+ return@withLock
+ }
+ applyRequestedDetailLocked()
+ }
+ }
+ }
+
+ fun markPermissionDenied() {
+ scope.launch {
+ mutex.withLock {
+ sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) }
+ updateState(
+ enabled = false,
+ status = HealthConnectExportStatus.PERMISSION_DENIED
+ )
+ }
+ }
+ }
+
+ fun enqueue(sample: HeartRateSample, deviceModel: String) {
+ if (!_state.value.enabled) return
+
+ scope.launch {
+ mutex.withLock {
+ if (!_state.value.enabled) return@withLock
+
+ val id = clientRecordId(sample)
+ pendingSamples.putIfAbsent(
+ id,
+ PendingSample(
+ id = id,
+ sample = sample,
+ deviceModel = deviceModel.ifBlank { "AirPods" }
+ )
+ )
+ trimBufferLocked()
+
+ if (pendingRecord != null) {
+ return@withLock
+ }
+ if (hasCompletedIntervalWindowLocked()) {
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ flushLocked()
+ } else {
+ scheduleNextFlushLocked()
+ }
+ }
+ }
+ }
+
+ fun flushAsync() {
+ scope.launch { flush(forcePartialInterval = true) }
+ }
+
+ suspend fun flush(forcePartialInterval: Boolean = false) {
+ mutex.withLock {
+ scheduledFlush?.cancel()
+ scheduledFlush = null
+ flushLocked(forcePartialInterval)
+ }
+ }
+
+ suspend fun closeAndFlush() {
+ flush(forcePartialInterval = true)
+ }
+
+ private suspend fun flushLocked(forcePartialInterval: Boolean = false): Boolean {
+ if (!hasPendingSamplesLocked()) {
+ applyRequestedDetailLocked()
+ return true
+ }
+ if (!_state.value.enabled) return false
+
+ while (_state.value.enabled && hasPendingSamplesLocked()) {
+ val record = getOrCreatePendingRecordLocked(
+ forcePartialInterval || requestedDetailedSamples != null
+ )
+ if (record == null) {
+ scheduleNextFlushLocked()
+ return false
+ }
+
+ try {
+ getClient().insertRecords(listOf(toRecord(record)))
+ completePendingRecordLocked(record)
+ updateState(status = HealthConnectExportStatus.ENABLED)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: SecurityException) {
+ Log.w(TAG, "Health Connect permission was revoked", error)
+ sharedPreferences.edit { putBoolean(EXPORT_PREFERENCE, false) }
+ updateState(
+ enabled = false,
+ status = HealthConnectExportStatus.PERMISSION_REQUIRED
+ )
+ return false
+ } catch (error: IOException) {
+ handleRetryableWriteFailureLocked(
+ "Health Connect write failed; keeping record 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)
+ updateState(status = HealthConnectExportStatus.ERROR)
+ scheduleFlushLocked(RETRY_INTERVAL_MILLIS)
+ }
+
+ private fun applyRequestedDetailLocked() {
+ val detailed = requestedDetailedSamples ?: return
+ if (hasPendingSamplesLocked()) return
+
+ intervalWindowStartMillis = null
+ sharedPreferences.edit { putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) }
+ updateState(detailedSamples = 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 (pendingRecord != null || pendingSamples.isEmpty()) return
+ val windowStart = ensureIntervalWindowStartLocked() ?: return
+ val windowEnd = windowStart + exportIntervalMillis()
+ val delayMillis = (windowEnd - System.currentTimeMillis()).coerceAtLeast(0L)
+ scheduleFlushLocked(delayMillis)
+ }
+
+ private fun getOrCreatePendingRecordLocked(
+ forcePartialInterval: Boolean
+ ): PendingRecord? {
+ pendingRecord?.let { return it }
+
+ val orderedSamples = pendingSamples.values.sortedWith(PENDING_SAMPLE_COMPARATOR)
+ if (orderedSamples.isEmpty()) return null
+
+ var windowStart = ensureIntervalWindowStartLocked() ?: return null
+ val earliestTimestamp = orderedSamples.first().sample.receivedAtMillis
+ val intervalMillis = exportIntervalMillis()
+ var windowEnd = windowStart + intervalMillis
+ while (earliestTimestamp >= windowEnd) {
+ windowStart = windowEnd
+ windowEnd = windowStart + intervalMillis
+ intervalWindowStartMillis = windowStart
+ }
+
+ val completedInterval = isIntervalCompleteLocked(windowEnd)
+ if (!forcePartialInterval && !completedInterval) 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 partialInterval = !completedInterval
+ val recordStartTime = maxOf(windowStart, firstSampleTime)
+ val recordEndTime = if (partialInterval) {
+ maxOf(recordStartTime + 1L, lastSampleTime + 1L)
+ } else {
+ maxOf(recordStartTime + 1L, windowEnd)
+ }
+
+ return PendingRecord(
+ samples = selectedSamples,
+ clientRecordId = recordClientRecordId(
+ samples = selectedSamples,
+ startTimeMillis = recordStartTime,
+ endTimeMillis = recordEndTime
+ ),
+ startTimeMillis = recordStartTime,
+ endTimeMillis = recordEndTime,
+ partialInterval = partialInterval
+ ).also { pendingRecord = it }
+ }
+
+ private fun completePendingRecordLocked(record: PendingRecord) {
+ pendingRecord = null
+ intervalWindowStartMillis = if (record.partialInterval) null else record.endTimeMillis
+ }
+
+ private fun toRecord(record: PendingRecord): HeartRateRecord {
+ val firstSample = record.samples.first()
+ val startTimestamp = Instant.ofEpochMilli(record.startTimeMillis)
+ val endTimestamp = Instant.ofEpochMilli(record.endTimeMillis)
+ val zoneRules = ZoneId.systemDefault().rules
+ val samples = listOf(
+ HeartRateRecord.Sample(
+ time = Instant.ofEpochMilli(
+ record.startTimeMillis +
+ (record.endTimeMillis - record.startTimeMillis) / 2L
+ ),
+ beatsPerMinute = averageBpm(record.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 = record.clientRecordId,
+ clientRecordVersion = 0L
+ )
+ )
+ }
+
+ private fun hasPendingSamplesLocked(): Boolean =
+ pendingRecord != null || pendingSamples.isNotEmpty()
+
+ private fun bufferedSampleCountLocked(): Int =
+ pendingSamples.size + (pendingRecord?.samples?.size ?: 0)
+
+ private fun hasCompletedIntervalWindowLocked(): Boolean {
+ val windowStart = ensureIntervalWindowStartLocked() ?: return false
+ return isIntervalCompleteLocked(windowStart + exportIntervalMillis())
+ }
+
+ private fun isIntervalCompleteLocked(windowEnd: Long): Boolean =
+ System.currentTimeMillis() >= windowEnd || pendingSamples.values.any {
+ it.sample.receivedAtMillis >= windowEnd
+ }
+
+ private fun ensureIntervalWindowStartLocked(): Long? {
+ intervalWindowStartMillis?.let { return it }
+ return pendingSamples.values.minOfOrNull { it.sample.receivedAtMillis }?.also {
+ intervalWindowStartMillis = it
+ }
+ }
+
+ private fun exportIntervalMillis(): Long = if (_state.value.detailedSamples) {
+ SECOND_INTERVAL_MILLIS
+ } else {
+ MINUTE_INTERVAL_MILLIS
+ }
+
+ 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.sumOf { it.sample.bpm.toLong() }
+ return (total + samples.size / 2L) / samples.size
+ }
+
+ private fun recordClientRecordId(
+ samples: List,
+ startTimeMillis: Long,
+ endTimeMillis: Long
+ ): String {
+ val stableRecordDescription = buildString {
+ append(startTimeMillis)
+ append('\u0000')
+ append(endTimeMillis)
+ append('\u0000')
+ append(samples.first().deviceModel)
+ samples.forEach { pending ->
+ append('\u0000')
+ append(pending.id)
+ }
+ }
+ return "$RECORD_CLIENT_RECORD_ID_PREFIX${sha256(stableRecordDescription)}"
+ }
+
+ 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 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 RECORD_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-record-v1-"
+ private const val MAX_BUFFERED_SAMPLES = 300
+ private const val SECOND_INTERVAL_MILLIS = 1_000L
+ private const val MINUTE_INTERVAL_MILLIS = 60_000L
+ private const val RETRY_INTERVAL_MILLIS = 30_000L
+
+ val WRITE_HEART_RATE_PERMISSION: String =
+ HealthPermission.getWritePermission(HeartRateRecord::class)
+ val REQUIRED_PERMISSIONS: Set = setOf(WRITE_HEART_RATE_PERMISSION)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt
new file mode 100644
index 000000000..f1a59a389
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/health/workout/WorkoutHealthConnectExporter.kt
@@ -0,0 +1,94 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.health.workout
+
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.metadata.Device
+import androidx.health.connect.client.records.metadata.Metadata
+import me.kavishdevar.librepods.data.workout.WorkoutSessionEntity
+import java.time.Instant
+import java.time.ZoneOffset
+
+sealed interface WorkoutHealthConnectExportResult {
+ data class Exported(val recordId: String?) : WorkoutHealthConnectExportResult
+ data object PermissionRequired : WorkoutHealthConnectExportResult
+ data object Unavailable : WorkoutHealthConnectExportResult
+ data class Failed(val message: String) : WorkoutHealthConnectExportResult
+}
+
+interface WorkoutHealthConnectExporter {
+ suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult
+}
+
+class AndroidWorkoutHealthConnectExporter(context: Context) : WorkoutHealthConnectExporter {
+ private val appContext = context.applicationContext
+ private var client: HealthConnectClient? = null
+
+ override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult {
+ val endMillis = session.endTimeEpochMillis ?: return WorkoutHealthConnectExportResult.Failed(
+ "Workout has not been finished"
+ )
+ return try {
+ if (HealthConnectClient.getSdkStatus(appContext) != HealthConnectClient.SDK_AVAILABLE) {
+ return WorkoutHealthConnectExportResult.Unavailable
+ }
+ val healthClient = client ?: HealthConnectClient.getOrCreate(appContext).also { client = it }
+ if (WRITE_EXERCISE_PERMISSION !in healthClient.permissionController.getGrantedPermissions()) {
+ return WorkoutHealthConnectExportResult.PermissionRequired
+ }
+
+ val record = ExerciseSessionRecord(
+ startTime = Instant.ofEpochMilli(session.startTimeEpochMillis),
+ startZoneOffset = ZoneOffset.ofTotalSeconds(session.startZoneOffsetSeconds),
+ endTime = Instant.ofEpochMilli(endMillis),
+ endZoneOffset = ZoneOffset.ofTotalSeconds(
+ session.endZoneOffsetSeconds ?: session.startZoneOffsetSeconds
+ ),
+ exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ title = "LibrePods workout",
+ metadata = Metadata.activelyRecorded(
+ device = Device(
+ type = Device.TYPE_UNKNOWN,
+ manufacturer = "Apple",
+ model = "AirPods",
+ ),
+ clientRecordId = session.healthConnectClientRecordId,
+ clientRecordVersion = 0L,
+ ),
+ )
+ val response = healthClient.insertRecords(listOf(record))
+ WorkoutHealthConnectExportResult.Exported(response.recordIdsList.firstOrNull())
+ } catch (security: SecurityException) {
+ WorkoutHealthConnectExportResult.PermissionRequired
+ } catch (error: Exception) {
+ WorkoutHealthConnectExportResult.Failed(
+ error.message ?: error.javaClass.simpleName
+ )
+ }
+ }
+
+ companion object {
+ val WRITE_EXERCISE_PERMISSION: String =
+ HealthPermission.getWritePermission(ExerciseSessionRecord::class)
+ val REQUIRED_PERMISSIONS: Set = setOf(WRITE_EXERCISE_PERMISSION)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt
new file mode 100644
index 000000000..9cbe88fbd
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateCard.kt
@@ -0,0 +1,371 @@
+/*
+ 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.draw.scale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import me.kavishdevar.librepods.presentation.theme.DesignSystem
+import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
+import me.kavishdevar.librepods.services.HeartRateMonitoringState
+import me.kavishdevar.librepods.services.HeartRateMonitoringStatus
+
+@Composable
+fun HeartRateCard(
+ state: HeartRateMonitoringState,
+ onMonitoringChanged: (Boolean) -> Unit,
+ onReconnectAacp: () -> Unit,
+ onOpenDetails: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable(
+ sample = state.latestSample,
+ monitoringStatus = state.status
+ )
+ val displayedBpm = state.latestSample
+ ?.takeIf { sampleIsDisplayable }
+ ?.bpm
+ ?.toString()
+ ?: EM_DASH
+ val graphValues = remember(state.samples) {
+ normalizedRecentHeartRates(state.samples)
+ }
+ val canReconnectAacp = state.status == HeartRateMonitoringStatus.COULDNT_START
+
+ when (LocalDesignSystem.current) {
+ DesignSystem.Material -> MaterialHeartRateCard(
+ displayedBpm = displayedBpm,
+ graphValues = graphValues,
+ state = state,
+ canReconnectAacp = canReconnectAacp,
+ onMonitoringChanged = onMonitoringChanged,
+ onReconnectAacp = onReconnectAacp,
+ onOpenDetails = onOpenDetails,
+ modifier = modifier
+ )
+
+ DesignSystem.Apple -> AppleHeartRateCard(
+ displayedBpm = displayedBpm,
+ graphValues = graphValues,
+ state = state,
+ canReconnectAacp = canReconnectAacp,
+ onMonitoringChanged = onMonitoringChanged,
+ onReconnectAacp = onReconnectAacp,
+ onOpenDetails = onOpenDetails,
+ modifier = modifier
+ )
+ }
+}
+
+@Composable
+private fun MaterialHeartRateCard(
+ displayedBpm: String,
+ graphValues: List,
+ state: HeartRateMonitoringState,
+ canReconnectAacp: Boolean,
+ onMonitoringChanged: (Boolean) -> Unit,
+ onReconnectAacp: () -> Unit,
+ onOpenDetails: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onOpenDetails),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HeartRateMiniGraph(
+ values = graphValues,
+ width = MATERIAL_GRAPH_WIDTH,
+ height = MATERIAL_GRAPH_HEIGHT
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = "Heart rate",
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+ HeartRateStatusChip(
+ status = state.status,
+ onRetry = onReconnectAacp.takeIf { canReconnectAacp },
+ compact = true
+ )
+ }
+
+ if (!canReconnectAacp) {
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = displayedBpm,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "BPM",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Switch(
+ checked = state.enabled,
+ onCheckedChange = onMonitoringChanged,
+ modifier = Modifier.scale(MATERIAL_SWITCH_SCALE)
+ )
+ }
+ }
+}
+
+@Composable
+private fun AppleHeartRateCard(
+ displayedBpm: String,
+ graphValues: List,
+ state: HeartRateMonitoringState,
+ canReconnectAacp: Boolean,
+ onMonitoringChanged: (Boolean) -> Unit,
+ onReconnectAacp: () -> Unit,
+ onOpenDetails: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .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
+ )
+ HeartRateStatusChip(
+ status = state.status,
+ onRetry = onReconnectAacp.takeIf { canReconnectAacp }
+ )
+ }
+
+ if (!canReconnectAacp) {
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = displayedBpm,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "BPM",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.width(14.dp))
+ }
+
+ StyledSwitch(
+ checked = state.enabled,
+ onCheckedChange = onMonitoringChanged
+ )
+ }
+ }
+}
+
+@Composable
+private fun HeartRateMiniGraph(
+ values: List,
+ modifier: Modifier = Modifier,
+ width: Dp = GRAPH_WIDTH,
+ height: Dp = GRAPH_HEIGHT
+) {
+ val graphColor = MaterialTheme.colorScheme.primary
+ val guideColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
+
+ Canvas(
+ modifier = modifier
+ .width(width)
+ .height(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 val GRAPH_WIDTH = 60.dp
+private val GRAPH_HEIGHT = 44.dp
+private val MATERIAL_GRAPH_WIDTH = 48.dp
+private val MATERIAL_GRAPH_HEIGHT = 36.dp
+private const val MATERIAL_SWITCH_SCALE = 0.82f
+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/components/HeartRateStatusChip.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt
new file mode 100644
index 000000000..2f055f57c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HeartRateStatusChip.kt
@@ -0,0 +1,113 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+*/
+
+package me.kavishdevar.librepods.presentation.components
+
+import android.os.SystemClock
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import me.kavishdevar.librepods.services.HeartRateMonitoringStatus
+
+@Composable
+fun HeartRateStatusChip(
+ status: HeartRateMonitoringStatus,
+ onRetry: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ compact: Boolean = false
+) {
+ val containerColor = when (status) {
+ HeartRateMonitoringStatus.LIVE -> MaterialTheme.colorScheme.primaryContainer
+ HeartRateMonitoringStatus.COULDNT_START -> MaterialTheme.colorScheme.errorContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ val contentColor = when (status) {
+ HeartRateMonitoringStatus.LIVE -> MaterialTheme.colorScheme.onPrimaryContainer
+ HeartRateMonitoringStatus.COULDNT_START -> MaterialTheme.colorScheme.onErrorContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ Surface(
+ modifier = if (onRetry == null) modifier else modifier.clickable(onClick = onRetry),
+ shape = RoundedCornerShape(999.dp),
+ color = containerColor,
+ contentColor = contentColor
+ ) {
+ Text(
+ text = if (onRetry == null) status.label else "${status.label} · Retry",
+ style = if (compact) {
+ MaterialTheme.typography.labelMedium
+ } else {
+ MaterialTheme.typography.labelLarge
+ },
+ modifier = Modifier.padding(
+ horizontal = if (compact) 10.dp else 12.dp,
+ vertical = if (compact) 4.dp else 7.dp
+ )
+ )
+ }
+}
+
+@Composable
+fun rememberHeartRateSampleIsDisplayable(
+ sample: HeartRateSample?,
+ monitoringStatus: HeartRateMonitoringStatus
+): Boolean {
+ val statusAllowsDisplay = monitoringStatus.allowsSampleDisplay
+ var sampleIsFresh by remember(sample?.receivedAtElapsedRealtime, statusAllowsDisplay) {
+ mutableStateOf(
+ sample != null &&
+ statusAllowsDisplay &&
+ SystemClock.elapsedRealtime() - sample.receivedAtElapsedRealtime <=
+ HEART_RATE_SAMPLE_STALE_AFTER_MILLIS
+ )
+ }
+
+ LaunchedEffect(sample?.receivedAtElapsedRealtime, statusAllowsDisplay) {
+ if (!sampleIsFresh || sample == null || !statusAllowsDisplay) return@LaunchedEffect
+
+ val expiresAtElapsedRealtime =
+ sample.receivedAtElapsedRealtime + HEART_RATE_SAMPLE_STALE_AFTER_MILLIS
+ delay((expiresAtElapsedRealtime - SystemClock.elapsedRealtime()).coerceAtLeast(0L))
+ sampleIsFresh = false
+ }
+
+ return sampleIsFresh
+}
+
+private const val HEART_RATE_SAMPLE_STALE_AFTER_MILLIS = 10_000L
+
+private val HeartRateMonitoringStatus.allowsSampleDisplay: Boolean
+ get() = this == HeartRateMonitoringStatus.LIVE
+
+private val HeartRateMonitoringStatus.label: String
+ get() = when (this) {
+ HeartRateMonitoringStatus.OFF -> "Off"
+ HeartRateMonitoringStatus.WAITING_FOR_AIRPODS -> "Waiting for AirPods"
+ HeartRateMonitoringStatus.WAITING_TO_BE_WORN -> "Waiting to be worn"
+ HeartRateMonitoringStatus.STARTING -> "Starting"
+ HeartRateMonitoringStatus.CALIBRATING -> "Calibrating"
+ HeartRateMonitoringStatus.LIVE -> "Live"
+ HeartRateMonitoringStatus.RECONNECTING -> "Reconnecting"
+ HeartRateMonitoringStatus.COULDNT_START -> "Couldn’t start"
+ }
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt
index 14479eb57..0c59ffcf7 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt
@@ -24,12 +24,14 @@ 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
import me.kavishdevar.librepods.presentation.screens.LoadingScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute
+import me.kavishdevar.librepods.presentation.screens.NearbyAirPodsFinderScreen
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
@@ -38,6 +40,10 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute
import me.kavishdevar.librepods.presentation.screens.VersionScreen
+import me.kavishdevar.librepods.presentation.screens.WorkoutDetailScreen
+import me.kavishdevar.librepods.presentation.screens.WorkoutHistoryScreen
+import me.kavishdevar.librepods.presentation.screens.WorkoutScreen
+import me.kavishdevar.librepods.presentation.screens.WorkoutSettingsScreen
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -111,6 +117,8 @@ fun AppNavGraph(
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
+ navigateToHeartRateTest = { navigate(Screen.HeartRateTest) },
+ navigateToNearbyFinder = { navigate(Screen.NearbyFinder) },
)
}
@@ -128,7 +136,7 @@ fun AppNavGraph(
navigateToPurchase = ::navigateToPurchase,
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
- navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }
+ navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
)
}
@@ -143,6 +151,53 @@ fun AppNavGraph(
HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase)
}
+ Screen.HeartRateTest ->
+ NavEntry(screen) {
+ if (!airPodsViewModel.isReady) LoadingScreen()
+ HeartRateTestScreen(
+ viewModel = airPodsViewModel,
+ navigateToWorkout = { navigate(Screen.Workout) },
+ )
+ }
+
+ Screen.Workout ->
+ NavEntry(screen) {
+ if (!airPodsViewModel.isReady) LoadingScreen()
+ WorkoutScreen(
+ viewModel = airPodsViewModel,
+ navigateToHistory = { navigate(Screen.WorkoutHistory) },
+ navigateToSettings = { navigate(Screen.WorkoutSettings) },
+ )
+ }
+
+ Screen.WorkoutHistory ->
+ NavEntry(screen) {
+ WorkoutHistoryScreen { sessionId ->
+ navigate(Screen.WorkoutDetail(sessionId))
+ }
+ }
+
+ is Screen.WorkoutDetail ->
+ NavEntry(screen) {
+ WorkoutDetailScreen(
+ sessionId = screen.sessionId,
+ onDeleted = {
+ if (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
+ },
+ )
+ }
+
+ Screen.WorkoutSettings ->
+ NavEntry(screen) {
+ WorkoutSettingsScreen()
+ }
+
+ Screen.NearbyFinder ->
+ NavEntry(screen) {
+ if (!airPodsViewModel.isReady) LoadingScreen()
+ NearbyAirPodsFinderScreen(airPodsViewModel)
+ }
+
Screen.Accessibility ->
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..2e3cf7ecc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt
@@ -59,6 +59,12 @@ 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.Workout -> "Workout"
+ Screen.WorkoutHistory -> "Workout history"
+ is Screen.WorkoutDetail -> "Workout details"
+ Screen.WorkoutSettings -> "Workout zones"
+ Screen.NearbyFinder -> "Find Nearby"
Screen.HearingAid -> stringResource(R.string.hearing_aid)
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt
index 1a8959f3f..906f32da5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt
@@ -28,6 +28,24 @@ sealed interface Screen: NavKey {
@Serializable
data object HeadTracking: Screen
+ @Serializable
+ data object HeartRateTest: Screen
+
+ @Serializable
+ data object Workout: Screen
+
+ @Serializable
+ data object WorkoutHistory: Screen
+
+ @Serializable
+ data class WorkoutDetail(val sessionId: String): Screen
+
+ @Serializable
+ data object WorkoutSettings: Screen
+
+ @Serializable
+ data object NearbyFinder: Screen
+
@Serializable
data object Accessibility: Screen
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt
index bf5eff89e..7de61721c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt
@@ -374,8 +374,13 @@ class IslandWindow(private val context: Context) {
}
val videoView = islandView.findViewById(R.id.island_video_view)
- val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
+ val videoUri = "android.resource://${context.packageName}/${R.raw.island}".toUri()
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
+ videoView.setOnErrorListener { _, what, extra ->
+ e("IslandWindow", "Island video playback failed what=$what extra=$extra")
+ videoView.visibility = View.GONE
+ true
+ }
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt
index 4247ea47a..74df5cae4 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt
@@ -139,7 +139,15 @@ class PopupWindow(
val vid = mView.findViewById(R.id.video)
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
- vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
+ vid.setOnErrorListener { _, what, extra ->
+ Log.e(
+ "PopupWindow",
+ "Connection video playback failed what=$what extra=$extra"
+ )
+ vid.visibility = View.GONE
+ true
+ }
+ vid.setVideoPath("android.resource://${context.packageName}/${R.raw.connected}")
vid.resolveAdjustedSize(vid.width, vid.height)
vid.start()
vid.setOnCompletionListener {
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..bb64e2674 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
@@ -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,9 @@ fun AirPodsSettingsRoute(
navigateToVersion: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToCallControlScreen: (action: String) -> Unit,
- navigateToMicrophoneSettings: () -> Unit
+ navigateToMicrophoneSettings: () -> Unit,
+ navigateToHeartRateTest: () -> Unit,
+ navigateToNearbyFinder: () -> Unit
) {
val state by viewModel.uiState.collectAsState()
@@ -190,6 +193,11 @@ fun AirPodsSettingsRoute(
navigateToTroubleshooting = navigateToTroubleshooting,
navigateToCallControlScreen = navigateToCallControlScreen,
navigateToMicrophoneSettings = navigateToMicrophoneSettings,
+ navigateToHeartRateTest = navigateToHeartRateTest,
+ navigateToNearbyFinder = navigateToNearbyFinder,
+
+ setHeartRateMonitoringEnabled = viewModel::setHeartRateMonitoringEnabled,
+ reconnectAacpForHeartRate = viewModel::reconnectAacpForHeartRate,
activateDemoMode = viewModel::activateDemoMode,
reconnectFromSavedMac = viewModel::reconnectFromSavedMac
@@ -232,6 +240,11 @@ fun AirPodsSettingsScreen(
navigateToTroubleshooting: () -> Unit,
navigateToCallControlScreen: (action: String) -> Unit,
navigateToMicrophoneSettings: () -> Unit,
+ navigateToHeartRateTest: () -> Unit,
+ navigateToNearbyFinder: () -> Unit,
+
+ setHeartRateMonitoringEnabled: (Boolean) -> Unit,
+ reconnectAacpForHeartRate: () -> Unit,
activateDemoMode: () -> Unit,
reconnectFromSavedMac: () -> Unit,
@@ -316,7 +329,7 @@ fun AirPodsSettingsScreen(
)
}
item(key = "spacer_battery") {
- Spacer(modifier = Modifier.height(32.dp))
+ Spacer(modifier = Modifier.height(24.dp))
}
item(key = "name") {
@@ -326,6 +339,28 @@ fun AirPodsSettingsScreen(
onClick = navigateToRename,
)
}
+ item(key = "spacer_nearby_finder") { Spacer(modifier = Modifier.height(16.dp)) }
+ item(key = "nearby_finder") {
+ StyledListItem(
+ name = "Find Nearby",
+ onClick = navigateToNearbyFinder
+ )
+ }
+ val hasHeartRateCapability =
+ state.instance?.model?.capabilities?.contains(Capability.HRM) == true
+ if (hasHeartRateCapability) {
+ item(key = "spacer_heart_rate") {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ item(key = "heart_rate") {
+ HeartRateCard(
+ state = state.heartRate,
+ onMonitoringChanged = setHeartRateMonitoringEnabled,
+ onReconnectAacp = reconnectAacpForHeartRate,
+ onOpenDetails = navigateToHeartRateTest
+ )
+ }
+ }
val hasHearingAidCapability =
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
@@ -966,6 +1001,11 @@ fun AirPodsSettingsScreenPreviewApple() {
navigateToTroubleshooting = {},
navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {},
+ navigateToHeartRateTest = {},
+ navigateToNearbyFinder = {},
+
+ setHeartRateMonitoringEnabled = {},
+ reconnectAacpForHeartRate = {},
activateDemoMode = {},
reconnectFromSavedMac = {}
@@ -1013,6 +1053,11 @@ fun AirPodsSettingsScreenPreviewMaterial() {
navigateToTroubleshooting = {},
navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {},
+ navigateToHeartRateTest = {},
+ navigateToNearbyFinder = {},
+
+ setHeartRateMonitoringEnabled = {},
+ reconnectAacpForHeartRate = {},
activateDemoMode = {},
reconnectFromSavedMac = {}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
index 06436561d..0b662834d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
@@ -105,7 +105,7 @@ fun AppSettingsScreen(
navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit,
- navigateToReleaseNotesScreen: () -> Unit
+ navigateToReleaseNotesScreen: () -> Unit,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt
new file mode 100644
index 000000000..92b237d6c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt
@@ -0,0 +1,701 @@
+/*
+ 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.Manifest
+import android.content.pm.PackageManager
+import android.graphics.Paint
+import android.graphics.Typeface
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+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.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.health.connect.client.PermissionController
+import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState
+import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralStatus
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import me.kavishdevar.librepods.health.HealthConnectExportState
+import me.kavishdevar.librepods.health.HealthConnectExportStatus
+import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter
+import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip
+import me.kavishdevar.librepods.presentation.components.StyledSwitch
+import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.components.rememberHeartRateSampleIsDisplayable
+import me.kavishdevar.librepods.presentation.theme.DesignSystem
+import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import me.kavishdevar.librepods.services.HeartRateMonitoringState
+import me.kavishdevar.librepods.services.HeartRateMonitoringStatus
+import java.text.DateFormat
+import java.util.Date
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlinx.coroutines.delay
+
+@Composable
+fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Unit) {
+ val state by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ var graphNowMillis by remember { mutableLongStateOf(System.currentTimeMillis()) }
+ val healthConnectPermissionLauncher = rememberLauncherForActivityResult(
+ PermissionController.createRequestPermissionResultContract()
+ ) { grantedPermissions: Set ->
+ if (HealthConnectHeartRateExporter.WRITE_HEART_RATE_PERMISSION in grantedPermissions) {
+ viewModel.setHealthConnectExportEnabled(true)
+ } else {
+ viewModel.markHealthConnectPermissionDenied()
+ }
+ }
+ val blePermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) {
+ viewModel.refreshHeartRateBlePeripheral()
+ }
+
+ fun enableOrRetryBlePeripheral() {
+ val permissions = arrayOf(
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_ADVERTISE
+ )
+ viewModel.setHeartRateBlePeripheralEnabled(true)
+ val missing = permissions.filter {
+ context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (missing.isNotEmpty()) {
+ blePermissionLauncher.launch(missing.toTypedArray())
+ } else {
+ viewModel.refreshHeartRateBlePeripheral()
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.refreshHealthConnectExportState()
+ viewModel.refreshHeartRateBlePeripheral()
+ }
+
+ // Keep the live chart's right edge moving while the stream is quiet. This makes a
+ // lost signal visible as blank time instead of leaving the last sample at the edge.
+ LaunchedEffect(Unit) {
+ while (true) {
+ graphNowMillis = System.currentTimeMillis()
+ delay(LIVE_HEART_RATE_GRAPH_TICK_MILLIS)
+ }
+ }
+
+ val materialDesign = LocalDesignSystem.current == DesignSystem.Material
+ val topPadding = if (materialDesign) {
+ 16.dp
+ } else {
+ WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
+ }
+ val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp
+
+ val heartRate = state.heartRate
+ val healthConnect = state.healthConnect
+ val sampleIsDisplayable = rememberHeartRateSampleIsDisplayable(
+ sample = heartRate.latestSample,
+ monitoringStatus = heartRate.status
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(topPadding))
+
+ HeartRateSummaryCard(
+ state = heartRate,
+ sampleIsDisplayable = sampleIsDisplayable,
+ onReconnectAacp = viewModel::reconnectAacpForHeartRate,
+ onMonitoringChanged = viewModel::setHeartRateMonitoringEnabled
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedButton(
+ onClick = navigateToWorkout,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Workouts & session history")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ HealthConnectControls(
+ state = healthConnect,
+ onExportChanged = { enabled ->
+ when {
+ !enabled -> viewModel.setHealthConnectExportEnabled(false)
+ healthConnect.status.canEnableExport ->
+ viewModel.setHealthConnectExportEnabled(true)
+
+ healthConnect.status.requiresPermissionRequest ->
+ healthConnectPermissionLauncher.launch(
+ HealthConnectHeartRateExporter.REQUIRED_PERMISSIONS
+ )
+ }
+ },
+ onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ BleHeartRatePeripheralControls(
+ state = state.heartRateBlePeripheral,
+ onEnabledChanged = { enabled ->
+ if (enabled) enableOrRetryBlePeripheral()
+ else viewModel.setHeartRateBlePeripheralEnabled(false)
+ },
+ onRetry = ::enableOrRetryBlePeripheral
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = "Recent samples",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
+ )
+
+ Text(
+ text = formatGraphSummary(heartRate.samples),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
+ )
+
+ HeartRateGraph(samples = heartRate.samples, nowMillis = graphNowMillis)
+
+ Spacer(modifier = Modifier.height(bottomPadding))
+ }
+}
+
+@Composable
+private fun HeartRateSummaryCard(
+ state: HeartRateMonitoringState,
+ sampleIsDisplayable: Boolean,
+ onReconnectAacp: () -> Unit,
+ onMonitoringChanged: (Boolean) -> Unit
+) {
+ val materialDesign = LocalDesignSystem.current == DesignSystem.Material
+ val displayBpm = state.latestSample?.takeIf { sampleIsDisplayable }?.bpm?.toString() ?: EM_DASH
+ val reconnectAction = onReconnectAacp.takeIf {
+ state.status == HeartRateMonitoringStatus.COULDNT_START
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(if (materialDesign) 24.dp else 28.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(if (materialDesign) 12.dp else 14.dp)
+ ) {
+ when (LocalDesignSystem.current) {
+ DesignSystem.Material -> {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = displayBpm,
+ style = MaterialTheme.typography.displayMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "BPM",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = state.enabled,
+ onCheckedChange = onMonitoringChanged,
+ modifier = Modifier.scale(MATERIAL_SWITCH_SCALE)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = formatLastReading(state.latestSample),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f)
+ )
+ HeartRateStatusChip(
+ status = state.status,
+ onRetry = reconnectAction,
+ compact = true
+ )
+ }
+ }
+
+ DesignSystem.Apple -> {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Column {
+ Text(
+ text = displayBpm,
+ style = MaterialTheme.typography.displayMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "BPM",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Box(modifier = Modifier.padding(end = 4.dp, bottom = 8.dp)) {
+ StyledSwitch(
+ checked = state.enabled,
+ onCheckedChange = onMonitoringChanged
+ )
+ }
+ HeartRateStatusChip(
+ status = state.status,
+ onRetry = reconnectAction
+ )
+ }
+ }
+
+ Text(
+ text = formatLastReading(state.latestSample),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HealthConnectControls(
+ state: HealthConnectExportState,
+ onExportChanged: (Boolean) -> Unit,
+ onDetailedSamplesChanged: (Boolean) -> Unit
+) {
+ val available = state.status.isAvailable
+
+ StyledToggle(
+ title = "Health Connect",
+ label = "Save heart-rate samples",
+ description = healthConnectDescription(state.status, state.detailedSamples),
+ checked = state.enabled,
+ enabled = available,
+ onCheckedChange = onExportChanged
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ StyledToggle(
+ title = null,
+ label = "Detailed samples",
+ description = if (state.detailedSamples) {
+ "Save one BPM record every second."
+ } else {
+ "Save one average BPM record every minute."
+ },
+ checked = state.detailedSamples,
+ enabled = available,
+ onCheckedChange = onDetailedSamplesChanged
+ )
+}
+
+@Composable
+private fun BleHeartRatePeripheralControls(
+ state: HeartRateBlePeripheralState,
+ onEnabledChanged: (Boolean) -> Unit,
+ onRetry: () -> Unit
+) {
+ StyledToggle(
+ title = "Bluetooth heart-rate sharing",
+ label = "Share as a BLE heart-rate sensor",
+ description = blePeripheralDescription(state),
+ checked = state.enabled,
+ onCheckedChange = onEnabledChanged
+ )
+
+ if (state.enabled && state.status in setOf(
+ HeartRateBlePeripheralStatus.ERROR,
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED,
+ HeartRateBlePeripheralStatus.BLUETOOTH_OFF
+ )
+ ) {
+ OutlinedButton(
+ onClick = onRetry,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(if (state.status == HeartRateBlePeripheralStatus.PERMISSION_REQUIRED) "Allow & retry" else "Retry")
+ }
+ }
+}
+
+private fun blePeripheralDescription(state: HeartRateBlePeripheralState): String {
+ val privacy = "Off by default. Shares only validated LibrePods heart-rate samples while enabled."
+ return when (state.status) {
+ HeartRateBlePeripheralStatus.DISABLED -> privacy
+ HeartRateBlePeripheralStatus.STARTING -> "Starting the standard Heart Rate Service (0x180D). $privacy"
+ HeartRateBlePeripheralStatus.ADVERTISING ->
+ "Advertising · ${state.connectedDeviceCount} connected · ${state.subscribedDeviceCount} subscribed. $privacy"
+ HeartRateBlePeripheralStatus.PERMISSION_REQUIRED ->
+ "Nearby devices permission is required to advertise and accept GATT connections. $privacy"
+ HeartRateBlePeripheralStatus.BLUETOOTH_OFF -> "Bluetooth is off. $privacy"
+ HeartRateBlePeripheralStatus.UNSUPPORTED ->
+ "BLE peripheral advertising is not supported by this adapter. $privacy"
+ HeartRateBlePeripheralStatus.ERROR ->
+ "${state.lastError ?: "BLE heart-rate sharing failed."} $privacy"
+ }
+}
+
+private val HealthConnectExportStatus.isAvailable: Boolean
+ get() = this != HealthConnectExportStatus.UNAVAILABLE &&
+ this != HealthConnectExportStatus.UPDATE_REQUIRED
+
+private val HealthConnectExportStatus.canEnableExport: Boolean
+ get() = this == HealthConnectExportStatus.READY ||
+ this == HealthConnectExportStatus.ENABLED
+
+private val HealthConnectExportStatus.requiresPermissionRequest: Boolean
+ get() = this == HealthConnectExportStatus.PERMISSION_REQUIRED ||
+ this == HealthConnectExportStatus.PERMISSION_DENIED ||
+ this == HealthConnectExportStatus.ERROR
+
+private fun healthConnectDescription(
+ status: HealthConnectExportStatus,
+ detailedSamples: Boolean
+): 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 heart-rate data is saved every second."
+ } 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, nowMillis: Long) {
+ val orderedSamples = remember(samples) {
+ samples.sortedBy { it.receivedAtMillis }
+ }
+ val minTime = maxOf(
+ orderedSamples.firstOrNull()?.receivedAtMillis ?: nowMillis,
+ nowMillis - LIVE_HEART_RATE_GRAPH_WINDOW_MILLIS,
+ )
+ val maxTime = maxOf(
+ orderedSamples.lastOrNull()?.receivedAtMillis ?: nowMillis,
+ nowMillis,
+ ).coerceAtLeast(minTime + 1L)
+ val visibleSamples = orderedSamples.filter {
+ it.receivedAtMillis in minTime..maxTime
+ }
+ val chartScale = remember(visibleSamples) {
+ calculateHeartRateChartScale(visibleSamples.map { it.bpm.toFloat() })
+ }
+ val lineColor = MaterialTheme.colorScheme.primary
+ val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f)
+ 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 (visibleSamples.isNotEmpty()) {
+ val path = Path()
+ var previousTimestamp: Long? = null
+ visibleSamples.forEachIndexed { index, sample ->
+ val x = plotLeft +
+ (sample.receivedAtMillis - minTime).toFloat() /
+ (maxTime - minTime).toFloat() * plotWidth
+ val y = chartScale.bpmY(
+ bpm = sample.bpm.toFloat(),
+ plotBottom = plotBottom,
+ plotHeight = plotHeight
+ )
+
+ val previous = previousTimestamp
+ if (
+ previous == null ||
+ sample.receivedAtMillis - previous > LIVE_HEART_RATE_GRAPH_GAP_MILLIS
+ ) {
+ // Do not invent a slope through a period with no validated data.
+ path.moveTo(x, y)
+ } else {
+ path.lineTo(x, y)
+ }
+ previousTimestamp = sample.receivedAtMillis
+ if (index == visibleSamples.lastIndex) {
+ drawCircle(
+ color = pointColor,
+ radius = 4.dp.toPx(),
+ center = Offset(x, y)
+ )
+ }
+ }
+ if (visibleSamples.size > 1) {
+ drawPath(
+ path = path,
+ color = lineColor,
+ style = Stroke(width = 3.dp.toPx())
+ )
+ }
+ }
+ }
+
+ if (visibleSamples.isEmpty()) {
+ Text(
+ text = "Waiting for recent validated heart-rate samples",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(start = CHART_AXIS_WIDTH)
+ )
+ }
+ }
+ }
+}
+
+private fun formatGraphSummary(samples: List): String {
+ if (samples.isEmpty()) return "Min $EM_DASH · Avg $EM_DASH · Max $EM_DASH BPM"
+
+ val min = samples.minOf { it.bpm }
+ val max = samples.maxOf { it.bpm }
+ val average = samples.sumOf { it.bpm }.toFloat() / samples.size
+ return "Min $min · Avg ${average.toInt()} · Max $max BPM"
+}
+
+private data class HeartRateChartScale(
+ val minBpm: Float,
+ val maxBpm: Float,
+ val gridLines: List
+) {
+ val spanBpm: Float
+ get() = maxBpm - minBpm
+
+ fun bpmY(bpm: Float, plotBottom: Float, plotHeight: Float): Float {
+ val normalized = ((bpm - minBpm) / spanBpm).coerceIn(0f, 1f)
+ return plotBottom - normalized * plotHeight
+ }
+}
+
+private fun calculateHeartRateChartScale(bpms: List): HeartRateChartScale {
+ if (bpms.isEmpty()) {
+ return HeartRateChartScale(
+ minBpm = CHART_DEFAULT_MIN_BPM,
+ maxBpm = CHART_DEFAULT_MAX_BPM,
+ gridLines = listOf(60f, 70f, 80f, 90f, 100f)
+ )
+ }
+
+ val dataMin = bpms.minOrNull() ?: CHART_DEFAULT_MIN_BPM
+ val dataMax = bpms.maxOrNull() ?: CHART_DEFAULT_MAX_BPM
+ val paddedMin = dataMin - CHART_MARGIN_BPM
+ val paddedMax = dataMax + CHART_MARGIN_BPM
+ val requestedSpan = maxOf(paddedMax - paddedMin, CHART_MIN_SPAN_BPM)
+ val center = (paddedMin + paddedMax) / 2f
+ val roughMin = center - requestedSpan / 2f
+ val roughMax = center + requestedSpan / 2f
+ val tickStep = niceTickStep(requestedSpan / CHART_TARGET_GRID_INTERVALS)
+
+ var minBpm = floor(roughMin / tickStep) * tickStep
+ var maxBpm = ceil(roughMax / tickStep) * tickStep
+ if (minBpm < CHART_OUTER_MIN_BPM) {
+ maxBpm -= minBpm - CHART_OUTER_MIN_BPM
+ minBpm = CHART_OUTER_MIN_BPM
+ }
+ if (maxBpm > CHART_OUTER_MAX_BPM) {
+ minBpm -= maxBpm - CHART_OUTER_MAX_BPM
+ maxBpm = CHART_OUTER_MAX_BPM
+ }
+
+ val gridLines = buildList {
+ var value = minBpm
+ while (value <= maxBpm + 0.01f) {
+ add(value)
+ value += tickStep
+ }
+ }
+ return HeartRateChartScale(minBpm, maxBpm, gridLines)
+}
+
+private fun niceTickStep(rawStep: Float): Float =
+ CHART_TICK_STEPS.firstOrNull { it >= rawStep } ?: CHART_TICK_STEPS.last()
+
+private fun formatLastReading(sample: HeartRateSample?): String {
+ if (sample == null) return "Last reading: $EM_DASH"
+ val time = DateFormat.getTimeInstance(DateFormat.SHORT)
+ .format(Date(sample.receivedAtMillis))
+ return "Last reading: ${sample.bpm} BPM at $time"
+}
+
+private const val EM_DASH = "—"
+private const val MATERIAL_SWITCH_SCALE = 0.82f
+private const val CHART_DEFAULT_MIN_BPM = 60f
+private const val CHART_DEFAULT_MAX_BPM = 100f
+private const val CHART_MIN_SPAN_BPM = 40f
+private const val CHART_MARGIN_BPM = 5f
+private const val CHART_OUTER_MIN_BPM = 0f
+private const val CHART_OUTER_MAX_BPM = 260f
+private const val CHART_TARGET_GRID_INTERVALS = 5f
+private const val LIVE_HEART_RATE_GRAPH_GAP_MILLIS = 4_000L
+private const val LIVE_HEART_RATE_GRAPH_WINDOW_MILLIS = 60_000L
+private const val LIVE_HEART_RATE_GRAPH_TICK_MILLIS = 1_000L
+private val CHART_TICK_STEPS = listOf(5f, 10f, 20f, 25f, 50f)
+private val CHART_AXIS_WIDTH = 42.dp
+private val CHART_AXIS_LABEL_GAP = 8.dp
+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/MicrophoneSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
index 91bf97502..af763a7d6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
@@ -52,7 +52,7 @@ fun MicrophoneSettingsRoute(
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
- }
+ },
)
}
}
@@ -62,7 +62,7 @@ fun MicrophoneSettingsScreen(
selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
- onMicrophoneSettingsChanged: (Int) -> Unit
+ onMicrophoneSettingsChanged: (Int) -> Unit,
) {
val scrollState = rememberScrollState()
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt
new file mode 100644
index 000000000..bc751d149
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/NearbyAirPodsFinderScreen.kt
@@ -0,0 +1,286 @@
+package me.kavishdevar.librepods.presentation.screens
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import me.kavishdevar.librepods.finder.NearbyFinderState
+import me.kavishdevar.librepods.finder.NearbyFinderStatus
+import me.kavishdevar.librepods.finder.ProximityBucket
+import me.kavishdevar.librepods.presentation.theme.DesignSystem
+import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import kotlin.math.roundToInt
+
+@Composable
+fun NearbyAirPodsFinderScreen(viewModel: AirPodsViewModel) {
+ val uiState by viewModel.uiState.collectAsState()
+ val finder = uiState.nearbyFinder
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) {
+ viewModel.refreshNearbyFinderPrerequisites()
+ }
+
+ DisposableEffect(Unit) {
+ onDispose { viewModel.stopNearbyFinder() }
+ }
+
+ val materialDesign = LocalDesignSystem.current == DesignSystem.Material
+ val topPadding = if (materialDesign) {
+ 16.dp
+ } else {
+ WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
+ }
+ val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 20.dp
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(topPadding))
+ Text(
+ text = "Find your AirPods",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "Move slowly while the signal settles. Distance is only an estimate.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 6.dp, start = 20.dp, end = 20.dp)
+ )
+
+ Spacer(modifier = Modifier.height(28.dp))
+ FinderIndicator(finder)
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Text(
+ text = finder.signal.proximity.label,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = statusLine(finder),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ finder.signal.approximateDistanceMeters?.let { distance ->
+ Text(
+ text = "Approx. ${formatDistance(distance)}",
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ FinderControls(
+ finder = finder,
+ onStart = viewModel::startNearbyFinder,
+ onStop = viewModel::stopNearbyFinder,
+ onRequestPermissions = {
+ permissionLauncher.launch(
+ arrayOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ )
+ )
+ }
+ )
+
+ Spacer(modifier = Modifier.height(18.dp))
+ SignalDebugCard(finder)
+ Spacer(modifier = Modifier.height(bottomPadding))
+ }
+}
+
+@Composable
+private fun FinderIndicator(state: NearbyFinderState) {
+ val proximityLevel = when (state.signal.proximity) {
+ ProximityBucket.VERY_CLOSE -> 1f
+ ProximityBucket.CLOSE -> 0.82f
+ ProximityBucket.NEARBY -> 0.62f
+ ProximityBucket.FAR -> 0.42f
+ ProximityBucket.SIGNAL_LOST -> 0.25f
+ }
+ val level by animateFloatAsState(targetValue = proximityLevel, animationSpec = tween(450), label = "finder-level")
+ val infinite = rememberInfiniteTransition(label = "finder-pulse")
+ val pulse by infinite.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(1800), RepeatMode.Restart),
+ label = "finder-pulse-phase"
+ )
+ val primary = MaterialTheme.colorScheme.primary
+ val muted = MaterialTheme.colorScheme.onSurfaceVariant
+
+ Box(modifier = Modifier.size(260.dp), contentAlignment = Alignment.Center) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val base = size.minDimension / 2f
+ repeat(3) { index ->
+ val phase = (pulse + index / 3f) % 1f
+ val radius = base * (0.34f + (0.58f * phase))
+ val alpha = (1f - phase) * 0.16f * level
+ drawCircle(color = primary.copy(alpha = alpha), radius = radius)
+ }
+ drawCircle(
+ color = if (state.signal.proximity == ProximityBucket.SIGNAL_LOST) muted.copy(alpha = 0.14f) else primary.copy(alpha = 0.16f),
+ radius = base * (0.28f + 0.08f * level)
+ )
+ drawCircle(
+ color = if (state.signal.proximity == ProximityBucket.SIGNAL_LOST) muted.copy(alpha = 0.55f) else primary,
+ radius = base * (0.12f + 0.035f * level)
+ )
+ }
+ }
+}
+
+@Composable
+private fun FinderControls(
+ finder: NearbyFinderState,
+ onStart: () -> Unit,
+ onStop: () -> Unit,
+ onRequestPermissions: () -> Unit
+) {
+ when (finder.status) {
+ NearbyFinderStatus.PERMISSION_REQUIRED -> Button(
+ onClick = onRequestPermissions,
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Allow Nearby devices & location") }
+
+ NearbyFinderStatus.BLUETOOTH_OFF -> {
+ Text(
+ "Turn on Bluetooth, then try again.",
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Button(onClick = onStart, modifier = Modifier.fillMaxWidth()) { Text("Try again") }
+ }
+
+ NearbyFinderStatus.NO_SELECTED_DEVICE -> {
+ Text(
+ "Connect or select your AirPods first.",
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ else -> if (finder.running) {
+ OutlinedButton(onClick = onStop, modifier = Modifier.fillMaxWidth()) { Text("Stop finding") }
+ } else {
+ Button(onClick = onStart, modifier = Modifier.fillMaxWidth()) { Text("Start finding") }
+ }
+ }
+}
+
+@Composable
+private fun SignalDebugCard(state: NearbyFinderState) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ ) {
+ Column(
+ modifier = Modifier.padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(9.dp)
+ ) {
+ Text("Signal details", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ DebugRow("Raw RSSI", state.signal.rawRssi?.let { "$it dBm" } ?: "—")
+ DebugRow("Smoothed", state.signal.smoothedRssi?.let { "${it.roundToInt()} dBm" } ?: "—")
+ DebugRow("Age", state.signal.sampleAgeMillis?.let(::formatAge) ?: "No sample")
+ if (state.signal.stale && state.signal.rawRssi != null) {
+ Text(
+ "Signal is stale; hold position or move back into range.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ state.errorMessage?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+}
+
+@Composable
+private fun DebugRow(label: String, value: String) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, fontWeight = FontWeight.Medium)
+ }
+}
+
+private fun statusLine(state: NearbyFinderState): String = when (state.status) {
+ NearbyFinderStatus.STOPPED -> "Ready"
+ NearbyFinderStatus.WAITING_FOR_SIGNAL -> if (state.signal.rawRssi == null) {
+ "Waiting for an AirPods proximity broadcast…"
+ } else {
+ "Signal lost — move back into range"
+ }
+ NearbyFinderStatus.ACTIVE -> state.signal.trend.label
+ NearbyFinderStatus.PERMISSION_REQUIRED -> "Nearby devices and location permission required"
+ NearbyFinderStatus.BLUETOOTH_OFF -> "Bluetooth is off"
+ NearbyFinderStatus.NO_SELECTED_DEVICE -> "No AirPods selected"
+ NearbyFinderStatus.ERROR -> state.errorMessage ?: "Finder unavailable"
+}
+
+private fun formatDistance(meters: Double): String = if (meters < 1.0) {
+ "<1 m"
+} else {
+ if (meters % 1.0 == 0.0) "${meters.roundToInt()} m" else "$meters m"
+}
+
+private fun formatAge(ageMillis: Long): String = when {
+ ageMillis < 1_000L -> "<1 s"
+ ageMillis < 10_000L -> "${ageMillis / 1_000L} s"
+ else -> "${ageMillis / 1_000L} s (stale)"
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt
new file mode 100644
index 000000000..aea03bbf2
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/WorkoutScreens.kt
@@ -0,0 +1,642 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.screens
+
+import android.content.Context
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.health.connect.client.PermissionController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.kavishdevar.librepods.LibrePodsApplication
+import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState
+import me.kavishdevar.librepods.data.workout.HeartRateZone
+import me.kavishdevar.librepods.data.workout.HeartRateZones
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity
+import me.kavishdevar.librepods.data.workout.WorkoutSummary
+import me.kavishdevar.librepods.export.workout.WorkoutFileExporter
+import me.kavishdevar.librepods.health.workout.AndroidWorkoutHealthConnectExporter
+import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip
+import me.kavishdevar.librepods.presentation.theme.DesignSystem
+import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import me.kavishdevar.librepods.services.HeartRateMonitoringStatus
+import java.text.DateFormat
+import java.util.Date
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+
+@Composable
+fun WorkoutScreen(
+ viewModel: AirPodsViewModel,
+ navigateToHistory: () -> Unit,
+ navigateToSettings: () -> Unit,
+) {
+ val context = LocalContext.current
+ val app = context.applicationContext as LibrePodsApplication
+ val repository = app.workoutRepository
+ val workout by repository.activeWorkout.collectAsState(initial = null)
+ val state by viewModel.uiState.collectAsState()
+ val scope = rememberCoroutineScope()
+ var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
+
+ LaunchedEffect(workout?.summary?.id) {
+ while (workout != null) {
+ now = System.currentTimeMillis()
+ delay(1_000L)
+ }
+ }
+
+ WorkoutPage {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedButton(onClick = navigateToHistory, modifier = Modifier.weight(1f)) {
+ Text("History")
+ }
+ OutlinedButton(onClick = navigateToSettings, modifier = Modifier.weight(1f)) {
+ Text("Zone settings")
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ if (workout == null) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text("No active workout", style = MaterialTheme.typography.titleLarge)
+ Text(
+ "Starting creates the local session immediately. Heart-rate monitoring is enabled so validated AirPods samples can be persisted to it.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ HeartRateStatusChip(
+ status = state.heartRate.status,
+ onRetry = if (state.heartRate.status == HeartRateMonitoringStatus.COULDNT_START)
+ viewModel::reconnectAacpForHeartRate else null,
+ compact = true,
+ )
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ scope.launch {
+ repository.startWorkout()
+ viewModel.setHeartRateMonitoringEnabled(true)
+ }
+ },
+ ) { Text("Start workout") }
+ }
+ }
+ } else {
+ val detail = workout!!
+ WorkoutSummaryCard(detail, now)
+ Spacer(Modifier.height(12.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Heart-rate source", fontWeight = FontWeight.SemiBold)
+ HeartRateStatusChip(
+ status = state.heartRate.status,
+ onRetry = if (state.heartRate.status == HeartRateMonitoringStatus.COULDNT_START)
+ viewModel::reconnectAacpForHeartRate else null,
+ compact = true,
+ )
+ }
+ if (detail.samples.isEmpty()) {
+ Text(
+ "No validated samples yet. The workout is still saved locally even while AirPods are disconnected.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ WorkoutChartCard(
+ samples = detail.samples,
+ startTimeMillis = detail.summary.startTimeEpochMillis,
+ endTimeMillis = now,
+ )
+ Spacer(Modifier.height(12.dp))
+ ZoneCard(detail.zones, detail.summary.maxHeartRateBpm)
+ Spacer(Modifier.height(16.dp))
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { scope.launch { repository.finishWorkout(detail.summary.id) } },
+ ) { Text("Finish workout") }
+ }
+ }
+}
+
+@Composable
+fun WorkoutHistoryScreen(navigateToDetail: (String) -> Unit) {
+ val context = LocalContext.current
+ val app = context.applicationContext as LibrePodsApplication
+ val repository = app.workoutRepository
+ val history by repository.history.collectAsState(initial = emptyList())
+ val scope = rememberCoroutineScope()
+ var workoutPendingDelete by remember { mutableStateOf(null) }
+
+ fun exportWorkout(sessionId: String, format: WorkoutExportFormat) {
+ scope.launch {
+ val workout = repository.snapshot(sessionId)
+ if (workout == null) {
+ Toast.makeText(context, "Workout no longer exists", Toast.LENGTH_SHORT).show()
+ } else {
+ exportAndShare(context, workout, format)
+ }
+ }
+ }
+
+ WorkoutPage {
+ if (history.isEmpty()) {
+ Text(
+ "Finished workouts will appear here.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ history.forEach { session ->
+ HistoryCard(
+ session = session,
+ onClick = { navigateToDetail(session.id) },
+ onExportCsv = { exportWorkout(session.id, WorkoutExportFormat.CSV) },
+ onExportFit = { exportWorkout(session.id, WorkoutExportFormat.FIT) },
+ onDelete = { workoutPendingDelete = session },
+ )
+ Spacer(Modifier.height(10.dp))
+ }
+ }
+ workoutPendingDelete?.let { session ->
+ DeleteWorkoutDialog(
+ onDismissRequest = { workoutPendingDelete = null },
+ onConfirm = {
+ workoutPendingDelete = null
+ scope.launch { repository.deleteWorkout(session.id) }
+ },
+ )
+ }
+}
+
+@Composable
+fun WorkoutDetailScreen(sessionId: String, onDeleted: () -> Unit = {}) {
+ val context = LocalContext.current
+ val app = context.applicationContext as LibrePodsApplication
+ val repository = app.workoutRepository
+ val detail by repository.workout(sessionId).collectAsState(initial = null)
+ val scope = rememberCoroutineScope()
+ var showDeleteConfirmation by remember { mutableStateOf(false) }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ PermissionController.createRequestPermissionResultContract()
+ ) { granted ->
+ if (AndroidWorkoutHealthConnectExporter.WRITE_EXERCISE_PERMISSION in granted) {
+ scope.launch { repository.retryHealthConnectExport(sessionId) }
+ }
+ }
+
+ WorkoutPage {
+ val workout = detail
+ if (workout == null) {
+ Text("Workout not found.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return@WorkoutPage
+ }
+ WorkoutSummaryCard(workout, workout.summary.endTimeEpochMillis ?: System.currentTimeMillis())
+ Spacer(Modifier.height(12.dp))
+ WorkoutChartCard(
+ samples = workout.samples,
+ startTimeMillis = workout.summary.startTimeEpochMillis,
+ endTimeMillis = workout.summary.endTimeEpochMillis,
+ )
+ Spacer(Modifier.height(12.dp))
+ ZoneCard(workout.zones, workout.summary.maxHeartRateBpm)
+ Spacer(Modifier.height(12.dp))
+ HealthConnectSessionCard(
+ workout = workout,
+ onRequestPermission = {
+ permissionLauncher.launch(AndroidWorkoutHealthConnectExporter.REQUIRED_PERMISSIONS)
+ },
+ onRetry = { scope.launch { repository.retryHealthConnectExport(sessionId) } },
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = { scope.launch { exportAndShare(context, workout, WorkoutExportFormat.CSV) } },
+ ) { Text("Export CSV") }
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = { scope.launch { exportAndShare(context, workout, WorkoutExportFormat.FIT) } },
+ ) { Text("Export FIT") }
+ }
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { showDeleteConfirmation = true },
+ ) { Text("Delete workout") }
+ if (showDeleteConfirmation) {
+ DeleteWorkoutDialog(
+ onDismissRequest = { showDeleteConfirmation = false },
+ onConfirm = {
+ showDeleteConfirmation = false
+ scope.launch {
+ if (repository.deleteWorkout(sessionId)) onDeleted()
+ }
+ },
+ )
+ }
+ }
+}
+
+@Composable
+fun WorkoutSettingsScreen() {
+ val app = LocalContext.current.applicationContext as LibrePodsApplication
+ val preferences = app.workoutPreferences
+ val keyboardController = LocalSoftwareKeyboardController.current
+ var text by remember { mutableStateOf(preferences.maxHeartRateBpm.toString()) }
+ var savedValue by remember { mutableStateOf(preferences.maxHeartRateBpm) }
+
+ WorkoutPage {
+ Text("Heart-rate zones", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = text,
+ onValueChange = { value -> text = value.filter(Char::isDigit).take(3) },
+ label = { Text("Maximum heart rate (BPM)") },
+ supportingText = { Text("Allowed ${HeartRateZones.MIN_CONFIGURABLE_MAX_HEART_RATE_BPM}–${HeartRateZones.MAX_CONFIGURABLE_MAX_HEART_RATE_BPM} BPM") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ Button(
+ onClick = {
+ val value = text.toIntOrNull() ?: HeartRateZones.DEFAULT_MAX_HEART_RATE_BPM
+ preferences.maxHeartRateBpm = value
+ savedValue = preferences.maxHeartRateBpm
+ text = savedValue.toString()
+ keyboardController?.hide()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Save for new workouts") }
+ Text("Current saved max HR: $savedValue BPM", fontWeight = FontWeight.SemiBold)
+ }
+}
+
+@Composable
+private fun WorkoutPage(content: @Composable ColumnScope.() -> Unit) {
+ val material = LocalDesignSystem.current == DesignSystem.Material
+ val topPadding = if (material) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
+ val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ ) {
+ Spacer(Modifier.height(topPadding))
+ content()
+ Spacer(Modifier.height(bottomPadding))
+ }
+}
+
+@Composable
+private fun WorkoutSummaryCard(detail: WorkoutDetail, nowMillis: Long) {
+ val summary = detail.summary
+ val end = summary.endTimeEpochMillis ?: nowMillis
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Column {
+ Text(summary.latestBpm?.toString() ?: "—", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold)
+ Text("Latest BPM", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text(formatDuration((end - summary.startTimeEpochMillis).coerceAtLeast(0L)), style = MaterialTheme.typography.titleLarge)
+ Text("Duration", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Stat("Min", summary.minBpm?.toString() ?: "—")
+ Stat("Avg", summary.avgBpm?.roundToInt()?.toString() ?: "—")
+ Stat("Max", summary.maxBpm?.toString() ?: "—")
+ Stat("Samples", summary.sampleCount.toString())
+ }
+ }
+ }
+}
+
+@Composable
+private fun Stat(label: String, value: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+@Composable
+private fun WorkoutChartCard(
+ samples: List,
+ startTimeMillis: Long? = null,
+ endTimeMillis: Long? = null,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Heart rate", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(8.dp))
+ if (samples.size < 2) {
+ Box(Modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) {
+ Text("The graph appears after two validated samples.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
+ HeartRateCanvas(
+ samples = samples,
+ startTimeMillis = startTimeMillis,
+ endTimeMillis = endTimeMillis,
+ modifier = Modifier.fillMaxWidth().height(180.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun HeartRateCanvas(
+ samples: List,
+ startTimeMillis: Long? = null,
+ endTimeMillis: Long? = null,
+ modifier: Modifier = Modifier,
+) {
+ val lineColor = MaterialTheme.colorScheme.primary
+ val gridColor = MaterialTheme.colorScheme.outlineVariant
+ Canvas(modifier = modifier) {
+ val ordered = samples.sortedBy { it.timestampEpochMillis }
+ val minTime = minOf(
+ ordered.first().timestampEpochMillis,
+ startTimeMillis ?: ordered.first().timestampEpochMillis,
+ )
+ val maxTime = maxOf(
+ ordered.last().timestampEpochMillis,
+ endTimeMillis ?: ordered.last().timestampEpochMillis,
+ ).coerceAtLeast(minTime + 1L)
+ val minBpm = (ordered.minOf { it.bpm } - 5).coerceAtLeast(20)
+ val maxBpm = (ordered.maxOf { it.bpm } + 5).coerceAtLeast(minBpm + 1)
+ repeat(4) { row ->
+ val y = size.height * row / 3f
+ drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
+ }
+ val path = Path()
+ var previousTimestamp: Long? = null
+ ordered.forEach { sample ->
+ val x = ((sample.timestampEpochMillis - minTime).toFloat() / (maxTime - minTime).toFloat()) * size.width
+ val y = size.height - ((sample.bpm - minBpm).toFloat() / (maxBpm - minBpm).toFloat()) * size.height
+ val previous = previousTimestamp
+ if (previous == null || sample.timestampEpochMillis - previous > HEART_RATE_GRAPH_GAP_MILLIS) {
+ // A long interval means no validated HR data was written. Start a new segment
+ // after the gap instead of inventing a slope across the disconnection.
+ path.moveTo(x, y)
+ } else {
+ path.lineTo(x, y)
+ }
+ previousTimestamp = sample.timestampEpochMillis
+ }
+ drawPath(path, lineColor, style = Stroke(width = 4f))
+ }
+}
+
+private const val HEART_RATE_GRAPH_GAP_MILLIS = 4_000L
+
+@Composable
+private fun ZoneCard(zones: List, maxHeartRateBpm: Int) {
+ val total = zones.sumOf { it.sampleCount }.coerceAtLeast(1)
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
+ Text("Heart-rate zones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text("Max HR $maxHeartRateBpm BPM", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ zones.forEach { zone ->
+ val range = zoneBpmRange(zone, maxHeartRateBpm)
+ val sharePercent = (zone.sampleCount.toDouble() / total.toDouble() * 100.0).roundToInt()
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("${zone.label} $range")
+ Text("$sharePercent%", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ LinearProgressIndicator(
+ progress = { zone.sampleCount.toFloat() / total.toFloat() },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun HistoryCard(
+ session: WorkoutSummary,
+ onClick: () -> Unit,
+ onExportCsv: () -> Unit,
+ onExportFit: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ val duration = (session.endTimeEpochMillis ?: session.startTimeEpochMillis) - session.startTimeEpochMillis
+ Card(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(session.startTimeEpochMillis)), fontWeight = FontWeight.SemiBold)
+ Text("${formatDuration(duration)} • ${session.sampleCount} samples • avg ${session.avgBpm?.roundToInt() ?: "—"} BPM")
+ Text("Health Connect: ${healthStateLabel(session.healthConnectExportState)}", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedButton(modifier = Modifier.weight(1f), onClick = onExportCsv) { Text("CSV") }
+ OutlinedButton(modifier = Modifier.weight(1f), onClick = onExportFit) { Text("FIT") }
+ }
+ TextButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("Delete workout") }
+ }
+ }
+}
+
+private fun zoneBpmRange(zone: HeartRateZone, maxHeartRateBpm: Int): String {
+ fun firstBpmAtPercent(percent: Int): Int = ceil(maxHeartRateBpm * percent / 100.0).toInt()
+ return when {
+ zone.minimumPercent == null -> "<${firstBpmAtPercent(zone.maximumPercentExclusive!!)} BPM"
+ zone.maximumPercentExclusive == null -> "≥${firstBpmAtPercent(zone.minimumPercent)} BPM"
+ else -> {
+ val start = firstBpmAtPercent(zone.minimumPercent)
+ val end = firstBpmAtPercent(zone.maximumPercentExclusive) - 1
+ "$start–$end BPM"
+ }
+ }
+}
+
+@Composable
+private fun DeleteWorkoutDialog(
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = { Text("Delete workout?") },
+ text = { Text("This removes the workout from LibrePods history. Health Connect data is unchanged.") },
+ confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
+ dismissButton = { TextButton(onClick = onDismissRequest) { Text("Cancel") } },
+ )
+}
+
+@Composable
+private fun HealthConnectSessionCard(
+ workout: WorkoutDetail,
+ onRequestPermission: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ val state = workout.summary.healthConnectExportState
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Health Connect session", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(healthStateLabel(state), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ workout.summary.healthConnectExportMessage?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
+ when (state) {
+ HealthConnectSessionExportState.PERMISSION_REQUIRED -> OutlinedButton(onClick = onRequestPermission) { Text("Grant permission and retry") }
+ HealthConnectSessionExportState.UNAVAILABLE,
+ HealthConnectSessionExportState.ERROR,
+ HealthConnectSessionExportState.PENDING -> OutlinedButton(onClick = onRetry) { Text("Retry Health Connect export") }
+ else -> Unit
+ }
+ }
+ }
+}
+
+private enum class WorkoutExportFormat { CSV, FIT }
+
+private suspend fun exportAndShare(
+ context: Context,
+ workout: WorkoutDetail,
+ format: WorkoutExportFormat,
+) {
+ try {
+ val exported = withContext(Dispatchers.IO) {
+ when (format) {
+ WorkoutExportFormat.CSV -> WorkoutFileExporter.exportCsv(context, workout)
+ WorkoutExportFormat.FIT -> WorkoutFileExporter.exportFit(context, workout)
+ }
+ }
+ WorkoutFileExporter.share(context, exported)
+ } catch (error: Exception) {
+ Toast.makeText(
+ context,
+ "${format.name} export failed: ${error.message ?: error.javaClass.simpleName}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+}
+
+private fun formatDuration(milliseconds: Long): String {
+ val totalSeconds = milliseconds.coerceAtLeast(0L) / 1000L
+ val hours = totalSeconds / 3600L
+ val minutes = totalSeconds % 3600L / 60L
+ val seconds = totalSeconds % 60L
+ return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%02d:%02d".format(minutes, seconds)
+}
+
+private fun healthStateLabel(state: HealthConnectSessionExportState): String = when (state) {
+ HealthConnectSessionExportState.NOT_FINISHED -> "Not finished"
+ HealthConnectSessionExportState.PENDING -> "Pending export"
+ HealthConnectSessionExportState.EXPORTED -> "Exported"
+ HealthConnectSessionExportState.PERMISSION_REQUIRED -> "Exercise permission required; local workout is safe"
+ HealthConnectSessionExportState.UNAVAILABLE -> "Unavailable; local workout is safe"
+ HealthConnectSessionExportState.ERROR -> "Export failed; local workout is safe"
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/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..144f4c95f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
@@ -42,7 +43,6 @@ import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
-import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -54,7 +54,13 @@ import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
+import me.kavishdevar.librepods.health.HealthConnectExportState
+import me.kavishdevar.librepods.health.HealthConnectExportStatus
+import me.kavishdevar.librepods.finder.NearbyFinderState
+import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState
import me.kavishdevar.librepods.services.AirPodsService
+import me.kavishdevar.librepods.services.HeartRateMonitoringState
+import me.kavishdevar.librepods.services.HeartRateMonitoringStatus
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
@@ -81,6 +87,11 @@ data class AirPodsUiState(
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
+ val heartRate: HeartRateMonitoringState = HeartRateMonitoringState(),
+ val healthConnect: HealthConnectExportState = HealthConnectExportState(),
+ val nearbyFinder: NearbyFinderState = NearbyFinderState(),
+ val heartRateBlePeripheral: HeartRateBlePeripheralState = HeartRateBlePeripheralState(),
+
val eqData: FloatArray = floatArrayOf(),
val automaticEarDetectionEnabled: Boolean = true,
@@ -210,6 +221,7 @@ class AirPodsViewModel(
loadInstance()
loadSharedPreferences()
observeAACP()
+ observeHeartRate()
loadCurrentStatus()
loadEq()
loadATT()
@@ -460,12 +472,36 @@ class AirPodsViewModel(
}
}
+ private fun observeHeartRate() {
+ viewModelScope.launch {
+ combine(service.heartRateState, service.healthConnectState) { heartRate, export ->
+ heartRate to export
+ }.collect { (heartRate, export) ->
+ _uiState.update { it.copy(heartRate = heartRate, healthConnect = export) }
+ }
+ }
+ viewModelScope.launch {
+ service.nearbyFinderState.collect { finder ->
+ _uiState.update { it.copy(nearbyFinder = finder) }
+ }
+ }
+ viewModelScope.launch {
+ service.heartRateBlePeripheralState.collect { peripheral ->
+ _uiState.update { it.copy(heartRateBlePeripheral = peripheral) }
+ }
+ }
+ }
+
fun loadCurrentStatus() {
if (isDemoMode) return
service.let { service ->
_uiState.update {
it.copy(
- isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
+ isLocallyConnected = service.isAacpTransportHealthy(),
+ heartRate = service.heartRateState.value,
+ healthConnect = service.healthConnectState.value,
+ nearbyFinder = service.nearbyFinderState.value,
+ heartRateBlePeripheral = service.heartRateBlePeripheralState.value,
battery = service.getBattery(),
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
controlStates = controlRepo.getMap()
@@ -625,6 +661,7 @@ class AirPodsViewModel(
}
fun reconnectFromSavedMac() {
+ if (!::service.isInitialized) return
service.reconnectFromSavedMac()
}
@@ -642,6 +679,99 @@ class AirPodsViewModel(
_uiState.update { it.copy(headTrackingActive = false) }
}
+ fun startNearbyFinder() {
+ if (!isReady || isDemoMode) return
+ service.startNearbyFinder()
+ }
+
+ fun stopNearbyFinder() {
+ if (!isReady || isDemoMode) return
+ service.stopNearbyFinder()
+ }
+
+ fun refreshNearbyFinderPrerequisites() {
+ if (!isReady || isDemoMode) return
+ service.refreshNearbyFinderPrerequisites()
+ }
+
+ fun setHeartRateBlePeripheralEnabled(enabled: Boolean) {
+ if (!isReady || isDemoMode) return
+ service.setHeartRateBlePeripheralEnabled(enabled)
+ }
+
+ fun refreshHeartRateBlePeripheral() {
+ if (!isReady || isDemoMode) return
+ service.refreshHeartRateBlePeripheral()
+ }
+
+ fun setHeartRateMonitoringEnabled(enabled: Boolean) {
+ if (!isReady) return
+ if (isDemoMode) {
+ _uiState.update {
+ it.copy(
+ heartRate = it.heartRate.copy(
+ enabled = enabled,
+ status = when {
+ !enabled -> HeartRateMonitoringStatus.OFF
+ it.isLocallyConnected -> HeartRateMonitoringStatus.LIVE
+ else -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS
+ }
+ )
+ )
+ }
+ return
+ }
+ service.setHeartRateMonitoringEnabled(enabled)
+ }
+
+ fun reconnectAacpForHeartRate() {
+ if (!isReady || isDemoMode) return
+ service.reconnectAacpForHeartRate()
+ }
+
+ fun refreshHealthConnectExportState() {
+ if (!isReady || isDemoMode) return
+ service.refreshHealthConnectExportState()
+ }
+
+ fun setHealthConnectExportEnabled(enabled: Boolean) {
+ if (!isReady) return
+ if (isDemoMode) {
+ _uiState.update {
+ it.copy(
+ healthConnect = it.healthConnect.copy(
+ enabled = enabled,
+ status = if (enabled) {
+ HealthConnectExportStatus.ENABLED
+ } else {
+ HealthConnectExportStatus.READY
+ }
+ )
+ )
+ }
+ return
+ }
+ service.setHealthConnectExportEnabled(enabled)
+ }
+
+ fun setHealthConnectDetailedSamples(detailed: Boolean) {
+ if (!isReady) return
+ if (isDemoMode) {
+ _uiState.update {
+ it.copy(
+ healthConnect = it.healthConnect.copy(detailedSamples = 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..e562bcc6c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.services
+import me.kavishdevar.librepods.LibrePodsApplication
//import me.kavishdevar.librepods.utils.CrossDevice
//import me.kavishdevar.librepods.utils.CrossDevicePackets
import android.Manifest
@@ -55,6 +56,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
@@ -70,16 +72,20 @@ import androidx.annotation.RequiresPermission
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.core.app.NotificationCompat
import androidx.core.content.edit
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
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
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
@@ -89,6 +95,9 @@ 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.HeartRateBlePeripheral
+import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
@@ -101,6 +110,10 @@ 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.HealthConnectExportState
+import me.kavishdevar.librepods.finder.NearbyAirPodsFinder
+import me.kavishdevar.librepods.finder.NearbyFinderState
+import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter
import me.kavishdevar.librepods.presentation.overlays.IslandType
import me.kavishdevar.librepods.presentation.overlays.IslandWindow
import me.kavishdevar.librepods.presentation.overlays.PopupWindow
@@ -128,11 +141,13 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
+import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
+import java.util.concurrent.atomic.AtomicReference
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.time.Duration.Companion.milliseconds
+import kotlin.coroutines.coroutineContext
private const val TAG = "AirPodsService"
@@ -231,11 +246,53 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private val maxLogEntries = 1000
private val inMemoryLogs = mutableSetOf()
+ private val heartRateScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val transportRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val transportRecoveryLock = Any()
+ private val aacpConnectLock = Any()
+ private var aacpReconnectJob: Job? = null
+ private var aacpLivenessJob: Job? = null
+ private var aacpReconnectSuppressed = false
+ private var aacpConnectionGeneration = 0L
+ private var aacpTransportResponsive = false
+ private var heartRateAutomaticAacpRecoveryAttempted = false
+ @Volatile
+ private var heartRateAirPodsWorn: Boolean? = null
+ @Volatile
+ private var lastAacpPacketElapsedRealtime = 0L
+
+ private lateinit var heartRateMonitor: HeartRateMonitor
+ val heartRateState: StateFlow
+ get() = heartRateMonitor.state
+
+ private lateinit var heartRateExporter: HealthConnectHeartRateExporter
+ val healthConnectState: StateFlow
+ get() = heartRateExporter.state
+
+ private lateinit var heartRateBlePeripheral: HeartRateBlePeripheral
+ val heartRateBlePeripheralState: StateFlow
+ get() = heartRateBlePeripheral.state
+
+ private lateinit var nearbyFinder: NearbyAirPodsFinder
+ val nearbyFinderState: StateFlow
+ get() = nearbyFinder.state
+
private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager
companion object {
+ private const val HEART_RATE_MONITORING_PREFERENCE = "heart_rate_monitoring_enabled"
+ private const val HEART_RATE_BLE_PERIPHERAL_PREFERENCE = "heart_rate_ble_peripheral_enabled"
+ private const val HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS = 3_000L
+ private const val AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS = 12_000L
+ private const val AACP_IDLE_PROBE_INTERVAL_MILLIS = 60_000L
+ private const val AACP_PROBE_RESPONSE_TIMEOUT_MILLIS = 5_000L
+ private const val EXTRA_AACP_TRANSPORT_FAILURE =
+ "me.kavishdevar.librepods.extra.AACP_TRANSPORT_FAILURE"
+ private val AACP_RECONNECT_BACKOFF_MILLIS =
+ longArrayOf(750L, 1_500L, 3_000L, 5_000L, 10_000L)
+
init {
System.loadLibrary("bluetooth_socket")
}
@@ -258,7 +315,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connectToSocket(bluetoothAdapter, bluetoothDevice)
}
Log.d(TAG, "Device status changed")
- if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
+ if (isAacpTransportHealthy()) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -279,6 +336,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) {
Log.d(TAG, "New address detected")
+ updateHeartRateWearState(
+ leftInEar = device.isLeftInEar,
+ rightInEar = device.isRightInEar,
+ source = "ble-initial"
+ )
}
override fun onLidStateChanged(
@@ -291,7 +353,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
?: "AirPods"
)
- if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
+ if (isAacpTransportHealthy()) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -317,6 +379,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean
) {
Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar")
+ updateHeartRateWearState(
+ leftInEar = leftInEar,
+ rightInEar = rightInEar,
+ source = "ble"
+ )
// In BLE-only mode, ear detection is purely based on BLE data
if (config.bleOnlyMode) {
@@ -325,7 +392,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
- if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
+ if (isAacpTransportHealthy()) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -345,6 +412,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "Battery changed")
}
+ override fun onVerifiedRssi(rssi: Int) {
+ if (::nearbyFinder.isInitialized) nearbyFinder.onVerifiedScanRssi(rssi)
+ }
+
+ override fun onScanError(errorCode: Int) {
+ if (::nearbyFinder.isInitialized) nearbyFinder.onScanError(errorCode)
+ }
+
override fun onDeviceDisappeared() {
Log.d(TAG, "All disappeared")
updateNotificationContent(
@@ -377,9 +452,59 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
_packetLogsFlow.value = inMemoryLogs.toSet()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
+ heartRateExporter = HealthConnectHeartRateExporter(
+ context = applicationContext,
+ sharedPreferences = sharedPreferences,
+ scope = heartRateScope
+ )
+ heartRateExporter.refresh()
initializeConfig()
+ heartRateBlePeripheral = HeartRateBlePeripheral(applicationContext)
+ nearbyFinder = NearbyAirPodsFinder(
+ context = applicationContext,
+ scope = heartRateScope,
+ hasSelectedDevice = { device != null || macAddress.isNotBlank() }
+ )
+
aacpManager = AACPManager()
+ heartRateMonitor = HeartRateMonitor(
+ scope = heartRateScope,
+ initiallyEnabled = sharedPreferences.getBoolean(
+ HEART_RATE_MONITORING_PREFERENCE,
+ false
+ ),
+ isTransportReady = ::isAacpTransportHealthy,
+ isAirPodsWorn = ::areAirPodsWornForHeartRate,
+ beforeFirstStart = {
+ if (isHeadTrackingActive) {
+ stopHeadTracking()
+ delay(220)
+ }
+ },
+ sendConnectService0 = aacpManager::sendHeartRateConnectService0,
+ sendCapabilitiesService0 = aacpManager::sendHeartRateCapabilitiesService0,
+ sendConnectService4 = aacpManager::sendHeartRateConnectService4,
+ sendCapabilitiesService4 = aacpManager::sendHeartRateCapabilitiesService4,
+ awaitHeartRateService = { aacpManager.awaitHeartRateServiceResolution() },
+ enableHeartRate = {
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.HRM_STATE.value,
+ true
+ )
+ },
+ sendStart = aacpManager::sendHeartRateStartFrame,
+ sendStop = { aacpManager.sendHeartRateStopFrame() },
+ requestTransportRecovery = ::requestAutomaticAacpRecoveryForHeartRate,
+ onPublishedSample = { sample ->
+ heartRateBlePeripheral.onValidatedSample(sample)
+ heartRateExporter.enqueue(
+ sample = sample,
+ deviceModel = config.airpodsModelNumber.ifBlank { config.deviceName }
+ )
+ (application as LibrePodsApplication).workoutRepository.recordValidatedSample(sample)
+ }
+ )
initializeAACPManagerCallback()
attManager = ATTManagerv2()
@@ -664,6 +789,14 @@ 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
+ aacpConnectionGeneration++
+ }
device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("device", BluetoothDevice::class.java)!!
} else {
@@ -692,13 +825,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// }
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
+ val isLocalTransportFailure = intent.getBooleanExtra(
+ EXTRA_AACP_TRANSPORT_FAILURE,
+ false
+ )
+ if (!isLocalTransportFailure) {
+ synchronized(transportRecoveryLock) {
+ heartRateAutomaticAacpRecoveryAttempted = false
+ heartRateAirPodsWorn = null
+ }
+ 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
}
}
}
@@ -774,10 +918,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
putString("mac_address", macAddress)
}
// }
- sendBroadcast(
- Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply {
- setPackage(packageName)
- })
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
@@ -797,6 +937,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
CoroutineScope(Dispatchers.IO).launch {
bleManager.startScanning()
}
+ if (sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false)) {
+ heartRateBlePeripheral.start()
+ }
}
@Suppress("unused")
@@ -1080,6 +1223,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
+ override fun onHeartRateReceived(sample: HeartRateSample) {
+ heartRateMonitor.onValidatedSample(sample)
+ }
+
override fun onProximityKeysReceived(proximityKeys: ByteArray) {
val keys = aacpManager.parseProximityKeysResponse(proximityKeys)
Log.d("AirPodsParser", "Proximity keys: $keys")
@@ -1238,15 +1385,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
var justEnabledA2dp = false
earDetectionNotification.setStatus(earDetection)
- if (config.earDetectionEnabled) {
- val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size)
- inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
+ val data = if (earDetection.size >= 2) {
+ earDetection.copyOfRange(earDetection.size - 2, earDetection.size)
+ } else {
+ null
+ }
+ val newInEarData = data?.let {
+ listOf(it[0] == 0x00.toByte(), it[1] == 0x00.toByte())
+ }
- val newInEarData = listOf(
- data[0] == 0x00.toByte(), data[1] == 0x00.toByte()
+ newInEarData?.let { currentInEarData ->
+ updateHeartRateWearState(
+ leftInEar = currentInEarData[0],
+ rightInEar = currentInEarData[1],
+ source = "aacp"
)
+ }
+
+ if (config.earDetectionEnabled) {
+ val currentData = data ?: return
+ val currentInEarData = newInEarData ?: return
+ inEar = currentData[0] == 0x00.toByte() && currentData[1] == 0x00.toByte()
- if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(
+ if (inEarData.sorted() == listOf(false, false) && currentInEarData.sorted() != listOf(
false, false
) && islandWindow?.isVisible != true
) {
@@ -1260,25 +1421,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
}
- if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
+ if (currentInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
islandWindow?.close()
}
- if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
+ if (currentInEarData.contains(true) && inEarData == listOf(false, false)) {
connectAudio(this@AirPodsService, device)
justEnabledA2dp = true
registerA2dpConnectionReceiver()
if (MediaController.getMusicActive()) {
MediaController.userPlayedTheMedia = true
}
- } else if (newInEarData == listOf(false, false)) {
+ } else if (currentInEarData == listOf(false, false)) {
MediaController.sendPause(force = true)
if (config.disconnectWhenNotWearing) {
disconnectAudio(this@AirPodsService, device)
}
}
val wasNone = inEarData == listOf(false, false)
- val nowSingle = newInEarData.count { it } == 1
+ val nowSingle = currentInEarData.count { it } == 1
if (wasNone && nowSingle) {
MediaController.sendPlay()
@@ -1286,22 +1447,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return
}
- if (inEarData.contains(false) && newInEarData == listOf(true, true)) {
+ if (inEarData.contains(false) && currentInEarData == listOf(true, true)) {
Log.d("AirPodsParser", "User put in both AirPods from just one.")
MediaController.userPlayedTheMedia = false
}
- if (newInEarData.contains(false) && inEarData == listOf(true, true)) {
+ if (currentInEarData.contains(false) && inEarData == listOf(true, true)) {
Log.d("AirPodsParser", "User took one of two out.")
MediaController.userPlayedTheMedia = false
}
Log.d(
"AirPodsParser",
- "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}"
+ "inEarData: ${inEarData.sorted()}, newInEarData: ${currentInEarData.sorted()}"
)
- if (newInEarData.sorted() != inEarData.sorted()) {
+ if (currentInEarData.sorted() != inEarData.sorted()) {
if (inEar) {
if (!justEnabledA2dp) {
MediaController.sendPlay()
@@ -2039,6 +2200,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connected: Boolean, airpodsName: String? = null, batteryList: List? = null
) {
val notificationManager = getSystemService(NotificationManager::class.java)
+ if (!connected) {
+ notificationManager.cancel(2)
+ return
+ }
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
@@ -2051,7 +2216,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (BluetoothConnectionManager.aacpSocket == null) {
return
}
- if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
+ if (isAacpTransportHealthy()) {
val updatedNotificationBuilder =
NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
@@ -2097,8 +2262,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(2, updatedNotification)
notificationManager.cancel(1)
- } else if (!connected) {
- notificationManager.cancel(2)
} else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) {
showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs")
}
@@ -2405,25 +2568,59 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
- if (bluetoothDevice.uuids?.contains(uuid) == true) {
- val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
- intent.putExtra("name", name)
- intent.putExtra("device", bluetoothDevice)
- context?.sendBroadcast(intent)
- } else {
+ // A raw ACL connection can arrive before BR/EDR authentication/encryption has
+ // settled. Opening the private AACP L2CAP channel at that exact moment can race
+ // the platform security transaction and disconnect the whole ACL. Wait for the
+ // per-device A2DP profile to report CONNECTED instead.
+ if (bluetoothDevice.uuids?.contains(uuid) != true) {
bluetoothDevice.fetchUuidsWithSdp()
+ } else {
+ Log.d(TAG, "ACL connected; deferring AACP until A2DP is connected")
+ }
+ } else if ("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" == action) {
+ val state = intent.getIntExtra(
+ BluetoothProfile.EXTRA_STATE,
+ BluetoothProfile.STATE_DISCONNECTED
+ )
+ if (state == BluetoothProfile.STATE_CONNECTED) {
+ val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
+ ?.getString("mac_address", "") ?: ""
+ val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
+ val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
+ if (matchedByUuid || matchedByMac) {
+ val connectionIntent =
+ Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
+ connectionIntent.putExtra("name", name)
+ connectionIntent.putExtra("device", bluetoothDevice)
+ context?.sendBroadcast(connectionIntent)
+ }
}
} else if ("android.bluetooth.device.action.UUID" == action) {
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("mac_address", "") ?: ""
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
- if (matchedByUuid || matchedByMac) {
+ val isA2dpConnected = context
+ ?.getSystemService(BluetoothManager::class.java)
+ ?.adapter
+ ?.getProfileConnectionState(BluetoothProfile.A2DP) ==
+ BluetoothProfile.STATE_CONNECTED
+ if ((matchedByUuid || matchedByMac) && isA2dpConnected) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
}
+ } else if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action) {
+ val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
+ ?.getString("mac_address", "") ?: ""
+ if (savedMac.isNotEmpty() && bluetoothDevice.address == savedMac) {
+ context?.sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
+ setPackage(context.packageName)
+ }
+ )
+ }
}
}
}
@@ -2637,6 +2834,41 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun connectToSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
) {
+ if (manual) {
+ val staleSocket = synchronized(transportRecoveryLock) {
+ BluetoothConnectionManager.aacpSocket.takeIf { !aacpTransportResponsive }
+ }
+ if (staleSocket != null) {
+ clearAacpTransport("manual-reconnect", expectedSocket = staleSocket)
+ }
+ }
+ val connectionGeneration = synchronized(transportRecoveryLock) {
+ if (manual) {
+ aacpReconnectSuppressed = false
+ aacpConnectionGeneration++
+ }
+ if (aacpReconnectSuppressed) null else aacpConnectionGeneration
+ }
+ if (connectionGeneration == null) {
+ Log.i(TAG, "Skipping suppressed AACP connection attempt")
+ return
+ }
+ synchronized(aacpConnectLock) {
+ connectToSocketLocked(adapter, device, manual, connectionGeneration)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun connectToSocketLocked(
+ adapter: BluetoothAdapter,
+ device: BluetoothDevice,
+ manual: Boolean,
+ connectionGeneration: Long
+ ) {
+ val attemptIsCurrent = synchronized(transportRecoveryLock) {
+ !aacpReconnectSuppressed && connectionGeneration == aacpConnectionGeneration
+ }
+ if (!attemptIsCurrent) return
if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return
Log.d(TAG, " Connecting to socket")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2646,100 +2878,142 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
+ if (!manual) scheduleAacpReconnect(device, "socket-creation-failed")
return
}
+ var attSocket: BluetoothSocket? = null
+ var socketInstalled = false
try {
- runBlocking {
- withTimeout(5000.milliseconds) {
+ try {
+ connectSocketWithTimeout(socket, "AACP")
+
+ // AACP is the primary transport. Install it as soon as it connects so an optional
+ // ATT-over-BR/EDR failure cannot tear down or restart an otherwise healthy AACP link.
+ socketInstalled = synchronized(transportRecoveryLock) {
+ if (aacpReconnectSuppressed ||
+ connectionGeneration != aacpConnectionGeneration ||
+ BluetoothConnectionManager.aacpSocket != null
+ ) {
+ false
+ } else {
+ BluetoothConnectionManager.aacpSocket = socket
+ BluetoothConnectionManager.attSocket = null
+ true
+ }
+ }
+ if (!socketInstalled) {
+ closeSocketQuietly(socket, "superseded AACP socket")
+ Log.i(TAG, "Discarding superseded AACP connection attempt")
+ return
+ }
+
+ val xposedRemotePref = XposedRemotePrefProvider.create()
+ if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
try {
- socket.connect()
- this@AirPodsService.device = device
- val xposedRemotePref = XposedRemotePrefProvider.create()
- val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
- createBluetoothSocket(
- adapter,
- device,
- ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
- 31
- )
- } else null
- attSocket?.connect()
-
- if (attSocket != null) {
- attManager.startReader()
- attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
- attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
- attManager.readCharacteristic(ATTHandles.HEARING_AID)
+ val candidateAttSocket = createBluetoothSocket(
+ adapter,
+ device,
+ ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
+ 31
+ )
+ attSocket = candidateAttSocket
+ connectSocketWithTimeout(candidateAttSocket, "ATT")
+
+ val attInstalled = synchronized(transportRecoveryLock) {
+ if (BluetoothConnectionManager.aacpSocket === socket &&
+ connectionGeneration == aacpConnectionGeneration &&
+ !aacpReconnectSuppressed
+ ) {
+ BluetoothConnectionManager.attSocket = candidateAttSocket
+ true
+ } else {
+ false
+ }
}
+ if (!attInstalled) {
+ closeSocketQuietly(candidateAttSocket, "superseded ATT socket")
+ attSocket = null
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Optional ATT socket unavailable; keeping AACP connected: ${e.message}")
+ closeSocketQuietly(attSocket, "failed optional ATT socket")
+ attSocket = null
+ }
+ }
- BluetoothConnectionManager.aacpSocket = socket
- BluetoothConnectionManager.attSocket = attSocket
-
- // Create AirPodsInstance from stored config if available
- if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
- val model =
- AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
- if (model != null) {
- airpodsInstance = AirPodsInstance(
- name = config.airpodsName,
- model = model,
- actualModelNumber = config.airpodsModelNumber,
- serialNumber = config.airpodsSerialNumber,
- leftSerialNumber = config.airpodsLeftSerialNumber,
- rightSerialNumber = config.airpodsRightSerialNumber,
- version1 = config.airpodsVersion1,
- version2 = config.airpodsVersion2,
- version3 = config.airpodsVersion3,
- )
- setMetadatas(device)
+ this@AirPodsService.device = device
+ startAacpLivenessWatchdog(socket, device)
+
+ if (attSocket != null) {
+ try {
+ attManager.startReader()
+ attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
+ attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
+ attManager.readCharacteristic(ATTHandles.HEARING_AID)
+ } catch (e: Exception) {
+ Log.w(TAG, "Optional ATT initialization failed; keeping AACP connected: ${e.message}")
+ synchronized(transportRecoveryLock) {
+ if (BluetoothConnectionManager.attSocket === attSocket) {
+ BluetoothConnectionManager.attSocket = null
}
}
+ closeSocketQuietly(attSocket, "failed optional ATT socket")
+ attSocket = null
+ if (::attManager.isInitialized) attManager.disconnected()
+ }
+ }
- updateNotificationContent(
- true, config.deviceName, batteryNotification.getBattery()
+ // Create AirPodsInstance from stored config if available
+ if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
+ val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
+ if (model != null) {
+ airpodsInstance = AirPodsInstance(
+ name = config.airpodsName,
+ model = model,
+ actualModelNumber = config.airpodsModelNumber,
+ serialNumber = config.airpodsSerialNumber,
+ leftSerialNumber = config.airpodsLeftSerialNumber,
+ rightSerialNumber = config.airpodsRightSerialNumber,
+ version1 = config.airpodsVersion1,
+ version2 = config.airpodsVersion2,
+ version3 = config.airpodsVersion3,
)
- Log.d(TAG, " Socket connected")
- sharedPreferences.edit { putBoolean("connection_successful", true) }
- if (!sharedPreferences.contains("first_connection_successful_time")) {
- sharedPreferences.edit {
- putLong(
- "first_connection_successful_time",
- System.currentTimeMillis()
- )
- }
- }
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))
- } catch (e: Exception) {
-// sharedPreferences.edit { putBoolean("connection_successful", false) }
- Log.d(
- TAG, " Socket not connected, ${e.message}"
+ setMetadatas(device)
+ }
+ }
+
+ Log.d(TAG, " Socket connected")
+ } catch (e: Exception) {
+ if (socketInstalled) {
+ if (handleAacpTransportFailure(
+ failedSocket = socket,
+ reconnectDevice = device,
+ source = "connection-initialization-failed"
)
- if (manual) {
- sendToast(
- "Couldn't connect to socket: ${e.localizedMessage}"
- )
- } else {
- showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
- }
- return@withTimeout
-// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
+ ) {
+ broadcastAacpTransportFailure()
}
+ } else {
+ closeSocketQuietly(socket, "failed AACP socket")
+ closeSocketQuietly(attSocket, "failed ATT socket")
}
- }
- if (!socket.isConnected) {
- Log.d(TAG, " socket not connected")
+ Log.d(TAG, " Socket not connected, ${e.message}")
if (manual) {
- sendToast(
- "Couldn't connect to socket: timeout."
- )
+ sendToast("Couldn't connect to socket: ${e.localizedMessage}")
} else {
- showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
+ showSocketConnectionFailureNotification(
+ "Couldn't connect to socket: ${e.localizedMessage}"
+ )
+ if (!socketInstalled) {
+ scheduleAacpReconnect(device, "connection-attempt-failed")
+ }
}
return
}
+
this@AirPodsService.device = device
- BluetoothConnectionManager.aacpSocket?.let {
+ socket.let {
aacpManager.sendPacket(aacpManager.createHandshakePacket())
aacpManager.sendSetFeatureFlagsPacket()
aacpManager.sendNotificationRequest()
@@ -2747,45 +3021,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
CoroutineScope(Dispatchers.IO).launch {
delay(200)
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
aacpManager.sendPacket(aacpManager.createHandshakePacket())
delay(200)
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
aacpManager.sendSetFeatureFlagsPacket()
delay(200)
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
aacpManager.sendNotificationRequest()
delay(200)
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
aacpManager.sendSomePacketIDontKnowWhatItIs()
delay(200)
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
- if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall()
+ if (!handleIncomingCallOnceConnected) {
+ if (!heartRateState.value.enabled) startHeadTracking()
+ } else {
+ handleIncomingCall()
+ }
Handler(Looper.getMainLooper()).postDelayed({
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) {
+ return@postDelayed
+ }
aacpManager.sendPacket(aacpManager.createHandshakePacket())
aacpManager.sendSetFeatureFlagsPacket()
aacpManager.sendNotificationRequest()
aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
- if (!handleIncomingCallOnceConnected) stopHeadTracking()
+ if (!handleIncomingCallOnceConnected && !heartRateState.value.enabled) {
+ stopHeadTracking()
+ }
}, 5000)
- sendBroadcast(
- Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device)
- .apply {
- setPackage(packageName)
- })
-
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) return@launch
setupStemActions()
+ startHeartRateMonitoringIfEnabled()
- while (socket.isConnected) {
+ while (isCurrentAacpConnection(socket, connectionGeneration)) {
try {
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
var data: ByteArray
if (bytesRead > 0) {
+ if (!isCurrentAacpConnection(socket, connectionGeneration)) {
+ return@launch
+ }
+ noteAacpPacketReceived(socket, device)
data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead))
setPackage(packageName)
})
val bytes = buffer.copyOfRange(0, bytesRead)
- val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
// CrossDevice.sendReceivedPacket(bytes)
updateNotificationContent(
true,
@@ -2793,42 +3080,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 +3149,289 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// }
}
+ private fun connectSocketWithTimeout(socket: BluetoothSocket, label: String) {
+ val failure = AtomicReference(null)
+ val connectThread = Thread({
+ try {
+ socket.connect()
+ } catch (throwable: Throwable) {
+ failure.set(throwable)
+ }
+ }, "LibrePods-$label-connect")
+ connectThread.start()
+ connectThread.join(5_000L)
+
+ if (connectThread.isAlive) {
+ closeSocketQuietly(socket, "$label socket after connect timeout")
+ connectThread.join(1_000L)
+ throw IOException("$label socket connection timed out")
+ }
+ failure.get()?.let { throw IOException("$label socket connection failed", it) }
+ if (!socket.isConnected) {
+ throw IOException("$label socket did not enter the connected state")
+ }
+ }
+
+ private fun closeSocketQuietly(socket: BluetoothSocket?, label: String) {
+ if (socket == null) return
+ try {
+ socket.close()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to close $label: ${e.message}")
+ }
+ }
+
+ private fun isCurrentAacpConnection(
+ socket: BluetoothSocket,
+ connectionGeneration: Long
+ ): Boolean = synchronized(transportRecoveryLock) {
+ !aacpReconnectSuppressed &&
+ connectionGeneration == aacpConnectionGeneration &&
+ BluetoothConnectionManager.aacpSocket === socket &&
+ socket.isConnected
+ }
+
+ /**
+ * Removes a dead AACP transport without sending any more packets through it. The expected
+ * socket check prevents an old reader from tearing down a newer connection.
+ */
+ private fun clearAacpTransport(
+ source: String,
+ expectedSocket: BluetoothSocket?
+ ): Boolean {
+ val aacpSocketToClose: BluetoothSocket?
+ val attSocketToClose: BluetoothSocket?
+ val livenessJobToCancel: Job?
+ 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
+ aacpTransportResponsive = false
+ lastAacpPacketElapsedRealtime = 0L
+ livenessJobToCancel = aacpLivenessJob
+ aacpLivenessJob = null
+ }
+
+ livenessJobToCancel?.cancel()
+ closeSocketQuietly(aacpSocketToClose, "AACP socket")
+ closeSocketQuietly(attSocketToClose, "ATT socket")
+ if (::attManager.isInitialized) attManager.disconnected()
+ handleHeartRateDisconnected()
+ aacpManager.disconnected()
+ updateNotificationContent(false)
+ Log.w(
+ TAG,
+ "AACP transport cleaned source=$source socketId=" +
+ aacpSocketToClose?.let { System.identityHashCode(it) }
+ )
+ return aacpSocketToClose != null
+ }
+
+ fun isAacpTransportHealthy(): Boolean = synchronized(transportRecoveryLock) {
+ BluetoothConnectionManager.aacpSocket?.isConnected == true && aacpTransportResponsive
+ }
+
+ private fun noteAacpPacketReceived(socket: BluetoothSocket, connectedDevice: BluetoothDevice) {
+ val becameResponsive = synchronized(transportRecoveryLock) {
+ if (BluetoothConnectionManager.aacpSocket !== socket) {
+ false
+ } else {
+ lastAacpPacketElapsedRealtime = SystemClock.elapsedRealtime()
+ if (aacpTransportResponsive) {
+ false
+ } else {
+ aacpTransportResponsive = true
+ true
+ }
+ }
+ }
+ if (!becameResponsive) return
+
+ updateNotificationContent(true, config.deviceName, batteryNotification.getBattery())
+ sharedPreferences.edit { putBoolean("connection_successful", true) }
+ if (!sharedPreferences.contains("first_connection_successful_time")) {
+ sharedPreferences.edit {
+ putLong("first_connection_successful_time", System.currentTimeMillis())
+ }
+ }
+ sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)
+ .putExtra("device", connectedDevice)
+ .apply { setPackage(packageName) }
+ )
+ Log.i(TAG, "AACP transport became responsive")
+ startHeartRateMonitoringIfEnabled()
+ }
+
+ private fun startAacpLivenessWatchdog(socket: BluetoothSocket, connectedDevice: BluetoothDevice) {
+ val job = synchronized(transportRecoveryLock) {
+ aacpLivenessJob?.cancel()
+ aacpTransportResponsive = false
+ lastAacpPacketElapsedRealtime = 0L
+
+ transportRecoveryScope.launch(start = CoroutineStart.LAZY) {
+ delay(AACP_INITIAL_RESPONSE_TIMEOUT_MILLIS)
+ if (!isCurrentAacpSocket(socket)) return@launch
+
+ if (!isAacpTransportHealthy() && !probeAacpTransport(socket)) {
+ failUnresponsiveAacpTransport(socket, connectedDevice, "initial-response-timeout")
+ return@launch
+ }
+
+ while (isCurrentAacpSocket(socket)) {
+ delay(AACP_IDLE_PROBE_INTERVAL_MILLIS)
+ if (!isCurrentAacpSocket(socket)) return@launch
+
+ val silentFor = SystemClock.elapsedRealtime() - lastAacpPacketElapsedRealtime
+ if (silentFor < AACP_IDLE_PROBE_INTERVAL_MILLIS) continue
+
+ if (!probeAacpTransport(socket)) {
+ failUnresponsiveAacpTransport(socket, connectedDevice, "idle-probe-timeout")
+ return@launch
+ }
+ }
+ }.also { aacpLivenessJob = it }
+ }
+ job.start()
+ }
+
+ private fun isCurrentAacpSocket(socket: BluetoothSocket): Boolean =
+ synchronized(transportRecoveryLock) {
+ BluetoothConnectionManager.aacpSocket === socket && socket.isConnected
+ }
+
+ private suspend fun probeAacpTransport(socket: BluetoothSocket): Boolean {
+ if (!isCurrentAacpSocket(socket)) return true
+ var probeStartedAt = SystemClock.elapsedRealtime()
+ Log.i(TAG, "Probing silent AACP transport")
+ if (!aacpManager.sendNotificationRequest()) return false
+ delay(AACP_PROBE_RESPONSE_TIMEOUT_MILLIS)
+ if (!isCurrentAacpSocket(socket)) return true
+ if (lastAacpPacketElapsedRealtime >= probeStartedAt) return true
+
+ // Retry with the normal handshake as well before declaring an otherwise-open socket dead.
+ probeStartedAt = SystemClock.elapsedRealtime()
+ if (!aacpManager.sendPacket(aacpManager.createHandshakePacket()) ||
+ !aacpManager.sendNotificationRequest()
+ ) {
+ return false
+ }
+ delay(AACP_PROBE_RESPONSE_TIMEOUT_MILLIS)
+ if (!isCurrentAacpSocket(socket)) return true
+ return lastAacpPacketElapsedRealtime >= probeStartedAt
+ }
+
+ private fun failUnresponsiveAacpTransport(
+ socket: BluetoothSocket,
+ connectedDevice: BluetoothDevice,
+ source: String
+ ) {
+ Log.w(TAG, "AACP transport did not respond source=$source")
+ if (handleAacpTransportFailure(socket, connectedDevice, source)) {
+ broadcastAacpTransportFailure()
+ }
+ }
+
+ private fun handleAacpTransportFailure(
+ failedSocket: BluetoothSocket,
+ reconnectDevice: BluetoothDevice,
+ 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 {
+ for ((attemptIndex, backoffMillis) in AACP_RECONNECT_BACKOFF_MILLIS.withIndex()) {
+ delay(backoffMillis)
+ val shouldReconnect = synchronized(transportRecoveryLock) {
+ aacpReconnectJob === currentJob &&
+ !aacpReconnectSuppressed &&
+ BluetoothConnectionManager.aacpSocket == null
+ }
+ if (!shouldReconnect) return@launch
+
+ Log.i(
+ TAG,
+ "AACP reconnect starting source=$source attempt=${attemptIndex + 1}"
+ )
+ val adapter = getSystemService(BluetoothManager::class.java).adapter
+ connectToSocket(adapter, reconnectDevice)
+
+ val reconnectWasCancelled = synchronized(transportRecoveryLock) {
+ aacpReconnectJob !== currentJob || aacpReconnectSuppressed
+ }
+ if (reconnectWasCancelled) return@launch
+ if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
+ Log.i(TAG, "AACP reconnect socket established source=$source")
+ return@launch
+ }
+ }
+ Log.w(TAG, "AACP reconnect attempts exhausted source=$source")
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.w(TAG, "AACP reconnect failed source=$source: ${error.message}", error)
+ } finally {
+ synchronized(transportRecoveryLock) {
+ if (aacpReconnectJob === currentJob) {
+ aacpReconnectJob = null
+ }
+ }
+ }
+ }
+ aacpReconnectJob = job
+ job.start()
+ }
+ }
+
+ private fun cancelAacpReconnect(source: String, suppressFutureReconnects: Boolean) {
+ val job = synchronized(transportRecoveryLock) {
+ if (suppressFutureReconnects) {
+ aacpReconnectSuppressed = true
+ aacpConnectionGeneration++
+ }
+ 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 +3464,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun disconnectAirPods() {
if (BluetoothConnectionManager.aacpSocket == null) return
+ stopHeartRateMonitoring()
try {
BluetoothConnectionManager.aacpSocket?.close()
} catch(e: Exception) {
@@ -3139,11 +3730,225 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.unregisterTelephonyCallback(phoneStateListener)
}
+ if (::nearbyFinder.isInitialized) nearbyFinder.stop()
+ if (::heartRateBlePeripheral.isInitialized) heartRateBlePeripheral.stop()
+ stopHeartRateMonitoring()
+ if (::heartRateExporter.isInitialized) {
+ runBlocking { heartRateExporter.closeAndFlush() }
+ }
+ 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 startNearbyFinder() {
+ if (!::nearbyFinder.isInitialized || !nearbyFinder.start()) return
+ prepareNearbyFinderScan()
+ if (!::bleManager.isInitialized ||
+ !bleManager.startScanning(
+ scanAllAdvertisementsForFinder = true,
+ resetFinderWatchdog = true,
+ )
+ ) {
+ nearbyFinder.onScanError(-1)
+ }
+ }
+
+ fun stopNearbyFinder() {
+ if (::nearbyFinder.isInitialized) nearbyFinder.stop()
+ if (::bleManager.isInitialized) bleManager.startScanning()
+ if (::heartRateBlePeripheral.isInitialized &&
+ sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false)
+ ) {
+ heartRateBlePeripheral.start()
+ }
+ }
+
+ fun refreshNearbyFinderPrerequisites() {
+ if (!::nearbyFinder.isInitialized || !nearbyFinder.refreshPrerequisites()) return
+ prepareNearbyFinderScan()
+ if (!::bleManager.isInitialized ||
+ !bleManager.startScanning(
+ scanAllAdvertisementsForFinder = true,
+ resetFinderWatchdog = true,
+ )
+ ) {
+ nearbyFinder.onScanError(-1)
+ }
+ }
+
+ private fun prepareNearbyFinderScan() {
+ // A few Android Bluetooth chipsets do not scan reliably while the same app owns a
+ // connectable advertiser. Finder takes priority; restore the opt-in HR peripheral when
+ // the user leaves this screen.
+ if (::heartRateBlePeripheral.isInitialized &&
+ sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false)
+ ) {
+ heartRateBlePeripheral.stop()
+ }
+ }
+
+ fun setHeartRateBlePeripheralEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, enabled) }
+ if (::heartRateBlePeripheral.isInitialized) heartRateBlePeripheral.setEnabled(enabled)
+ }
+
+ fun refreshHeartRateBlePeripheral() {
+ if (!::heartRateBlePeripheral.isInitialized) return
+ heartRateBlePeripheral.setEnabled(
+ sharedPreferences.getBoolean(HEART_RATE_BLE_PERIPHERAL_PREFERENCE, false)
+ )
+ }
+
+ fun setHeartRateMonitoringEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean(HEART_RATE_MONITORING_PREFERENCE, enabled) }
+ if (!enabled && ::heartRateExporter.isInitialized) heartRateExporter.flushAsync()
+ heartRateMonitor.setEnabled(enabled)
+ }
+
+ private fun startHeartRateMonitoringIfEnabled() {
+ if (::heartRateMonitor.isInitialized) heartRateMonitor.startIfPossible()
+ }
+
+ private fun areAirPodsWornForHeartRate(): Boolean = heartRateAirPodsWorn != false
+
+ private fun updateHeartRateWearState(
+ leftInEar: Boolean,
+ rightInEar: Boolean,
+ source: String
+ ) {
+ // A single AirPod can keep providing heart-rate samples. Only pause once both are out.
+ val isWorn = leftInEar || rightInEar
+ val previousState = synchronized(transportRecoveryLock) {
+ heartRateAirPodsWorn.also {
+ heartRateAirPodsWorn = isWorn
+ if (isWorn && it != true) {
+ heartRateAutomaticAacpRecoveryAttempted = false
+ }
+ }
+ }
+ if (previousState == isWorn) return
+
+ Log.i(
+ TAG,
+ "Heart-rate wear state changed worn=$isWorn source=$source; " +
+ if (isWorn) "resuming when transport is ready" else "pausing retries"
+ )
+ if (!::heartRateMonitor.isInitialized) return
+
+ heartRateMonitor.onWearStateChanged(isWorn)
+ if (isWorn && previousState == false && heartRateState.value.enabled &&
+ !isAacpTransportHealthy() && device != null
+ ) {
+ rebuildAacpForHeartRate(source = "heart-rate-wear-resume")
+ }
+ }
+
+ private fun stopHeartRateMonitoring(
+ forceStop: Boolean = false,
+ sendStopFrame: Boolean = true
+ ) {
+ if (::heartRateMonitor.isInitialized) {
+ heartRateMonitor.stop(forceStop = forceStop, sendStopFrame = sendStopFrame)
+ }
+ }
+
+ fun reconnectAacpForHeartRate() {
+ rebuildAacpForHeartRate(source = "heart-rate-manual-reconnect")
+ }
+
+ private fun requestAutomaticAacpRecoveryForHeartRate(): Boolean {
+ if (!areAirPodsWornForHeartRate()) {
+ heartRateMonitor.onWearStateChanged(false)
+ return false
+ }
+ val claimed = synchronized(transportRecoveryLock) {
+ if (heartRateAutomaticAacpRecoveryAttempted) {
+ false
+ } else {
+ heartRateAutomaticAacpRecoveryAttempted = true
+ true
+ }
+ }
+ if (!claimed) return false
+
+ val started = rebuildAacpForHeartRate(source = "heart-rate-auto-reconnect")
+ if (!started) {
+ Log.w(TAG, "Automatic AACP rebuild could not be started for heart-rate monitoring")
+ }
+ return started
+ }
+
+ private fun rebuildAacpForHeartRate(source: String): Boolean {
+ if (!heartRateState.value.enabled) return false
+ if (!areAirPodsWornForHeartRate()) {
+ heartRateMonitor.onWearStateChanged(false)
+ Log.i(TAG, "Deferring AACP rebuild source=$source until an AirPod is in ear")
+ return false
+ }
+ val reconnectDevice = device ?: run {
+ stopHeartRateMonitoring(sendStopFrame = false)
+ return false
+ }
+
+ cancelAacpReconnect(
+ source = source,
+ suppressFutureReconnects = false
+ )
+ transportRecoveryScope.launch {
+ if (!heartRateState.value.enabled || !areAirPodsWornForHeartRate()) return@launch
+ stopHeartRateMonitoring(forceStop = true)
+ BluetoothConnectionManager.aacpSocket?.let { socket ->
+ clearAacpTransport(
+ source = source,
+ expectedSocket = socket
+ )
+ }
+ heartRateMonitor.markReconnecting()
+ Log.i(
+ TAG,
+ "Waiting ${HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS}ms " +
+ "before rebuilding AACP after heart-rate reset source=$source"
+ )
+ delay(HEART_RATE_AACP_RESET_QUIET_PERIOD_MILLIS)
+ if (!heartRateState.value.enabled || !areAirPodsWornForHeartRate()) {
+ if (!areAirPodsWornForHeartRate()) {
+ heartRateMonitor.onWearStateChanged(false)
+ }
+ return@launch
+ }
+
+ val adapter = getSystemService(BluetoothManager::class.java).adapter
+ Log.i(TAG, "Starting AACP reconnect for heart-rate monitoring source=$source")
+ connectToSocket(adapter, reconnectDevice, manual = true)
+ }
+ return true
+ }
+
+ private fun handleHeartRateDisconnected() {
+ if (::heartRateExporter.isInitialized) heartRateExporter.flushAsync()
+ stopHeartRateMonitoring(sendStopFrame = false)
+ }
+
var isHeadTrackingActive = false
fun startHeadTracking() {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt
new file mode 100644
index 000000000..8ed1fe603
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitor.kt
@@ -0,0 +1,403 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+*/
+
+package me.kavishdevar.librepods.services
+
+import android.os.SystemClock
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+
+/**
+ * Owns the heart-rate stream lifecycle and its user-visible state.
+ *
+ * A single coroutine performs startup, warm-up, live sample collection, and in-stream retries.
+ * RTBuddy callbacks only enqueue validated samples; they do not mutate status or run watchdogs.
+ */
+internal class HeartRateMonitor(
+ private val scope: CoroutineScope,
+ initiallyEnabled: Boolean,
+ private val isTransportReady: () -> Boolean,
+ private val isAirPodsWorn: () -> Boolean,
+ private val beforeFirstStart: suspend () -> Unit,
+ private val sendConnectService0: () -> Boolean,
+ private val sendCapabilitiesService0: () -> Boolean,
+ private val sendConnectService4: () -> Boolean,
+ private val sendCapabilitiesService4: () -> Boolean,
+ private val awaitHeartRateService: suspend () -> Boolean,
+ private val enableHeartRate: () -> Boolean,
+ private val sendStart: () -> Boolean,
+ private val sendStop: () -> Unit,
+ private val requestTransportRecovery: () -> Boolean,
+ private val onPublishedSample: (HeartRateSample) -> Unit
+) {
+ private enum class RefreshReason(val diagnosticName: String) {
+ FIRST_SAMPLE_TIMEOUT("first-sample-timeout"),
+ STREAM_STALLED("stream-stalled")
+ }
+
+ private data class RefreshWindow(
+ val reason: RefreshReason,
+ var deadlineElapsedRealtime: Long,
+ var attempts: Int = 0
+ )
+
+ private val lock = Any()
+ private val incomingSamples = Channel(Channel.UNLIMITED)
+ private var monitoringJob: Job? = null
+ private var sessionNeedsStop = false
+ private var acceptingSamples = false
+
+ private val _state = MutableStateFlow(
+ HeartRateMonitoringState(
+ enabled = initiallyEnabled,
+ status = if (initiallyEnabled) {
+ HeartRateMonitoringStatus.WAITING_FOR_AIRPODS
+ } else {
+ HeartRateMonitoringStatus.OFF
+ }
+ )
+ )
+ val state: StateFlow = _state
+
+ fun setEnabled(enabled: Boolean) {
+ val wasEnabled = state.value.enabled
+ val transportReady = isTransportReady()
+ val airPodsWorn = isAirPodsWorn()
+ updateState {
+ it.copy(
+ enabled = enabled,
+ status = when {
+ !enabled -> HeartRateMonitoringStatus.OFF
+ !transportReady -> HeartRateMonitoringStatus.WAITING_FOR_AIRPODS
+ !airPodsWorn -> HeartRateMonitoringStatus.WAITING_TO_BE_WORN
+ !wasEnabled -> HeartRateMonitoringStatus.STARTING
+ else -> it.status
+ }
+ )
+ }
+
+ if (enabled) startIfPossible() else stop(forceStop = wasEnabled)
+ }
+
+ fun startIfPossible() {
+ val currentState = state.value
+ if (!currentState.enabled) {
+ updateStatus(HeartRateMonitoringStatus.OFF)
+ return
+ }
+ if (!isTransportReady()) {
+ updateStatus(HeartRateMonitoringStatus.WAITING_FOR_AIRPODS)
+ return
+ }
+ if (!isAirPodsWorn()) {
+ updateStatus(HeartRateMonitoringStatus.WAITING_TO_BE_WORN)
+ return
+ }
+
+ val job = synchronized(lock) {
+ if (monitoringJob?.isActive == true) return
+
+ updateStatus(HeartRateMonitoringStatus.STARTING)
+ scope.launch(start = CoroutineStart.LAZY) { runMonitoringLoop() }
+ .also { monitoringJob = it }
+ }
+ job.start()
+ }
+
+ fun onValidatedSample(sample: HeartRateSample) {
+ val accepted = synchronized(lock) {
+ state.value.enabled && acceptingSamples && isTransportReady()
+ }
+ if (accepted) incomingSamples.trySend(sample)
+ }
+
+ fun markReconnecting() {
+ if (state.value.enabled) updateStatus(HeartRateMonitoringStatus.RECONNECTING)
+ }
+
+ fun onWearStateChanged(isWorn: Boolean) {
+ if (isWorn) {
+ startIfPossible()
+ } else {
+ stopAndUpdateStatus(
+ forceStop = false,
+ sendStopFrame = true,
+ enabledStatus = HeartRateMonitoringStatus.WAITING_TO_BE_WORN
+ )
+ }
+ }
+
+ fun stop(forceStop: Boolean = false, sendStopFrame: Boolean = true) {
+ stopAndUpdateStatus(
+ forceStop = forceStop,
+ sendStopFrame = sendStopFrame,
+ enabledStatus = HeartRateMonitoringStatus.WAITING_FOR_AIRPODS
+ )
+ }
+
+ private fun stopAndUpdateStatus(
+ forceStop: Boolean,
+ sendStopFrame: Boolean,
+ enabledStatus: HeartRateMonitoringStatus
+ ) {
+ synchronized(lock) {
+ val jobWasActive = monitoringJob?.isActive == true
+ monitoringJob?.cancel()
+ monitoringJob = null
+ stopSessionLocked(forceStop || jobWasActive, sendStopFrame)
+ drainIncomingSamples()
+ }
+ updateStatus(
+ if (state.value.enabled) {
+ enabledStatus
+ } else {
+ HeartRateMonitoringStatus.OFF
+ }
+ )
+ }
+
+ private suspend fun runMonitoringLoop() {
+ val currentJob = kotlinx.coroutines.currentCoroutineContext()[Job]
+ var refreshWindow: RefreshWindow? = null
+
+ try {
+ beforeFirstStart()
+
+ while (canRun()) {
+ updateStatus(
+ if (refreshWindow == null) {
+ HeartRateMonitoringStatus.STARTING
+ } else {
+ HeartRateMonitoringStatus.RECONNECTING
+ }
+ )
+
+ val attemptStartedAt = startStreamAttempt()
+ if (!canRun()) return
+ if (attemptStartedAt != null) {
+ refreshWindow?.deadlineElapsedRealtime =
+ attemptStartedAt + FIRST_SAMPLE_TIMEOUT_MILLIS
+ }
+
+ val failure = if (attemptStartedAt == null) {
+ RefreshReason.FIRST_SAMPLE_TIMEOUT
+ } else {
+ awaitStreamFailure(
+ attemptStartedAt = attemptStartedAt,
+ refreshDeadline = refreshWindow?.deadlineElapsedRealtime,
+ onStreamStarted = { refreshWindow = null }
+ ) ?: return
+ }
+
+ synchronized(lock) { stopSessionLocked() }
+ if (!canRun()) return
+ val window = refreshWindow ?: RefreshWindow(
+ reason = failure,
+ deadlineElapsedRealtime =
+ SystemClock.elapsedRealtime() + RECONNECT_WINDOW_MILLIS
+ ).also {
+ refreshWindow = it
+ Log.i(
+ TAG,
+ "RTBuddy heart-rate refresh requested " +
+ "reason=${it.reason.diagnosticName} transport=healthy"
+ )
+ }
+
+ if (!waitForRetry(window)) {
+ Log.w(
+ TAG,
+ "RTBuddy heart-rate refresh failed " +
+ "reason=${window.reason.diagnosticName} attempts=${window.attempts}"
+ )
+ updateStatus(HeartRateMonitoringStatus.RECONNECTING)
+ if (requestTransportRecovery()) {
+ Log.i(TAG, "Requesting one automatic AACP rebuild for heart-rate recovery")
+ } else if (!canRun()) {
+ return
+ } else {
+ updateStatus(HeartRateMonitoringStatus.COULDNT_START)
+ Log.i(TAG, "Automatic AACP rebuild unavailable; waiting for manual Retry")
+ }
+ return
+ }
+ }
+ } finally {
+ synchronized(lock) {
+ if (monitoringJob === currentJob) {
+ stopSessionLocked()
+ monitoringJob = null
+ }
+ }
+ }
+ }
+
+ private suspend fun startStreamAttempt(): Long? {
+ drainIncomingSamples()
+ if (!initializeAacpSession()) return null
+ if (!awaitHeartRateService()) return null
+ val enabled = synchronized(lock) {
+ canRun() && enableHeartRate().also { sent ->
+ if (sent) sessionNeedsStop = true
+ }
+ }
+ if (!enabled) return null
+
+ delay(START_COMMAND_DELAY_MILLIS)
+
+ return synchronized(lock) {
+ if (!canRun()) {
+ null
+ } else {
+ val startedAt = SystemClock.elapsedRealtime()
+ val started = sendStart()
+ acceptingSamples = started
+ sessionNeedsStop = sessionNeedsStop || started
+ Log.d(TAG, "RTBuddy heart-rate start sent=$started")
+ startedAt.takeIf { started }
+ }
+ }
+ }
+
+ private suspend fun initializeAacpSession(): Boolean {
+ val frames = listOf(
+ sendConnectService0 to 180L,
+ sendCapabilitiesService0 to 220L,
+ sendConnectService4 to 180L,
+ sendCapabilitiesService4 to 220L
+ )
+
+ for ((sendFrame, delayAfter) in frames) {
+ if (!sendIfRunning(sendFrame)) return false
+ delay(delayAfter)
+ }
+
+ Log.d(TAG, "RTBuddy heart-rate AACP 1.3 session initialized")
+ return canRun()
+ }
+
+ private suspend fun awaitStreamFailure(
+ attemptStartedAt: Long,
+ refreshDeadline: Long?,
+ onStreamStarted: () -> Unit
+ ): RefreshReason? {
+ var warmupSamplesRemaining = WARMUP_SAMPLE_COUNT
+ var streamStarted = false
+ val firstSampleDeadline = refreshDeadline
+ ?: (attemptStartedAt + FIRST_SAMPLE_TIMEOUT_MILLIS)
+
+ while (canRun()) {
+ val timeout = if (streamStarted) {
+ STALL_TIMEOUT_MILLIS
+ } else {
+ (firstSampleDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(0L)
+ }
+ if (timeout == 0L) return RefreshReason.FIRST_SAMPLE_TIMEOUT
+
+ val sample = withTimeoutOrNull(timeout) { incomingSamples.receive() }
+ ?: return if (streamStarted) {
+ RefreshReason.STREAM_STALLED
+ } else {
+ RefreshReason.FIRST_SAMPLE_TIMEOUT
+ }
+
+ if (!canRun()) return null
+ if (!streamStarted) {
+ streamStarted = true
+ if (refreshDeadline != null) {
+ Log.i(TAG, "RTBuddy heart-rate reconnect succeeded")
+ }
+ onStreamStarted()
+ }
+ if (warmupSamplesRemaining > 0) {
+ warmupSamplesRemaining--
+ updateStatus(HeartRateMonitoringStatus.CALIBRATING)
+ continue
+ }
+
+ publish(sample)
+ updateStatus(HeartRateMonitoringStatus.LIVE)
+ }
+ return null
+ }
+
+ private fun waitForRetry(window: RefreshWindow): Boolean {
+ if (window.attempts >= MAX_RECONNECT_ATTEMPTS) return false
+
+ window.attempts++
+ updateStatus(HeartRateMonitoringStatus.RECONNECTING)
+ Log.w(
+ TAG,
+ "RTBuddy heart-rate reconnect attempt=${window.attempts} " +
+ "reason=${window.reason.diagnosticName} timeout=${FIRST_SAMPLE_TIMEOUT_MILLIS}ms"
+ )
+ return canRun()
+ }
+
+ private fun publish(sample: HeartRateSample) {
+ updateState { current ->
+ current.copy(samples = (current.samples + sample).takeLast(MAX_SAMPLES))
+ }
+ onPublishedSample(sample)
+ }
+
+ private fun sendIfRunning(sendFrame: () -> Boolean): Boolean = synchronized(lock) {
+ canRun() && sendFrame()
+ }
+
+ private fun stopSessionLocked(
+ forceStop: Boolean = false,
+ sendStopFrame: Boolean = true
+ ) {
+ val shouldStop = forceStop || sessionNeedsStop || acceptingSamples
+ sessionNeedsStop = false
+ acceptingSamples = false
+ if (sendStopFrame && shouldStop && isTransportReady()) sendStop()
+ }
+
+ private fun canRun(): Boolean =
+ state.value.enabled && isTransportReady() && isAirPodsWorn()
+
+ private fun updateStatus(status: HeartRateMonitoringStatus) {
+ updateState { it.copy(status = status) }
+ }
+
+ private inline fun updateState(
+ transform: (HeartRateMonitoringState) -> HeartRateMonitoringState
+ ) {
+ synchronized(lock) {
+ _state.value = transform(_state.value)
+ }
+ }
+
+ private fun drainIncomingSamples() {
+ while (incomingSamples.tryReceive().isSuccess) Unit
+ }
+
+ private companion object {
+ const val TAG = "HeartRateMonitor"
+ const val MAX_SAMPLES = 60
+ const val FIRST_SAMPLE_TIMEOUT_MILLIS = 8_000L
+ const val RECONNECT_WINDOW_MILLIS = FIRST_SAMPLE_TIMEOUT_MILLIS
+ const val STALL_TIMEOUT_MILLIS = 2_000L
+ const val START_COMMAND_DELAY_MILLIS = 120L
+ const val WARMUP_SAMPLE_COUNT = 4
+ const val MAX_RECONNECT_ATTEMPTS = 1
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt
new file mode 100644
index 000000000..f74da4f89
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/HeartRateMonitoringStatus.kt
@@ -0,0 +1,33 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+*/
+
+package me.kavishdevar.librepods.services
+
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+
+enum class HeartRateMonitoringStatus {
+ OFF,
+ WAITING_FOR_AIRPODS,
+ WAITING_TO_BE_WORN,
+ STARTING,
+ CALIBRATING,
+ LIVE,
+ RECONNECTING,
+ COULDNT_START
+}
+
+data class HeartRateMonitoringState(
+ val enabled: Boolean = false,
+ val status: HeartRateMonitoringStatus = HeartRateMonitoringStatus.OFF,
+ val samples: List = emptyList()
+) {
+ val latestSample: HeartRateSample?
+ get() = samples.lastOrNull()
+}
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
index 4558e635d..001873109 100644
--- a/android/app/src/main/res/xml/file_paths.xml
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -1,4 +1,5 @@
+
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt
new file mode 100644
index 000000000..8b8f2e7f4
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/HeartRateMeasurementEncoderTest.kt
@@ -0,0 +1,31 @@
+package me.kavishdevar.librepods.bluetooth
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+
+class HeartRateMeasurementEncoderTest {
+ @Test
+ fun bpmAtOrBelow255UsesUint8Format() {
+ assertArrayEquals(
+ byteArrayOf(0x00, 72),
+ HeartRateMeasurementEncoder.encodeBpm(72)
+ )
+ assertArrayEquals(
+ byteArrayOf(0x00, 0xff.toByte()),
+ HeartRateMeasurementEncoder.encodeBpm(255)
+ )
+ }
+
+ @Test
+ fun bpmAbove255UsesUint16LittleEndianFormat() {
+ assertArrayEquals(
+ byteArrayOf(0x01, 0x2c, 0x01),
+ HeartRateMeasurementEncoder.encodeBpm(300)
+ )
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun bpmOutsideUint16IsRejected() {
+ HeartRateMeasurementEncoder.encodeBpm(65_536)
+ }
+}
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt
new file mode 100644
index 000000000..6897323c7
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/HeartRateZonesTest.kt
@@ -0,0 +1,37 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class HeartRateZonesTest {
+ @Test
+ fun boundariesAreDeterministicPercentagesOfConfiguredMax() {
+ val max = 200
+ assertEquals(0, HeartRateZones.zoneIndex(99, max))
+ assertEquals(1, HeartRateZones.zoneIndex(100, max))
+ assertEquals(1, HeartRateZones.zoneIndex(119, max))
+ assertEquals(2, HeartRateZones.zoneIndex(120, max))
+ assertEquals(3, HeartRateZones.zoneIndex(140, max))
+ assertEquals(4, HeartRateZones.zoneIndex(160, max))
+ assertEquals(5, HeartRateZones.zoneIndex(180, max))
+ assertEquals(5, HeartRateZones.zoneIndex(205, max))
+ }
+}
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt
new file mode 100644
index 000000000..d7c51af1a
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/data/workout/WorkoutRepositoryTest.kt
@@ -0,0 +1,194 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data.workout
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import me.kavishdevar.librepods.bluetooth.HeartRateSample
+import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExportResult
+import me.kavishdevar.librepods.health.workout.WorkoutHealthConnectExporter
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class WorkoutRepositoryTest {
+ @Test
+ fun finishIsIdempotentAndDoesNotMoveEndTimeOrDuplicateHealthConnectExport() = runBlocking {
+ var now = 1_000L
+ val store = FakeStore()
+ val exporter = FakeExporter(WorkoutHealthConnectExportResult.Exported("hc-record"))
+ val repository = WorkoutRepository(
+ localStore = store,
+ healthConnectExporter = exporter,
+ maxHeartRateProvider = { 190 },
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
+ nowMillis = { now },
+ newId = { "session-1" },
+ zoneOffsetSecondsAt = { 0 },
+ )
+
+ assertEquals("session-1", repository.startWorkout())
+ now = 5_000L
+ repository.finishWorkout()
+ now = 9_000L
+ repository.finishWorkout("session-1")
+
+ val stored = store.getSession("session-1")
+ assertNotNull(stored)
+ assertEquals(5_000L, stored!!.endTimeEpochMillis)
+ assertEquals(HealthConnectSessionExportState.EXPORTED.name, stored.healthConnectExportState)
+ assertEquals("hc-record", stored.healthConnectRecordId)
+ assertEquals(1, exporter.calls)
+ }
+
+ @Test
+ fun permissionFailureKeepsFinishedLocalSessionAndRetryUsesSameIdentity() = runBlocking {
+ var now = 10_000L
+ val store = FakeStore()
+ val exporter = SequencedExporter(
+ mutableListOf(
+ WorkoutHealthConnectExportResult.PermissionRequired,
+ WorkoutHealthConnectExportResult.Exported("hc-2"),
+ )
+ )
+ val repository = WorkoutRepository(
+ localStore = store,
+ healthConnectExporter = exporter,
+ maxHeartRateProvider = { 185 },
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
+ nowMillis = { now },
+ newId = { "stable-id" },
+ zoneOffsetSecondsAt = { 3600 },
+ )
+
+ repository.startWorkout()
+ now = 20_000L
+ repository.finishWorkout()
+ val afterDenied = store.getSession("stable-id")!!
+ assertEquals(20_000L, afterDenied.endTimeEpochMillis)
+ assertEquals(HealthConnectSessionExportState.PERMISSION_REQUIRED.name, afterDenied.healthConnectExportState)
+
+ now = 30_000L
+ repository.retryHealthConnectExport("stable-id")
+ val afterRetry = store.getSession("stable-id")!!
+ assertEquals(20_000L, afterRetry.endTimeEpochMillis)
+ assertEquals("librepods-workout:stable-id", afterRetry.healthConnectClientRecordId)
+ assertEquals(HealthConnectSessionExportState.EXPORTED.name, afterRetry.healthConnectExportState)
+ assertEquals(2, exporter.calls)
+ }
+
+
+ @Test
+ fun validatedSamplePublishedBeforeFinishIsPersistedToThatSession() = runBlocking {
+ var now = 1_000L
+ val store = FakeStore()
+ val repository = WorkoutRepository(
+ localStore = store,
+ healthConnectExporter = FakeExporter(WorkoutHealthConnectExportResult.Exported("hc")),
+ maxHeartRateProvider = { 190 },
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
+ nowMillis = { now },
+ newId = { "sample-session" },
+ zoneOffsetSecondsAt = { 0 },
+ )
+
+ repository.startWorkout()
+ repository.recordValidatedSample(
+ HeartRateSample(
+ bpm = 137,
+ sequence = 42,
+ receivedAtMillis = 1_500L,
+ receivedAtElapsedRealtime = 500L,
+ )
+ )
+ now = 2_000L
+ repository.finishWorkout()
+
+ val samples = store.getSamples("sample-session")
+ assertEquals(1, samples.size)
+ assertEquals(137, samples.single().bpm)
+ assertEquals(42, samples.single().sequence)
+ }
+
+ private class FakeExporter(private val result: WorkoutHealthConnectExportResult) : WorkoutHealthConnectExporter {
+ var calls = 0
+ override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult {
+ calls++
+ return result
+ }
+ }
+
+ private class SequencedExporter(
+ private val results: MutableList
+ ) : WorkoutHealthConnectExporter {
+ var calls = 0
+ override suspend fun export(session: WorkoutSessionEntity): WorkoutHealthConnectExportResult {
+ calls++
+ return results.removeAt(0)
+ }
+ }
+
+ private class FakeStore : WorkoutLocalStore {
+ private val sessions = linkedMapOf()
+ private val samples = mutableListOf()
+
+ override fun observeActiveSummary(): Flow = flowOf(null)
+ override fun observeFinishedSummaries(): Flow> = flowOf(emptyList())
+ override fun observeSummary(sessionId: String): Flow = flowOf(null)
+ override fun observeSamples(sessionId: String): Flow> = flowOf(emptyList())
+ override fun observeActiveSamples(): Flow> = flowOf(emptyList())
+ override suspend fun getActiveSession(): WorkoutSessionEntity? = sessions.values.lastOrNull { it.endTimeEpochMillis == null }
+ override suspend fun getSession(sessionId: String): WorkoutSessionEntity? = sessions[sessionId]
+ override suspend fun getPendingHealthConnectSessions(): List =
+ sessions.values.filter { it.healthConnectExportState == HealthConnectSessionExportState.PENDING.name }
+ override suspend fun getSamples(sessionId: String): List = samples.filter { it.sessionId == sessionId }
+ override suspend fun createSession(session: WorkoutSessionEntity) { sessions[session.id] = session }
+ override suspend fun addSample(sample: WorkoutSampleEntity) { samples += sample }
+
+ override suspend fun finishLocally(sessionId: String, endMillis: Long, endOffsetSeconds: Int): WorkoutSessionEntity? {
+ val current = sessions[sessionId] ?: return null
+ if (current.endTimeEpochMillis == null) {
+ sessions[sessionId] = current.copy(
+ endTimeEpochMillis = endMillis,
+ endZoneOffsetSeconds = endOffsetSeconds,
+ healthConnectExportState = HealthConnectSessionExportState.PENDING.name,
+ )
+ }
+ return sessions[sessionId]
+ }
+
+ override suspend fun updateHealthConnectExport(
+ sessionId: String,
+ state: HealthConnectSessionExportState,
+ recordId: String?,
+ message: String?,
+ ) {
+ val current = sessions[sessionId] ?: return
+ sessions[sessionId] = current.copy(
+ healthConnectExportState = state.name,
+ healthConnectRecordId = recordId,
+ healthConnectExportMessage = message,
+ )
+ }
+ }
+}
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt
new file mode 100644
index 000000000..dd2e3d415
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/FitActivityEncoderTest.kt
@@ -0,0 +1,111 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.export.workout
+
+import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState
+import me.kavishdevar.librepods.data.workout.HeartRateZones
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity
+import me.kavishdevar.librepods.data.workout.WorkoutSummary
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FitActivityEncoderTest {
+ @Test
+ fun headerAndDataCrcAreSelfConsistent() {
+ val bytes = FitActivityEncoder.encode(detail())
+ assertEquals(14, bytes[0].toInt() and 0xFF)
+ assertEquals(".FIT", String(bytes, 8, 4, Charsets.US_ASCII))
+
+ val dataSize = le32(bytes, 4).toInt()
+ assertEquals(bytes.size - 14 - 2, dataSize)
+
+ val storedHeaderCrc = le16(bytes, 12)
+ assertEquals(FitCrc.compute(bytes, 0, 12), storedHeaderCrc)
+ assertEquals(0, FitCrc.compute(bytes, 0, 14))
+
+ val storedFileCrc = le16(bytes, 14 + dataSize)
+ assertEquals(FitCrc.compute(bytes, 14, dataSize), storedFileCrc)
+ assertEquals(FitCrc.compute(bytes, 0, 14 + dataSize), storedFileCrc)
+ assertEquals(setOf(0, 18, 19, 20, 34), globalMessageDefinitions(bytes, dataSize))
+ assertTrue(dataSize > 0)
+ }
+
+ private fun globalMessageDefinitions(bytes: ByteArray, dataSize: Int): Set {
+ val globals = linkedSetOf()
+ val localSizes = IntArray(16)
+ var offset = 14
+ val dataEnd = 14 + dataSize
+ while (offset < dataEnd) {
+ val header = bytes[offset++].toInt() and 0xFF
+ val local = header and 0x0F
+ if ((header and 0x40) != 0) {
+ offset++ // reserved
+ val architecture = bytes[offset++].toInt() and 0xFF
+ require(architecture == 0)
+ val global = le16(bytes, offset)
+ offset += 2
+ globals += global
+ val fieldCount = bytes[offset++].toInt() and 0xFF
+ var messageSize = 0
+ repeat(fieldCount) {
+ offset++ // field number
+ messageSize += bytes[offset++].toInt() and 0xFF
+ offset++ // base type
+ }
+ localSizes[local] = messageSize
+ } else {
+ offset += localSizes[local]
+ }
+ }
+ return globals
+ }
+
+ private fun le16(bytes: ByteArray, offset: Int): Int =
+ (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8)
+
+ private fun le32(bytes: ByteArray, offset: Int): Long =
+ (bytes[offset].toLong() and 0xFF) or
+ ((bytes[offset + 1].toLong() and 0xFF) shl 8) or
+ ((bytes[offset + 2].toLong() and 0xFF) shl 16) or
+ ((bytes[offset + 3].toLong() and 0xFF) shl 24)
+
+ private fun detail(): WorkoutDetail {
+ val start = 1_704_067_200_000L
+ val samples = listOf(
+ WorkoutSampleEntity(id = 1, sessionId = "fit", timestampEpochMillis = start + 1_000, sequence = 1, bpm = 120),
+ WorkoutSampleEntity(id = 2, sessionId = "fit", timestampEpochMillis = start + 2_000, sequence = 2, bpm = 128),
+ )
+ val summary = WorkoutSummary(
+ id = "fit",
+ startTimeEpochMillis = start,
+ endTimeEpochMillis = start + 60_000,
+ maxHeartRateBpm = 190,
+ sampleCount = samples.size.toLong(),
+ latestBpm = 128,
+ minBpm = 120,
+ avgBpm = 124.0,
+ maxBpm = 128,
+ healthConnectExportState = HealthConnectSessionExportState.EXPORTED,
+ healthConnectExportMessage = null,
+ )
+ return WorkoutDetail(summary, samples, HeartRateZones.distribution(samples, 190))
+ }
+}
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt
new file mode 100644
index 000000000..0c4b99357
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/export/workout/WorkoutCsvEncoderTest.kt
@@ -0,0 +1,76 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.export.workout
+
+import me.kavishdevar.librepods.data.workout.HealthConnectSessionExportState
+import me.kavishdevar.librepods.data.workout.HeartRateZones
+import me.kavishdevar.librepods.data.workout.WorkoutDetail
+import me.kavishdevar.librepods.data.workout.WorkoutSampleEntity
+import me.kavishdevar.librepods.data.workout.WorkoutSummary
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WorkoutCsvEncoderTest {
+ @Test
+ fun escapingDoublesQuotesAndWrapsSpecialFields() {
+ assertEquals("\"a,\"\"b\"\"\n\"", WorkoutCsvEncoder.csvEscape("a,\"b\"\n"))
+ }
+
+ @Test
+ fun timestampsAreIso8601UtcInstants() {
+ val workout = detail(id = "session,\"quoted\"")
+ val csv = WorkoutCsvEncoder.encode(workout)
+ assertTrue(csv.contains("2024-01-01T00:00:00Z"))
+ assertTrue(csv.contains("2024-01-01T00:00:01Z"))
+ assertTrue(csv.contains("\"session,\"\"quoted\"\"\""))
+ }
+
+
+ @Test
+ fun emptyWorkoutStillExportsOneSessionMetadataRow() {
+ val workout = detail(id = "empty").copy(samples = emptyList())
+ val csv = WorkoutCsvEncoder.encode(workout)
+ val lines = csv.trimEnd().lines()
+ assertEquals(2, lines.size)
+ assertTrue(lines[1].startsWith("empty,2024-01-01T00:00:00Z,"))
+ assertTrue(lines[1].endsWith(",190,,,"))
+ }
+
+ private fun detail(id: String): WorkoutDetail {
+ val start = 1_704_067_200_000L
+ val samples = listOf(
+ WorkoutSampleEntity(id = 1, sessionId = id, timestampEpochMillis = start + 1_000L, sequence = 7, bpm = 123)
+ )
+ val summary = WorkoutSummary(
+ id = id,
+ startTimeEpochMillis = start,
+ endTimeEpochMillis = start + 10_000L,
+ maxHeartRateBpm = 190,
+ sampleCount = 1,
+ latestBpm = 123,
+ minBpm = 123,
+ avgBpm = 123.0,
+ maxBpm = 123,
+ healthConnectExportState = HealthConnectSessionExportState.EXPORTED,
+ healthConnectExportMessage = null,
+ )
+ return WorkoutDetail(summary, samples, HeartRateZones.distribution(samples, 190))
+ }
+}
diff --git a/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt
new file mode 100644
index 000000000..0d451bea3
--- /dev/null
+++ b/android/app/src/test/java/me/kavishdevar/librepods/finder/RssiSignalProcessorTest.kt
@@ -0,0 +1,81 @@
+package me.kavishdevar.librepods.finder
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RssiSignalProcessorTest {
+ @Test
+ fun emaDampensSingleStrongSpike() {
+ val processor = RssiSignalProcessor(medianWindowSize = 1)
+ processor.addSample(-60, 0L)
+ processor.addSample(-60, 500L)
+ val snapshot = processor.addSample(-30, 1_000L)
+
+ val smoothed = requireNotNull(snapshot.smoothedRssi)
+ assertTrue(smoothed < -45.0)
+ assertTrue(smoothed > -60.0)
+ assertEquals(-30, snapshot.rawRssi)
+ }
+
+ @Test
+ fun bucketHysteresisPreventsBoundaryFlapping() {
+ val processor = RssiSignalProcessor(
+ emaAlpha = 1.0,
+ hysteresisDb = 3.0,
+ medianWindowSize = 1
+ )
+
+ assertEquals(
+ ProximityBucket.CLOSE,
+ processor.addSample(-57, 0L).proximity
+ )
+ assertEquals(
+ ProximityBucket.CLOSE,
+ processor.addSample(-59, 500L).proximity
+ )
+ assertEquals(
+ ProximityBucket.NEARBY,
+ processor.addSample(-62, 1_000L).proximity
+ )
+ }
+
+ @Test
+ fun signalBecomesStaleThenLost() {
+ val processor = RssiSignalProcessor(emaAlpha = 1.0, medianWindowSize = 1)
+ processor.addSample(-55, 1_000L)
+
+ val stale = processor.snapshot(4_000L)
+ assertTrue(stale.stale)
+ assertEquals(ProximityBucket.CLOSE, stale.proximity)
+
+ val lost = processor.snapshot(9_000L)
+ assertTrue(lost.stale)
+ assertEquals(ProximityBucket.SIGNAL_LOST, lost.proximity)
+ assertEquals(null, lost.approximateDistanceMeters)
+ }
+
+ @Test
+ fun trendNeedsHistoryAndDetectsCloserFartherAndStable() {
+ fun trendFor(values: List): SignalTrend {
+ val processor = RssiSignalProcessor(
+ emaAlpha = 1.0,
+ minTrendSamples = 5,
+ minTrendSpanMillis = 2_000L,
+ trendThresholdDb = 2.0,
+ medianWindowSize = 1
+ )
+ values.forEachIndexed { index, rssi ->
+ processor.addSample(
+ rssi,
+ index * 500L,
+ )
+ }
+ return processor.snapshot(2_000L).trend
+ }
+
+ assertEquals(SignalTrend.GETTING_CLOSER, trendFor(listOf(-70, -68, -66, -64, -62)))
+ assertEquals(SignalTrend.GETTING_FARTHER, trendFor(listOf(-62, -64, -66, -68, -70)))
+ assertEquals(SignalTrend.STABLE, trendFor(listOf(-65, -64, -65, -64, -65)))
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 45682a060..d756f7216 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -3,5 +3,6 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.aboutLibraries) apply false
+ alias(libs.plugins.ksp) apply false
// alias(libs.plugins.hilt) apply false
}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 0999d3957..dfd24fb97 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -17,6 +17,10 @@ materialIconsCore = "1.7.8"
backdrop = "2.0.0-alpha03"
billing = "8.3.0"
hilt = "2.59.2"
+healthConnect = "1.1.0"
+room = "2.8.4"
+ksp = "2.3.11"
+junit = "4.13.2"
xposed = "101.0.0"
lifecycleProcess = "2.10.0"
play = "2.0.2"
@@ -52,6 +56,11 @@ 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" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
@@ -70,3 +79,4 @@ android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }