Skip to content

[Android] Don't take over AirPods on service start - #804

Open
sleepysec wants to merge 6 commits into
librepods-org:mainfrom
sleepysec:fix/android-startup-connection-hijack
Open

sleepysec wants to merge 6 commits into
librepods-org:mainfrom
sleepysec:fix/android-startup-connection-hijack

Conversation

@sleepysec

@sleepysec sleepysec commented Sep 26, 2026 •

Copy link
Copy Markdown

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, AirPodsService calls fetchUuidsWithSdp() on every bonded device. The SDP query briefly brings up an ACL link to the AirPods, which emits ACL_CONNECTED / UUID broadcasts. Those were treated as "AirPods connected to this phone":

  1. connectionReceiver opened the AACP socket unconditionally.
  2. The first battery packet over that socket hit connectAudio() (the else branch 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.kt only)

  • Socket attach: connection broadcasts now open the AACP socket only if A2DP or HFP is actually connected to these AirPods (connectToSocketIfAudioConnected). A2DP/HFP CONNECTION_STATE_CHANGED → CONNECTED now also triggers detection, so a normal connection (e.g. from system Bluetooth settings) still attaches automatically.
  • Startup check: require these AirPods to be in A2DP's connected list, instead of any connected A2DP device (e.g. a car or speaker).
  • BLE "Disconnected" path: gated on takeover_when_disconnected.
  • Audio reconnect: the battery and ear-detection paths only reconnect audio that LibrePods itself released or claimed:
    • Released: both buds in the case or disconnect-when-not-wearing, where that profile (A2DP or HFP) was connected and setConnectionPolicy(FORBIDDEN) returned true.
    • Claimed: through the opted-in Disconnected takeover.
    • A restore re-allows only the profiles LibrePods forbade (so media audio the user disabled stays off), and each release record is kept until its policy is successfully re-allowed.
    • Ownership loss or an explicit connect revokes this. After ownership loss, no new release is granted until audio is back on this phone (explicit connect, an A2DP/HFP transition to CONNECTED, or an opted-in claim). The app's Disconnect revokes all of it.
    • Grants are checked and revoked under a lock. A background restore carries its epoch into connectAudio(), whose profile callbacks do nothing if it was revoked, or the AACP socket closed, before they ran.
  • Released policy across socket drops: on BLUETOOTH_PRIVILEGED installs, 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.
  • Play-on-connect receiver: registered in the A2DP callback right before a restore's 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:

  • Repro from [Android] Launching LibrePods takes AirPods Pro 2 away from desktop despite disabled auto-connect status options #784: AirPods playing on desktop → force-stop LibrePods → open it. Startup SDP produced 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.
  • Normal connect: connected from system Bluetooth settings → HFP/A2DP connected → AACP socket attached automatically about 1 s later. Battery and noise control were available without tapping Reconnect. LibrePods made no connectAudio() call.
  • Manual reconnect from the app still connects the socket and audio.

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:

  • Ownership loss: as on main, the ownership-loss paths set the policy to FORBIDDEN. Before this PR, the unconditional battery-path connectAudio() is what re-allowed it.
  • Process restart: if LibrePods is restarted after it released the AirPods (buds in case), the release is only remembered in memory, so it's lost.

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.

assembleFossDebug builds. lintFossDebug fails on the same errors as main (BluetoothDevice#disconnect API 37, UseAppTint); none are in the changed lines.

🤖 Generated with Claude Code

sleepysec and others added 6 commits September 26, 2026 15:22
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>
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.

[Android] Launching LibrePods takes AirPods Pro 2 away from desktop despite disabled auto-connect status options

1 participant