mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(aap): Log decoded 0x1D segments for engraving discovery
Diagnostic-only NUL-delimited UTF-8 segmentation of the 0x1D INFORMATION packet, logged at INFO so it lands in debug recordings via the existing FileLogger pipeline. The production decode path stays untouched — this only adds visibility, no behavior change. Refs #173.
This commit is contained in:
@@ -5,6 +5,7 @@ import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
@@ -34,6 +35,9 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CharacterCodingException
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
/**
|
||||
* Manages a single AAP L2CAP connection to a device.
|
||||
@@ -305,6 +309,27 @@ internal class AapConnection(
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
|
||||
|
||||
// Issue #173: diagnostic dump of the 0x1D INFORMATION packet so testers with engraved AirPods
|
||||
// can share a debug recording that reveals where (or whether) the engraving message lives.
|
||||
// Runs unconditionally for 0x1D — not gated on decodeDeviceInfo success, since an engraving-
|
||||
// shaped packet may not match the strict production decoder's expectations.
|
||||
if (message.commandType == 0x001D) {
|
||||
val segments = describeDeviceInfoSegments(message.payload)
|
||||
log(TAG, INFO) { "DeviceInfoDump #173: payload=${message.payload.size} bytes, segments=${segments.size}" }
|
||||
segments.forEach { seg ->
|
||||
val label = when (seg.index) {
|
||||
0 -> "name"
|
||||
1 -> "modelNumber"
|
||||
2 -> "manufacturer"
|
||||
3 -> "serialNumber"
|
||||
4 -> "firmwareVersion"
|
||||
else -> "unknown"
|
||||
}
|
||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||
log(TAG, INFO) { "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try stem press event (transient — emitted via SharedFlow, not stored in state)
|
||||
profile.decodeStemPress(message)?.let { event ->
|
||||
_stemPressEvents.tryEmit(event)
|
||||
@@ -425,5 +450,74 @@ internal class AapConnection(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("AapConnection")
|
||||
|
||||
/**
|
||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the
|
||||
* issue #173 engraving discovery logging. Not part of the production decode path — the
|
||||
* production parser [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile]
|
||||
* intentionally stays ASCII-only to preserve existing wire-format assumptions.
|
||||
*
|
||||
* Skips binary header bytes until the first printable ASCII byte (mirroring the production
|
||||
* decoder's start condition — all known captures begin the real data with the device name,
|
||||
* which is always ASCII like "AirPods Pro"), then splits on NUL bytes. Each non-empty chunk
|
||||
* becomes one [DeviceInfoSegment]. UTF-8 decode is attempted strictly; if a chunk contains
|
||||
* invalid UTF-8 (e.g. the post-serials encrypted blob), [DeviceInfoSegment.utf8] is null
|
||||
* and the caller falls back to [DeviceInfoSegment.hex].
|
||||
*
|
||||
* Offsets are relative to the supplied payload (post message-header), matching what the
|
||||
* caller holds in [AapMessage.payload].
|
||||
*/
|
||||
internal fun describeDeviceInfoSegments(payload: ByteArray): List<DeviceInfoSegment> {
|
||||
var start = 0
|
||||
while (start < payload.size) {
|
||||
val b = payload[start].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
start++
|
||||
}
|
||||
if (start >= payload.size) return emptyList()
|
||||
|
||||
val segments = mutableListOf<DeviceInfoSegment>()
|
||||
var segIndex = 0
|
||||
var i = start
|
||||
while (i < payload.size) {
|
||||
while (i < payload.size && payload[i] == 0x00.toByte()) i++
|
||||
if (i >= payload.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < payload.size && payload[i] != 0x00.toByte()) i++
|
||||
val segBytes = payload.copyOfRange(segStart, i)
|
||||
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(segBytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
||||
|
||||
segments.add(
|
||||
DeviceInfoSegment(
|
||||
index = segIndex,
|
||||
offset = segStart,
|
||||
length = segBytes.size,
|
||||
utf8 = utf8,
|
||||
hex = hex,
|
||||
)
|
||||
)
|
||||
segIndex++
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
internal data class DeviceInfoSegment(
|
||||
val index: Int,
|
||||
val offset: Int,
|
||||
val length: Int,
|
||||
val utf8: String?,
|
||||
val hex: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
/**
|
||||
* Tests for the issue #173 diagnostic helper [AapConnection.describeDeviceInfoSegments].
|
||||
*
|
||||
* The helper must:
|
||||
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
|
||||
* - Decode non-ASCII UTF-8 (emoji, non-Latin script) — the production parser drops these.
|
||||
* - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo]
|
||||
* would return null (malformed / unknown-shaped packets).
|
||||
*/
|
||||
class AapConnectionTest : BaseTest() {
|
||||
|
||||
private fun hex(s: String): ByteArray = s
|
||||
.replace("\n", " ")
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `describeDeviceInfoSegments decodes known slots from AirPods Pro 2 USB-C capture`() {
|
||||
// Exact payload (post message-header) from AirPodsPro2UsbcAapSessionTest.
|
||||
// Headers `02 DF 00 04 00` are skipped by the helper until the first printable ASCII byte.
|
||||
val payload = hex(
|
||||
"""
|
||||
02 DF 00 04 00
|
||||
41 69 72 50 6F 64 73 20 50 72 6F 00
|
||||
41 33 30 34 38 00
|
||||
41 70 70 6C 65 20 49 6E 63 2E 00
|
||||
57 35 4A 37 4B 56 30 4E 30 34 00
|
||||
38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 30 38 32 00
|
||||
38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 30 38 32 00
|
||||
31 2E 30 2E 30 00
|
||||
63 6F 6D 2E 61 70 70 6C 65 2E 61 63 63 65 73 73 6F 72 79 2E 75 70 64 61 74 65 72 2E 61 70 70 2E 37 31 00
|
||||
48 33 4B 4C 37 48 52 39 32 36 4A 59 00
|
||||
48 33 4B 4C 32 41 59 4C 32 36 4B 30 00
|
||||
38 34 35 34 34 38 30 00
|
||||
1F 3F B4 B7 E9 81 48 11 94 6B C2 6F 3C 5F 5A 34 0B AB 7E 42 AA BD F1 49 E3 A8 98 E7 81 D6 04 F5 68 1F
|
||||
31 36 39 37 34 38 30 32 31 31 00 31 36 39 37 34 38 30 32 31 31 00
|
||||
"""
|
||||
)
|
||||
|
||||
val segments = AapConnection.describeDeviceInfoSegments(payload)
|
||||
|
||||
// The first five segments mirror the production decoder's name/modelNumber/manufacturer/
|
||||
// serialNumber/firmwareVersion slots.
|
||||
segments[0].utf8 shouldBe "AirPods Pro"
|
||||
segments[1].utf8 shouldBe "A3048"
|
||||
segments[2].utf8 shouldBe "Apple Inc."
|
||||
segments[3].utf8 shouldBe "W5J7KV0N04"
|
||||
segments[4].utf8 shouldBe "81.2675000075000000.6082"
|
||||
|
||||
// Every well-formed text segment retains a hex rendering too.
|
||||
segments[0].hex shouldBe "416972506F64732050726F"
|
||||
|
||||
// The encrypted blob immediately follows "8454480" without a NUL separator, so it merges
|
||||
// with the subsequent manufacturing-date string into a single non-UTF-8 chunk. The helper
|
||||
// surfaces it as a segment with utf8 == null and a hex rendering that still contains the
|
||||
// original blob bytes so maintainers can spot it in a debug recording.
|
||||
val nonUtf8 = segments.firstOrNull { it.utf8 == null }
|
||||
nonUtf8.shouldNotBeNull()
|
||||
nonUtf8.hex.contains("1F3FB4B7E98148") shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describeDeviceInfoSegments decodes UTF-8 emoji that the production ASCII parser would drop`() {
|
||||
// Production parseNullTerminatedStrings filters to 0x20..0x7E and decodes as US_ASCII,
|
||||
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
|
||||
val engraving = "My AirPods 🌈"
|
||||
val payload = hex("02 DF 00 04 00") +
|
||||
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
||||
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
||||
|
||||
val segments = AapConnection.describeDeviceInfoSegments(payload)
|
||||
|
||||
segments.size shouldBe 2
|
||||
segments[0].utf8 shouldBe engraving
|
||||
segments[1].utf8 shouldBe "A3048"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describeDeviceInfoSegments emits segments even when decodeDeviceInfo returns null`() {
|
||||
// Only two strings — production decoder requires at least four and returns null. The
|
||||
// diagnostic dump must still surface whatever segments it finds so maintainers can inspect
|
||||
// unexpected-shape packets from an engraved device.
|
||||
val payload = hex("02 DF 00 04 00") +
|
||||
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
||||
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
||||
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
|
||||
val message = AapMessage.parse(fullMessageBytes)!!
|
||||
|
||||
DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2_USBC).decodeDeviceInfo(message).shouldBeNull()
|
||||
|
||||
val segments = AapConnection.describeDeviceInfoSegments(payload)
|
||||
segments.size shouldBe 2
|
||||
segments[0].utf8 shouldBe "Name"
|
||||
segments[1].utf8 shouldBe "Model"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user