最近刚好在想,怎么在 Android 上接入 AirPods 的全部能力,刚好就看到了 librepods这个项目,它是一个能让 Android 使用 AirPods 的专属功能的开源项目,比如:
在非 Apple 平台上支持更改降噪模式、快速入耳检测、精确电池状态、对话感知这些能力。

librepods 原理是通过逆向,还原了 Apple 私有的 AirPods 通信协议(AACP,Apple Accessory Communication Protocol),然后在 Android/Linux 上用标准蓝牙 API 重新实现了这套协议:
- BLE 广播层,被动读取设备状态
- L2CAP (PSM 0x1001/4097) , AACP 控制信道,双向命令通信
- ATT (PSM 31) , GATT 属性层,读写特定特性(透明度/降噪/听力辅助)
首先是 BLE 广播解析,因为 AirPods 会持续广播 BLE 数据包,manufacturer ID 为 76(即 Apple 的 company ID),所以项目里 BLEManager.kt 会针对这些数据包进行扫描和解析,从中读取:
- 型号识别(model ID,如
0x2420对应 AirPods Pro 2 USB-C) - 电量(左右耳机和充电盒,4 bit 一组)
- 入耳状态(status 字节的位运算)
- 充电盒盖开关状态
- 连接状态
这一层是被动的,只要扫描到蓝牙广播包即可解析,同时为了安全性,AirPods 较新固件的 BLE 广播数据做了加密(最后 16 字节用 AES-ECB 加密),解密密钥(ENC_KEY)和身份解析密钥(IRK)需要通过 AACP 连接后主动向设备请求 proximity keys 获取,然后存储在本地,这就是为什么 BLE 功能需要先建立一次 AACP 连接。
然后就是 L2CAP 通道上的 AACP 控制协议,这也是整个项目最核心的部分 ,AirPods 在标准蓝牙协议栈之上开放了一个私有的 L2CAP 通道,PSM(Protocol Service Multiplexer)为 0x1001(4097),对应在 AACPManager.kt 代码完整实现了这套协议,协议里,所有 AACP 包头固定为:
04 00 04 00
之后跟两字节小端 opcode,然后是数据,然后握手包是连接后必须发送的第一个包,如果没有的话 AirPods 不会响应任何后续命令:
00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00
握手后需要发送 feature flags 包(opcode 0x4D)来解锁对话感知(Conversational Awareness)和自适应透明度等功能,再发送 notification request 包(opcode 0x0F)订阅来自 AirPods 的主动通知(电量、入耳、噪音模式等)。
控制命令格式统一为 opcode 0x09,跟随 identifier 字节和数据字节:
css
04 00 04 00 09 00 [identifier] [data1] [data2] [data3] [data4]
目前已逆向出 60 余个控制命令,覆盖:降噪模式切换、对话感知开关、按键配置、自动连接、单耳 ANC、入耳检测开关、听力辅助开关等场景。
AirPods 的响应是对称的,发什么格式就回什么格式,状态变更也用同一套包结构主动推送。
然后就是 ATT 层(GATT over L2CAP),通过 PSM 31 建立另一个 L2CAP 连接。其实就是裸 ATT 协议,绕过了 GATT 的 UUID 层,实现在 ATTManager.kt 代码,主要用来读写下面放特性:
ATTHandles.TRANSPARENCY// handle 0x18ATTHandles.LOUD_SOUND_REDUCTION// handle 0x1BATTHandles.HEARING_AID// handle 0x2A
其实透明度模式的精细参数(EQ、放大、音调、对话增益等)和听力辅助参数(听力图)就通过这个通道读写。
然后就是 Android 上的适配,这部分其实还是有点难度,因为 Android 蓝牙栈的 L2CAP 限制,标准 Android 蓝牙栈(Fluoride/Gabeldorsche)对经典蓝牙 L2CAP 连接的 FCR(Flow Control and Retransmission)模式,协商存在一个 bug ,在大多数设备上不能直接建立到 PSM 0x1001 的 L2CAP 通道,这其实是所有问题的起点。
然后目前项目用了两个解法:
Xposed + 原生 Hook
KotlinModule.kt是一个 libxposed 模块,在蓝牙应用进程(com.google.android.bluetooth或com.android.bluetooth)加载时,会注入一个名为libl2c_fcr_hook.so的原生共享库,这个库 hook 了蓝牙协议栈底层的l2c_fcr_chk_chan_modes函数,修改了它对 FCR 模式检查的行为,从而让底层的 L2CAP 连接能够成功建立。BluetoothConnectionManager.kt通过反射调用BluetoothSocket的私有构造函数,直接指定 socket 类型为 L2CAP(type=3)和 PSM 值,绕过了 Android 公开 API 的限制:
go
val type = 3 // L2CAP
arrayOf(adapter, device, type, true, true, psm, uuid) // 多种签名尝试
由于不同 Android 版本的
BluetoothSocket内部构造函数签名不同,代码枚举了 5 种参数组合逐一尝试。
VendorID 欺骗
由于部分功能(多设备连接切换、ATT 特性访问、听力辅助等)被 AirPods 锁定,只向 Apple 设备开放,所以 AirPods 通过蓝牙的 DID Profile(Device ID Profile)检查连接设备的 VendorID,Apple 的 VendorID 为 0x004C。
所以只要通过将 Android 设备的蓝牙 DID Profile 的 VendorID 改为 0x004C,AirPods 会把设备识别为 Apple 设备并解锁这些功能,在 Android 上这需要通过 Xposed hook 蓝牙服务来修改,在 Linux 上是在 /etc/bluetooth/main.conf 中添加:
ini
DeviceID = bluetooth:004C:0000:0000
外,在 ColorOS/OxygenOS 16、Realme UI 7.0 以及 Pixel 的 Android 16 QPR3 ,目前已经修复了上面蓝牙栈 bug ,也有提供合规的 L2CAP 支持,在这些设备上不需要 Xposed 支持 AACP 连接。
另外还有一个重要功能就是多设备切换(Smart Routing) ,AirPods 支持同时连接两台设备,设备间通过 AACP 的 Smart Routing 机制(opcode 0x10/0x11)协商谁拥有连接控制权。
librepods 里也完整实现了这套协议,createMediaInformationPacket 和 createHijackRequestPacket 分别对应"通知对方我在播放 "和"主动抢占连接 "两个场景,数据包内甚至包含类似 JSON key-value 格式的字段(PlayingApp、HostStreamingState、btName: Android 等)。
目前看起来,作者主要是通过 macOS 设备的 PacketLogger 抓取蓝牙流量逆向的 AirPods 机制,不过空间音频(头部追踪 HRTF)需要系统级音频集成,这个能力还是暂时确实,而且心率监测(AirPods Pro 3 及以后)协议也还没逆向完成,不过能让 AirPods 多发挥点场景适配还是挺不错的。
不过最让我没想到的是,原来 Android 上的 L2CAP bug 居然是常见的,我一直以为只是小部分厂商问题。