Skip to content

Document host 0x004D handshake and harden 0x001D parsing on Android - #797

Open
jashparekh wants to merge 1 commit into
librepods-org:mainfrom
jashparekh:docs/android-l2cap-handshake-and-device-info
Open

jashparekh wants to merge 1 commit into
librepods-org:mainfrom
jashparekh:docs/android-l2cap-handshake-and-device-info

Conversation

@jashparekh

@jashparekh jashparekh commented Sep 23, 2026 •

Copy link
Copy Markdown

Summary

Third-party Android hosts that open the Apple AAP L2CAP channel (PSM 0x1001) often miss unsolicited device information (0x001D) even when the link is up. Common causes are skipping host capabilities (0x004D) after the initial handshake, sending subscribe packets back-to-back with no pacing, discarding inbound data before the read loop runs, and parsing 0x001D as if UTF-8 fields always start at byte offset 6.

This PR documents the recommended connect sequence for non-Apple clients, fixes the broken docs/host-capabilities.md link from opcodes.md, and makes small Android-side changes so LibrePods is more tolerant of real-world 0x001D framing.

Not in scope: changing the 0xD7 feature-flag payload LibrePods already sends, fixing Fluoride L2CAP/socket creation on devices that never connect, or resolving notification-mask differences (FF FF FE FF vs FF FF FF FF) — see #770.


Problem / motivation

  1. opcodes.md links to /docs/host-capabilities.md, which did not exist (404).
  2. docs/device-info.md described field order but not Android-specific framing (nested 04 00 04 00 headers, length prefixes, multiple 0x001D SDUs per session).
  3. AACPManager.parseInformationPacket() assumed payload strings always begin after a fixed 6-byte header slice; some stacks deliver a longer preamble, yielding empty name/model/serial in the UI while the wire still contains valid strings.
  4. AirPodsService sent handshake → 0x004D → 0x0F immediately on the IO thread, while a delayed coroutine repeated the same sequence 200 ms later. The first burst had no pacing; on timing-sensitive hosts the accessory sometimes emitted a thin startup burst (short 0x002B only) before richer metadata arrived.

Documentation changes

New: docs/host-capabilities.md

  • Opcode 0x004D: host → accessory, after handshake.
  • Recommended order: L2CAP connect → 0x0001 → ~100–350 ms → 0x004D → ~100–350 ms → 0x000F → continuous read.
  • Documents observed capability payloads (macOS FF… per AAP Definitions, LibrePods D7… in app code, shorter FF variants in other clients).
  • Pitfalls: do not drain RX queue after connect; 0x001D may arrive before notification register completes; 0x004F is not a device-info read.
  • Links: Google issuetracker #371713238, capod#215.

Updated: docs/device-info.md

  • Push-only reminder; cross-link to host capabilities.
  • Offset-scan guidance for third-party parsers; multiple 0x001D frames; battery 0x0004 may be sparse even when 0x001D succeeds.

Updated: docs/opcodes.md, docs/AAP Definitions.md

  • Clarify 0x004D as host → accessory; link new doc after handshake section.

Android code changes

AACPManager.parseInformationPacket()

  • Try legacy offset-6 parsing, scan for nested 04 00 04 00 1D 00 headers, and score candidate null-terminated UTF-8 string runs (e.g. manufacturer Apple Inc., model A####, serial-shaped tokens).
  • Pick the highest-scoring candidate for AirPodsInformation fields.
  • KDoc points to docs/device-info.md.
  • No API-level guard — applies on all Android versions where L2CAP AAP already works.

AirPodsService (L2CAP connect)

  • Move the first handshake / sendSetFeatureFlagsPacket() / sendNotificationRequest() sequence into the existing Dispatchers.IO coroutine with delay(200) between steps (same pacing already used for the retry burst).
  • Avoids duplicate immediate + delayed double-send of the first triplet without delays.

Manual testing (Google Pixel, Android 17)

Testing was done on a Google Pixel phone running Android 17 with bonded AirPods Pro (USB-C and Pro 3 generations), using the native L2CAP path (no root/Xposed required on this OS build for AAP socket connect).

Step What we did Expected / observed
Pairing AirPods bonded in system Bluetooth; LibrePods granted Bluetooth / nearby permissions Classic audio + AAP L2CAP available
Connect Open LibrePods / trigger L2CAP service connect Socket to PSM 0x1001 succeeds
Protocol Capture AAP traffic via app logging / btsnoop After handshake + 0x004D, accessory sends burst including 0x002B and unsolicited 0x001D on good runs
Failure mode (pre-fix mental model) Sessions with thin 0x002B (~102 B) and no 0x001D on the wire Not a parser bug — host never received device-info SDU (missing caps, drained queue, or timing)
Success mode Paced handshake sequence; read loop processes queued inbound data Larger 0x002B and 0x001D (~200+ B payload); name, model, firmware, and serial fields populate in app storage / UI
Parser 0x001D frames with non-zero preamble before string block Offset scan recovers fields that fixed offset-6 parsing left blank
Battery Same session 0x0004 sometimes absent on Android even when 0x001D present (documented; not fixed here)
Regression Noise control, notification register, proximity key request still run after paced burst No change to feature set; only ordering/timing of first burst

Platforms: Changes are not gated on SDK 37. They help any device where LibrePods already establishes AAP L2CAP; they do not by themselves fix OEMs that cannot open the socket (see existing L2CAP issue threads).


Related issues (partial overlap)

Issue Relationship
#770 Docs overlap on handshake + 0x4D; battery notification mask still open
#288 Similar “wrong packet type / device info” symptom on linux/rust — not fixed by this Android-only parser
#726 L2CAP never connects on some OEMs — out of scope
#782 Disconnect / handshake duplication research — pacing may help marginally; root causes differ

Test plan (for reviewers)

  • Click through doc links: host-capabilities.md ← opcodes.md, device-info.md, AAP Definitions handshake section.
  • Android: bonded AirPods on a Pixel (or Android 16 QPR3+ / ColorOS 16+ native L2CAP) device — confirm device information still appears after connect.
  • Android 17 (optional): repeat on AOS 17 Pixel if available — validates timing-sensitive path described above.
  • Regression: listening mode / notifications / proximity key flow after connect.
  • No new unit tests — Android module has no existing AACPManager test harness; parser change is covered by manual 0x001D captures.

Files changed

  • docs/host-capabilities.md (new)
  • docs/device-info.md, docs/opcodes.md, docs/AAP Definitions.md
  • android/.../AACPManager.kt, android/.../AirPodsService.kt

Add host-capabilities docs for third-party Android L2CAP clients, extend device-info notes, and pace the initial AACP connect burst with short delays.
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