android(fix): connection lifecycle — ordering, re-wear audio/calls, read loop spin, heart rate start - #811
Open
QuerTeal wants to merge 11 commits into
Open
android(fix): connection lifecycle — ordering, re-wear audio/calls, read loop spin, heart rate start#811QuerTeal wants to merge 11 commits into
QuerTeal wants to merge 11 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
1. Connect after loading cached state, off the main thread
onDeviceConnected()calleddevice.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.startHr()could run before the socket existed.The steps now run in order in the IO coroutine:
A
devicesBeingConnectedguard keepsACL_CONNECTEDandACTION_UUIDfrom 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:
sendPlay()fired before A2DP was back, so the paused media resumed on the phone speaker.Fix:
connectAudio()(A2DP + headset).AudioManager.getDevices); otherwise wait for A2DP.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.isConnectedcan 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 leaveCONNECTINGfirst.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:
connect() ignored, already CONNECTING.connect()is issued on ear-in.sendPlay()is issued only afterA2dpStateMachineenters 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