Repository navigation
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>
|
Closing since #784 is fixed by e81e4dd in v1.0.1-rc1, which I've confirmed on my phone. Note that e81e4dd only disables the UUID/SDP path; the ACL_CONNECTED path still opens the AACP socket unconditionally and the battery path still calls connectAudio(), so the takeover could still happen if the AirPods' UUIDs are already cached when the ACL comes up. This branch covers those paths if you ever want a fuller fix. |
I'm actually rewriting the whole service, getting rid of whatever mess of connection detection I had previously. And obviously taking care of all this while reimplementing stuff. My change was just a temporary fix because I was already making changes to main for AirPods 5. rewrite progress: Android v1.0.0 stable release |
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