diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index ac6d356b7..fd732c173 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -1279,26 +1279,82 @@ class AACPManager { audioSource = null } + /** + * Parses unsolicited `0x001D` device information. + * See project docs: `docs/device-info.md` (nested headers / offset scan on some Android hosts). + */ fun parseInformationPacket(packet: ByteArray): AirPodsInformation { - val data = packet.sliceArray(6 until packet.size) + val candidates = mutableListOf>() + + candidates.add(parseInformationStringsAtOffset(packet, 6)) + + val scanLimit = minOf(packet.size - 10, 96) + for (offset in 0 until scanLimit) { + if (packet.getOrNull(offset) == 0x04.toByte() && + packet.getOrNull(offset + 1) == 0x00 && + packet.getOrNull(offset + 2) == 0x04.toByte() && + packet.getOrNull(offset + 3) == 0x00 && + packet.getOrNull(offset + 4) == Opcodes.INFORMATION && + packet.getOrNull(offset + 5) == 0x00 + ) { + candidates.add(parseInformationStringsAtOffset(packet, offset + 6)) + } + } + + for (offset in 0 until minOf(packet.size - 16, 64)) { + val strings = parseInformationStringsAtOffset(packet, offset) + if (strings.size >= 4) { + candidates.add(strings) + } + } + + val strings = candidates.maxByOrNull { scoreDeviceInformationFields(it) } + ?: emptyList() + + return airPodsInformationFromFieldStrings(strings) + } - var index = 0 - while (index < data.size && data[index] != 0x00.toByte()) index++ + private fun parseInformationStringsAtOffset(packet: ByteArray, payloadStart: Int): List { + if (payloadStart >= packet.size) return emptyList() + + var index = payloadStart + while (index < packet.size && packet[index] != 0x00.toByte()) index++ val strings = mutableListOf() - while (index < data.size) { - // skip 0x00 bytes - while (index < data.size && data[index] == 0x00.toByte()) index++ - if (index >= data.size) break + while (index < packet.size) { + while (index < packet.size && packet[index] == 0x00.toByte()) index++ + if (index >= packet.size) break val start = index - // find next 0x00 byte - while (index < data.size && data[index] != 0x00.toByte()) index++ - val str = data.sliceArray(start until index).decodeToString() - strings.add(str) + while (index < packet.size && packet[index] != 0x00.toByte()) index++ + val slice = packet.sliceArray(start until index) + if (!slice.all { it in 0x20..0x7E }) { + break + } + strings.add(slice.decodeToString()) } - strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string + if (strings.isNotEmpty() && strings[0].isEmpty()) { + strings.removeAt(0) + } + return strings + } + + private fun scoreDeviceInformationFields(strings: List): Int { + if (strings.isEmpty()) return 0 + var score = strings.size + if (strings.getOrNull(2) == "Apple Inc.") score += 6 + val model = strings.getOrNull(1) + if (model != null && model.length == 5 && model[0] == 'A' && model.drop(1).all { it.isDigit() }) { + score += 3 + } + val serial = strings.getOrNull(3) + if (serial != null && serial.length in 10..12 && serial.all { it.isLetterOrDigit() }) { + score += 2 + } + return score + } + private fun airPodsInformationFromFieldStrings(strings: List): AirPodsInformation { return AirPodsInformation( name = strings.getOrNull(0) ?: "", modelNumber = strings.getOrNull(1) ?: "", diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..bafeec921 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -2740,12 +2740,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } this@AirPodsService.device = device BluetoothConnectionManager.aacpSocket?.let { - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - Log.d(TAG, "Requesting proximity keys") - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { + aacpManager.sendPacket(aacpManager.createHandshakePacket()) + delay(200) + aacpManager.sendSetFeatureFlagsPacket() + delay(200) + aacpManager.sendNotificationRequest() + delay(200) + Log.d(TAG, "Requesting proximity keys") + aacpManager.sendRequestProximityKeys( + (AACPManager.Companion.ProximityKeyType.IRK.value + + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte() + ) delay(200) aacpManager.sendPacket(aacpManager.createHandshakePacket()) delay(200) diff --git a/docs/AAP Definitions.md b/docs/AAP Definitions.md index 87b23d905..1f2b99b65 100644 --- a/docs/AAP Definitions.md +++ b/docs/AAP Definitions.md @@ -9,6 +9,8 @@ This packet is necessary to establish a connection with the AirPods. Or else, th 00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00 ``` +After the handshake, third-party hosts should send [host capabilities (`0x004D`)](/docs/host-capabilities.md) before requesting notifications. See that document for timing notes and Android L2CAP pitfalls. + # Setting specific features for AirPods Pro 2 > *may work for airpods 4 anc also, not tested* diff --git a/docs/device-info.md b/docs/device-info.md index 2c8177607..2a74242f9 100644 --- a/docs/device-info.md +++ b/docs/device-info.md @@ -24,3 +24,23 @@ The data is in this order: - Version (?) (I have `8454371`) - A few more bytes, I don't know what they are +## Push-only + +Hosts cannot request this opcode. Opcode `0x004F` does not provide a reliable way to read serials or model numbers from the accessory. + +After L2CAP connect, send the [handshake](/docs/AAP%20Definitions.md#handshake) and [host capabilities `0x004D`](/docs/host-capabilities.md) so the accessory is likely to include `0x001D` in the startup burst. + +## Parsing on third-party hosts (especially Android) + +On some non-Apple stacks the `0x001D` SDU is not always aligned at a fixed byte offset after the six-byte `04 00 04 00` + opcode header: + +- The payload may include **length prefixes** or an extra nested `04 00 04 00` header before the UTF-8 string block. +- Fields remain **null-terminated strings** in the order listed above once the string run is found. + +Implementations should **scan the SDU** for a plausible sequence of printable UTF-8 strings (name, model, `Apple Inc.`, serial-shaped tokens, version strings) instead of assuming parsing always starts at byte index 6. + +A single session may deliver **more than one** `0x001D` packet with different lengths. When duplicates disagree, prefer the frame with the **most complete** set of fields. + +## Battery report (`0x0004`) + +Unsolicited battery packets may arrive before or after `0x001D`. On some Android L2CAP sessions battery reports are sparse or absent even when `0x001D` was received successfully. diff --git a/docs/host-capabilities.md b/docs/host-capabilities.md new file mode 100644 index 000000000..fee3fe169 --- /dev/null +++ b/docs/host-capabilities.md @@ -0,0 +1,55 @@ +--- +opcode: 0x004D +title: Host capabilities (feature flags) +description: Sent by the host to the accessory after the initial L2CAP handshake so the accessory emits the full startup notification burst. +--- + +## Overview + +After the [handshake](/docs/AAP%20Definitions.md#handshake) on PSM `0x1001`, third-party hosts should send opcode **`0x004D`** before (or shortly before) [requesting notifications](/docs/AAP%20Definitions.md#requesting-notifications). The accessory uses this to decide which host-side features are supported and often follows with a burst of unsolicited packets (for example paired-device metadata `0x002B` and [device information](/docs/device-info.md) `0x001D`). + +This opcode is **not** a poll for device info; it only advertises host capability. Device information remains push-only on `0x001D`. + +## Packet shape + +Typical framing uses the usual AACP prefix `04 00 04 00`, little-endian opcode `4D 00`, then a capability bitmask or feature bytes. + +### Observed on macOS (PacketLogger) + +Documented in [AAP Definitions](/docs/AAP%20Definitions.md#setting-specific-features-for-airpods-pro-2): + +```plaintext +04 00 04 00 4d 00 ff 00 00 00 00 00 00 00 +``` + +### Observed in LibrePods (Android) + +The Android app sends a longer payload starting with `D7` (see `AACPManager.createSetFeatureFlagsPacket()`). + +### Other third-party clients + +Some Android clients use a shorter capability byte (for example `FF`) with the same opcode. Behavior can vary by accessory firmware; if the startup burst is thin (short `0x002B` only, no `0x001D`), verify that `0x004D` is sent and that the host is not discarding early inbound data. + +## Recommended connection order (third-party hosts) + +1. Open L2CAP to PSM `0x1001` on a bonded device. +2. Send handshake `0x0001`. +3. Wait roughly **100–350 ms** (some Android stacks are timing-sensitive). +4. Send **`0x004D`** host capabilities. +5. Wait roughly **100–350 ms**. +6. Send **`0x000F`** notification register. +7. Read and process inbound packets continuously; **`0x001D` may arrive before step 6 completes**. + +## Client pitfalls + +- **Do not drain or drop the socket receive queue** immediately after connect. The accessory may already have queued `0x001D` or `0x002B` during the handshake burst. +- **Do not rely on `0x004F`** to fetch serials or model data; it does not behave like a device-info read even with Apple Device ID spoofing. +- On Android, L2CAP to AACP may require stack-specific socket construction; see [Google issue 371713238](https://issuetracker.google.com/issues/371713238) and [capod#215](https://github.com/d4rken-org/capod/issues/215). + +## Related opcodes + +| Opcode | Direction (typical) | Notes | +| ------ | ------------------- | ----- | +| `0x0029` | Host → accessory (?) | Also associated with host capabilities in some captures; less common in open-source clients than `0x004D`. | +| `0x002B` | Accessory → host | Often appears in the startup burst after capabilities. | +| `0x001D` | Accessory → host | [Device information](/docs/device-info.md). | diff --git a/docs/opcodes.md b/docs/opcodes.md index d5299564a..36082de2c 100644 --- a/docs/opcodes.md +++ b/docs/opcodes.md @@ -28,6 +28,6 @@ AACP (Apple Accessory Communication Protocol) uses various opcodes to define dif | 0x0030 | Accessory | [BLE keys req](/docs/ble-keys.md) | | 0x0031 | Host | [BLE keys response](/docs/ble-keys.md) | | 0x004B | Host | [Conversation awareness](/docs/conversational-awareness.md) | -| 0x004D | Accessory | [Host capabilities](/docs/host-capabilities.md) | +| 0x004D | Accessory | [Host capabilities](/docs/host-capabilities.md) (host → accessory) | | 0x004F | Both | Information req/res (doesn't work, even with apple's DID) | | 0x0053 | Both | [EQ data](/docs/eq.md) | \ No newline at end of file