From c9ccf5dc871443ec5ea94dc191484da5890d33c9 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:28 +0900 Subject: [PATCH 01/11] android(fix): handle each device state change only once loadDevices()/createDevice() registers state/settings/metadata observers for every bonded device, and onDeviceConnected() registered a second set without cancelling the first. The first set was orphaned but kept collecting, so every state change was processed twice: heart rate samples were inserted into the local db and Health Connect twice, and play/pause was sent twice on ear detection changes. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/services/LibrePodsService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index df87fe626..1c8770e13 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -308,6 +308,8 @@ class LibrePodsService: Service() { } } + // createDevice() already registered observers for this device; cancel them so each state change is handled once + deviceJobs[MacAddress(bluetoothDevice.address)]?.forEach { it.cancel() } deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) From cb1469445261b1214c00b453b3f05eca6f9f3613 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:28 +0900 Subject: [PATCH 02/11] android(fix): don't open a second AACP socket while connecting onDeviceConnected() only skipped devices that were already CONNECTED, so a connect() started from the device list (or an ACL_CONNECTED followed by ACTION_UUID) while the first one was still CONNECTING opened a second L2CAP socket and ran the init handshake on both. Guard connect() itself with an atomic check on the connection state. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/devices/AppleDevice.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt index 595a791b9..803957cff 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.kavishdevar.librepods.bluetooth.MacAddress @@ -124,8 +125,13 @@ class AppleDevice( } override fun connect(): Boolean { - _connectionState.update { - ConnectionState.CONNECTING + // the service (ACL/UUID broadcasts) and the UI can both call connect(); only let one of them open a socket + val previousState = _connectionState.getAndUpdate { + if (it == ConnectionState.CONNECTING || it == ConnectionState.CONNECTED) it else ConnectionState.CONNECTING + } + if (previousState == ConnectionState.CONNECTING || previousState == ConnectionState.CONNECTED) { + Log.d(TAG, "connect() ignored, already $previousState") + return previousState == ConnectionState.CONNECTED } val success = aacp.connect() // && att.connect() From 018c03b08af7e203ba75f3f6e324731607c879a4 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:29 +0900 Subject: [PATCH 03/11] android(fix): read heart rate as an unsigned byte payload[1].toInt() sign-extends, so any reading >= 128 bpm became negative and was dropped as invalid, i.e. most readings during a workout. Also log the raw payload next to each reading to help decode the other fields. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 03b46cb38..7f95d4485 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -1181,7 +1181,8 @@ class AACPManager(private val device: AppleDevice) { val payload = data.command.payload.toByteArray() val timestamp = Clock.System.now() if (payload.size == 18) { - val heartRate = payload[1].toInt() + // unsigned: a signed read turns anything >= 128 bpm negative and drops it + val heartRate = payload[1].toInt() and 0xFF // same as healthconnect's datatype. 300 isn't possible anyway, but whatever if (heartRate !in 1..300) { @@ -1202,7 +1203,7 @@ class AACPManager(private val device: AppleDevice) { Log.i( TAG, - "hr: $heartRate bpm" + "hr: $heartRate bpm, payload: ${payload.toHexString()}" ) val heartRateSample = HeartRateSample( From a024ed02a25fd2d09626c79cb17a1b47b1d7ddeb Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:29 +0900 Subject: [PATCH 04/11] android(fix): show and close the island window on the main thread processComponentStateChange() runs in observeAppleState() on Dispatchers.IO, so showIsland() -> WindowManager.addView() threw "Can't create handler inside thread ... that has not called Looper.prepare()" and the connection island never appeared. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/LibrePodsService.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index 1c8770e13..552539d24 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -24,6 +24,7 @@ import android.os.BatteryManager import android.os.Binder import android.os.Build import android.os.IBinder +import android.os.Looper import android.os.ParcelUuid import android.os.ext.SdkExtensions import android.provider.Settings @@ -921,6 +922,14 @@ class LibrePodsService: Service() { reversed: Boolean = false, otherDeviceName: String? = null ) { + // the island is a window, so it has to be added from the main thread (state observers run on IO) + if (Looper.myLooper() != Looper.getMainLooper()) { + CoroutineScope(Dispatchers.Main).launch { + showIsland(device, type, reversed, otherDeviceName) + } + return + } + Log.d(TAG, "Showing island window") val state = device.state.value @@ -1223,7 +1232,9 @@ class LibrePodsService: Service() { } if (new == EarPresence.NONE && islandWindow?.isVisible == true) { - islandWindow?.close() + CoroutineScope(Dispatchers.Main).launch { + islandWindow?.close() + } } var justEnabledA2dp = false From 77bfd95a7bfc9b36897313c196fd163b01be4ade Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:48:41 +0900 Subject: [PATCH 05/11] android(fix): update the island battery on the main thread observeAppleState() runs on Dispatchers.IO and calls islandWindow.updateBattery() when the battery changes while the island is visible. That touches the island's views off the main thread and crashes with CalledFromWrongThreadException. It only became reachable once the island could actually be shown (previous commit), typically right when putting the AirPods back in, which also killed the in-progress audio reconnect. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/services/LibrePodsService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index 552539d24..eb2617b0c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -679,7 +679,9 @@ class LibrePodsService: Service() { Log.d(TAG, "updating island window") if (islandWindow?.isVisible == true) { - islandWindow?.updateBattery(state.battery) + CoroutineScope(Dispatchers.Main).launch { + islandWindow?.updateBattery(state.battery) + } } Log.d(TAG, "updating notification") From b37db6aac065c1b76e84ed453dd99cd9d70439b4 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:05 +0900 Subject: [PATCH 06/11] android(fix): skip heart rate samples while the sensor is still acquiring Right after the heart rate sensor service starts, the first few samples are unreliable, e.g. 169 bpm followed by 91, 74, 77 while resting at ~75. In the 18-byte payload, those samples have bit 0 of the last byte set (flags 82 81, then 02 81), and payload[2], which looks like a confidence value, is 20 instead of the usual 120-237. Skip samples with that bit set, so they are neither shown, stored nor written to Health Connect, and hrmState stays WAITING until the sensor settles. Samples flagged 00 80 (seen briefly during motion, still plausible values) are kept. Checked against a recorded session of 112 samples: the 4 warm-up samples are dropped, the remaining 108 (75-147 bpm) are kept. Co-Authored-By: Claude Opus 5.5 --- .../kavishdevar/librepods/bluetooth/aacp/AACPManager.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 7f95d4485..5f6532818 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -1184,6 +1184,14 @@ class AACPManager(private val device: AppleDevice) { // unsigned: a signed read turns anything >= 128 bpm negative and drops it val heartRate = payload[1].toInt() and 0xFF + // bit 0 of the last byte is set for the first few samples after the sensor starts (payload[2], + // which looks like a confidence value, is also very low then); those readings are unreliable, + // e.g. 169 bpm at rest, so skip them and stay in WAITING until the sensor settles + if (payload[17].toInt() and 0x01 != 0) { + Log.d(TAG, "skipping heart rate sample while the sensor is acquiring: ${payload.toHexString()}") + return + } + // same as healthconnect's datatype. 300 isn't possible anyway, but whatever if (heartRate !in 1..300) { Log.w( From 5350fe1a35aceb68464b7ef0f7088458a85ef00a Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:50:54 +0900 Subject: [PATCH 07/11] android(fix): connect after loading cached state, off the main thread onDeviceConnected() called device.connect() synchronously from the bluetooth broadcast receiver, i.e. a blocking L2CAP socket connect on the main thread, while the cached state was loaded from the db and the observers were registered concurrently on IO. Depending on timing: - loadInitialState() replaced the whole state after the first packets had already arrived (battery, ear detection, ...), - the observers were registered after those changes and missed them, - startHr() ran before the socket was connected and silently failed, so the heart rate alert didn't start on connect. Do it in order inside the IO coroutine: load the cache, register the observers, connect, then start heart rate monitoring. Since connecting is now asynchronous, guard against ACL_CONNECTED and ACTION_UUID setting up the same device concurrently. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/LibrePodsService.kt | 97 +++++++++++-------- 1 file changed, 56 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index eb2617b0c..66b595a3d 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -75,6 +75,7 @@ import me.kavishdevar.librepods.utils.MediaController import me.kavishdevar.librepods.utils.calculateLevel import me.kavishdevar.librepods.utils.redactMac import java.time.ZoneOffset +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaInstant @@ -93,6 +94,7 @@ class LibrePodsService: Service() { val devices = _devices.asStateFlow() private val deviceJobs = mutableMapOf>() + private val devicesBeingConnected: MutableSet = ConcurrentHashMap.newKeySet() val irkMap = mutableMapOf() val rpasByPublicMac = mutableMapOf>() @@ -273,61 +275,74 @@ class LibrePodsService: Service() { return } + // ACL_CONNECTED and ACTION_UUID can both arrive for the same connection; set the device up once at a time + if (!devicesBeingConnected.add(device.macAddress)) { + Log.d(TAG, "Device already being connected: ${bluetoothDevice.address}") + return + } + when (device) { is AppleDevice -> CoroutineScope(Dispatchers.IO).launch { - Log.i(TAG, "Loading device ${device.macAddress.toRedactedString()} from db") + try { + Log.i(TAG, "Loading device ${device.macAddress.toRedactedString()} from db") + + appleRepository.load(device.macAddress)?.let { entity -> + val cache = entity.cache + Log.i( + TAG, + "Loaded cached state for device ${device.macAddress.toRedactedString()}: $cache" + ) + val settings = entity.settings + Log.i( + TAG, + "Loaded settings for device ${device.macAddress.toRedactedString()}: $settings" + ) + val metadata = entity.metadata + Log.i( + TAG, + "Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata" + ) + + device.loadInitialState( + state = AppleState().copy( + capabilities = cache.capabilities, + magicKeys = cache.magicKeys, + controlStates = cache.controlStates, + ), + settings = settings, + metadata = metadata + ) + } + + // createDevice() already registered observers for this device; cancel them so each state change is handled once + deviceJobs[MacAddress(bluetoothDevice.address)]?.forEach { it.cancel() } + deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() + + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMicrophoneFrames(device)) + + // connect only after the cached state is loaded and the observers are registered, otherwise + // loadInitialState() can overwrite what the first packets set and the observers miss those changes. + // This also keeps the blocking socket connect off the main thread (this runs from a broadcast receiver). + device.connect() - appleRepository.load(device.macAddress)?.let { entity -> - val cache = entity.cache - Log.i( - TAG, - "Loaded cached state for device ${device.macAddress.toRedactedString()}: $cache" - ) - val settings = entity.settings - Log.i( - TAG, - "Loaded settings for device ${device.macAddress.toRedactedString()}: $settings" - ) - val metadata = entity.metadata Log.i( TAG, - "Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata" + "Device connected: ${device.macAddress.toRedactedString()} (${device.javaClass.simpleName})" ) - device.loadInitialState( - state = AppleState().copy( - capabilities = cache.capabilities, - magicKeys = cache.magicKeys, - controlStates = cache.controlStates, - ), - settings = settings, - metadata = metadata - ) + _devices.update { it + (device.macAddress to device) } if (device.settings.value.hrmAlertEnabled) { device.startHr() } + } finally { + devicesBeingConnected.remove(device.macAddress) } - - // createDevice() already registered observers for this device; cancel them so each state change is handled once - deviceJobs[MacAddress(bluetoothDevice.address)]?.forEach { it.cancel() } - deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() - - deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) - deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device)) - deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device)) - deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMicrophoneFrames(device)) } } - - device.connect() - - Log.i( - TAG, - "Device connected: ${device.macAddress.toRedactedString()} (${device.javaClass.simpleName})" - ) - - _devices.update { it + (device.macAddress to device) } } private fun onDeviceDisconnected(mac: MacAddress) { From cc8f3f16adee2c8e06f86e92a01565ebbe70297b Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:51:31 +0900 Subject: [PATCH 08/11] android(fix): reconnect calls and resume on AirPods when putting them back in With "Disconnect AirPods when not wearing", taking both buds out calls disableAudio() + disconnectAudio(), which disconnects A2DP *and* the headset profile. Putting one back in only reconnected A2DP, so calls and the microphone stayed on the phone until the next full reconnect. Use connectAudio() to reconnect both. Resuming playback was also racy: - For a single bud, sendPlay() was sent immediately, before A2DP was back, so the paused media resumed on the phone speaker. - For both buds at once, playback only resumed on an A2DP "connected" transition, so it never resumed if A2DP was already connected (e.g. the AirPods reconnected it themselves on case open). - The A2DP receiver was never unregistered when that transition didn't come, and could resume playback at a random later reconnect. Resume right away when the AirPods are already an A2DP output, otherwise wait for A2DP, and drop the waiting receiver after 15 seconds. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/LibrePodsService.kt | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index 66b595a3d..dc7878417 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.BatteryManager import android.os.Binder @@ -81,6 +82,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaInstant private const val TAG = "LibrePodsService" +private val A2DP_RECONNECT_TIMEOUT = 15.seconds @SuppressLint("MissingPermission") class LibrePodsService: Service() { @@ -1263,20 +1265,32 @@ class LibrePodsService: Service() { "User put in at least one component, enabling audio for device ${device.macAddress.toRedactedString()}" ) device.enableAudio() - device.connectA2dp() + // both profiles: disconnectAudio() drops A2DP and the headset profile when all components are taken out + device.connectAudio() justEnabledA2dp = true - device.waitForA2dpConnection(this) { - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - } - if (MediaController.getMusicActive()) { MediaController.userPlayedTheMedia = true } - if (new == EarPresence.PARTIAL) { + + if (isA2dpAudioConnected(device.macAddress)) { MediaController.sendPlay() MediaController.iPausedTheMedia = false + } else { + // resuming before A2DP is up starts playback on the phone speaker, so wait for it + val receiver = device.waitForA2dpConnection(this) { + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + } + // if A2DP doesn't connect, don't leave the receiver around to resume playback much later + CoroutineScope(Dispatchers.Main).launch { + delay(A2DP_RECONNECT_TIMEOUT) + try { + unregisterReceiver(receiver) + } catch (_: IllegalArgumentException) { + // already unregistered itself after A2DP connected + } + } } } @@ -1313,6 +1327,11 @@ class LibrePodsService: Service() { } } + private fun isA2dpAudioConnected(macAddress: MacAddress): Boolean = + getSystemService(AudioManager::class.java) + .getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP && it.address.equals(macAddress.value, ignoreCase = true) } + private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration, alertThreshold: Int) { CoroutineScope(Dispatchers.IO).launch { Log.d(TAG, "inserting to local db") From fca4fa1b18e38d37c414bdeaa627a43b0eb22fc6 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:50:18 +0900 Subject: [PATCH 09/11] android(fix): stop the AACP read loop when the socket closes On EOF the loop only logged "socket closed (bytesRead = -1)" and kept looping, and on a read exception it kept looping too. Since BluetoothSocket.isConnected can stay true after the remote end closed the channel, the loop busy-spun until something else closed the socket: when the AirPods disconnected, ~4200 identical log lines were written within milliseconds before the ACL disconnect closed it. If only the AACP channel goes away, it would spin indefinitely. Break out of the loop on EOF and on read errors. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 5f6532818..3f8f22378 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -146,11 +146,15 @@ class AACPManager(private val device: AppleDevice) { } } else if (bytesRead == -1) { - Log.i("AirPodsService", "socket closed (bytesRead = -1)") + // isConnected can stay true after the remote end closes the channel, so without breaking + // this spins on read() returning -1 until something else closes the socket + Log.i(TAG, "socket closed (bytesRead = -1), stopping read loop") + break } } catch (e: Exception) { Log.i(TAG, "Error reading data, we have probably disconnected.") e.printStackTrace() + break } } } From 8c193702d6b0bf3a90ee3cac8ad09181c269cff1 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:50:19 +0900 Subject: [PATCH 10/11] android(fix): wait for an in-flight connect before starting heart rate When a connect() is already in progress (e.g. started from the device list), the service's connect() call returns immediately. It then logged "Device connected" and sent the heart rate request while the socket was still connecting, so the request was lost. Wait until the connection state leaves CONNECTING and only start heart rate monitoring if it ended up connected. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/services/LibrePodsService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index dc7878417..67533be30 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.kavishdevar.librepods.LibrePodsApplication @@ -329,15 +330,18 @@ class LibrePodsService: Service() { // loadInitialState() can overwrite what the first packets set and the observers miss those changes. // This also keeps the blocking socket connect off the main thread (this runs from a broadcast receiver). device.connect() + // connect() returns right away if another caller (e.g. the device list) is already connecting, + // so wait for that attempt to finish before using the connection + val connected = device.connectionState.first { it != ConnectionState.CONNECTING } == ConnectionState.CONNECTED Log.i( TAG, - "Device connected: ${device.macAddress.toRedactedString()} (${device.javaClass.simpleName})" + "Device ${if (connected) "connected" else "failed to connect"}: ${device.macAddress.toRedactedString()} (${device.javaClass.simpleName})" ) _devices.update { it + (device.macAddress to device) } - if (device.settings.value.hrmAlertEnabled) { + if (connected && device.settings.value.hrmAlertEnabled) { device.startHr() } } finally { From edbace91c6cdf387a552e7f0ba105ca124122bf3 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:50:19 +0900 Subject: [PATCH 11/11] android(fix): request heart rate again when the AirPods are put in The heart rate sensor only streams while the AirPods are worn. With the heart rate alert enabled, the request sent on connect usually happens while the buds are still in the case, and no samples arrived after putting them in (58 s without data in testing until the request was sent again, after which samples arrived within 2 s). Send the request again whenever at least one bud is put in and the alert is enabled. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/services/LibrePodsService.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index 67533be30..f269f5dd8 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -1273,6 +1273,12 @@ class LibrePodsService: Service() { device.connectAudio() justEnabledA2dp = true + // the heart rate sensor only streams while worn; a request made while the buds were in the case + // (e.g. on connect) doesn't start delivering once they are put in, so request it again now + if (device is AppleDevice && device.settings.value.hrmAlertEnabled) { + device.startHr() + } + if (MediaController.getMusicActive()) { MediaController.userPlayedTheMedia = true }