android(fix): duplicate state handling, double AACP connect, heart rate parsing, island window threading - #806
Open
QuerTeal wants to merge 6 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>
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
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 replaceddeviceJobs[mac]with a second set without cancelling the first. The orphaned jobs kept collecting.As a result:
MediaController.sendPlay()/sendPause()fired twice on each ear detection change.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 forCONNECTED. Whenconnect()was already running (tapping the device icon inDeviceListScreen, orACL_CONNECTEDfollowed byACTION_UUID), a second L2CAP socket was opened. The init handshake then ran on both sockets.Fix:
AppleDevice.connect()now does an atomicgetAndUpdateon the connection state. It returns early if the state is alreadyCONNECTINGorCONNECTED.3. Heart rate readings >= 128 bpm were discarded
payload[1].toInt()sign-extends the byte, so 128–255 bpm became negative and failed the1..300check. 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 insideobserveAppleState()onDispatchers.IO. The call toshowIsland()→WindowManager.addView()therefore threwCan't create handler inside thread ... that has not called Looper.prepare().Fix:
showIsland()re-dispatches itself toDispatchers.Mainwhen called off the main thread, and the islandclose()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()(onDispatchers.IO) calledislandWindow.updateBattery()whenever the battery changed while the island was visible, and that crashed withCalledFromWrongThreadException.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:
payload[17]81)payload[16..17]82 81on the first sample, then02 8100 00; briefly00 80during motionpayload[2](looks like confidence)payload[3]Samples with bit 0 of
payload[17]set are now skipped, so they are not shown, stored, or written to Health Connect.hrmStatestaysWAITINGuntil the sensor settles.Tested:
00 80ones) were kept.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.sendPlayper ear-inconnecting...per connection (after tapping the device icon)Invalid heart rate value: -87; everything ≥ 128 droppedRuntimeExceptionin log, nothing shown🤖 Generated with Claude Code