Skip to content

android(fix): connection lifecycle — ordering, re-wear audio/calls, read loop spin, heart rate start - #811

Open
QuerTeal wants to merge 11 commits into
librepods-org:android/rewritefrom
QuerTeal:fix/rewear-audio-reconnect
Open

QuerTeal wants to merge 11 commits into
librepods-org:android/rewritefrom
QuerTeal:fix/rewear-audio-reconnect

Conversation

@QuerTeal

Copy link
Copy Markdown

Depends on #806. This branch is built on top of it, so until #806 is merged the diff also shows its 6 commits. The 5 commits specific to this PR are the last 5.

Summary

1. Connect after loading cached state, off the main thread

onDeviceConnected() called device.connect() from the Bluetooth broadcast receiver. That is a blocking L2CAP connect on the main thread. Meanwhile, the DB load and observer registration ran concurrently on IO, which caused three problems:

  • loadInitialState() could replace state that the first packets had already set.
  • The observers could miss those first changes.
  • startHr() could run before the socket existed.

The steps now run in order in the IO coroutine:

  1. Load the cache.
  2. Register the observers.
  3. Connect.
  4. Start heart rate.

A devicesBeingConnected guard keeps ACL_CONNECTED and ACTION_UUID from setting up the same device twice.

2. Re-wear: reconnect the call profile too, and resume only once A2DP is up

With "Disconnect AirPods when not wearing" enabled, taking both buds out disconnects A2DP and the headset profile. Putting one back in only reconnected A2DP, so calls and the mic stayed on the phone.

Resuming playback was also racy:

  • With one bud in, sendPlay() fired before A2DP was back, so the paused media resumed on the phone speaker.
  • With both buds in at once, playback only resumed on an A2DP "connected" transition. It therefore never resumed if A2DP was already connected.
  • The A2DP receiver was never unregistered if that transition didn't come.

Fix:

  • Call connectAudio() (A2DP + headset).
  • Resume right away if the AirPods are already an A2DP output (AudioManager.getDevices); otherwise wait for A2DP.
  • Drop the waiting receiver after 15 s.

3. Stop the AACP read loop when the socket closes

On EOF, the loop only logged and continued. On a read exception it also continued. Because BluetoothSocket.isConnected can stay true after the remote end closes the channel, the loop busy-spun: I saw about 4,200 identical log lines within milliseconds of a disconnect. It now breaks on EOF and on read errors.

4. Wait for an in-flight connect before starting heart rate

If a connect was already in progress (e.g. started from the device list), the service's connect() returned immediately. It then logged "Device connected" and sent the heart rate request while the socket was still connecting, so the request was lost. The service now waits for the connection state to leave CONNECTING first.

5. Request heart rate again when the AirPods are put in

The sensor only streams while the AirPods are worn. With the heart rate alert enabled, a request sent on connect (buds still in the case) never delivered samples after the buds were put in: 58 s without data. After re-sending the request, samples arrived within 2 s. The request is now re-sent on ear-in when the alert is enabled.

Testing

Galaxy S26 Ultra (One UI 9.0), AirPods Pro 3 (fw 9442752), about 50 minutes of connect, wear, remove, and re-wear cycles.

Verified on device:

  • Connection order (1): "Loading device" → "connecting..." → "connected!" happens in that order on every connection.
  • Connect guard: when the device list had already started a connect, the service logged connect() ignored, already CONNECTING.
  • Re-wear audio (2): the headset connect() is issued on ear-in.
  • Resume timing (2): sendPlay() is issued only after A2dpStateMachine enters Connected (6–9 ms later), both when A2DP had to reconnect and when it was already connected.

Built but not yet re-verified on device: 3, 4 and 5 were written from the logs of that test session. I'll update this PR after the next test run.

🤖 Generated with Claude Code

QuerTeal and others added 11 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>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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