diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt index 440c065a..f8551550 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt @@ -21,16 +21,28 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper 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 +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 internal fun DeviceInfoCard( deviceInfo: AapDeviceInfo?, + modelLabel: String?, + systemBluetoothName: String?, connectionStateLabel: String?, lastSeen: String?, firstSeen: String?, @@ -57,17 +69,25 @@ internal fun DeviceInfoCard( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.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.name.isNotBlank()) { + val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { InfoRow( - label = stringResource(R.string.device_settings_info_name_label), + label = stringResource(R.string.device_settings_info_bt_name_label), value = deviceInfo.name, modifier = Modifier.weight(1f), + valueFontFamily = if (nameMismatch) FontFamily.Cursive else null, ) if (canRename) { IconButton(onClick = { showRenameDialog = true }) { @@ -92,6 +112,31 @@ internal fun DeviceInfoCard( 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) { InfoRow( @@ -99,24 +144,38 @@ internal fun DeviceInfoCard( value = connectionStateLabel, ) } - if (lastSeen != null) { + if (lastSeen != null && firstSeen != null) { + Row(modifier = Modifier.fillMaxWidth()) { + InfoRow( + label = stringResource(R.string.device_settings_info_last_seen_label), + value = lastSeen, + modifier = Modifier.weight(1f), + ) + InfoRow( + label = stringResource(R.string.device_settings_info_first_seen_label), + 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, ) } - if (firstSeen != null) { - InfoRow( - label = stringResource(R.string.device_settings_info_first_seen_label), - value = firstSeen, - ) - } } } } @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( modifier = modifier.padding(vertical = 2.dp), ) { @@ -124,10 +183,15 @@ private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) text = label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), ) Text( text = value, style = MaterialTheme.typography.bodyMedium, + textAlign = textAlign, + fontFamily = valueFontFamily, + modifier = Modifier.fillMaxWidth(), ) } } @@ -142,7 +206,12 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper { manufacturer = "Apple Inc.", serialNumber = "W5J7KV0N04", firmwareVersion = "7A305", + leftEarbudSerial = "H3KL7HR926JY", + rightEarbudSerial = "H3KL2AYL26K0", + buildNumber = "8454624", ), + modelLabel = "AirPods Pro 2 (A2699)", + systemBluetoothName = "AirPods Pro", connectionStateLabel = "Connected", lastSeen = "Just now", firstSeen = "5 minutes ago", @@ -152,7 +221,27 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper { @Composable @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( deviceInfo = AapDeviceInfo( name = "AirPods Pro", @@ -161,6 +250,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper { serialNumber = "W5J7KV0N04", firmwareVersion = "7A305", ), + modelLabel = "AirPods Pro 2 (A2699)", + systemBluetoothName = "AirPods Pro", connectionStateLabel = "Disconnected", lastSeen = "2 hours ago", firstSeen = null, @@ -173,6 +264,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper { private fun DeviceInfoCardSparsePreview() = PreviewWrapper { DeviceInfoCard( deviceInfo = null, + modelLabel = "AirPods Pro 2", + systemBluetoothName = null, connectionStateLabel = "Disconnected", lastSeen = "Just now", firstSeen = null, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt index e6c15682..2ebc96b2 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt @@ -23,6 +23,9 @@ data class CachedDeviceState( @SerialName("deviceName") val deviceName: String? = null, @SerialName("serialNumber") val serialNumber: 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) @SerialName("lastSeenAt") val lastSeenAt: Instant, ) { @@ -35,6 +38,9 @@ data class CachedDeviceState( manufacturer = "", serialNumber = serialNumber ?: "", firmwareVersion = firmwareVersion ?: "", + leftEarbudSerial = leftEarbudSerial, + rightEarbudSerial = rightEarbudSerial, + buildNumber = buildNumber, ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt index 73040e9a..741bf62c 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -48,6 +48,9 @@ fun PodDevice.toCachedState( deviceName = liveDeviceInfo?.name ?: existing?.deviceName, serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber, firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, + leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial, + rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial, + buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber, lastSeenAt = seenLastAt ?: now, ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 9caaa34b..f55dd07e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -346,6 +346,14 @@ internal class AapConnection( 2 -> "manufacturer" 3 -> "serialNumber" 4 -> "firmwareVersion" + 5 -> "firmwareVersionDup" + 6 -> "protocolVersion" + 7 -> "updaterAppId" + 8 -> "leftEarbudSerial" + 9 -> "rightEarbudSerial" + 10 -> "buildNumber" + 11 -> "encryptedBlob" + 12 -> "timestamp" else -> "unknown" } val rendered = seg.utf8?.let { "\"$it\"" } ?: "" @@ -390,6 +398,8 @@ internal class AapConnection( // Try setting update (merge into existing state) profile.decodeSetting(message)?.let { (key, value) -> + val previous = _state.value.settings[key] + // 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. if (value is AapSetting.AncMode) { @@ -398,7 +408,7 @@ internal class AapConnection( if (isFirstAncMode || sinceLastCommand <= 3000L) { ancDebounceJob?.cancel() _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. // Schedule a verification: if settled mode != commanded mode, re-send once. @@ -421,7 +431,7 @@ internal class AapConnection( ancDebounceJob = connectionScope?.launch { delay(1500L) _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 @@ -438,7 +448,7 @@ internal class AapConnection( newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class) } _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 if (value is AapSetting.EarDetection && value.isEitherPodInEar) { @@ -463,6 +473,59 @@ internal class AapConnection( val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt 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) { "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 { 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 * issue #173 engraving discovery logging. Not part of the production decode path — the diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt index f9442e73..dd3fe02d 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt @@ -9,4 +9,7 @@ data class AapDeviceInfo( val manufacturer: String, val serialNumber: String, val firmwareVersion: String, + val leftEarbudSerial: String? = null, + val rightEarbudSerial: String? = null, + val buildNumber: String? = null, ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index d1c49160..4174e2ee 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -186,4 +186,17 @@ sealed class AapSetting { ) : AapSetting() { 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) + } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index ffef71ea..d89916ae 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -50,6 +50,12 @@ class DefaultAapDeviceProfile( const val SETTING_SLEEP_DETECTION = 0x35 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 const val CMD_RENAME = 0x001E const val CMD_STEM_PRESS = 0x0019 @@ -269,6 +275,12 @@ class DefaultAapDeviceProfile( val enabled = decodeAppleBool(value) ?: return null AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) } + in UNCONFIRMED_SETTING_IDS -> { + AapSetting.UnknownSetting::class to AapSetting.UnknownSetting( + settingId = settingId, + rawValue = value, + ) + } else -> null } } @@ -362,6 +374,12 @@ class DefaultAapDeviceProfile( manufacturer = strings.getOrElse(2) { "" }, serialNumber = strings.getOrElse(3) { "" }, 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 { - val strings = mutableListOf() - var start = 0 + // Skip binary header until the first printable byte (device name always starts + // with a printable character). This skips the length/type prefix bytes. + var headerEnd = 0 + while (headerEnd < data.size) { + val b = data[headerEnd].toInt() and 0xFF + if (b in 0x20..0x7E) break + headerEnd++ + } + if (headerEnd >= data.size) return emptyList() - // Find runs of printable ASCII (0x20..0x7E) separated by null bytes. - // Header bytes and non-ASCII bytes are skipped. - var i = 0 + // 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() + var i = headerEnd while (i < data.size) { - 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++ + // 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 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 650d68e7..6f7db5cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Donate Check Close + Edit Save Guide Continue @@ -438,9 +439,15 @@ Device Settings + Profile: %s Name + Bluetooth Device Label + Model Serial Number Firmware + Build + Left Serial + Right Serial Status Last Seen First Seen @@ -495,13 +502,18 @@ Auto Right Left - Noise Control Cycle - Select which modes cycle when pressing and holding the stem + Current mode + Long-press cycle + Choose which modes are available when holding the stem and in the quick switcher. + Customize Long-Press Cycle + Used when holding the stem and in the quick switcher + Used in the quick switcher. A custom stem long-press action overrides AirPods cycling. + At least 2 modes must stay enabled. Off Noise Cancellation Transparency Adaptive - Include Off in Noise Control + Include Off Show Off as an option when cycling noise control modes Sleep Detection Automatically pause audio when you fall asleep @@ -542,5 +554,6 @@ Volume Down Reset to defaults Reset all stem actions to defaults? + Open Stem Actions diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index 3e261300..8198469d 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -572,4 +572,32 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @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 `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" + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt index 6ac50d32..28ffbdfa 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt @@ -56,6 +56,9 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { info.name shouldBe "AirPods Pro" info.modelNumber shouldBe "A3048" info.manufacturer shouldBe "Apple Inc." + info.leftEarbudSerial shouldBe "H3KL7HR926JY" + info.rightEarbudSerial shouldBe "H3KL2AYL26K0" + info.buildNumber shouldBe "8454480" } // ── Battery ────────────────────────────────────────────── @@ -242,11 +245,18 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { } @Test - fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33) - for (id in unknownIds) { - profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull() + fun `unconfirmed settings IDs decode as UnknownSetting`() { + val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33) + for (id in unconfirmedIds) { + val setting = decodeSetting(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() + } } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 5534fd3e..73404eed 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -56,6 +56,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { info.name shouldBe "AirPods Pro 3" info.modelNumber shouldBe "A3064" info.manufacturer shouldBe "Apple Inc." + info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ" + info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY" + info.buildNumber shouldBe "8454624" } // ── Battery ────────────────────────────────────────────── @@ -236,11 +239,18 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { } @Test - fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B) - for (id in unknownIds) { - profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull() + fun `unconfirmed settings IDs decode as UnknownSetting`() { + val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B) + for (id in unconfirmedIds) { + val setting = decodeSetting(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() + } } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt index b76ba02c..98316467 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt @@ -56,6 +56,9 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() { info.name shouldBe "AirPods Pro" info.modelNumber shouldBe "A2084" info.manufacturer shouldBe "Apple Inc." + info.leftEarbudSerial shouldBe "GXDDRFNW0C6K" + info.rightEarbudSerial shouldBe "H6RHL0HF0C6J" + info.buildNumber shouldBe "3344646" } // ── Battery ──────────────────────────────────────────────