跪拜 Guibai
← Back to the summary

Reverse-Engineering AirPods: An Open-Source Project Brings Full AACP Protocol to Android

Apple AirPods Protocol: Full AirPods Capabilities on Android

Recently I was thinking about how to integrate all of AirPods' capabilities on Android, and I came across the librepods project. It's an open-source project that enables Android to use AirPods' exclusive features, such as:

Changing noise cancellation modes, fast in-ear detection, precise battery status, and conversational awareness on non-Apple platforms.

librepods works by reverse-engineering Apple's proprietary AirPods communication protocol (AACP, Apple Accessory Communication Protocol) and reimplementing it on Android/Linux using standard Bluetooth APIs:

First is BLE broadcast parsing. Because AirPods continuously broadcast BLE data packets with manufacturer ID 76 (Apple's company ID), the BLEManager.kt in the project scans and parses these packets to read:

This layer is passive; it can be parsed simply by scanning Bluetooth broadcast packets. For security, newer AirPods firmware encrypts the BLE broadcast data (last 16 bytes encrypted with AES-ECB). The decryption key (ENC_KEY) and identity resolving key (IRK) need to be obtained by actively requesting proximity keys from the device after an AACP connection is established, and then stored locally. This is why BLE functionality requires an AACP connection to be established first.

Then there's the AACP control protocol over the L2CAP channel, which is the core part of the entire project. AirPods open a private L2CAP channel on top of the standard Bluetooth protocol stack, with PSM (Protocol Service Multiplexer) 0x1001 (4097). The AACPManager.kt code fully implements this protocol. In this protocol, all AACP packet headers are fixed as:

04 00 04 00

Followed by a two-byte little-endian opcode, then data. The handshake packet is the first packet that must be sent after connection; without it, AirPods won't respond to any subsequent commands:

00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00

After the handshake, you need to send a feature flags packet (opcode 0x4D) to unlock features like Conversational Awareness and Adaptive Transparency, and then send a notification request packet (opcode 0x0F) to subscribe to active notifications from AirPods (battery, in-ear, noise mode, etc.).

Control commands follow a unified format: opcode 0x09, followed by an identifier byte and data bytes:

04 00 04 00 09 00 [identifier] [data1] [data2] [data3] [data4]

Over 60 control commands have been reverse-engineered so far, covering: noise cancellation mode switching, conversational awareness toggle, button configuration, auto-connect, single-ear ANC, in-ear detection toggle, hearing aid toggle, and more.

AirPods' responses are symmetric; whatever format you send, you get back the same format. Status changes are also actively pushed using the same packet structure.

Then there's the ATT layer (GATT over L2CAP), which establishes another L2CAP connection via PSM 31. This is essentially the raw ATT protocol, bypassing GATT's UUID layer, implemented in ATTManager.kt. It's mainly used to read/write the following characteristics:

Fine-grained parameters for transparency mode (EQ, amplification, tone, conversation gain, etc.) and hearing aid parameters (audiogram) are read/written through this channel.

Then there's the adaptation on Android. This part is still somewhat difficult because of L2CAP limitations in the Android Bluetooth stack. The standard Android Bluetooth stack (Fluoride/Gabeldorsche) has a bug in the FCR (Flow Control and Retransmission) mode negotiation for classic Bluetooth L2CAP connections. On most devices, it's not possible to directly establish an L2CAP channel to PSM 0x1001. This is the starting point of all problems.

The project currently uses two solutions:

Xposed + Native Hook

val type = 3 // L2CAP
arrayOf(adapter, device, type, true, true, psm, uuid) // multiple signature attempts

Since the internal constructor signature of BluetoothSocket varies across Android versions, the code enumerates 5 parameter combinations and tries each one.

VendorID Spoofing

Because some features (multi-device connection switching, ATT characteristic access, hearing aid, etc.) are locked by AirPods and only exposed to Apple devices, AirPods check the VendorID of the connected device via the Bluetooth DID Profile (Device ID Profile). Apple's VendorID is 0x004C.

So by changing the VendorID in the Android device's Bluetooth DID Profile to 0x004C, AirPods will recognize the device as an Apple device and unlock these features. On Android, this requires hooking the Bluetooth service via Xposed to modify it. On Linux, add the following to /etc/bluetooth/main.conf:

DeviceID = bluetooth:004C:0000:0000

Additionally, ColorOS/OxygenOS 16, Realme UI 7.0, and Android 16 QPR3 for Pixel have already fixed the aforementioned Bluetooth stack bug and provide compliant L2CAP support. On these devices, Xposed is not required for AACP connections.

Another important feature is multi-device switching (Smart Routing). AirPods support simultaneous connection to two devices. The devices negotiate connection control rights via AACP's Smart Routing mechanism (opcode 0x10/0x11).

librepods also fully implements this protocol. createMediaInformationPacket and createHijackRequestPacket correspond to the scenarios of "notifying the other device that I am playing" and "actively seizing the connection," respectively. The data packets even contain fields in a JSON-like key-value format (PlayingApp, HostStreamingState, btName: Android, etc.).

Currently, it seems the author reverse-engineered the AirPods mechanism by capturing Bluetooth traffic using macOS's PacketLogger. However, spatial audio (head tracking HRTF) requires system-level audio integration, so this capability is temporarily absent. Also, the protocol for heart rate monitoring (AirPods Pro 3 and later) hasn't been reverse-engineered yet. Still, it's quite nice to be able to make AirPods more versatile in different scenarios.

What surprised me the most is that the L2CAP bug on Android is actually common. I always thought it was just a problem with a few manufacturers.

Links

https://github.com/kavishdevar/librepods