Skip to content

android(fix): duplicate state handling, double AACP connect, heart rate parsing, island window threading - #806

Open
QuerTeal wants to merge 6 commits into
librepods-org:android/rewritefrom
QuerTeal:fix/duplicate-events-and-connect
Open

QuerTeal wants to merge 6 commits into
librepods-org:android/rewritefrom
QuerTeal:fix/duplicate-events-and-connect

Conversation

@QuerTeal

@QuerTeal QuerTeal commented Sep 27, 2026 •

Copy link
Copy Markdown

Summary

This PR fixes six independent bugs in android/rewrite. I found them while testing on a Galaxy S26 Ultra (One UI 9.0 / Android 17, no root) with AirPods Pro 3 (A3063, firmware build 9442752). Each bug is its own commit.

1. Every device state change was handled twice

loadDevices() → createDevice() registers state, settings and metadata observers for each bonded device. onDeviceConnected() then replaced deviceJobs[mac] with a second set without cancelling the first. The orphaned jobs kept collecting.

As a result:

  • Each heart rate sample was inserted into the local DB and Health Connect twice.
  • MediaController.sendPlay() / sendPause() fired twice on each ear detection change.
  • Every log line from the observers appeared twice.

Fix: cancel the existing jobs before registering the new ones.

2. A second AACP socket was opened while the first was still connecting

The guard in onDeviceConnected() only returns early for CONNECTED. When connect() was already running (tapping the device icon in DeviceListScreen, or ACL_CONNECTED followed by ACTION_UUID), a second L2CAP socket was opened. The init handshake then ran on both sockets.

Fix: AppleDevice.connect() now does an atomic getAndUpdate on the connection state. It returns early if the state is already CONNECTING or CONNECTED.

3. Heart rate readings >= 128 bpm were discarded

payload[1].toInt() sign-extends the byte, so 128–255 bpm became negative and failed the 1..300 check. That covers most readings during a workout.

Fix: read the byte as unsigned (and 0xFF). The raw payload is now logged next to each reading, which helps with decoding the remaining fields.

4. The island popup never appeared

processComponentStateChange() runs inside observeAppleState() on Dispatchers.IO. The call to showIsland() → WindowManager.addView() therefore threw Can't create handler inside thread ... that has not called Looper.prepare().

Fix: showIsland() re-dispatches itself to Dispatchers.Main when called off the main thread, and the island close() call does the same.

5. Island battery update crashed off the main thread (added in a follow-up commit)

Once fix 4 made the island actually appear, a second path was exposed. observeAppleState() (on Dispatchers.IO) called islandWindow.updateBattery() whenever the battery changed while the island was visible, and that crashed with CalledFromWrongThreadException.

This typically happened right after putting the AirPods in, which also killed the audio reconnect that was in progress. The update is now dispatched to the main thread.

Tested: before the fix, the app crashed when the battery changed while the island was visible. After the fix, a battery update arrived 2 s after the island was shown and there was no crash.

6. Unreliable heart rate samples during sensor warm-up (added in a follow-up commit)

The first samples after the sensor starts are unreliable. In testing: 169 bpm, then 91, 74, 77, while resting at about 75.

Payload bytes observed across samples:

Byte(s) Warm-up samples After warm-up
payload[17] bit 0 set (81) bit 0 clear
payload[16..17] 82 81 on the first sample, then 02 81 00 00; briefly 00 80 during motion
payload[2] (looks like confidence) 20 120–237
payload[3] sample sequence number sample sequence number

Samples with bit 0 of payload[17] set are now skipped, so they are not shown, stored, or written to Health Connect. hrmState stays WAITING until the sensor settles.

Tested:

  • Replayed against a recorded session of 112 samples: the 4 warm-up samples were dropped and the 108 others (75–147 bpm, including the 00 80 ones) were kept.
  • On device, across two more sessions: exactly the first 4 samples were skipped each time (169, 100, 76, 76 and 169, 117, 67, 71), followed by normal readings (70–82 bpm).

Testing

Tested on a Galaxy S26 Ultra (SM-S948N, One UI 9.0, SDK 37, rootless native L2CAP) with AirPods Pro 3 (A3063, fw 9442752), using assembleFossDebug.

Before After
Observer callbacks per state change 2 1
sendPlay per ear-in 2 1
connecting... per connection (after tapping the device icon) 2, with 4 handshakes 1
Heart rate while exercising first sample logged Invalid heart rate value: -87; everything ≥ 128 dropped 128 → 147 bpm recorded
Island on ear-in RuntimeException in log, nothing shown shown; removed when both buds are taken out

🤖 Generated with Claude Code

QuerTeal and others added 6 commits September 28, 2026 00:26
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ring

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 <noreply@anthropic.com>
@QuerTeal QuerTeal changed the title android(fix): duplicate state handling, double AACP connect, HR >= 128 bpm dropped, island not shown android(fix): duplicate state handling, double AACP connect, heart rate parsing, island window threading Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant