Conversation
Opening LibrePods (or the service restarting after boot) pulled AirPods away from another device even with every Auto-Connect preference off. On start the service calls fetchUuidsWithSdp() on bonded devices. The SDP query briefly brings up an ACL link and emits ACL_CONNECTED/UUID broadcasts, which were treated as "AirPods connected to this phone": the AACP socket was opened, and the first battery/ear-detection packet then called connectAudio(), moving audio to the phone. - Open the AACP socket from connection broadcasts only when A2DP or HFP is actually connected to these AirPods, and re-check when either profile connects so normal connections still attach automatically. - Startup check: require these AirPods to be in A2DP's connected list, not just any A2DP device. - Gate the BLE "Disconnected" reconnect on takeover_when_disconnected. - Battery and ear-detection paths only reconnect audio that LibrePods itself disconnected (case / not wearing) or claimed, never after another device took ownership. - Serialize connectToSocket() since several broadcasts can race. Fixes librepods-org#784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the previous commit, addressing review feedback: - Background restore was armed right after calling disconnectAudio(), even if nothing was released (no BLUETOOTH_PRIVILEGED, audio already on another device). Now it is armed only when A2DP was connected and setConnectionPolicy(FORBIDDEN) returned true, and a callback still in flight when permission is revoked (ownership loss, explicit connect) can't re-arm it. - The "play when A2DP connects" receiver is registered only when the ear-detection path actually restores audio, replaces any previous one, and expires after 10 s, so a later manual connection doesn't auto-play. - On privileged installs the forbidden policy outlives the AACP socket (e.g. buds in case, lid closed), leaving no audio profile to pass the new socket gate. Keep the release across socket drops and attach the socket for that device, so taking the buds out restores audio as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- After ownership loss, block new restore grants until audio is back on this phone (explicit connect, a connected A2DP/HFP profile, or an opted-in Disconnected claim). Previously a charging/out-of-ear packet arriving while A2DP was still connected or disconnecting could grant a fresh release and later pull audio back from the new owner. A restorable release now also requires STATE_CONNECTED, and the epoch check and grant happen under a lock with revocation. - Background restores carry the epoch into connectAudio(), whose profile callbacks drop out if it was revoked before they ran. Explicit connects are unaffected. - The Play-on-connect receiver is registered in the A2DP callback right before a restore's connect, keyed to that device and epoch, and removed if the connect is rejected or throws, on revocation (ownership loss, explicit connect) and on socket closure. The 10 s timeout remains a fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Clear the ownership-loss barrier only on an A2DP/HFP transition to CONNECTED (plus explicit connect / opted-in claim), not from a profile state query, which could return a pre-loss connection that is still tearing down and run after the barrier was set. - Background restores also require the AACP socket that requested them, so a callback delivered after the socket closed does nothing. The release itself is still kept across socket drops for case recovery. - The app's explicit Disconnect revokes all background restore permission, and onDestroy invalidates in-flight restores. - Record accepted HFP releases too, so call-only connections released on case/not-wearing can be restored like A2DP ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Track which profiles (A2DP / HFP) LibrePods forbade, and have a background restore re-allow only those. An HFP-only release no longer enables media audio the user had disabled (and vice versa). - Keep each release record until its policy is successfully re-allowed, instead of clearing it when the restore is queued. A restore cancelled before its callbacks run (e.g. the socket closed) no longer strands a FORBIDDEN policy with nothing left to reattach the socket for it. Ownership loss and the app's Disconnect still revoke everything. - Explicit connects keep connecting both profiles as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restorable release's profile callbacks set the policy to FORBIDDEN first and only then checked whether the request was still current. If a restore ran between the A2DP and HFP callbacks, the late HFP callback still forbade HFP but was not recorded, so nothing ever re-allowed it and call audio stayed blocked until a manual reconnect. Revalidate the request, set FORBIDDEN and record the release together under the restore lock, so a restore or revocation that superseded the request (including ownership loss) prevents the policy change itself. Non-restorable disconnects are unchanged. 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
Fixes #784. Opening LibrePods (or the service restarting after boot) takes AirPods away from another device, even with every Auto-Connect preference disabled.
Cause
On start,
AirPodsServicecallsfetchUuidsWithSdp()on every bonded device. The SDP query briefly brings up an ACL link to the AirPods, which emitsACL_CONNECTED/UUIDbroadcasts. Those were treated as "AirPods connected to this phone":connectionReceiveropened the AACP socket unconditionally.connectAudio()(theelsebranch next to the both-buds-charging check), and the ear-detection path did the same. That moved A2DP/HFP to the phone.None of these paths consulted the takeover preferences.
Changes (
AirPodsService.ktonly)connectToSocketIfAudioConnected). A2DP/HFPCONNECTION_STATE_CHANGED→CONNECTEDnow also triggers detection, so a normal connection (e.g. from system Bluetooth settings) still attaches automatically.takeover_when_disconnected.setConnectionPolicy(FORBIDDEN)returnedtrue.CONNECTED, or an opted-in claim). The app's Disconnect revokes all of it.connectAudio(), whose profile callbacks do nothing if it was revoked, or the AACP socket closed, before they ran.BLUETOOTH_PRIVILEGEDinstalls, the forbidden policy outlives the AACP socket (e.g. buds in case, lid closed). So a released device is still remembered, and its socket is attached when it comes back, letting the battery path restore audio as before.connect(), keyed to that device and restore. It is removed if the connect is rejected, on revocation, on socket closure, or after a 10 s fallback, so a later manual connection doesn't auto-play.connectToSocket()is@Synchronized: several broadcasts (ACL, UUID, A2DP, HFP) can race to connect.Manual reconnect,
takeOver()and the phone-state triggers are unchanged.Testing
OnePlus 12 (OxygenOS 16.0.10, not rooted), AirPods Pro 2 USB-C (9A348), FOSS debug build, all Auto-Connect preferences off. Verified with logcat:
ACL_CONNECTED/UUID→ "connected", no audio profile on the phone → socket not opened. The ACL dropped about 0.2 s later and the AirPods stayed on the desktop. Before the fix this reliably moved them to the phone.connectAudio()call.The tests above were run on the first commit; later commits were built and startup-checked on the same phone: startup detection still does not open the socket. My phone lacks
BLUETOOTH_PRIVILEGED, so the release/restore paths couldn't be exercised on it. Testing on a privileged (root/system) install would be welcome.Known limitations on privileged installs: in these cases the A2DP policy can stay FORBIDDEN until the user reconnects or takes over from the app:
main, the ownership-loss paths set the policy to FORBIDDEN. Before this PR, the unconditional battery-pathconnectAudio()is what re-allowed it.Neither is undone automatically. After a restart the app can't reliably tell idle AirPods from AirPods in use on another device, and guessing wrong reintroduces #784. Happy to adjust if you'd prefer a different recovery.
assembleFossDebugbuilds.lintFossDebugfails on the same errors asmain(BluetoothDevice#disconnectAPI 37,UseAppTint); none are in the changed lines.🤖 Generated with Claude Code