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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(