最近剛好在想,怎麼在 Android 上接入 AirPods 的全部能力,剛好就看到了 librepods 這個專案,它是一個能讓 Android 使用 AirPods 專屬功能的開源專案,比如:
在非 Apple 平台上支援更改降噪模式、快速入耳檢測、精確電池狀態、對話感知這些能力。

librepods 原理是透過逆向工程,還原了 Apple 私有的 AirPods 通訊協議(AACP,Apple Accessory Communication Protocol),然後在 Android/Linux 上用標準藍牙 API 重新實作了這套協議:
首先是 BLE 廣播解析,因為 AirPods 會持續廣播 BLE 資料封包,manufacturer ID 為 76(即 Apple 的 company ID),所以專案裡的 BLEManager.kt 會針對這些資料封包進行掃描和解析,從中讀取:
0x2420 對應 AirPods Pro 2 USB-C)這一層是被動的,只要掃描到藍牙廣播包即可解析,同時為了安全性,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 通道,這其實是所有問題的起點。
然後目前專案用了兩個解法:
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 種參數組合逐一嘗試。
由於部分功能(多裝置連線切換、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 居然是常見的,我一直以為只是小部分廠商問題。