Skip to content

fix(android): hook l2c_fcr_process_peer_cfg_req so the AAP channel survives allowed_modes == 0 - #785

Open
SAGIRIxr wants to merge 2 commits into
librepods-org:mainfrom
SAGIRIxr:fix/l2cap-peer-cfg-disconnect
Open

SAGIRIxr wants to merge 2 commits into
librepods-org:mainfrom
SAGIRIxr:fix/l2cap-peer-cfg-disconnect

Conversation

@SAGIRIxr

Copy link
Copy Markdown

The gap in the current workaround

l2c_fcr_chk_chan_modes() is, in AOSP, essentially return ertm_info.allowed_modes != 0 — it strips the modes the peer does not advertise and reports whether anything is left:

if (!(p_ccb->p_lcb->peer_ext_fea & L2CAP_EXTFEA_ENH_RETRANS))
    p_ccb->ertm_info.allowed_modes &= ~L2CAP_FCR_CHAN_OPT_ERTM;
if (!(p_ccb->p_lcb->peer_ext_fea & L2CAP_EXTFEA_STREAM))
    p_ccb->ertm_info.allowed_modes &= ~L2CAP_FCR_CHAN_OPT_STREAM;
return p_ccb->ertm_info.allowed_modes != 0;

Forcing it to return 1 makes that one call site pass, but it does not put L2CAP_FCR_CHAN_OPT_BASIC back into the bitmask. When the mask has been reduced to 0, l2c_fcr_process_peer_cfg_req() re-tests it as soon as the peer sends its own config request, and asks for a disconnect.

What that looks like on the wire

HCI snoop, AirPods Pro 3, PSM 0x1001:

TX ConnReq   PSM=0x1001 SCID=0x0050
RX ConnRsp   DCID=0x4707 result=0 (Success)      <- the AirPods accept the channel
TX ConfigReq MTU=1691 RFC[mode=Basic]
RX ConfigReq MTU=2582 FlushTimeout=0x001E         <- peer's config request arrives
TX DisconnReq                                     <- we tear it down, no ConfigRsp sent
RX ConfigRsp result=0 (Success)

The stack says why:

bt_l2cap: L2CA_ConfigReq()  CID 0x0043: fcr_present:1 (mode 3) mtu_present:1 (8087)
bt_l2cap: L2C CFG: mode is ERTM, but peer does not support; Try BASIC
bt_l2cap: LCID: 0x0043  st: CONFIG  evt: PEER_CONFIG_REQ
bt_l2cap: l2c_fcr_process_peer_cfg_req() CFG fcr_present:0 fcr.mode:0 \
              CCB FCR mode:0 preferred: 3 allowed:0
bt_l2cap: L2CAP - incompatible configurations disconnect
bt_l2cap: L2CAP - disconnect_chnl CID: 0x0043

allowed:0 is the whole bug. The connection was never refused by the AirPods — we drop it ourselves one round trip later, which surfaces in the app as

AirPodsService: <LogCollector:Complete:Failed> Socket not connected, \
    read failed, socket might closed or timeout, read ret: -1

i.e. indistinguishable from a peer refusal, which I suspect is why this has been hard to pin down.

The change

Hook l2c_fcr_process_peer_cfg_req() alongside the existing two, call the original, and only rewrite the verdict when it asks for a disconnect:

uint8_t fake_l2c_fcr_process_peer_cfg_req(void *p_ccb, void *p_cfg) {
    uint8_t orig = L2CAP_PEER_CFG_OK;
    if (original_l2c_fcr_process_peer_cfg_req)
        orig = original_l2c_fcr_process_peer_cfg_req(p_ccb, p_cfg);

    if (orig == L2CAP_PEER_CFG_DISCONNECT)
        return L2CAP_PEER_CFG_OK;

    return orig;
}

Two notes on the shape of this:

  • Patching the verdict rather than allowed_modes avoids hardcoding a t_l2c_ccb field offset, which would be fragile across OEM stacks.
  • Only DISCONNECT is rewritten, so channels that negotiate normally (A2DP, AVRCP, …) are untouched. UNACCEPTABLE is passed through so the peer still gets its chance to renegotiate.

Note for reviewers: the constants are UNACCEPTABLE = 0, OK = 1, DISCONNECT = 2 (stack/l2cap/l2c_int.h). OK being 1 rather than 0 is easy to get backwards — a version of this patch that returned 0 built fine and changed nothing.

The symbol resolves through the existing .gnu_debugdata path with no changes to the finder:

findSymbolOffset: matched symbol _Z28l2c_fcr_process_peer_cfg_reqP9t_l2c_ccbP15tL2CAP_CFG_INFO at 0x601e24
hooked chk
hooked sdp
hooked peer cfg req

Verification

Xiaomi 14 Pro (shennong), HyperOS 2.0.45 / Android 14, Snapdragon 8 Gen 3, /system_ext/lib64/libbluetooth_qti.so, Magisk + LSPosed, AirPods Pro 3 (A3065).

Before: read ret: -1 on every attempt. After:

