feat(aap): Decode unknown settings, extend DeviceInfo, fix UTF-8 parsing

- Add UnknownSetting catch-all for 9 unconfirmed H2+ setting IDs (0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E)

- Downgrade known non-settings commands (0x0000, 0x0002, 0x000C, 0x0017, 0x002B, 0x004E, 0x0055, 0x0057) from INFO to VERBOSE

- Add earbud serials and build number to DeviceInfo (parsed from 0x001D segments 8-10, shown in DeviceInfoCard, persisted in cache)

- Fix UTF-8 device name parsing (curly quotes etc. no longer break segment indices)

- Add [was: ...] to setting change logs for easier protocol diff analysis

- Label DeviceInfoDump segments 5-12 (firmwareVersionDup, protocolVersion, updaterAppId, earbudSerials, buildNumber, encryptedBlob, timestamp)
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent fd6a9667e2
commit 32ff7e5bdd
12 changed files with 331 additions and 36 deletions
@@ -21,16 +21,28 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
internal fun buildModelLabel(device: PodDevice): String? {
if (device.model == PodModel.UNKNOWN) return null
val modelNumber = device.deviceInfo?.modelNumber?.takeIf { it.isNotBlank() }
return if (modelNumber != null) "${device.model.label} ($modelNumber)" else device.model.label
}
@Composable @Composable
internal fun DeviceInfoCard( internal fun DeviceInfoCard(
deviceInfo: AapDeviceInfo?, deviceInfo: AapDeviceInfo?,
modelLabel: String?,
systemBluetoothName: String?,
connectionStateLabel: String?, connectionStateLabel: String?,
lastSeen: String?, lastSeen: String?,
firstSeen: String?, firstSeen: String?,
@@ -57,17 +69,25 @@ internal fun DeviceInfoCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
if (modelLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_model_label),
value = modelLabel,
)
}
if (deviceInfo != null) { if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) { if (deviceInfo.name.isNotBlank()) {
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
InfoRow( InfoRow(
label = stringResource(R.string.device_settings_info_name_label), label = stringResource(R.string.device_settings_info_bt_name_label),
value = deviceInfo.name, value = deviceInfo.name,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
) )
if (canRename) { if (canRename) {
IconButton(onClick = { showRenameDialog = true }) { IconButton(onClick = { showRenameDialog = true }) {
@@ -92,6 +112,31 @@ internal fun DeviceInfoCard(
value = deviceInfo.firmwareVersion, value = deviceInfo.firmwareVersion,
) )
} }
if (!deviceInfo.buildNumber.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_build_label),
value = deviceInfo.buildNumber,
)
}
if (!deviceInfo.leftEarbudSerial.isNullOrBlank() || !deviceInfo.rightEarbudSerial.isNullOrBlank()) {
Row(modifier = Modifier.fillMaxWidth()) {
if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_left_serial_label),
value = deviceInfo.leftEarbudSerial,
modifier = Modifier.weight(1f),
)
}
if (!deviceInfo.rightEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_right_serial_label),
value = deviceInfo.rightEarbudSerial,
modifier = Modifier.weight(1f),
textAlign = if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) TextAlign.End else TextAlign.Start,
)
}
}
}
} }
if (connectionStateLabel != null) { if (connectionStateLabel != null) {
InfoRow( InfoRow(
@@ -99,16 +144,24 @@ internal fun DeviceInfoCard(
value = connectionStateLabel, value = connectionStateLabel,
) )
} }
if (lastSeen != null) { if (lastSeen != null && firstSeen != null) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow( InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label), label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen, value = lastSeen,
modifier = Modifier.weight(1f),
) )
}
if (firstSeen != null) {
InfoRow( InfoRow(
label = stringResource(R.string.device_settings_info_first_seen_label), label = stringResource(R.string.device_settings_info_first_seen_label),
value = firstSeen, value = firstSeen,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
} else if (lastSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
) )
} }
} }
@@ -116,7 +169,13 @@ internal fun DeviceInfoCard(
} }
@Composable @Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) { private fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start,
valueFontFamily: FontFamily? = null,
) {
Column( Column(
modifier = modifier.padding(vertical = 2.dp), modifier = modifier.padding(vertical = 2.dp),
) { ) {
@@ -124,10 +183,15 @@ private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier)
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = textAlign,
modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
fontFamily = valueFontFamily,
modifier = Modifier.fillMaxWidth(),
) )
} }
} }
@@ -142,7 +206,12 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
manufacturer = "Apple Inc.", manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04", serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305", firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
buildNumber = "8454624",
), ),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Connected", connectionStateLabel = "Connected",
lastSeen = "Just now", lastSeen = "Just now",
firstSeen = "5 minutes ago", firstSeen = "5 minutes ago",
@@ -152,7 +221,27 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
@Composable @Composable
@Preview2 @Preview2
private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper { private fun DeviceInfoCardMismatchPreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "My AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
canRename = true,
)
}
@Composable
@Preview2
private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper {
DeviceInfoCard( DeviceInfoCard(
deviceInfo = AapDeviceInfo( deviceInfo = AapDeviceInfo(
name = "AirPods Pro", name = "AirPods Pro",
@@ -161,6 +250,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
serialNumber = "W5J7KV0N04", serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305", firmwareVersion = "7A305",
), ),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Disconnected", connectionStateLabel = "Disconnected",
lastSeen = "2 hours ago", lastSeen = "2 hours ago",
firstSeen = null, firstSeen = null,
@@ -173,6 +264,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
private fun DeviceInfoCardSparsePreview() = PreviewWrapper { private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
DeviceInfoCard( DeviceInfoCard(
deviceInfo = null, deviceInfo = null,
modelLabel = "AirPods Pro 2",
systemBluetoothName = null,
connectionStateLabel = "Disconnected", connectionStateLabel = "Disconnected",
lastSeen = "Just now", lastSeen = "Just now",
firstSeen = null, firstSeen = null,
@@ -23,6 +23,9 @@ data class CachedDeviceState(
@SerialName("deviceName") val deviceName: String? = null, @SerialName("deviceName") val deviceName: String? = null,
@SerialName("serialNumber") val serialNumber: String? = null, @SerialName("serialNumber") val serialNumber: String? = null,
@SerialName("firmwareVersion") val firmwareVersion: String? = null, @SerialName("firmwareVersion") val firmwareVersion: String? = null,
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
@SerialName("buildNumber") val buildNumber: String? = null,
@Serializable(with = InstantEpochMillisSerializer::class) @Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("lastSeenAt") val lastSeenAt: Instant, @SerialName("lastSeenAt") val lastSeenAt: Instant,
) { ) {
@@ -35,6 +38,9 @@ data class CachedDeviceState(
manufacturer = "", manufacturer = "",
serialNumber = serialNumber ?: "", serialNumber = serialNumber ?: "",
firmwareVersion = firmwareVersion ?: "", firmwareVersion = firmwareVersion ?: "",
leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial,
buildNumber = buildNumber,
) )
} }
@@ -48,6 +48,9 @@ fun PodDevice.toCachedState(
deviceName = liveDeviceInfo?.name ?: existing?.deviceName, deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber, serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
lastSeenAt = seenLastAt ?: now, lastSeenAt = seenLastAt ?: now,
) )
@@ -346,6 +346,14 @@ internal class AapConnection(
2 -> "manufacturer" 2 -> "manufacturer"
3 -> "serialNumber" 3 -> "serialNumber"
4 -> "firmwareVersion" 4 -> "firmwareVersion"
5 -> "firmwareVersionDup"
6 -> "protocolVersion"
7 -> "updaterAppId"
8 -> "leftEarbudSerial"
9 -> "rightEarbudSerial"
10 -> "buildNumber"
11 -> "encryptedBlob"
12 -> "timestamp"
else -> "unknown" else -> "unknown"
} }
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>" val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
@@ -390,6 +398,8 @@ internal class AapConnection(
// Try setting update (merge into existing state) // Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) -> profile.decodeSetting(message)?.let { (key, value) ->
val previous = _state.value.settings[key]
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions). // Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
// Skip debounce for: first ANC mode (initial setup), echoes after our own command. // Skip debounce for: first ANC mode (initial setup), echoes after our own command.
if (value is AapSetting.AncMode) { if (value is AapSetting.AncMode) {
@@ -398,7 +408,7 @@ internal class AapConnection(
if (isFirstAncMode || sinceLastCommand <= 3000L) { if (isFirstAncMode || sinceLastCommand <= 3000L) {
ancDebounceJob?.cancel() ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting: ${key.simpleName} = $value" } log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
// After our command, firmware may cycle through modes before settling. // After our command, firmware may cycle through modes before settling.
// Schedule a verification: if settled mode != commanded mode, re-send once. // Schedule a verification: if settled mode != commanded mode, re-send once.
@@ -421,7 +431,7 @@ internal class AapConnection(
ancDebounceJob = connectionScope?.launch { ancDebounceJob = connectionScope?.launch {
delay(1500L) delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" } log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
} }
} }
return return
@@ -438,7 +448,7 @@ internal class AapConnection(
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class) newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
} }
_state.value = newState _state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" } log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
// Flush queued ANC command when a pod goes in ear // Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) { if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
@@ -463,6 +473,59 @@ internal class AapConnection(
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none" val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
if (message.commandType == 0x0009 && message.payload.size >= 2) {
val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF
val boolHint = appleBoolHint(value)
val tailHex = if (message.payload.size > 2) {
message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
log(TAG, INFO) {
buildString {
append("Unhandled setting id=0x${"%02X".format(settingId)} ")
append("value=0x${"%02X".format(value)}")
boolHint?.let { append(" appleBool=$it") }
append(" payload=${message.payload.size}B")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
if (message.commandType == 0x000C && message.payload.size >= 6) {
val macRaw = formatMac(message.payload, reverse = false)
val macReversed = formatMac(message.payload, reverse = true)
val tailHex = if (message.payload.size > 6) {
message.payload.copyOfRange(6, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
buildString {
append("Known cmd=0x000C")
append(" payload=${message.payload.size}B")
append(" macRaw=$macRaw macReversed=$macReversed")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
// Known non-settings commands: log at VERBOSE, refresh lastMessageAt
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
}
return
}
log(TAG, INFO) { log(TAG, INFO) {
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend" "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
} }
@@ -479,6 +542,29 @@ internal class AapConnection(
companion object { companion object {
private val TAG = logTag("AapConnection") private val TAG = logTag("AapConnection")
// Non-settings commands observed in real sessions. Logged at VERBOSE instead of INFO
// to reduce noise, but still fully logged with payload hex for debug log analysis.
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
0x0000, // Handshake acknowledgment
0x0002, // Capability/feature table (H2+ only)
0x0017, // HID/service descriptors
0x002B, // Session metadata / event history
0x004E, // Unknown (all-zero payload)
0x0055, // Audio/session state
0x0057, // Connection lifecycle
)
private fun appleBoolHint(wireValue: Int): Boolean? = when (wireValue) {
0x01 -> true
0x02 -> false
else -> null
}
private fun formatMac(bytes: ByteArray, reverse: Boolean): String {
val indices = if (reverse) (5 downTo 0) else (0..5)
return indices.joinToString(":") { "%02X".format(bytes[it]) }
}
/** /**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the * 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 * issue #173 engraving discovery logging. Not part of the production decode path — the
@@ -9,4 +9,7 @@ data class AapDeviceInfo(
val manufacturer: String, val manufacturer: String,
val serialNumber: String, val serialNumber: String,
val firmwareVersion: String, val firmwareVersion: String,
val leftEarbudSerial: String? = null,
val rightEarbudSerial: String? = null,
val buildNumber: String? = null,
) )
@@ -186,4 +186,17 @@ sealed class AapSetting {
) : AapSetting() { ) : AapSetting() {
enum class Pod { LEFT, RIGHT } enum class Pod { LEFT, RIGHT }
} }
/**
* Catch-all for setting IDs whose semantics are unconfirmed.
* Decoded to keep lastMessageAt fresh and log via the normal "Setting:" path.
* Not exposed in UI. When a setting's purpose is confirmed, promote it to
* its own named subclass.
*/
data class UnknownSetting(
val settingId: Int,
val rawValue: Int,
) : AapSetting() {
override fun toString(): String = "UnknownSetting(settingId=0x%02X, rawValue=0x%02X)".format(settingId, rawValue)
}
} }
@@ -50,6 +50,12 @@ class DefaultAapDeviceProfile(
const val SETTING_SLEEP_DETECTION = 0x35 const val SETTING_SLEEP_DETECTION = 0x35
const val SETTING_STEM_CONFIG = 0x39 const val SETTING_STEM_CONFIG = 0x39
// Known-but-unconfirmed setting IDs (H2+ exclusive). Decoded as UnknownSetting
// to keep lastMessageAt fresh. Observed on Pro 2 USB-C and/or Pro 3.
val UNCONFIRMED_SETTING_IDS = setOf(
0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E,
)
// Command types for non-settings messages // Command types for non-settings messages
const val CMD_RENAME = 0x001E const val CMD_RENAME = 0x001E
const val CMD_STEM_PRESS = 0x0019 const val CMD_STEM_PRESS = 0x0019
@@ -269,6 +275,12 @@ class DefaultAapDeviceProfile(
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
} }
in UNCONFIRMED_SETTING_IDS -> {
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
settingId = settingId,
rawValue = value,
)
}
else -> null else -> null
} }
} }
@@ -362,6 +374,12 @@ class DefaultAapDeviceProfile(
manufacturer = strings.getOrElse(2) { "" }, manufacturer = strings.getOrElse(2) { "" },
serialNumber = strings.getOrElse(3) { "" }, serialNumber = strings.getOrElse(3) { "" },
firmwareVersion = strings.getOrElse(4) { "" }, firmwareVersion = strings.getOrElse(4) { "" },
// Segments [5]=firmware dup, [6]=protocol version, [7]=updater app ID — skipped
// Segments [8] and [9] are individual earbud serials (observed on Pro 1, Pro 2, Pro 3)
leftEarbudSerial = strings.getOrNull(8)?.takeIf { it.isNotBlank() },
rightEarbudSerial = strings.getOrNull(9)?.takeIf { it.isNotBlank() },
// Segment [10] is the build number (e.g. "8454624")
buildNumber = strings.getOrNull(10)?.takeIf { it.isNotBlank() },
) )
} }
@@ -493,20 +511,29 @@ class DefaultAapDeviceProfile(
} }
private fun parseNullTerminatedStrings(data: ByteArray): List<String> { private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
val strings = mutableListOf<String>() // Skip binary header until the first printable byte (device name always starts
var start = 0 // with a printable character). This skips the length/type prefix bytes.
var headerEnd = 0
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes. while (headerEnd < data.size) {
// Header bytes and non-ASCII bytes are skipped. val b = data[headerEnd].toInt() and 0xFF
var i = 0 if (b in 0x20..0x7E) break
while (i < data.size) { headerEnd++
val b = data[i].toInt() and 0xFF
if (b in 0x20..0x7E) {
start = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, start, i - start, Charsets.US_ASCII))
} }
i++ if (headerEnd >= data.size) return emptyList()
// Split on NUL bytes and decode each segment as UTF-8.
// This handles device names with non-ASCII characters (e.g. curly quotes
// in "Matthias\u2019s AirPods Pro") that the old ASCII-only scanner would break on.
val strings = mutableListOf<String>()
var i = headerEnd
while (i < data.size) {
// Skip NUL separators
while (i < data.size && data[i] == 0x00.toByte()) i++
if (i >= data.size) break
val segStart = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, segStart, i - segStart, Charsets.UTF_8))
} }
return strings return strings
} }
+16 -3
View File
@@ -10,6 +10,7 @@
<string name="general_donate_action">Donate</string> <string name="general_donate_action">Donate</string>
<string name="general_check_action">Check</string> <string name="general_check_action">Check</string>
<string name="general_close_action">Close</string> <string name="general_close_action">Close</string>
<string name="general_edit_action">Edit</string>
<string name="general_save_action">Save</string> <string name="general_save_action">Save</string>
<string name="general_guide_action">Guide</string> <string name="general_guide_action">Guide</string>
<string name="general_continue_action">Continue</string> <string name="general_continue_action">Continue</string>
@@ -438,9 +439,15 @@
<!-- Device Settings --> <!-- Device Settings -->
<string name="device_settings_title">Device Settings</string> <string name="device_settings_title">Device Settings</string>
<string name="device_settings_subtitle_profile_prefix">Profile: %s</string>
<string name="device_settings_info_name_label">Name</string> <string name="device_settings_info_name_label">Name</string>
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
<string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_serial_label">Serial Number</string> <string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string> <string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_build_label">Build</string>
<string name="device_settings_info_left_serial_label">Left Serial</string>
<string name="device_settings_info_right_serial_label">Right Serial</string>
<string name="device_settings_info_status_label">Status</string> <string name="device_settings_info_status_label">Status</string>
<string name="device_settings_info_last_seen_label">Last Seen</string> <string name="device_settings_info_last_seen_label">Last Seen</string>
<string name="device_settings_info_first_seen_label">First Seen</string> <string name="device_settings_info_first_seen_label">First Seen</string>
@@ -495,13 +502,18 @@
<string name="device_settings_microphone_mode_auto">Auto</string> <string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string> <string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string> <string name="device_settings_microphone_mode_left">Left</string>
<string name="device_settings_listening_mode_cycle_label">Noise Control Cycle</string> <string name="device_settings_noise_control_current_mode_label">Current mode</string>
<string name="device_settings_listening_mode_cycle_description">Select which modes cycle when pressing and holding the stem</string> <string name="device_settings_listening_mode_cycle_label">Long-press cycle</string>
<string name="device_settings_listening_mode_cycle_description">Choose which modes are available when holding the stem and in the quick switcher.</string>
<string name="device_settings_listening_mode_cycle_dialog_title">Customize Long-Press Cycle</string>
<string name="device_settings_listening_mode_cycle_summary_helper">Used when holding the stem and in the quick switcher</string>
<string name="device_settings_listening_mode_cycle_summary_helper_override">Used in the quick switcher. A custom stem long-press action overrides AirPods cycling.</string>
<string name="device_settings_listening_mode_cycle_minimum">At least 2 modes must stay enabled.</string>
<string name="device_settings_listening_mode_cycle_off">Off</string> <string name="device_settings_listening_mode_cycle_off">Off</string>
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string> <string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string>
<string name="device_settings_listening_mode_cycle_transparency">Transparency</string> <string name="device_settings_listening_mode_cycle_transparency">Transparency</string>
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string> <string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string>
<string name="device_settings_allow_off_label">Include Off in Noise Control</string> <string name="device_settings_allow_off_label">Include Off</string>
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string> <string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string> <string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string> <string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
@@ -542,5 +554,6 @@
<string name="stem_action_volume_down">Volume Down</string> <string name="stem_action_volume_down">Volume Down</string>
<string name="stem_actions_reset_label">Reset to defaults</string> <string name="stem_actions_reset_label">Reset to defaults</string>
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string> <string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
<string name="device_settings_noise_control_open_stem_actions_action">Open Stem Actions</string>
</resources> </resources>
@@ -572,4 +572,32 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() } @Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
@Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() } @Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() }
@Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() } @Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() }
// ── DeviceInfo UTF-8 ────────────────────────────────────
@Test
fun `decodeDeviceInfo handles curly quote in device name`() {
// "Matthias\u2019s AirPods Pro" — curly right single quote is UTF-8 E2 80 99.
// The old ASCII-only parser would split at these bytes, breaking all segment indices.
// Header bytes must all be < 0x20 to avoid being mistaken for string content.
val msg = aapMessage(
"04 00 04 00 1D 00 02 0F 00 04 00",
// [0] name: "Matthias\u2019s AirPods Pro" (UTF-8)
"4D 61 74 74 68 69 61 73 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F 00",
// [1] model
"41 33 30 34 38 00",
// [2] manufacturer
"41 70 70 6C 65 20 49 6E 63 2E 00",
// [3] serial
"57 35 4A 37 4B 56 30 4E 30 34 00",
// [4] firmware
"37 41 33 30 35 00",
)
val info = profile.decodeDeviceInfo(msg)!!
info.name shouldBe "Matthias\u2019s AirPods Pro"
info.modelNumber shouldBe "A3048"
info.manufacturer shouldBe "Apple Inc."
info.serialNumber shouldBe "W5J7KV0N04"
info.firmwareVersion shouldBe "7A305"
}
} }
@@ -56,6 +56,9 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro" info.name shouldBe "AirPods Pro"
info.modelNumber shouldBe "A3048" info.modelNumber shouldBe "A3048"
info.manufacturer shouldBe "Apple Inc." info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "H3KL7HR926JY"
info.rightEarbudSerial shouldBe "H3KL2AYL26K0"
info.buildNumber shouldBe "8454480"
} }
// ── Battery ────────────────────────────────────────────── // ── Battery ──────────────────────────────────────────────
@@ -242,11 +245,18 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
} }
@Test @Test
fun `unknown settings IDs return null`() { fun `unconfirmed settings IDs decode as UnknownSetting`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33) val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33)
for (id in unknownIds) { for (id in unconfirmedIds) {
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull() val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x02))
setting.settingId shouldBe id
setting.rawValue shouldBe 0x02
} }
} }
@Test
fun `truly unknown setting ID returns null`() {
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
}
} }
} }
@@ -56,6 +56,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro 3" info.name shouldBe "AirPods Pro 3"
info.modelNumber shouldBe "A3064" info.modelNumber shouldBe "A3064"
info.manufacturer shouldBe "Apple Inc." info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ"
info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY"
info.buildNumber shouldBe "8454624"
} }
// ── Battery ────────────────────────────────────────────── // ── Battery ──────────────────────────────────────────────
@@ -236,11 +239,18 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
} }
@Test @Test
fun `unknown settings IDs return null`() { fun `unconfirmed settings IDs decode as UnknownSetting`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B) val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
for (id in unknownIds) { for (id in unconfirmedIds) {
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull() val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x01))
setting.settingId shouldBe id
setting.rawValue shouldBe 0x01
} }
} }
@Test
fun `truly unknown setting ID returns null`() {
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
}
} }
} }
@@ -56,6 +56,9 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro" info.name shouldBe "AirPods Pro"
info.modelNumber shouldBe "A2084" info.modelNumber shouldBe "A2084"
info.manufacturer shouldBe "Apple Inc." info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GXDDRFNW0C6K"
info.rightEarbudSerial shouldBe "H6RHL0HF0C6J"
info.buildNumber shouldBe "3344646"
} }
// ── Battery ────────────────────────────────────────────── // ── Battery ──────────────────────────────────────────────