Skip to content

android: fix AirPods AACP on Qualcomm (qti) Bluetooth stacks - #872

Open
jevonlipsey wants to merge 1 commit into
librepods-org:mainfrom
jevonlipsey:qti-l2cap-basic-fallback
Open

jevonlipsey wants to merge 1 commit into
librepods-org:mainfrom
jevonlipsey:qti-l2cap-basic-fallback

Conversation

@jevonlipsey

Copy link
Copy Markdown

Fixes AirPods AACP on Qualcomm's libbluetooth_qti.so Bluetooth stack, which is currently unsupported (#309, #385).

Tested on a Retroid Pocket Nova (SM8550 kalama, Android 13, build RPN_V1.0.0.436), Magisk 30.7 + NeoZygisk 2.4 + Vector 2.2.

Root cause

libbluetooth_qti.so is a fork of an older Fluoride. In it, l2c_fcr_chk_chan_modes() does two things:

  1. masks ccb->ertm_info.allowed_modes against what the peer supports, clearing the ERTM and Streaming bits, and
  2. returns the resulting mask.

AirPods speak AACP (PSM 0x1001) in Basic mode only. Stripping ERTM leaves allowed_modes == 0, it logs L2CAP - Peer does not support our desired channel types, and returns 0.

The reason the existing hook doesn't help is that the caller, l2c_fcr_adj_our_req_options(), re-reads the allowed_modes field directly rather than using the return value:

0x53eaf8:  bl    l2c_fcr_chk_chan_modes
0x53eafc:  tst   w0, #0xff
0x53eb00:  b.eq  0x53eb60            ; -> l2cu_disconnect_chnl()
0x53eb04:  ldrb  w8, [x19, #0x109]   ; re-reads allowed_modes, which is now 0
0x53eb08:  cmp   w8, #1              ; != 1, so the clean BASIC path is skipped

So by the time we return 1, the struct field is already zeroed and the channel is torn down before any config exchange. The socket then fails with read failed, socket might closed or timeout, read ret: -1.

The fix

When the original rejected every mode, re-permit Basic in the ccb. Channels that negotiate ERTM normally never reach this branch, so other devices are unaffected.

The field offset is derived at runtime rather than hardcoded: since the function returns ccb->ertm_info.allowed_modes, the LDRB that produces its return value encodes the offset. On the build above this recovers 0x109. If derivation fails the value stays -1 and the hook behaves exactly as it does today.

Evidence

Before:

L2CAP - Peer does not support our desired channel types
fake_l2c_fcr_chk_chan_modes: orig = 0, returning 1
<LogCollector:Complete:Failed> socket not connected

After:

l2c_fcr_adj_our_req_options (mode 3): ERROR: No FCR options set using BASIC mode
L2CAP channel is opened: lcid=64(0x40), rcid=14599(0x3907), psm=0x1002
L2CAP channel is opened: lcid=65(0x41), rcid=14860(0x3a0c), psm=0x1004
<LogCollector:Complete:Success> Socket connected
Requesting proximity keys
already connected locally, hijacking connection by asking AirPods

Peer does not support our desired channel types goes from 62 occurrences to 0. Working in the app: per-earbud battery, listening modes, Automatic Ear Detection, head gestures, conversation awareness, and handoff between a Mac, an iPhone and the Android device.

Testing status — please read

I want to be precise about what has and hasn't been verified:

  • ✅ The mechanism is validated on real hardware. I first proved it by binary-patching libbluetooth_qti.so to force allowed_modes = BASIC on exactly this path and shipping it as a Magisk overlay. AACP then worked end to end; reverting broke it again. This PR performs the same write from the hook instead.
  • ✅ The offset-derivation routine is validated against the real libbluetooth_qti.so, compiled and run as C: it returns 0x109, matching the value confirmed on-device.
  • ❌ This exact patch has not been compiled into the app and run. I had no NDK available. The new code compiles cleanly standalone (-Wall -Wextra), but it needs a real build before merging.

Alternative I tried and rejected

I first assumed the cause was the static tL2CAP_ERTM_INFO the socket layer passes (allowed_modes = 0x08, ERTM with no Basic) and planned to hook BTA_JvL2capConnect to substitute it. I tested that and it does not work — patching that constant to 0x09 changed nothing, still 62× orig = 0. btsock_l2cap_listen_or_connect has a second path that passes ertm_info = NULL, which is evidently the one the AACP socket takes. Recording it here so nobody else burns time on it.

Happy to iterate, and I can test revisions on the same hardware.

l2c_fcr_chk_chan_modes() zeroes ccb->ertm_info.allowed_modes before
returning, and l2c_fcr_adj_our_req_options() reads that field directly
instead of our return value, so forcing the return to 1 is not enough on
libbluetooth_qti.so. The channel is torn down before config and the AACP
socket never opens.

Re-permit Basic mode in the ccb when the original rejected every mode.
The field offset is derived at runtime from the LDRB that produces the
function's return value, so no per-build constant is needed.
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.

1 participant