LibrePodsHook: fake_l2c_fcr_process_peer_cfg_req: orig = 2 (DISCONNECT), overriding to OK
AirPodsService: <LogCollector:Complete:Success> Socket connected

Full AAP session then comes up — battery, model detection (A3065), listening-mode state, and the complete 22-entry control command list all read back from the device.

Possibly related

I have only this one device, so I can't confirm these share the root cause — but the signature to check is allowed:0 plus incompatible configurations disconnect in bt_l2cap, which is cheap to grep for:

#672, #624, #416, #413, #393, #335, #211

A quick way for anyone to check their own device:

adb logcat --pid=$(adb shell pidof com.android.bluetooth) | grep bt_l2cap

Unrelated note that cost me an hour

While tracking this down: libl2c_fcr_hook.so is loaded via memfd, so it does not appear by name in /proc/<bluetooth pid>/maps — only the APK does. I briefly concluded the hook wasn't injecting at all. logcat | grep LibrePodsHook is the reliable check. Might be worth a line in the troubleshooting docs.

… is 0

l2c_fcr_chk_chan_modes() reports whether ertm_info.allowed_modes is
non-zero; forcing it to return 1 does not put BASIC back into the
bitmask. On stacks where the mask ends up 0 the channel still dies,
because l2c_fcr_process_peer_cfg_req() re-tests it when the peer sends
its config request:

  l2c_fcr_process_peer_cfg_req() CFG fcr_present:0 fcr.mode:0 \
      CCB FCR mode:0 preferred:3 allowed:0
  L2CAP - incompatible configurations disconnect

PSM 0x1001 is accepted by the AirPods and then dropped by us, so the
socket surfaces as "read failed, socket might closed or timeout,
read ret: -1" and looks like a refused connection.

Hook l2c_fcr_process_peer_cfg_req() as well and only rewrite the verdict
when the original asks for a disconnect, leaving mode negotiation on
every other L2CAP channel untouched. Patching the verdict rather than
allowed_modes avoids hardcoding a t_l2c_ccb field offset.

Verified on a Xiaomi 14 Pro (HyperOS 2.0.45, Android 14, Snapdragon
8 Gen 3, libbluetooth_qti.so) with AirPods Pro 3.
@SAGIRIxr

Copy link
Copy Markdown
Author

The red check here is not from this patch — the job dies in Setup Android SDK, about 20 seconds in, before Gradle is invoked:

Warning: Failed to find package 'tools'
Error: The process '.../sdkmanager' failed with exit code 1

android-actions/setup-android defaults packages to tools platform-tools, and the obsolete tools package is no longer in the SDK repository. Any PR touching android/** hits it.

Opened #786 for that separately rather than folding it in here. Once it lands this branch should go green on a re-run.

Built and verified locally in the meantime: assembleFossDebug with NDK 30.0.14904198, installed on the device described above, AAP session comes up.

@kavishdevar

Copy link
Copy Markdown
Member

Could you run the Action on your repo and link it here? I’ll merge the fix for tools later.

@SAGIRIxr

Copy link
Copy Markdown
Author

Ran it on my fork with this branch plus the tools fix on top, so the job could get past Setup Android SDK:

https://github.com/SAGIRIxr/librepods/actions/runs/35490704019build green, assembleFossDebug completes.

(Branch: ci-verify/l2cap-fix = this PR's commit + #786's commit. That branch is only for the verification run — this PR itself stays the single-file change.)

Two warnings surfaced in the log that are unrelated to either patch, just flagging them:

  • setup-java v4 is deprecated and will no longer receive updates
  • The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026

@kavishdevar

Copy link
Copy Markdown
Member

Thanks! I'll ask someone to test it.

android-actions/setup-android defaults `packages` to "tools platform-tools".
The obsolete "tools" package is no longer in the SDK repository, so the
step fails before Gradle runs:

  Warning: Failed to find package 'tools'
  Error: The process '.../sdkmanager' failed with exit code 1

Ask for platform-tools only. The workflow already installs the NDK and
accepts licenses in its own steps, so nothing else relied on "tools".
@SAGIRIxr

Copy link
Copy Markdown
Author

The build check was failing on Setup Android SDK, not on anything in this patch:

Warning: Failed to find package 'tools'
Error: The process '.../sdkmanager' failed with exit code 1

android-actions/setup-android@v3 installs tools platform-tools by default, and the obsolete tools package has been removed from the SDK repository, so the job dies before Gradle ever runs. Every PR against main hits this right now.

I opened #786 for the one-line workflow fix on its own, but that PR can never show a green check itself: the pull_request trigger here is filtered to paths: android/**, so a workflow-only change starts no run. To get this PR's check meaningful I've cherry-picked the same commit onto this branch — please drop it if you merge #786 first, the two are textually identical so either merge order resolves cleanly.

The resulting tree (58a449a) is byte-identical to a branch I built on my fork, which went green: https://github.com/SAGIRIxr/librepods/actions/runs/35490704019

The new run on this PR is currently sitting at action_required — it needs a maintainer to approve the workflow run before it will execute.

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.

2 participants