diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index abd09540..5228d682 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -258,7 +258,7 @@ fun DeviceSettingsScreen( ) } val hasFirmware = info.firmwareVersion.isNotBlank() - val hasBuild = !info.buildNumber.isNullOrBlank() + val hasBuild = !info.marketingVersion.isNullOrBlank() if (hasFirmware && hasBuild) { add( DeviceDetailItem.Paired( @@ -268,7 +268,7 @@ fun DeviceSettingsScreen( ), end = DeviceDetailItem.Single( stringResource(R.string.device_settings_info_build_label), - info.buildNumber!! + info.marketingVersion!! ), ) ) @@ -283,7 +283,7 @@ fun DeviceSettingsScreen( add( DeviceDetailItem.Single( stringResource(R.string.device_settings_info_build_label), - info.buildNumber!! + info.marketingVersion!! ) ) } @@ -464,13 +464,21 @@ fun DeviceSettingsScreen( } } - // EQ visualization (debug only) - val eqBands = device.eqBands - if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) { + // PME Config visualization (debug only, opcode 0x53). + // The Wireshark dissector calls this "PME Config"; captures so far are + // all-zero on in-production firmware, so the bar chart stays hidden in + // that case — showing empty bars would imply the device has a flat EQ, + // which we don't actually know. + val pmeConfig = device.pmeConfig + if (eu.darken.capod.BuildConfig.DEBUG && + pmeConfig != null && + pmeConfig.sets.isNotEmpty() && + !pmeConfig.isAllZero + ) { item("eq_section") { SettingsSection(title = stringResource(R.string.device_settings_eq_label)) { EqBarsChart( - sets = eqBands.sets, + sets = pmeConfig.sets, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } 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 7d9c2a24..92d17c2e 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 @@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper { firmwareVersion = "7A305", leftEarbudSerial = "H3KL7HR926JY", rightEarbudSerial = "H3KL2AYL26K0", - buildNumber = "8454624", + marketingVersion = "8454624", ), modelLabel = "AirPods Pro 2 (A2699)", systemBluetoothName = "AirPods Pro", diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt index 53b23900..8505357d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt @@ -100,6 +100,13 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") }, ) } + item { + SettingsBaseItem( + title = "apple-wireshark", + subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.", + onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") }, + ) + } item { SettingsBaseItem( title = "crowdin.com", @@ -110,13 +117,6 @@ fun AcknowledgementsScreen( item { SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label)) } - item { - SettingsBaseItem( - title = "Glide", - subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)", - onClick = { onOpenUrl("https://github.com/bumptech/glide") }, - ) - } item { SettingsBaseItem( title = "Material Design Icons", @@ -145,6 +145,13 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") }, ) } + item { + SettingsBaseItem( + title = "apple-wireshark", + subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)", + onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") }, + ) + } item { SettingsBaseItem( title = "Kotlin", @@ -166,13 +173,6 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://source.android.com/source/licenses.html") }, ) } - item { - SettingsBaseItem( - title = "Android", - subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.", - onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") }, - ) - } } } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index c3bd0a35..bbd05fc7 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -343,7 +343,7 @@ data class PodDevice( val audioSource: AapSetting.AudioSource? get() = aap?.setting() - val eqBands: AapSetting.EqBands? + val pmeConfig: AapSetting.PmeConfig? get() = aap?.setting() val deviceInfo: AapDeviceInfo? 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 2ebc96b2..2c0d8e3e 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 @@ -25,7 +25,12 @@ data class CachedDeviceState( @SerialName("firmwareVersion") val firmwareVersion: String? = null, @SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null, @SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null, - @SerialName("buildNumber") val buildNumber: String? = null, + /** + * Formerly known as `buildNumber` — the Wireshark AAP dissector calls this + * "Marketing Version". The `@SerialName("buildNumber")` preserves the wire + * key so cache entries from older CAPod versions keep deserializing. + */ + @SerialName("buildNumber") val marketingVersion: String? = null, @Serializable(with = InstantEpochMillisSerializer::class) @SerialName("lastSeenAt") val lastSeenAt: Instant, ) { @@ -40,7 +45,7 @@ data class CachedDeviceState( firmwareVersion = firmwareVersion ?: "", leftEarbudSerial = leftEarbudSerial, rightEarbudSerial = rightEarbudSerial, - buildNumber = buildNumber, + marketingVersion = marketingVersion, ) } 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 741bf62c..6e58f3c7 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 @@ -50,7 +50,7 @@ fun PodDevice.toCachedState( firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial, rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial, - buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber, + marketingVersion = liveDeviceInfo?.marketingVersion ?: existing?.marketingVersion, lastSeenAt = seenLastAt ?: now, ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index b6c52217..8aef76a0 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -1,5 +1,6 @@ package eu.darken.capod.pods.core.apple.aap +import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import java.time.Instant @@ -16,6 +17,16 @@ data class AapPodState( val lastMessageAt: Instant? = null, val pendingAncMode: AapSetting.AncMode.Value? = null, val pendingSettingsCount: Int = 0, + /** + * 64-bit features bitmask from the Connect Response (packet type 0x0001). + * Opaque — we don't yet know the bit-to-feature mapping. Logged for future + * correlation work. Null until a Connect Response is seen. + */ + val negotiatedFeatures: ULong? = null, + /** Status field from the Connect Response. 0 = success. Null before Connect Response. */ + val connectResponseStatus: Int? = null, + /** Parsed Case Info (message type 0x23), if the device responded to a 0x22 probe. */ + val caseInfo: AapCaseInfo? = null, ) { inline fun setting(): T? = settings[T::class] as? T diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index 37505004..ace5f5be 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -12,7 +12,7 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer -import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage +import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CoroutineScope @@ -143,11 +143,36 @@ internal class AapConnection( break } - // L2CAP SEQPACKET: each read() returns exactly one complete message + // L2CAP SEQPACKET: each read() returns exactly one complete frame val raw = buf.copyOfRange(0, len) - val message = AapMessage.parse(raw) - if (message != null) { - engine.processMessage(message) + val packet = AapPacket.parse(raw) ?: continue + + when (packet) { + is AapPacket.Message -> engine.processMessage(packet) + is AapPacket.ConnectResponse -> { + engine.processConnectResponse(packet) + if (packet.status == 0) dispatchCaseInfoProbe() + } + is AapPacket.Disconnect -> { + log(TAG, Logging.Priority.INFO) { + "Disconnect received: service=0x${"%04X".format(packet.service)} status=0x${"%04X".format(packet.status)}" + } + } + is AapPacket.DisconnectResponse -> { + log(TAG) { "DisconnectResponse received: service=0x${"%04X".format(packet.service)}" } + } + is AapPacket.Connect -> { + // Unexpected — we're the source, not the peer. Log and ignore. + log(TAG, Logging.Priority.WARN) { + "Unexpected Connect packet from peer: service=0x${"%04X".format(packet.service)}" + } + } + is AapPacket.Unknown -> { + val hex = packet.raw.joinToString(" ") { "%02X".format(it) } + log(TAG, Logging.Priority.INFO) { + "Unknown AAP packet type=0x${"%04X".format(packet.packetType)} len=${packet.raw.size} raw=$hex" + } + } } } } catch (e: IOException) { @@ -158,6 +183,31 @@ internal class AapConnection( } } + /** + * Fire-and-forget Case Info request (message type 0x22). Sent after a + * successful Connect Response. Not an AapCommand — bypasses the outbound + * queue, ear-gating, and pending-settings counter. If the device doesn't + * reply, nothing happens; the next connect attempt re-probes. + */ + private suspend fun dispatchCaseInfoProbe() { + val bytes = profile.encodeCaseInfoRequest() ?: return + try { + writeMutex.withLock { + withContext(Dispatchers.IO) { + val sock = socket ?: return@withContext + sock.outputStream.write(bytes) + sock.outputStream.flush() + val hex = bytes.joinToString(" ") { "%02X".format(it) } + log(TAG, Logging.Priority.VERBOSE) { "SEND CaseInfoProbe len=${bytes.size} raw=$hex" } + } + } + } catch (e: Exception) { + // Non-critical: the probe is fire-and-forget; a send failure just means + // we don't get Case Info this session. No retry, no state change. + log(TAG, Logging.Priority.WARN) { "CaseInfoProbe send failed: $e" } + } + } + private fun cleanupSocket() { try { socket?.close() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt index dda1286c..2522ac59 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt @@ -4,47 +4,88 @@ import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction /** - * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for - * issue #173 engraving discovery logging. + * Diagnostic-only segmentation of a 0x1D INFORMATION payload, used for issue #173 + * engraving discovery logging. + * + * Schema matches the Wireshark AAP dissector's INFORMATION message: + * - Segments 0-10 are NUL-delimited UTF-8 strings. + * - Segments 11 and 12 are fixed 17-byte UUIDs (may contain 0x00 internally — + * splitting on NUL here would corrupt the offsets for every later segment). + * - Segments 13 and 14 are NUL-delimited ASCII-decimal timestamps. + * + * Payloads that truncate early just produce fewer segments — the helper stays + * best-effort and never throws. */ internal object AapDeviceInfoDiagnostics { + private const val UUID_LEN = 17 + private const val LAST_STRING_SEGMENT = 10 // after this the UUID blob starts + fun describeSegments(payload: ByteArray): List { - 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() + var offset = skipBinaryHeader(payload) + if (offset >= payload.size) return emptyList() val segments = mutableListOf() 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 += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex) + // Segments 0..10 — NUL-delimited UTF-8 strings + while (segIndex <= LAST_STRING_SEGMENT && offset < payload.size) { + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + if (offset >= payload.size) break + val segStart = offset + while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++ + segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset)) segIndex++ } + + // Skip the NUL terminator of segment 10 before the UUID blob. + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + + // Segments 11 and 12 — fixed 17-byte UUIDs, read verbatim. + for (targetIdx in 11..12) { + if (offset + UUID_LEN > payload.size) break + val segBytes = payload.copyOfRange(offset, offset + UUID_LEN) + segments += buildSegment(targetIdx, offset, segBytes) + offset += UUID_LEN + segIndex = targetIdx + 1 + } + + // Segments 13+ — NUL-delimited timestamps / any trailing strings. + while (offset < payload.size) { + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + if (offset >= payload.size) break + val segStart = offset + while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++ + segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset)) + segIndex++ + } + return segments } + + private fun skipBinaryHeader(payload: ByteArray): Int { + var i = 0 + while (i < payload.size) { + val b = payload[i].toInt() and 0xFF + if (b in 0x20..0x7E) return i + i++ + } + return payload.size + } + + private fun buildSegment(index: Int, offset: Int, bytes: ByteArray): DeviceInfoSegment { + val utf8: String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } catch (_: CharacterCodingException) { + null + } + val hex = bytes.joinToString("") { "%02X".format(it) } + return DeviceInfoSegment(index, offset, bytes.size, utf8, hex) + } } internal data class DeviceInfoSegment( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt index a8dd11aa..76a55666 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt @@ -1,10 +1,13 @@ package eu.darken.capod.pods.core.apple.aap.engine import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile +import eu.darken.capod.pods.core.apple.aap.protocol.AapDynamicEndOfChargeEvent import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlin.reflect.KClass @@ -14,6 +17,9 @@ internal sealed interface AapInboundUpdate { data class Battery(val batteries: Map) : AapInboundUpdate data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate + data class CaseInfo(val info: AapCaseInfo) : AapInboundUpdate + data class SleepEvent(val event: AapSleepEvent) : AapInboundUpdate + data class DynamicEndOfChargeEvent(val event: AapDynamicEndOfChargeEvent) : AapInboundUpdate data class Setting(val key: KClass, val value: AapSetting) : AapInboundUpdate } @@ -25,6 +31,11 @@ internal class AapInboundInterpreter( profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) } profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) } profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) } + profile.decodeCaseInfo(message)?.let { return AapInboundUpdate.CaseInfo(it) } + profile.decodeSleepEvent(message)?.let { return AapInboundUpdate.SleepEvent(it) } + profile.decodeDynamicEndOfChargeEvent(message)?.let { + return AapInboundUpdate.DynamicEndOfChargeEvent(it) + } profile.decodeSetting(message)?.let { (key, value) -> return AapInboundUpdate.Setting(key, value) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index 6775b88b..9c67a2f2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -10,6 +10,8 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage +import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType +import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent @@ -88,6 +90,21 @@ internal class AapSessionEngine( dispatch(AapEngineEvent.MessageReceived(message)) } + /** + * Handle a Connect Response (packet type 0x0001) from the peer. Stores + * the 64-bit features bitmask in [AapPodState.negotiatedFeatures] for + * future correlation work, but does NOT drive state transitions itself — + * the first subsequent Message packet triggers HANDSHAKING → READY via + * the existing logic. + * + * On non-zero status we log an error and skip the features update; the + * engine stays in HANDSHAKING (no probes should fire until the session + * succeeds). + */ + fun processConnectResponse(packet: AapPacket.ConnectResponse) { + dispatch(AapEngineEvent.ConnectResponseReceived(packet)) + } + private fun dispatch(event: AapEngineEvent) { when (event) { is AapEngineEvent.SessionStarted -> { @@ -112,13 +129,36 @@ internal class AapSessionEngine( } is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message) + is AapEngineEvent.ConnectResponseReceived -> handleConnectResponse(event.packet) is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update) is AapEngineEvent.TimerFired -> handleTimerFired(event.key) } } + private fun handleConnectResponse(packet: AapPacket.ConnectResponse) { + if (packet.status != 0) { + log(TAG, ERROR) { + "ConnectResponse failed: status=0x${"%04X".format(packet.status)} " + + "major=${packet.major} minor=${packet.minor} " + + "features=0x${"%016X".format(packet.features.toLong())}" + } + _state.value = _state.value.copy(connectResponseStatus = packet.status) + return + } + + log(TAG, INFO) { + "ConnectResponse OK: major=${packet.major} minor=${packet.minor} " + + "features=0x${"%016X".format(packet.features.toLong())}" + } + _state.value = _state.value.copy( + negotiatedFeatures = packet.features, + connectResponseStatus = packet.status, + lastMessageAt = timeSource.now(), + ) + } + private fun handleMessageReceived(message: AapMessage) { - if (message.commandType != CMD_HID_DESCRIPTOR) { + if (message.commandType != AapMessageType.BUDDY_COMMAND.value) { val hex = message.raw.joinToString(" ") { "%02X".format(it) } log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" @@ -127,7 +167,7 @@ internal class AapSessionEngine( } if (!runtimeState.handshakeResponseReceived && - message.commandType != CMD_SETTING && + message.commandType != AapMessageType.CONTROL.value && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING ) { runtimeState = runtimeState.copy(handshakeResponseReceived = true) @@ -135,13 +175,13 @@ internal class AapSessionEngine( log(TAG) { "Connection READY" } } - if (message.commandType == CMD_HID_DESCRIPTOR) { + if (message.commandType == AapMessageType.BUDDY_COMMAND.value) { hidTracker.consume(message.payload) _state.value = _state.value.copy(lastMessageAt = timeSource.now()) return } - if (message.commandType == CMD_DEVICE_INFO) { + if (message.commandType == AapMessageType.INFORMATION.value) { logDeviceInfoDiagnostics(message.payload) } @@ -186,6 +226,24 @@ internal class AapSessionEngine( log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" } } + is AapInboundUpdate.CaseInfo -> { + _state.value = _state.value.copy(caseInfo = update.info, lastMessageAt = timeSource.now()) + val hex = update.info.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Case info: ${update.info.rawPayload.size}B payload=[$hex]" } + } + + is AapInboundUpdate.SleepEvent -> { + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" } + } + + is AapInboundUpdate.DynamicEndOfChargeEvent -> { + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Dynamic EoC event: ${update.event.rawPayload.size}B payload=[$hex]" } + } + is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value) } } @@ -411,20 +469,24 @@ internal class AapSessionEngine( "DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}" } segments.forEach { seg -> + // Labels per the Wireshark AAP dissector. The segmenter knows the schema — + // segments 11 and 12 are fixed 17-byte UUIDs. val label = when (seg.index) { 0 -> "name" 1 -> "modelNumber" 2 -> "manufacturer" 3 -> "serialNumber" 4 -> "firmwareVersion" - 5 -> "firmwareVersionDup" - 6 -> "protocolVersion" - 7 -> "updaterAppId" + 5 -> "firmwareVersionPending" + 6 -> "hardwareVersion" + 7 -> "eaProtocolName" 8 -> "leftEarbudSerial" 9 -> "rightEarbudSerial" - 10 -> "buildNumber" - 11 -> "encryptedBlob" - 12 -> "timestamp" + 10 -> "marketingVersion" + 11 -> "leftEarbudUuid (17 bytes fixed)" + 12 -> "rightEarbudUuid (17 bytes fixed)" + 13 -> "leftEarbudFirstPaired" + 14 -> "rightEarbudFirstPaired" else -> "unknown" } val rendered = seg.utf8?.let { "\"$it\"" } ?: "" @@ -438,7 +500,7 @@ internal class AapSessionEngine( val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } val sendInfo = currentSendDebugInfo() - if (message.commandType == CMD_SETTING && message.payload.size >= 2) { + if (message.commandType == AapMessageType.CONTROL.value && message.payload.size >= 2) { val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF val boolHint = value.appleBoolHint() @@ -461,7 +523,7 @@ internal class AapSessionEngine( return } - if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) { + if (message.commandType == AapMessageType.MAC_ADDRESS.value && message.payload.size >= 6) { val macRaw = message.payload.formatMac(reverse = false) val macReversed = message.payload.formatMac(reverse = true) val tailHex = if (message.payload.size > 6) { @@ -472,7 +534,7 @@ internal class AapSessionEngine( _state.value = _state.value.copy(lastMessageAt = timeSource.now()) log(TAG, VERBOSE) { buildString { - append("Known cmd=0x000C") + append("Known cmd=0x000C (${AapMessageType.MAC_ADDRESS.wiresharkName})") append(" payload=${message.payload.size}B") append(" macRaw=$macRaw macReversed=$macReversed") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") @@ -484,26 +546,56 @@ internal class AapSessionEngine( if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) { _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val namedType = AapMessageType.byValue(message.commandType) + val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: "" log(TAG, VERBOSE) { - "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" + "Known cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } return } + val namedType = AapMessageType.byValue(message.commandType) + val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: " (unknown)" log(TAG, INFO) { - "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" + "Unhandled cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } } companion object { private val TAG = logTag("AAP", "Engine") - private const val CMD_SETTING = 0x0009 - private const val CMD_CONNECTED_DEVICE = 0x000C - private const val CMD_DEVICE_INFO = 0x001D - private const val CMD_HID_DESCRIPTOR = 0x0017 - private val KNOWN_NON_SETTINGS_COMMANDS = setOf( - 0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057, + /** + * Opcodes we see on-wire but don't model as a domain update. Decoded only + * enough to refresh `lastMessageAt` (freshness feeds the AAP quality boost + * in PodDevice.computeAapBoost). Add newly-catalogued push-only opcodes here + * when they appear in captures so logs get a named label AND freshness stays + * intact. + */ + private val KNOWN_NON_SETTINGS_COMMANDS: Set = setOf( + 0x0000, // Connect (shouldn't reach here but historically observed) + AapMessageType.CAPABILITIES.value, // 0x0002 + AapMessageType.DEVICE_LIST.value, // 0x000B + AapMessageType.TRIANGLE_STATUS_REQUEST.value, // 0x0015 + AapMessageType.MAGNET_LINK.value, // 0x0016 + AapMessageType.TIMESTAMP.value, // 0x001B + AapMessageType.UNKNOWN_0X21.value, // 0x0021 + AapMessageType.CASE_INFO.value, // 0x0023 (handled via decoder later; still refresh) + AapMessageType.GYRO_INFO.value, // 0x0028 + AapMessageType.STREAM_STATE_INFO.value, // 0x002B + AapMessageType.GAPA_CHALLENGE.value, // 0x002C + AapMessageType.UNKNOWN_0X40.value, // 0x0040 + AapMessageType.ADAPTIVE_VOLUME_MESSAGE.value, // 0x004C + AapMessageType.SOURCE_FEATURE_CAPABILITIES.value, // 0x004D + AapMessageType.FEATURE_PROX_CARD_STATUS_UPDATE.value, // 0x004E + AapMessageType.UARP_DATA.value, // 0x004F + AapMessageType.UNKNOWN_0X50.value, // 0x0050 + AapMessageType.SOURCE_CONTEXT.value, // 0x0052 + AapMessageType.SET_BAND_EDGES.value, // 0x0054 (real EQ; distinct from 0x53 PmeConfig which CAPod decodes) + AapMessageType.UNKNOWN_0X55.value, // 0x0055 + AapMessageType.SLEEP_DETECTION_UPDATE.value, // 0x0057 + AapMessageType.UNKNOWN_0X58.value, // 0x0058 + AapMessageType.DYNAMIC_END_OF_CHARGE.value, // 0x0059 + AapMessageType.PERSONAL_TRANSLATION.value, // 0x0060 ) } } @@ -517,6 +609,7 @@ internal sealed interface AapEngineEvent { data object HandshakeSent : AapEngineEvent data object ResetRequested : AapEngineEvent data class MessageReceived(val message: AapMessage) : AapEngineEvent + data class ConnectResponseReceived(val packet: AapPacket.ConnectResponse) : AapEngineEvent data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent data class TimerFired(val key: EngineTimerKey) : AapEngineEvent } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt index 34711fb3..4944258e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt @@ -42,6 +42,12 @@ internal class HidTracker(private val log: (String) -> Unit) { log("HID: terminator (${type.payloadSize}B)") } + is HidFrameType.ServiceInfo -> { + flush() + val tokens = type.asciiTokens.joinToString(", ") + log("HID: service info tokens=[$tokens] (${type.payloadSize}B)") + } + is HidFrameType.Other -> { flush() val hex = payload.joinToString(" ") { "%02X".format(it) } @@ -70,10 +76,25 @@ internal class HidTracker(private val log: (String) -> Unit) { data class ServiceDirectory(val services: List) : HidFrameType() data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() data class Terminator(val payloadSize: Int) : HidFrameType() + + /** + * 0x0017 "service info" frames — a TLV-ish metadata dump that carries + * ASCII key/value pairs like `VendorID`, `SerialNumber`, `CFG`, + * `ReportDescriptor`, etc. + * + * We don't decode the TLV structure yet (the framing is not fully + * documented). Instead we extract all printable ASCII runs of length + * ≥ 3 so the log tells you which keys/values the frame contains. + */ + data class ServiceInfo(val asciiTokens: List, val payloadSize: Int) : HidFrameType() + data object Other : HidFrameType() } companion object { + /** Minimum length for an ASCII run to count as a token in a ServiceInfo frame. */ + private const val MIN_ASCII_TOKEN_LEN = 3 + internal fun classify(payload: ByteArray): HidFrameType { // Service directory frame — starts with FE 00 00 and contains repeated // [len=4? ascii service name + 4B flags] blocks. For logging, extract names. @@ -111,7 +132,40 @@ internal class HidTracker(private val log: (String) -> Unit) { return HidFrameType.Terminator(payload.size) } + // "Service info" frame — 4-byte magic 00 00 10 00 followed by a TLV-ish + // payload with ASCII keys and mixed binary values. Observed on AirPods Pro 2 + // USB-C after the descriptor batch. + if (payload.size >= 8 && + payload[0] == 0x00.toByte() && + payload[1] == 0x00.toByte() && + payload[2] == 0x10.toByte() && + payload[3] == 0x00.toByte() + ) { + return HidFrameType.ServiceInfo( + asciiTokens = extractAsciiTokens(payload), + payloadSize = payload.size, + ) + } + return HidFrameType.Other } + + /** Extract printable ASCII runs ≥ 3 chars. Skips all non-printable bytes. */ + private fun extractAsciiTokens(payload: ByteArray): List { + val tokens = mutableListOf() + var i = 0 + while (i < payload.size) { + val startByte = payload[i].toInt() and 0xFF + if (startByte in 0x20..0x7E) { + val start = i + while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++ + val run = String(payload, start, i - start, Charsets.US_ASCII) + if (run.length >= MIN_ASCII_TOKEN_LEN) tokens += run + } else { + i++ + } + } + return tokens + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt new file mode 100644 index 00000000..663bcad9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt @@ -0,0 +1,54 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Information about the case itself (message type 0x23, requested via 0x22). + * + * Fields defined by the Wireshark AAP dissector: + * - CaseInfoMessageVersion + * - CaseInfoVID / CaseInfoPID / CaseInfoVIDSource + * - CaseInfoColor + * - CaseInfoVersion (case firmware) + * - CaseInfoName + * + * The Wireshark dissector does not yet decode the per-field byte offsets + * reliably — the payload length and presence of each field varies across + * models. This class holds the raw bytes for future iteration once more + * captures are available, and a best-effort named-field view. + */ +data class AapCaseInfo( + /** The complete payload (after the 6-byte header) for future analysis. */ + val rawPayload: ByteArray, + val messageVersion: Int? = null, + val vid: Int? = null, + val pid: Int? = null, + val vidSource: Int? = null, + val color: Int? = null, + val version: String? = null, + val name: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapCaseInfo) return false + if (!rawPayload.contentEquals(other.rawPayload)) return false + if (messageVersion != other.messageVersion) return false + if (vid != other.vid) return false + if (pid != other.pid) return false + if (vidSource != other.vidSource) return false + if (color != other.color) return false + if (version != other.version) return false + if (name != other.name) return false + return true + } + + override fun hashCode(): Int { + var r = rawPayload.contentHashCode() + r = 31 * r + (messageVersion ?: 0) + r = 31 * r + (vid ?: 0) + r = 31 * r + (pid ?: 0) + r = 31 * r + (vidSource ?: 0) + r = 31 * r + (color ?: 0) + r = 31 * r + (version?.hashCode() ?: 0) + r = 31 * r + (name?.hashCode() ?: 0) + return r + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt new file mode 100644 index 00000000..1e68ad4f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt @@ -0,0 +1,134 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Catalog of AAP control/setting IDs — the 8-bit ID at payload byte 0 of a + * Control message (see [AapMessageType.CONTROL], opcode 0x0009). + * + * Sources: + * - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua + * - LibrePods / MagicPodsCore + * - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3) + * + * Where CAPod ships a setting under a different user-facing name than the + * Wireshark catalog uses, both names are preserved — the enum uses the + * Wireshark name, UI-facing strings use the CAPod name. + */ +enum class AapControlId(val value: Int, val wiresharkName: String) { + MIC_MODE(0x01, "Mic Mode"), + SCAN(0x02, "Scan"), + RESET(0x03, "Reset"), + BASIC_DOUBLE_TAP_MODE(0x04, "Basic Double Tap Mode"), + BUTTON_SEND_MODE(0x05, "Button Send Mode"), + OWNERSHIP_STATE(0x06, "Ownership state"), + TAP_INTERVAL(0x07, "Tap Interval"), + + /** Request the connected bud to go secondary. */ + BUD_ROLE(0x08, "Bud Role"), + + DEBUG_GET_DATA(0x09, "Debug Get Data"), + IN_EAR_DETECTION(0x0A, "In Ear Detection"), + + /** Aka "Dynamic Latency". */ + JITTER_BUFFER(0x0B, "Jitter Buffer"), + + DOUBLE_TAP_MODE(0x0C, "Double Tap Mode"), + LISTEN_MODE(0x0D, "Listen Mode"), + HEART_RATE_MONITOR_1(0x0E, "Heart Rate Monitor"), + HEART_RATE_MONITOR_2(0x0F, "Heart Rate Monitor"), + UNKNOWN_0X10(0x10, "Unknown/Unassigned"), + SWITCH_CONTROL(0x11, "Switch Control"), + VOICE_TRIGGER(0x12, "Voice Trigger"), + + /** "Dictation over AirPods" for Siri. */ + DOAP_MODE(0x13, "DoAP mode"), + + SINGLE_CLICK(0x14, "Single Click"), + DOUBLE_CLICK(0x15, "Double Click"), + CLICK_AND_HOLD(0x16, "Click and Hold"), + + /** CAPod ships this as "Press Speed". */ + DOUBLE_CLICK_INTERVAL(0x17, "Double Click Interval"), + + /** CAPod ships this as "Press Hold Duration". */ + CLICK_AND_HOLD_INTERVAL(0x18, "Click and Hold Interval"), + + UNKNOWN_0X19(0x19, "Unknown/Unassigned"), + LISTENING_MODE_CONFIGS(0x1A, "Listening Mode Configs"), + ONE_BUD_ANC_MODE(0x1B, "One Bud ANC Mode"), + CROWN_ROTATION_DIRECTION(0x1C, "Crown Rotation Direction"), + UNKNOWN_0X1D(0x1D, "Unknown/Unassigned"), + AUTO_ANSWER_MODE(0x1E, "Auto Answer Mode"), + + /** CAPod ships this as "Tone Volume". */ + CHIME_VOLUME(0x1F, "Chime Volume"), + + SMART_ROUTING_MODE(0x20, "Smart Routing Mode"), + UNKNOWN_0X21(0x21, "Unknown/Unassigned"), + HFP_UPLINK_MODE(0x22, "HFP Uplink Mode"), + + /** CAPod ships this as "Volume Swipe Length". */ + VOLUME_SWIPE_INTERVAL(0x23, "Volume Swipe Interval"), + + /** CAPod ships this as "End Call / Mute Mic". */ + CALL_MANAGEMENT_CONFIG(0x24, "Call Management Config"), + + /** CAPod ships this as "Volume Swipe". */ + VOLUME_SWIPE_MODE(0x25, "Volume Swipe Mode"), + + /** + * "Adaptive Volume" per Wireshark. CAPod ships it as "Personalized Volume" + * because that matches the iOS Settings.app label — unclear if this is + * the same feature or the two sources disagree on naming. + */ + ADAPTIVE_VOLUME(0x26, "Adaptive Volume"), + + SOFTWARE_MUTE(0x27, "Software Mute"), + + /** CAPod ships this as "Conversational Awareness". */ + CONVERSATION_DETECT(0x28, "Conversation Detect"), + + SELECTIVE_SPEECH_LISTENING(0x29, "Selective Speech Listening"), + UNKNOWN_0X2A(0x2A, "Unknown/Unassigned"), + UNKNOWN_0X2B(0x2B, "Unknown/Unassigned"), + HEARING_AID(0x2C, "Hearing Aid"), + UNKNOWN_0X2D(0x2D, "Unknown/Unassigned"), + + /** CAPod ships this as "Adaptive Audio Noise". */ + AUTO_ANC_STRENGTH(0x2E, "Auto ANC Strength"), + + HEARING_AID_GAIN_SWIPE(0x2F, "Hearing Aid Gain Swipe"), + HEART_RATE_MONITOR_3(0x30, "Heart Rate Monitor"), + IN_CASE_TONE(0x31, "In-Case Tone"), + SIRI_MULTITONE(0x32, "Siri Multitone"), + HEARING_ASSIST(0x33, "Hearing Assist"), + ALLOW_OFF_OPTION(0x34, "Allow Off Option"), + SLEEP_DETECTION(0x35, "Sleep Detection"), + ALLOW_AUTO_CONNECT_FROM_AUDIO_ACCESSORY(0x36, "Allow Auto Connect from Audio Accessory"), + HEARING_PROTECTION_PPE(0x37, "Hearing Protection PPE"), + PPE_CAP_LEVEL_CONFIG(0x38, "PPE Cap Level Config"), + + /** CAPod ships this as "Stem Config". */ + RAW_GESTURES_CONFIG(0x39, "Raw Gestures Config"), + + ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A, "Allow Temporary Managed Pairing"), + DYNAMIC_END_OF_CHARGE(0x3B, "Dynamic End of Charge"), + SYSTEM_SIRI_MODE(0x3C, "System Siri Mode"), + + /** "hearingAidV2SourceRegionSupport" per Wireshark. */ + HEARING_AID_GENERIC(0x3D, "Hearing Aid Generic"), + + UPLINK_EQ_BUD(0x3E, "Uplink EQ Bud"), + UPLINK_EQ_SOURCE(0x3F, "Uplink EQ Source"), + + /** Separate from [IN_CASE_TONE] (0x31) — this is a volume level, not an on/off. */ + IN_CASE_TONE_VOLUME(0x40, "In Case Tone Volume"), + + DISABLE_BUTTON_INPUT(0x41, "Disable Button Input"), + EXTENDED_HOLD_AND_RELEASE(0x42, "Extended Hold and Release"), + ; + + companion object { + private val byValue: Map = entries.associateBy { it.value } + fun byValue(value: Int): AapControlId? = byValue[value] + } +} 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 dd3fe02d..1ef760ba 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 @@ -1,15 +1,96 @@ package eu.darken.capod.pods.core.apple.aap.protocol +import java.time.Instant + /** - * Device identity parsed from the AAP handshake response (message type 0x1D). + * Device identity parsed from the AAP Information message (type 0x1D). + * + * Field ordering and semantics are sourced from the Wireshark AAP dissector: + * + * + * Segments 0-10 are NUL-delimited UTF-8. Segments 11-12 are fixed 17-byte + * UUIDs (may contain 0x00 — never treat as strings). Segments 13-14 are + * NUL-delimited timestamps (Unix epoch seconds, ASCII). + * + * Any field past the system fields may be absent on older devices or + * truncated payloads — all optional fields are nullable. */ data class AapDeviceInfo( + /** Segment 0 — user-visible device name ("AirPods Pro"). */ val name: String, + /** Segment 1 — Apple model identifier ("A2084"). */ val modelNumber: String, + /** Segment 2 — manufacturer string ("Apple Inc."). */ val manufacturer: String, + /** Segment 3 — system (case) serial number. */ val serialNumber: String, + /** Segment 4 — currently-running firmware version. */ val firmwareVersion: String, + /** Segment 5 — pending firmware version after next reboot. Null if equal to active. */ + val firmwareVersionPending: String? = null, + /** Segment 6 — hardware revision ("1.0.0"). */ + val hardwareVersion: String? = null, + /** Segment 7 — External Accessory protocol name ("com.apple.accessory.updater.app.71"). */ + val eaProtocolName: String? = null, + /** Segment 8 — left earbud serial. */ val leftEarbudSerial: String? = null, + /** Segment 9 — right earbud serial. */ val rightEarbudSerial: String? = null, - val buildNumber: String? = null, -) + /** + * Segment 10 — marketing/build version (e.g. "8454624"). Originally + * mis-labeled `buildNumber` in CAPod; the Wireshark dissector calls it + * "Marketing Version". + */ + val marketingVersion: String? = null, + /** Segment 11 — opaque 17-byte left-bud UUID. May contain arbitrary bytes. */ + val leftEarbudUuid: ByteArray? = null, + /** Segment 12 — opaque 17-byte right-bud UUID. May contain arbitrary bytes. */ + val rightEarbudUuid: ByteArray? = null, + /** Segment 13 — first-time-pairing timestamp for the left bud. */ + val leftEarbudFirstPaired: Instant? = null, + /** Segment 14 — first-time-pairing timestamp for the right bud. */ + val rightEarbudFirstPaired: Instant? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapDeviceInfo) return false + if (name != other.name) return false + if (modelNumber != other.modelNumber) return false + if (manufacturer != other.manufacturer) return false + if (serialNumber != other.serialNumber) return false + if (firmwareVersion != other.firmwareVersion) return false + if (firmwareVersionPending != other.firmwareVersionPending) return false + if (hardwareVersion != other.hardwareVersion) return false + if (eaProtocolName != other.eaProtocolName) return false + if (leftEarbudSerial != other.leftEarbudSerial) return false + if (rightEarbudSerial != other.rightEarbudSerial) return false + if (marketingVersion != other.marketingVersion) return false + if (!leftEarbudUuid.contentOptionalEquals(other.leftEarbudUuid)) return false + if (!rightEarbudUuid.contentOptionalEquals(other.rightEarbudUuid)) return false + if (leftEarbudFirstPaired != other.leftEarbudFirstPaired) return false + if (rightEarbudFirstPaired != other.rightEarbudFirstPaired) return false + return true + } + + override fun hashCode(): Int { + var r = name.hashCode() + r = 31 * r + modelNumber.hashCode() + r = 31 * r + manufacturer.hashCode() + r = 31 * r + serialNumber.hashCode() + r = 31 * r + firmwareVersion.hashCode() + r = 31 * r + (firmwareVersionPending?.hashCode() ?: 0) + r = 31 * r + (hardwareVersion?.hashCode() ?: 0) + r = 31 * r + (eaProtocolName?.hashCode() ?: 0) + r = 31 * r + (leftEarbudSerial?.hashCode() ?: 0) + r = 31 * r + (rightEarbudSerial?.hashCode() ?: 0) + r = 31 * r + (marketingVersion?.hashCode() ?: 0) + r = 31 * r + (leftEarbudUuid?.contentHashCode() ?: 0) + r = 31 * r + (rightEarbudUuid?.contentHashCode() ?: 0) + r = 31 * r + (leftEarbudFirstPaired?.hashCode() ?: 0) + r = 31 * r + (rightEarbudFirstPaired?.hashCode() ?: 0) + return r + } +} + +private fun ByteArray?.contentOptionalEquals(other: ByteArray?): Boolean = + if (this == null || other == null) this === other else contentEquals(other) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt index efdb9ccd..e1d27dd9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt @@ -74,6 +74,33 @@ interface AapDeviceProfile { */ fun decodeStemPress(message: AapMessage): StemPressEvent? + /** + * Encode a Case Info request (command 0x22). Returns null when this profile + * shouldn't probe the case — older models may not respond, or the payload + * schema is unconfirmed for that model. Fire-and-forget: if the device + * doesn't reply, nothing happens. + */ + fun encodeCaseInfoRequest(): ByteArray? = null + + /** + * Decode a Case Info response (command 0x23). Returns null if the message + * is not a Case Info response or the payload cannot be recognised. + */ + fun decodeCaseInfo(message: AapMessage): AapCaseInfo? = null + + /** + * Decode a Sleep Detection Update event (command 0x57). Returns null for + * any other message type. Payload schema is not fully documented — the + * default implementation returns the raw bytes verbatim. + */ + fun decodeSleepEvent(message: AapMessage): AapSleepEvent? = null + + /** + * Decode a Dynamic End-of-Charge event (command 0x59). Returns null for + * any other message type. + */ + fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? = null + companion object { fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt new file mode 100644 index 00000000..732dc0bf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt @@ -0,0 +1,20 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Push-only event paired with the Dynamic End-of-Charge setting (control ID 0x3B). + * Message type 0x59 — reports charge-cap status transitions (e.g. "80% cap reached"). + * + * Payload schema is not yet documented publicly — this type preserves the raw + * bytes so future capture-based analysis can fill in structured fields. + */ +data class AapDynamicEndOfChargeEvent( + val rawPayload: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapDynamicEndOfChargeEvent) return false + return rawPayload.contentEquals(other.rawPayload) + } + + override fun hashCode(): Int = rawPayload.contentHashCode() +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt similarity index 59% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt index 62765a04..d8f7242c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt @@ -1,36 +1,5 @@ package eu.darken.capod.pods.core.apple.aap.protocol -/** - * A parsed AAP protocol message. - */ -data class AapMessage( - val raw: ByteArray, - val commandType: Int, - val payload: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is AapMessage) return false - return raw.contentEquals(other.raw) - } - - override fun hashCode(): Int = raw.contentHashCode() - - companion object { - /** - * Parse a complete AAP message from raw bytes. - * AAP messages have the format: [4-byte header] [2-byte command type] [payload…] - * Minimum message size is 6 bytes (header + command type with no payload). - */ - fun parse(raw: ByteArray): AapMessage? { - if (raw.size < 6) return null - val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8) - val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0) - return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload) - } - } -} - /** * Accumulates bytes from a stream and emits complete [AapMessage] objects. * @@ -39,6 +8,12 @@ data class AapMessage( * * AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian) * indicate total message length (excluding the first 4 header bytes). + * + * Note: production code bypasses this framer — [AapConnection.readLoop] + * parses whole reads directly because L2CAP SEQPACKET already delivers + * per-frame boundaries. The framer is retained for future stream-mode + * paths but its splitting rule doesn't match every real capture + * (see `AapFramerTest` for the shape it expects). */ class AapFramer { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt new file mode 100644 index 00000000..ef09d761 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt @@ -0,0 +1,112 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Catalog of AAP (Apple Accessory Protocol, also "AACP") message types — + * the 16-bit opcode at bytes 4-5 of a Message-type packet (packet type 0x04). + * + * Sources: + * - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua + * - LibrePods / MagicPodsCore + * - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3) + * + * Entries whose names start with `UNKNOWN_` exist in Wireshark's dissector + * but have no known meaning. They're catalogued here so unhandled-message + * logging still reports a named opcode instead of a bare int. + */ +enum class AapMessageType(val value: Int, val wiresharkName: String) { + CAPABILITIES_REQUEST(0x0001, "Capabilities Request"), + CAPABILITIES(0x0002, "Capabilities"), + BATTERY_INFO_REQUEST(0x0003, "Battery Info Request"), + BATTERY_INFO(0x0004, "Battery Info"), + EAR_DETECTION_REQUEST(0x0005, "Ear Detection Request"), + EAR_DETECTION(0x0006, "Ear Detection"), + BUD_ROLE_REQUEST(0x0007, "Bud Role Request"), + BUD_ROLE(0x0008, "Bud Role"), + + /** Controls / settings. The control ID is payload byte 0 — see [AapControlId]. */ + CONTROL(0x0009, "Control"), + + DEVICE_LIST(0x000B, "Device List"), + MAC_ADDRESS(0x000C, "MAC Address"), + STREAM_STATE_INFO_REQUEST(0x000D, "Stream State Info Request"), + AUDIO_SOURCE(0x000E, "Audio Source"), + SET_NOTIFICATION_FILTER(0x000F, "Set Notification Filter"), + SMART_ROUTING_1(0x0010, "Smart Routing"), + SMART_ROUTING_2(0x0011, "Smart Routing"), + EASY_PAIR_REQUEST(0x0012, "Easy Pair Request?"), + CONNECT_PRIORITY_LIST(0x0014, "Connect Priority List"), + TRIANGLE_STATUS_REQUEST(0x0015, "Triangle Status Request"), + MAGNET_LINK(0x0016, "Magnet Link"), + + /** + * Buddy Command per Wireshark. In CAPod we treat payloads of this opcode as + * HID descriptor frames (Service Directory / descriptor / terminator — see [HidTracker]). + * Both interpretations may be correct for different sub-payloads. + */ + BUDDY_COMMAND(0x0017, "BuddyCommand"), + + STEM_PRESS(0x0019, "Stem Press"), + RENAME(0x001A, "Rename"), + TIMESTAMP(0x001B, "Timestamp"), + INFORMATION(0x001D, "Information"), + SEND_EXTERNAL_ACCESSORY_SESSION_PACKET(0x001E, "Send External Accessory Session Packet"), + NOTIFY_SESSION_STATE(0x001F, "Notify Session State?"), + SEND_REMOTE_FIRMWARE_AUTH_DATA(0x0020, "Send Remote Firmware Auth Data"), + UNKNOWN_0X21(0x0021, "Unknown"), + CASE_INFO_REQUEST(0x0022, "Case Info Request"), + CASE_INFO(0x0023, "Case Info"), + SEND_DEVICE_INFO(0x0024, "Send Device Info?"), + CERTIFICATES_REQUEST(0x0026, "Certificates Request"), + CERTIFICATES(0x0027, "Certificates"), + GYRO_INFO(0x0028, "Gyro Info"), + SET_COUNTRY_CODE(0x0029, "Set Country Code"), + STREAM_STATE_INFO(0x002B, "Stream State Info"), + GAPA_CHALLENGE(0x002C, "GAPA Challenge"), + CONNECTED_DEVICES_REQUEST(0x002D, "Connected Devices Request"), + CONNECTED_DEVICES(0x002E, "Connected Devices"), + MAGIC_KEYS_REQUEST(0x0030, "Magic Keys Request"), + MAGIC_KEYS(0x0031, "Magic Keys"), + MAGIC_KEYS_2(0x0032, "Magic Keys"), + UNKNOWN_0X40(0x0040, "Unknown"), + SEND_SMART_ROUTING_2_INFO(0x0044, "Send Smart Routing 2.0 Info"), + FAST_CONNECT_COMPLETE(0x0045, "Fast Connect Complete?"), + BUD_SWAP_2_PROCEDURE(0x0047, "Bud Swap 2.0 Procedure?"), + SWAP_IMMINENT_CONFIRM(0x0048, "Swap Imminent Confirm?"), + BUD_SWAP_2_COMPLETION(0x0049, "Bud Swap 2.0 Completion?"), + SWAP_COMPLETE_CONFIRM(0x004A, "Swap Complete Confirm?"), + CONVERSATIONAL_AWARENESS(0x004B, "Conversational Awareness"), + ADAPTIVE_VOLUME_MESSAGE(0x004C, "Adaptive Volume Message"), + + /** + * Source Feature Capabilities. Sent by the source after Connect Response to + * advertise what it supports. In CAPod this is "InitExt" — we only emit a + * fixed template, we don't read/decode the reply. + */ + SOURCE_FEATURE_CAPABILITIES(0x004D, "Source Feature Capabilities"), + + FEATURE_PROX_CARD_STATUS_UPDATE(0x004E, "Feature ProxCard Status Update"), + UARP_DATA(0x004F, "UARP Data"), + UNKNOWN_0X50(0x0050, "Unknown"), + SOURCE_CONTEXT(0x0052, "Source Context"), + + /** + * PME (Personal Mixing Engine?) config per Wireshark. CAPod decodes this as + * 4 × 8 Float32 values (see [AapSetting.PmeConfig]). Real EQ likely lives + * at [SET_BAND_EDGES]. + */ + PME_CONFIG(0x0053, "PME Config"), + + SET_BAND_EDGES(0x0054, "Set Band Edges"), + UNKNOWN_0X55(0x0055, "Unknown"), + USB_SPATIAL_SENSOR_DATA_REQUEST(0x0056, "USB Spatial Sensor Data Request"), + SLEEP_DETECTION_UPDATE(0x0057, "Sleep Detection Update"), + UNKNOWN_0X58(0x0058, "Unknown"), + DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"), + PERSONAL_TRANSLATION(0x0060, "Personal Translation"), + ; + + companion object { + private val byValue: Map = entries.associateBy { it.value } + fun byValue(value: Int): AapMessageType? = byValue[value] + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt new file mode 100644 index 00000000..39fec9a7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt @@ -0,0 +1,172 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * A parsed AAP (AACP) frame. Packet type lives at bytes 0-1 (little-endian) + * and selects which variant we're looking at. + * + * Per the Wireshark dissector, packet types are: + * - 0x0000 — Connect (session open request; source → pods) + * - 0x0001 — Connect Response (pods → source) + * - 0x0002 — Disconnect + * - 0x0003 — Disconnect Response + * - 0x0004 — Message (the large Message type with a 16-bit message/command ID) + * + * Only `Message` packets carry the `(commandType, payload)` pair that the + * existing decoder pipeline consumes; the other variants have their own + * field schema and bypass the decoder. + */ +sealed class AapPacket(val raw: ByteArray) { + + /** Source → pods session open. We emit this as our handshake. */ + class Connect( + raw: ByteArray, + val service: Int, + val major: Int, + val minor: Int, + val features: ULong, + ) : AapPacket(raw) + + /** + * Pods → source response to a Connect. The `features` bitmask is opaque + * for now (no public bit-to-feature mapping); CAPod logs it and stores + * it in `AapPodState` for future correlation work. + */ + class ConnectResponse( + raw: ByteArray, + val service: Int, + val status: Int, + val major: Int, + val minor: Int, + val features: ULong, + ) : AapPacket(raw) + + class Disconnect( + raw: ByteArray, + val service: Int, + val status: Int, + ) : AapPacket(raw) + + class DisconnectResponse( + raw: ByteArray, + val service: Int, + ) : AapPacket(raw) + + /** + * Message packet — bytes 4-5 are the message/command ID. This is what + * [AapDeviceProfile.decodeSetting] / `decodeBattery` / etc. consume. + * + * Typealiased to `AapMessage` for backward compatibility. + */ + class Message( + raw: ByteArray, + val commandType: Int, + val payload: ByteArray, + ) : AapPacket(raw) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Message) return false + return raw.contentEquals(other.raw) + } + + override fun hashCode(): Int = raw.contentHashCode() + + override fun toString(): String = + "AapPacket.Message(cmd=0x${"%04X".format(commandType)}, payload=${payload.size}B)" + + companion object { + /** + * Parse a Message-type AAP frame. Returns null if the bytes aren't + * a complete Message packet (packet type != 0x0004, or payload too short). + * Non-Message packets (Connect Response etc.) return null here — + * use [AapPacket.parse] if you need to handle them. + */ + fun parse(raw: ByteArray): Message? { + val packet = AapPacket.parse(raw) ?: return null + return packet as? Message + } + } + } + + /** Unknown / unrecognised packet type. Preserved for logging. */ + class Unknown(raw: ByteArray, val packetType: Int) : AapPacket(raw) + + companion object { + private const val PACKET_TYPE_CONNECT = 0x0000 + private const val PACKET_TYPE_CONNECT_RESPONSE = 0x0001 + private const val PACKET_TYPE_DISCONNECT = 0x0002 + private const val PACKET_TYPE_DISCONNECT_RESPONSE = 0x0003 + private const val PACKET_TYPE_MESSAGE = 0x0004 + + /** + * Parse a raw AAP frame. Returns: + * - `null` if the buffer is too short to even read the packet type. + * - An [Unknown] if the packet type isn't one we recognise. + * - The appropriate subclass otherwise. + * + * Note: this assumes one frame per call — L2CAP SEQPACKET delivers + * complete frames per read, matching this expectation. The + * (unused-in-prod) [AapFramer] uses a different splitting approach. + */ + fun parse(raw: ByteArray): AapPacket? { + if (raw.size < 4) return null + val packetType = readLe16(raw, 0) + val service = readLe16(raw, 2) + return when (packetType) { + PACKET_TYPE_CONNECT -> parseConnect(raw, service) + PACKET_TYPE_CONNECT_RESPONSE -> parseConnectResponse(raw, service) + PACKET_TYPE_DISCONNECT -> parseDisconnect(raw, service) + PACKET_TYPE_DISCONNECT_RESPONSE -> DisconnectResponse(raw.copyOf(), service) + PACKET_TYPE_MESSAGE -> parseMessage(raw) + else -> Unknown(raw.copyOf(), packetType) + } + } + + private fun parseConnect(raw: ByteArray, service: Int): Connect? { + if (raw.size < 16) return null + val major = readLe16(raw, 4) + val minor = readLe16(raw, 6) + val features = readLe64(raw, 8) + return Connect(raw.copyOf(), service, major, minor, features) + } + + private fun parseConnectResponse(raw: ByteArray, service: Int): ConnectResponse? { + if (raw.size < 18) return null + val status = readLe16(raw, 4) + val major = readLe16(raw, 6) + val minor = readLe16(raw, 8) + val features = readLe64(raw, 10) + return ConnectResponse(raw.copyOf(), service, status, major, minor, features) + } + + private fun parseDisconnect(raw: ByteArray, service: Int): Disconnect? { + if (raw.size < 6) return null + val status = readLe16(raw, 4) + return Disconnect(raw.copyOf(), service, status) + } + + private fun parseMessage(raw: ByteArray): Message? { + if (raw.size < 6) return null + val commandType = readLe16(raw, 4) + val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0) + return Message(raw = raw.copyOf(), commandType = commandType, payload = payload) + } + + private fun readLe16(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + + private fun readLe64(data: ByteArray, offset: Int): ULong { + var result = 0UL + for (i in 0 until 8) { + result = result or ((data[offset + i].toLong() and 0xFFL).toULong() shl (i * 8)) + } + return result + } + } +} + +/** + * Backward-compat alias — CAPod historically named the Message packet just + * `AapMessage`. Kept as a type alias so decoder signatures don't churn. + */ +typealias AapMessage = AapPacket.Message 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 4174e2ee..6050c7be 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 @@ -163,9 +163,21 @@ sealed class AapSetting { enum class AudioSourceType { NONE, CALL, MEDIA } } - data class EqBands( + /** + * Payload of message type 0x0053. The Wireshark AAP dissector calls this + * "PME Config" (Personal Mixing Engine). CAPod used to label it as EQ + * bands, but captures so far show this data is all zeros on in-production + * firmware — the real equalizer path is likely 0x0054 "Set Band Edges". + * + * The payload is decoded as 4 sets × 8 Float32 values until the true schema + * is confirmed. Callers should treat all-zero [sets] as "no config reported". + */ + data class PmeConfig( val sets: List>, - ) : AapSetting() + ) : AapSetting() { + val isAllZero: Boolean + get() = sets.all { set -> set.all { it == 0f } } + } /** Per-pod placement reported by the device (command 0x06). */ data class EarDetection( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt new file mode 100644 index 00000000..699852df --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt @@ -0,0 +1,20 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Push-only event emitted by newer AirPods firmware when the Sleep Detection + * feature (setting 0x35) is enabled (message type 0x57 "Sleep Detection Update"). + * + * Payload schema is not yet documented publicly — this type preserves the raw + * bytes so future capture-based analysis can fill in structured fields. + */ +data class AapSleepEvent( + val rawPayload: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapSleepEvent) return false + return rawPayload.contentEquals(other.rawPayload) + } + + override fun hashCode(): Int = rawPayload.contentHashCode() +} 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 7ddd2235..254c7fd4 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 @@ -16,58 +16,43 @@ class DefaultAapDeviceProfile( ) : AapDeviceProfile { companion object { - // AAP command types (bytes 4-5 of the message, little-endian) - const val CMD_SETTINGS = 0x0009 - const val CMD_BATTERY = 0x0004 - const val CMD_DEVICE_INFO = 0x001D - const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031 - const val CMD_EAR_DETECTION = 0x0006 - const val CMD_PRIMARY_POD = 0x0008 - const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B - - // Setting IDs (first byte of settings command payload) - const val SETTING_ANC_MODE = 0x0D - const val SETTING_PRESS_SPEED = 0x17 - const val SETTING_PRESS_HOLD_DURATION = 0x18 - const val SETTING_NC_ONE_AIRPOD = 0x1B - const val SETTING_TONE_VOLUME = 0x1F - const val SETTING_VOLUME_SWIPE_LENGTH = 0x23 - const val SETTING_END_CALL_MUTE_MIC = 0x24 - const val SETTING_VOLUME_SWIPE = 0x25 - const val SETTING_PERSONALIZED_VOLUME = 0x26 - const val SETTING_CONVERSATIONAL_AWARENESS = 0x28 - const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E - const val SETTING_MICROPHONE_MODE = 0x01 - const val SETTING_EAR_DETECTION_ENABLED = 0x0A - const val SETTING_LISTENING_MODE_CYCLE = 0x1A - // Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the - // decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh, - // which AAP freshness / boost logic in PodDevice.computeAapBoost depends on. - // Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here; - // the actual effect of this setting is unknown, so we don't expose or write it. - const val SETTING_IN_CASE_TONE = 0x31 - const val SETTING_ALLOW_OFF_OPTION = 0x34 - 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, + /** + * Catalogued control IDs we see on-wire but don't yet model as a named + * [AapSetting] subclass. Decoded as [AapSetting.UnknownSetting] so + * `lastMessageAt` still refreshes (freshness feeds AAP quality boost). + * Promote an entry to a dedicated subclass when its semantics are + * confirmed from captures. + * + * Observed on Pro 2 USB-C and/or Pro 3. + */ + val UNMAPPED_SETTING_IDS = setOf( + AapControlId.SELECTIVE_SPEECH_LISTENING.value, // 0x29 + AapControlId.HEARING_AID.value, // 0x2C + AapControlId.HEARING_AID_GAIN_SWIPE.value, // 0x2F + AapControlId.HEART_RATE_MONITOR_3.value, // 0x30 + AapControlId.HEARING_ASSIST.value, // 0x33 + AapControlId.HEARING_PROTECTION_PPE.value, // 0x37 + AapControlId.PPE_CAP_LEVEL_CONFIG.value, // 0x38 + AapControlId.DYNAMIC_END_OF_CHARGE.value, // 0x3B + AapControlId.UPLINK_EQ_BUD.value, // 0x3E ) - // Command types for non-settings messages - const val CMD_RENAME = 0x001E - const val CMD_STEM_PRESS = 0x0019 - const val CMD_CONNECTED_DEVICES = 0x002E - const val CMD_AUDIO_SOURCE = 0x000E - const val CMD_EQ_DATA = 0x0053 - // ANC mode wire values const val ANC_WIRE_OFF = 0x01 const val ANC_WIRE_ON = 0x02 const val ANC_WIRE_TRANSPARENCY = 0x03 const val ANC_WIRE_ADAPTIVE = 0x04 + + /** Fixed length of each earbud UUID segment in the Information payload (segments 11 & 12). */ + private const val UUID_LEN = 17 + + /** + * Models where sending a 0x22 CASE_INFO_REQUEST yields a 0x23 response. + * Grow this list as captures confirm other models respond correctly. + */ + private val CASE_INFO_ALLOWLIST = setOf( + PodModel.AIRPODS_PRO3, + ) } private val supportedAncModes: List by lazy { @@ -95,31 +80,31 @@ class DefaultAapDeviceProfile( ) override fun encodeCommand(command: AapCommand): ByteArray = when (command) { - is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode)) - is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled)) - is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue) - is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue) - is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled)) - is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64)) - is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue) - is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled)) - is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled)) + is AapCommand.SetAncMode -> buildSettingsMessage(AapControlId.LISTEN_MODE.value, encodeAncMode(command.mode)) + is AapCommand.SetConversationalAwareness -> buildSettingsMessage(AapControlId.CONVERSATION_DETECT.value, encodeAppleBool(command.enabled)) + is AapCommand.SetPressSpeed -> buildSettingsMessage(AapControlId.DOUBLE_CLICK_INTERVAL.value, command.value.wireValue) + is AapCommand.SetPressHoldDuration -> buildSettingsMessage(AapControlId.CLICK_AND_HOLD_INTERVAL.value, command.value.wireValue) + is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(AapControlId.ONE_BUD_ANC_MODE.value, encodeAppleBool(command.enabled)) + is AapCommand.SetToneVolume -> buildSettingsMessage(AapControlId.CHIME_VOLUME.value, command.level.coerceIn(0x0F, 0x64)) + is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_INTERVAL.value, command.value.wireValue) + is AapCommand.SetVolumeSwipe -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_MODE.value, encodeAppleBool(command.enabled)) + is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(AapControlId.ADAPTIVE_VOLUME.value, encodeAppleBool(command.enabled)) // Wire semantics are inverted: wire 0 = max noise reduction, wire 100 = min (transparency-like). // UI value 0..100 follows user intuition (100 = max NC), so flip on write/read. - is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, 100 - command.level.coerceIn(0, 100)) + is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(AapControlId.AUTO_ANC_STRENGTH.value, 100 - command.level.coerceIn(0, 100)) is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall) - is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue) - is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled)) - is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F) - is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled)) - is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F) - is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled)) + is AapCommand.SetMicrophoneMode -> buildSettingsMessage(AapControlId.MIC_MODE.value, command.mode.wireValue) + is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(AapControlId.IN_EAR_DETECTION.value, encodeAppleBool(command.enabled)) + is AapCommand.SetListeningModeCycle -> buildSettingsMessage(AapControlId.LISTENING_MODE_CONFIGS.value, command.modeMask and 0x0F) + is AapCommand.SetAllowOffOption -> buildSettingsMessage(AapControlId.ALLOW_OFF_OPTION.value, encodeAppleBool(command.enabled)) + is AapCommand.SetStemConfig -> buildSettingsMessage(AapControlId.RAW_GESTURES_CONFIG.value, command.claimedPressMask and 0x0F) + is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled)) is AapCommand.SetDeviceName -> buildRenameMessage(command.name) } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { // Primary pod identity (push-only, fires on mic/primary swap) - if (message.commandType == CMD_PRIMARY_POD) { + if (message.commandType == AapMessageType.BUD_ROLE.value) { if (message.payload.size < 4) return null val podId = message.payload[0].toInt() and 0xFF // Validate known fixed bytes: [podId] 00 [00|01] [00|01] @@ -136,7 +121,7 @@ class DefaultAapDeviceProfile( } // Ear detection is a separate command type (push-only from device) - if (message.commandType == CMD_EAR_DETECTION) { + if (message.commandType == AapMessageType.EAR_DETECTION.value) { if (message.payload.size < 2) return null return AapSetting.EarDetection::class to AapSetting.EarDetection( primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF), @@ -145,7 +130,7 @@ class DefaultAapDeviceProfile( } // Connected devices list (push-only from device) - if (message.commandType == CMD_CONNECTED_DEVICES) { + if (message.commandType == AapMessageType.CONNECTED_DEVICES.value) { if (message.payload.size < 3) return null val count = message.payload[2].toInt() and 0xFF val devices = mutableListOf() @@ -161,7 +146,7 @@ class DefaultAapDeviceProfile( } // Audio source tracking (push-only from device) - if (message.commandType == CMD_AUDIO_SOURCE) { + if (message.commandType == AapMessageType.AUDIO_SOURCE.value) { if (message.payload.size < 7) return null val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":") val typeValue = message.payload[6].toInt() and 0xFF @@ -173,8 +158,11 @@ class DefaultAapDeviceProfile( return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type) } - // EQ data (push-only from device) - if (message.commandType == CMD_EQ_DATA) { + // 0x53 is "PME Config" per the Wireshark AAP dissector. CAPod decodes the + // 4-set × 8-band Float32 payload verbatim; captures so far have all-zero + // payloads so the schema is unconfirmed. Real EQ likely lives on 0x54 + // "Set Band Edges" with a different byte layout. + if (message.commandType == AapMessageType.PME_CONFIG.value) { if (message.payload.size < 6 + 128) return null val sets = mutableListOf>() var offset = 6 // skip header @@ -190,92 +178,97 @@ class DefaultAapDeviceProfile( } sets.add(bands) } - return AapSetting.EqBands::class to AapSetting.EqBands(sets) + return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets) } // Conversation Awareness State is a separate command type (push-only) - if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { + if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) { if (message.payload.isEmpty()) return null val value = message.payload[0].toInt() and 0xFF val speaking = value == 0x01 return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking) } - if (message.commandType != CMD_SETTINGS) return null + if (message.commandType != AapMessageType.CONTROL.value) return null if (message.payload.size < 2) return null val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF return when (settingId) { - SETTING_ANC_MODE -> { + AapControlId.LISTEN_MODE.value -> { val mode = decodeAncMode(value) ?: return null AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes) } - SETTING_CONVERSATIONAL_AWARENESS -> { + AapControlId.CONVERSATION_DETECT.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled) } - SETTING_PRESS_SPEED -> { + AapControlId.DOUBLE_CLICK_INTERVAL.value -> { val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed) } - SETTING_PRESS_HOLD_DURATION -> { + AapControlId.CLICK_AND_HOLD_INTERVAL.value -> { val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration) } - SETTING_NC_ONE_AIRPOD -> { + AapControlId.ONE_BUD_ANC_MODE.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled) } - SETTING_TONE_VOLUME -> { + AapControlId.CHIME_VOLUME.value -> { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value) } - SETTING_VOLUME_SWIPE_LENGTH -> { + AapControlId.VOLUME_SWIPE_INTERVAL.value -> { val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length) } - SETTING_END_CALL_MUTE_MIC -> { + AapControlId.CALL_MANAGEMENT_CONFIG.value -> { decodeEndCallMuteMic(message.payload) } - SETTING_VOLUME_SWIPE -> { + AapControlId.VOLUME_SWIPE_MODE.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled) } - SETTING_PERSONALIZED_VOLUME -> { + AapControlId.ADAPTIVE_VOLUME.value -> { + // Wireshark calls 0x26 "Adaptive Volume"; CAPod ships the user-facing name + // "Personalized Volume" because that matches the iOS Settings.app label. val enabled = decodeAppleBool(value) ?: return null AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled) } - SETTING_ADAPTIVE_AUDIO_NOISE -> { + AapControlId.AUTO_ANC_STRENGTH.value -> { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 100 - value.coerceIn(0, 100)) } - SETTING_MICROPHONE_MODE -> { + AapControlId.MIC_MODE.value -> { val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode) } - SETTING_EAR_DETECTION_ENABLED -> { + AapControlId.IN_EAR_DETECTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled) } - SETTING_LISTENING_MODE_CYCLE -> { + AapControlId.LISTENING_MODE_CONFIGS.value -> { AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value) } - SETTING_ALLOW_OFF_OPTION -> { + AapControlId.ALLOW_OFF_OPTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled) } - SETTING_STEM_CONFIG -> { + AapControlId.RAW_GESTURES_CONFIG.value -> { AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value) } - SETTING_SLEEP_DETECTION -> { + AapControlId.SLEEP_DETECTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled) } - SETTING_IN_CASE_TONE -> { + AapControlId.IN_CASE_TONE.value -> { + // Decoded internally (never exposed in UI) to keep lastMessageAt fresh. + // Originally labeled "Charging Sounds" — the real case tones are controlled + // over ATT, not here; the actual effect of this setting is unknown. val enabled = decodeAppleBool(value) ?: return null AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) } - in UNCONFIRMED_SETTING_IDS -> { + in UNMAPPED_SETTING_IDS -> { AapSetting.UnknownSetting::class to AapSetting.UnknownSetting( settingId = settingId, rawValue = value, @@ -286,7 +279,7 @@ class DefaultAapDeviceProfile( } override fun decodeBattery(message: AapMessage): Map? { - if (message.commandType != CMD_BATTERY) return null + if (message.commandType != AapMessageType.BATTERY_INFO.value) return null val payload = message.payload if (payload.isEmpty()) return null @@ -328,7 +321,7 @@ class DefaultAapDeviceProfile( ) override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? { - if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null + if (message.commandType != AapMessageType.MAGIC_KEYS.value) return null val payload = message.payload if (payload.isEmpty()) return null @@ -360,26 +353,31 @@ class DefaultAapDeviceProfile( } override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? { - if (message.commandType != CMD_DEVICE_INFO) return null + if (message.commandType != AapMessageType.INFORMATION.value) return null if (message.payload.size < 10) return null - // Device info payload contains null-terminated UTF-8 strings - // Format: [binary header] [NUL-delimited strings...] - val strings = parseNullTerminatedStrings(message.payload) - if (strings.size < 4) return null + val parsed = parseDeviceInfoPayload(message.payload) ?: return null + if (parsed.strings.size < 4) return null + + val activeFirmware = parsed.strings.getOrElse(4) { "" } + val pendingFirmware = parsed.strings.getOrNull(5)?.takeIf { it.isNotBlank() && it != activeFirmware } return AapDeviceInfo( - name = strings.getOrElse(0) { "" }, - modelNumber = strings.getOrElse(1) { "" }, - 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() }, + name = parsed.strings.getOrElse(0) { "" }, + modelNumber = parsed.strings.getOrElse(1) { "" }, + manufacturer = parsed.strings.getOrElse(2) { "" }, + serialNumber = parsed.strings.getOrElse(3) { "" }, + firmwareVersion = activeFirmware, + firmwareVersionPending = pendingFirmware, + hardwareVersion = parsed.strings.getOrNull(6)?.takeIf { it.isNotBlank() }, + eaProtocolName = parsed.strings.getOrNull(7)?.takeIf { it.isNotBlank() }, + leftEarbudSerial = parsed.strings.getOrNull(8)?.takeIf { it.isNotBlank() }, + rightEarbudSerial = parsed.strings.getOrNull(9)?.takeIf { it.isNotBlank() }, + marketingVersion = parsed.strings.getOrNull(10)?.takeIf { it.isNotBlank() }, + leftEarbudUuid = parsed.leftEarbudUuid, + rightEarbudUuid = parsed.rightEarbudUuid, + leftEarbudFirstPaired = parsed.leftEarbudFirstPaired, + rightEarbudFirstPaired = parsed.rightEarbudFirstPaired, ) } @@ -445,7 +443,7 @@ class DefaultAapDeviceProfile( return byteArrayOf( 0x04, 0x00, 0x04, 0x00, 0x09, 0x00, - SETTING_END_CALL_MUTE_MIC.toByte(), 0x20, + AapControlId.CALL_MANAGEMENT_CONFIG.value.toByte(), 0x20, combined.toByte(), 0x00, 0x00, ) @@ -480,13 +478,49 @@ class DefaultAapDeviceProfile( } override fun decodeStemPress(message: AapMessage): StemPressEvent? { - if (message.commandType != CMD_STEM_PRESS) return null + if (message.commandType != AapMessageType.STEM_PRESS.value) return null if (message.payload.size < 2) return null val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null return StemPressEvent(pressType, bud) } + /** + * Case Info probing is allowlisted to models observed to respond. Pro 3 is the + * confirmed first entry; other models can be added once captures verify they + * reply to 0x22 with a 0x23 payload. + */ + override fun encodeCaseInfoRequest(): ByteArray? { + if (model !in CASE_INFO_ALLOWLIST) return null + // Fire-and-forget 6-byte template, matching the private key request shape + // (header + command type with no payload). + return byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + AapMessageType.CASE_INFO_REQUEST.value.toByte(), 0x00, + ) + } + + /** + * Best-effort decoder. Per the dissector, the payload contains a mix of + * binary VID/PID/color bytes plus a NUL-delimited name string. Without + * more captures we preserve the raw payload and leave individual fields + * null — future iteration can map specific offsets. + */ + override fun decodeCaseInfo(message: AapMessage): AapCaseInfo? { + if (message.commandType != AapMessageType.CASE_INFO.value) return null + return AapCaseInfo(rawPayload = message.payload.copyOf()) + } + + override fun decodeSleepEvent(message: AapMessage): AapSleepEvent? { + if (message.commandType != AapMessageType.SLEEP_DETECTION_UPDATE.value) return null + return AapSleepEvent(rawPayload = message.payload.copyOf()) + } + + override fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? { + if (message.commandType != AapMessageType.DYNAMIC_END_OF_CHARGE.value) return null + return AapDynamicEndOfChargeEvent(rawPayload = message.payload.copyOf()) + } + private fun buildRenameMessage(name: String): ByteArray { // Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation. // Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts @@ -510,31 +544,89 @@ class DefaultAapDeviceProfile( ) + nameBytes } - private fun parseNullTerminatedStrings(data: ByteArray): List { - // 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++ + /** + * Heuristic binary-prefix skip: walk forward until we hit a printable byte. + * The real header schema is `[02 XX 00 04 00]` in every capture to date, but + * documenting that without an authoritative source would be guessing. The + * heuristic breaks only for device names that start with a non-printable + * byte \u2014 theoretically emoji names (UTF-8 first byte 0xF0-0xF4, outside the + * printable range) could misalign here. None observed in real captures so far. + */ + private fun skipBinaryHeader(data: ByteArray): Int { + var offset = 0 + while (offset < data.size) { + val b = data[offset].toInt() and 0xFF + if (b in 0x20..0x7E) return offset + offset++ } - 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() - 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 data.size } + + private fun parseDeviceInfoPayload(data: ByteArray): DeviceInfoSegments? { + var offset = skipBinaryHeader(data) + if (offset >= data.size) return null + + // Segments 0..10: NUL-delimited UTF-8 strings. UTF-8 means non-ASCII (e.g. + // curly quotes in "Matthias's AirPods Pro") decodes correctly. + val strings = mutableListOf() + while (strings.size < 11 && offset < data.size) { + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + if (offset >= data.size) break + val segStart = offset + while (offset < data.size && data[offset] != 0x00.toByte()) offset++ + strings.add(String(data, segStart, offset - segStart, Charsets.UTF_8)) + } + + // Skip the NUL that terminated segment 10 (if present) before the UUID blob. + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + + // Segments 11 and 12: fixed 17-byte UUIDs (NOT NUL-terminated, may contain 0x00). + // Best-effort: if payload is shorter, both UUIDs are null. + val leftUuid: ByteArray? = if (offset + UUID_LEN <= data.size) { + val slice = data.copyOfRange(offset, offset + UUID_LEN) + offset += UUID_LEN + slice + } else null + + val rightUuid: ByteArray? = if (offset + UUID_LEN <= data.size) { + val slice = data.copyOfRange(offset, offset + UUID_LEN) + offset += UUID_LEN + slice + } else null + + // Segments 13 and 14: NUL-delimited ASCII-decimal epoch seconds (e.g. "1697480211"). + val trailingStrings = mutableListOf() + while (trailingStrings.size < 2 && offset < data.size) { + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + if (offset >= data.size) break + val segStart = offset + while (offset < data.size && data[offset] != 0x00.toByte()) offset++ + trailingStrings.add(String(data, segStart, offset - segStart, Charsets.UTF_8)) + } + + val leftPaired = trailingStrings.getOrNull(0).parseEpochSecondsOrNull() + val rightPaired = trailingStrings.getOrNull(1).parseEpochSecondsOrNull() + + return DeviceInfoSegments( + strings = strings, + leftEarbudUuid = leftUuid, + rightEarbudUuid = rightUuid, + leftEarbudFirstPaired = leftPaired, + rightEarbudFirstPaired = rightPaired, + ) + } + + private fun String?.parseEpochSecondsOrNull(): java.time.Instant? { + if (this.isNullOrBlank()) return null + val seconds = this.toLongOrNull() ?: return null + return runCatching { java.time.Instant.ofEpochSecond(seconds) }.getOrNull() + } + + private data class DeviceInfoSegments( + val strings: List, + val leftEarbudUuid: ByteArray?, + val rightEarbudUuid: ByteArray?, + val leftEarbudFirstPaired: java.time.Instant?, + val rightEarbudFirstPaired: java.time.Instant?, + ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt new file mode 100644 index 00000000..f594bf30 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt @@ -0,0 +1,74 @@ +package eu.darken.capod.monitor.core.cache + +import eu.darken.capod.pods.core.apple.PodModel +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +/** + * Guards the cache schema against accidental breakage when AAP-layer fields are + * renamed. The wire key `buildNumber` was renamed to `marketingVersion` in the + * domain type; the cache keeps the original key via `@SerialName("buildNumber")` + * so devices that upgrade from older CAPod versions don't drop their cached data. + */ +class CachedDeviceStateMigrationTest : BaseTest() { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `pre-rename JSON with buildNumber key loads into marketingVersion field`() { + val legacy = """ + { + "profileId": "legacy-profile", + "model": "airpods.pro3", + "address": "AA:BB:CC:DD:EE:FF", + "deviceName": "AirPods Pro 3", + "serialNumber": "W5J7KV0N04", + "firmwareVersion": "81.26750000075000000.6503", + "leftEarbudSerial": "GMPHNZ16P5Z0000UHZ", + "rightEarbudSerial": "GMVHNX15UED0000UHY", + "buildNumber": "8454624", + "lastSeenAt": 1767364074000 + } + """.trimIndent() + + val state = json.decodeFromString(CachedDeviceState.serializer(), legacy) + state.marketingVersion shouldBe "8454624" + state.profileId shouldBe "legacy-profile" + state.model shouldBe PodModel.AIRPODS_PRO3 + state.firmwareVersion shouldBe "81.26750000075000000.6503" + } + + @Test + fun `round-trip serializes marketingVersion back to buildNumber key`() { + val state = CachedDeviceState( + profileId = "roundtrip", + model = PodModel.AIRPODS_PRO3, + marketingVersion = "8454624", + lastSeenAt = java.time.Instant.ofEpochMilli(1767364074000L), + ) + val encoded = json.encodeToString(CachedDeviceState.serializer(), state) + // Cache back-compat: the JSON key is still "buildNumber", never the in-memory name + (encoded.contains("\"buildNumber\":\"8454624\"")) shouldBe true + (encoded.contains("\"marketingVersion\"")) shouldBe false + } + + @Test + fun `deviceInfo surfaces marketingVersion from legacy cached state`() { + val legacy = """ + { + "profileId": "legacy-profile", + "model": "airpods.pro3", + "deviceName": "AirPods", + "serialNumber": "ABC", + "firmwareVersion": "81.x", + "buildNumber": "8454480", + "lastSeenAt": 1697480211000 + } + """.trimIndent() + + val state = json.decodeFromString(CachedDeviceState.serializer(), legacy) + state.deviceInfo!!.marketingVersion shouldBe "8454480" + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt index 94ae0023..00a4d8a9 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -119,7 +119,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { // ── In-Case Tone (0x31) ───────────────────────────────── // Decode path is kept internally even though the setting is no longer exposed in the UI. - // See DefaultAapDeviceProfile.SETTING_IN_CASE_TONE for rationale. + // See the IN_CASE_TONE branch in DefaultAapDeviceProfile.decodeSetting for rationale. @Nested inner class InCaseToneTests { 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 28ffbdfa..0a35a677 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 @@ -25,11 +25,15 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { // ── Handshake ──────────────────────────────────────────── @Test - fun `handshake response - 18 bytes`() { - val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") - msg.commandType shouldBe 0x0000 - msg.raw.size shouldBe 18 - msg.payload.size shouldBe 12 + fun `handshake response - 18 bytes parses as ConnectResponse status=0 major=1 minor=3`() { + val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") + check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse) + packet.service shouldBe 0x0004 + packet.status shouldBe 0x0000 + packet.major shouldBe 0x0001 + packet.minor shouldBe 0x0003 + packet.features shouldBe 0UL + packet.raw.size shouldBe 18 } // ── Device Info ────────────────────────────────────────── @@ -58,7 +62,15 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { info.manufacturer shouldBe "Apple Inc." info.leftEarbudSerial shouldBe "H3KL7HR926JY" info.rightEarbudSerial shouldBe "H3KL2AYL26K0" - info.buildNumber shouldBe "8454480" + info.marketingVersion shouldBe "8454480" + info.hardwareVersion shouldBe "1.0.0" + info.eaProtocolName shouldBe "com.apple.accessory.updater.app.71" + info.firmwareVersion shouldBe "81.2675000075000000.6082" + info.firmwareVersionPending shouldBe null + info.leftEarbudUuid!!.size shouldBe 17 + info.rightEarbudUuid!!.size shouldBe 17 + info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L) + info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L) } // ── Battery ────────────────────────────────────────────── 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 73404eed..28c2ea0c 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 @@ -25,11 +25,15 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { // ── Handshake ──────────────────────────────────────────── @Test - fun `handshake response - 18 bytes`() { - val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") - msg.commandType shouldBe 0x0000 - msg.raw.size shouldBe 18 - msg.payload.size shouldBe 12 + fun `handshake response - 18 bytes parses as ConnectResponse status=0 major=1 minor=3`() { + val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") + check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse) + packet.service shouldBe 0x0004 + packet.status shouldBe 0x0000 + packet.major shouldBe 0x0001 + packet.minor shouldBe 0x0003 + packet.features shouldBe 0UL + packet.raw.size shouldBe 18 } // ── Device Info ────────────────────────────────────────── @@ -58,7 +62,15 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { info.manufacturer shouldBe "Apple Inc." info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ" info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY" - info.buildNumber shouldBe "8454624" + info.marketingVersion shouldBe "8454624" + info.hardwareVersion shouldBe "1.0.0" + info.eaProtocolName shouldBe "com.apple.accessory.updater.app.71" + info.firmwareVersion shouldBe "81.2675000075000000.6503" + info.firmwareVersionPending shouldBe null + info.leftEarbudUuid!!.size shouldBe 17 + info.rightEarbudUuid!!.size shouldBe 17 + info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1767364074L) + info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1767364074L) } // ── Battery ────────────────────────────────────────────── 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 98316467..be921f33 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 @@ -25,11 +25,19 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() { // ── Handshake ──────────────────────────────────────────── @Test - fun `handshake response - 18 bytes`() { - val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2") - msg.commandType shouldBe 0x0000 - msg.raw.size shouldBe 18 - msg.payload.size shouldBe 12 + fun `handshake response - 18 bytes parses as ConnectResponse with non-zero features bitmask`() { + // Pro 1 (firmware 51.9.6) reports actual bits in the 64-bit features field. + // Pro 2 USB-C and Pro 3 both report all zeroes. Useful for future + // bit-to-feature correlation work. + val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2") + check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse) + packet.service shouldBe 0x0004 + packet.status shouldBe 0x0000 + packet.major shouldBe 0x0001 + packet.minor shouldBe 0x0003 + // Little-endian 8-byte features: 04 00 B1 E1 04 00 51 E2 + packet.features shouldBe 0xE251_0004_E1B1_0004UL + packet.raw.size shouldBe 18 } // ── Device Info ────────────────────────────────────────── @@ -58,7 +66,16 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() { info.manufacturer shouldBe "Apple Inc." info.leftEarbudSerial shouldBe "GXDDRFNW0C6K" info.rightEarbudSerial shouldBe "H6RHL0HF0C6J" - info.buildNumber shouldBe "3344646" + info.marketingVersion shouldBe "3344646" + info.hardwareVersion shouldBe "1.0.0" + info.eaProtocolName shouldBe "com.apple.accessory.updater.app.60-ANC" + info.firmwareVersion shouldBe "51.9.6" + // Active firmware matches pending firmware verbatim → pending should be null + info.firmwareVersionPending shouldBe null + info.leftEarbudUuid!!.size shouldBe 17 + info.rightEarbudUuid!!.size shouldBe 17 + info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1637166475L) + info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1651135834L) } // ── Battery ────────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt index 7abe99da..3f0ea976 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt @@ -4,7 +4,6 @@ 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 @@ -63,13 +62,28 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() { // 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 + // Segments 11 and 12 are fixed 17-byte UUIDs per the dissector schema, not + // NUL-delimited. Verify the segmenter knows this and keeps the right offsets + // for everything that follows (timestamps at 13 and 14). + segments[5].utf8 shouldBe "81.2675000075000000.6082" // firmwareVersionPending + segments[6].utf8 shouldBe "1.0.0" // hardwareVersion + segments[7].utf8 shouldBe "com.apple.accessory.updater.app.71" + segments[8].utf8 shouldBe "H3KL7HR926JY" + segments[9].utf8 shouldBe "H3KL2AYL26K0" + segments[10].utf8 shouldBe "8454480" + + segments[11].length shouldBe 17 + segments[11].hex shouldBe "1F3FB4B7E9814811946BC26F3C5F5A340B" + segments[11].utf8.shouldBeNull() // binary UUID — not decodable as UTF-8 + + segments[12].length shouldBe 17 + segments[12].hex shouldBe "AB7E42AABDF149E3A898E781D604F5681F" + segments[12].utf8.shouldBeNull() + + segments[13].utf8 shouldBe "1697480211" + segments[14].utf8 shouldBe "1697480211" + + segments.size shouldBe 15 } @Test diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index b7561e19..9bebdb10 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage +import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import io.kotest.matchers.collections.shouldBeEmpty @@ -50,11 +51,15 @@ class AapSessionEngineTest : BaseTest() { every { decodeBattery(any()) } returns null every { decodePrivateKeyResponse(any()) } returns null every { decodeDeviceInfo(any()) } returns null + every { decodeCaseInfo(any()) } returns null + every { decodeSleepEvent(any()) } returns null + every { decodeDynamicEndOfChargeEvent(any()) } returns null every { decodeSetting(any()) } returns null every { encodeHandshake() } returns ByteArray(10) every { encodeNotificationEnable() } returns emptyList() every { encodeInitExt() } returns ByteArray(10) every { encodePrivateKeyRequest() } returns null + every { encodeCaseInfoRequest() } returns null block() } @@ -188,6 +193,52 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING } + + @Test + fun `Connect Response with status=0 stores features bitmask`() { + val engine = createEngine() + val scope = TestScope(UnconfinedTestDispatcher()) + + engine.start(scope) + engine.onHandshakeSent() + + val packet = AapPacket.ConnectResponse( + raw = ByteArray(18), + service = 0x0004, + status = 0x0000, + major = 0x0001, + minor = 0x0003, + features = 0xE251_0004_E1B1_0004UL, + ) + engine.processConnectResponse(packet) + + engine.state.value.negotiatedFeatures shouldBe 0xE251_0004_E1B1_0004UL + engine.state.value.connectResponseStatus shouldBe 0 + engine.state.value.lastMessageAt.shouldNotBeNull() + } + + @Test + fun `Connect Response with non-zero status does not store features`() { + val engine = createEngine() + val scope = TestScope(UnconfinedTestDispatcher()) + + engine.start(scope) + engine.onHandshakeSent() + + val packet = AapPacket.ConnectResponse( + raw = ByteArray(18), + service = 0x0004, + status = 0x0001, + major = 0x0001, + minor = 0x0003, + features = 0xFFFFFFFFFFFFFFFFUL, + ) + engine.processConnectResponse(packet) + + engine.state.value.connectResponseStatus shouldBe 0x0001 + engine.state.value.negotiatedFeatures.shouldBeNull() + engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING + } } // ── Send path ─────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt index 7262b75c..6a8a8fab 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt @@ -77,6 +77,50 @@ class HidTrackerTest : BaseTest() { val result = HidTracker.classify(payload) result.shouldBeInstanceOf() } + + @Test + fun `service info frame extracts ASCII tokens (VendorID, SerialNumber, CFG…)`() { + // Excerpt from a real AirPods Pro 2 USB-C 0x0017 frame observed after + // the descriptor batch. Magic 00 00 10 00 + TLV-ish mix of ASCII keys + // and binary values. + val payload = hexToBytes( + "00 00 10 00 D4 01 08 01 10 03 2A CD 03 08 10 12 C8 03 D3 00 00 00 " + + "0C 00 00 81 08 00 00 09 56 65 6E 64 6F 72 49 44 40 00 00 04 AC 05 00 00 " + + "00 00 00 00 0C 00 00 09 53 65 72 69 61 6C 4E 75 6D 62 65 72 0C 00 00 09 " + + "48 33 4B 4C 37 48 52 39 32 36 4A 59 04 00 00 09 43 46 47" + ) + val result = HidTracker.classify(payload) + result.shouldBeInstanceOf() + // 0x40 ('@') is printable ASCII and sits right after "VendorID" as a TLV + // tag byte — the extractor includes any adjacent printable byte, so the + // token surfaces as "VendorID@". That's an acceptable cosmetic side-effect + // for a debug log; the key string stays grep-able. + result.asciiTokens shouldContainExactly listOf( + "VendorID@", + "SerialNumber", + "H3KL7HR926JY", + "CFG", + ) + result.payloadSize shouldBe payload.size + } + + @Test + fun `service info frame with no ASCII runs returns empty token list`() { + val payload = hexToBytes("00 00 10 00 01 02 03 04 05 06 07 08") + val result = HidTracker.classify(payload) + result.shouldBeInstanceOf() + result.asciiTokens.shouldBeEmpty() + } + + @Test + fun `service info magic requires exact 00 00 10 00 prefix`() { + // 00 04 00 00 (descriptor prefix) must not match ServiceInfo + HidTracker.classify(hexToBytes("00 04 00 00 44 00 01 A1 81") + ByteArray(65) { 0xFF.toByte() }) + .shouldBeInstanceOf() + // 00 00 10 01 must not match + HidTracker.classify(hexToBytes("00 00 10 01 AA BB CC DD")) + .shouldBeInstanceOf() + } } // ── Batching ─────────────────────────────────────────── @@ -184,6 +228,25 @@ class HidTrackerTest : BaseTest() { logs[1] shouldBe "HID: terminator (7B)" } + @Test + fun `service info flushes pending batch and logs named tokens`() { + val logs = mutableListOf() + val tracker = HidTracker { logs.add(it) } + + val fill = ByteArray(65) { 0xFF.toByte() } + val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill + val serviceInfo = hexToBytes( + "00 00 10 00 00 00 00 00 56 65 6E 64 6F 72 49 44 00 00" + ) + + tracker.consume(descriptor) + tracker.consume(serviceInfo) + + logs.size shouldBe 2 + logs[0] shouldBe "HID: 1 descriptor frames phase=0x81 fill=0xFF" + logs[1] shouldBe "HID: service info tokens=[VendorID] (${serviceInfo.size}B)" + } + @Test fun `flush with zero count is no-op`() { val logs = mutableListOf() diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt new file mode 100644 index 00000000..c6f3c218 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt @@ -0,0 +1,53 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AapControlIdTest : BaseTest() { + + @Test + fun `no duplicate control IDs`() { + val grouped = AapControlId.entries.groupBy { it.value } + val duplicates = grouped.filterValues { it.size > 1 } + duplicates shouldBe emptyMap() + } + + @Test + fun `every entry round-trips via byValue`() { + for (id in AapControlId.entries) { + AapControlId.byValue(id.value) shouldBe id + } + } + + @Test + fun `unknown control IDs return null`() { + AapControlId.byValue(0x7F).shouldBeNull() + AapControlId.byValue(-1).shouldBeNull() + AapControlId.byValue(0x50).shouldBeNull() // Past the known catalog + } + + @Test + fun `load-bearing control IDs keep their values`() { + AapControlId.MIC_MODE.value shouldBe 0x01 + AapControlId.IN_EAR_DETECTION.value shouldBe 0x0A + AapControlId.LISTEN_MODE.value shouldBe 0x0D + AapControlId.DOUBLE_CLICK_INTERVAL.value shouldBe 0x17 + AapControlId.CLICK_AND_HOLD_INTERVAL.value shouldBe 0x18 + AapControlId.LISTENING_MODE_CONFIGS.value shouldBe 0x1A + AapControlId.ONE_BUD_ANC_MODE.value shouldBe 0x1B + AapControlId.CHIME_VOLUME.value shouldBe 0x1F + AapControlId.VOLUME_SWIPE_INTERVAL.value shouldBe 0x23 + AapControlId.CALL_MANAGEMENT_CONFIG.value shouldBe 0x24 + AapControlId.VOLUME_SWIPE_MODE.value shouldBe 0x25 + AapControlId.ADAPTIVE_VOLUME.value shouldBe 0x26 + AapControlId.CONVERSATION_DETECT.value shouldBe 0x28 + AapControlId.AUTO_ANC_STRENGTH.value shouldBe 0x2E + AapControlId.IN_CASE_TONE.value shouldBe 0x31 + AapControlId.ALLOW_OFF_OPTION.value shouldBe 0x34 + AapControlId.SLEEP_DETECTION.value shouldBe 0x35 + AapControlId.RAW_GESTURES_CONFIG.value shouldBe 0x39 + AapControlId.DYNAMIC_END_OF_CHARGE.value shouldBe 0x3B + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt index ee76afa8..6220c468 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt @@ -21,14 +21,16 @@ class AapMessageTest : BaseTest() { } @Test - fun `parse handshake response`() { + fun `handshake response parses via AapMessage-parse as null (not a Message packet)`() { + // Packet type 0x0001 = Connect Response. AapMessage.parse now only returns + // Message-type packets; the full packet lives in AapPacket.parse. + // Historically this test asserted it decoded as a Message with commandType=0 — + // that was treating the Connect Response's `status` field as a command ID. val raw = byteArrayOf( 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ) - val msg = AapMessage.parse(raw) - msg.shouldNotBeNull() - msg.commandType shouldBe 0x0000 + AapMessage.parse(raw).shouldBeNull() } @Test diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt new file mode 100644 index 00000000..5c3be828 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt @@ -0,0 +1,49 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AapMessageTypeTest : BaseTest() { + + @Test + fun `no duplicate opcodes`() { + val grouped = AapMessageType.entries.groupBy { it.value } + val duplicates = grouped.filterValues { it.size > 1 } + duplicates shouldBe emptyMap() + } + + @Test + fun `every entry round-trips via byValue`() { + for (type in AapMessageType.entries) { + AapMessageType.byValue(type.value) shouldBe type + } + } + + @Test + fun `unknown opcodes return null`() { + AapMessageType.byValue(0x7FFF).shouldBeNull() + AapMessageType.byValue(-1).shouldBeNull() + AapMessageType.byValue(0x0018).shouldBeNull() // Gap in the known catalog + } + + @Test + fun `load-bearing opcodes keep their values`() { + AapMessageType.BATTERY_INFO.value shouldBe 0x0004 + AapMessageType.EAR_DETECTION.value shouldBe 0x0006 + AapMessageType.BUD_ROLE.value shouldBe 0x0008 + AapMessageType.CONTROL.value shouldBe 0x0009 + AapMessageType.AUDIO_SOURCE.value shouldBe 0x000E + AapMessageType.BUDDY_COMMAND.value shouldBe 0x0017 + AapMessageType.STEM_PRESS.value shouldBe 0x0019 + AapMessageType.RENAME.value shouldBe 0x001A + AapMessageType.INFORMATION.value shouldBe 0x001D + AapMessageType.CONNECTED_DEVICES.value shouldBe 0x002E + AapMessageType.MAGIC_KEYS_REQUEST.value shouldBe 0x0030 + AapMessageType.MAGIC_KEYS.value shouldBe 0x0031 + AapMessageType.CONVERSATIONAL_AWARENESS.value shouldBe 0x004B + AapMessageType.SOURCE_FEATURE_CAPABILITIES.value shouldBe 0x004D + AapMessageType.PME_CONFIG.value shouldBe 0x0053 + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt new file mode 100644 index 00000000..217cbafa --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt @@ -0,0 +1,113 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AapPacketTest : BaseTest() { + + private fun hex(str: String): ByteArray = str.split(" ") + .filter { it.isNotBlank() } + .map { it.toInt(16).toByte() } + .toByteArray() + + @Test + fun `parse Connect (type 0x0000)`() { + // CAPod's handshake packet + val bytes = hex("00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.service shouldBe 0x0004 + packet.major shouldBe 0x0001 + packet.minor shouldBe 0x0002 + packet.features shouldBe 0UL + } + + @Test + fun `parse Connect Response (type 0x0001) status=0 zero features`() { + val bytes = hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.service shouldBe 0x0004 + packet.status shouldBe 0x0000 + packet.major shouldBe 0x0001 + packet.minor shouldBe 0x0003 + packet.features shouldBe 0UL + } + + @Test + fun `parse Connect Response with non-zero features (Pro 1 capture)`() { + val bytes = hex("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.status shouldBe 0 + packet.features shouldBe 0xE251_0004_E1B1_0004UL + } + + @Test + fun `parse Connect Response with non-zero status does not crash`() { + // Simulate a failure response — status=1, everything else zeroed + val bytes = hex("01 00 04 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.status shouldBe 0x0001 + } + + @Test + fun `parse Disconnect (type 0x0002)`() { + val bytes = hex("02 00 04 00 00 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.service shouldBe 0x0004 + packet.status shouldBe 0x0000 + } + + @Test + fun `parse Disconnect Response (type 0x0003)`() { + val bytes = hex("03 00 04 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.service shouldBe 0x0004 + } + + @Test + fun `parse Message (type 0x0004)`() { + // ANC mode ON control message + val bytes = hex("04 00 04 00 09 00 0D 02 00 00 00") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.commandType shouldBe 0x0009 + packet.payload.size shouldBe 5 + packet.payload[0] shouldBe 0x0D.toByte() + } + + @Test + fun `parse unknown packet type returns Unknown variant`() { + val bytes = hex("09 00 04 00 AA BB CC") + val packet = AapPacket.parse(bytes).shouldBeInstanceOf() + packet.packetType shouldBe 0x0009 + } + + @Test + fun `parse returns null for too-short input`() { + AapPacket.parse(byteArrayOf()).shouldBeNull() + AapPacket.parse(hex("04 00")).shouldBeNull() + // Message packet minimum is 6 bytes (4-byte header + 2-byte command type) + AapPacket.parse(hex("04 00 04 00 09")).shouldBeNull() + } + + @Test + fun `parse truncated Connect Response returns null`() { + // Need 18 bytes for a Connect Response; this is only 16 + AapPacket.parse(hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00")).shouldBeNull() + } + + @Test + fun `AapMessage-parse only returns Message-type packets`() { + // Connect Response bytes — AapMessage.parse (= AapPacket.Message.parse) rejects + val connectResponseBytes = hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00") + AapMessage.parse(connectResponseBytes).shouldBeNull() + + // Message bytes — accepted + val messageBytes = hex("04 00 04 00 09 00 0D 02 00 00 00") + val msg = AapMessage.parse(messageBytes) + msg.shouldNotBeNull() + msg.commandType shouldBe 0x0009 + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt index bfabdacf..a0913a34 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt @@ -18,14 +18,29 @@ abstract class BaseAapSessionTest : BaseTest() { // ── Hex parsing ────────────────────────────────────────── - /** Parse hex string(s) into an [AapMessage]. Multiple args are concatenated. */ - protected fun aapMessage(vararg hexParts: String): AapMessage { - val bytes = hexParts.joinToString(" ") + /** Concatenate hex string parts into a byte array. */ + protected fun parseHex(vararg hexParts: String): ByteArray = + hexParts.joinToString(" ") .split(" ") .filter { it.isNotBlank() } .map { it.toInt(16).toByte() } .toByteArray() - return AapMessage.parse(bytes) ?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}") + + /** Parse hex string(s) into an [AapMessage]. Fails if the bytes aren't a Message-type packet. */ + protected fun aapMessage(vararg hexParts: String): AapMessage { + val bytes = parseHex(*hexParts) + return AapMessage.parse(bytes) + ?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}") + } + + /** + * Parse hex string(s) into an [AapPacket] — use this for Connect / Connect Response + * / Disconnect frames where [aapMessage] would return null. + */ + protected fun aapPacket(vararg hexParts: String): AapPacket { + val bytes = parseHex(*hexParts) + return AapPacket.parse(bytes) + ?: error("Failed to parse AapPacket from: ${hexParts.joinToString(" ")}") } // ── Message builders (hide protocol header bytes) ──────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt new file mode 100644 index 00000000..532227ea --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt @@ -0,0 +1,162 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +import eu.darken.capod.pods.core.apple.PodModel +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +/** + * Edge-case coverage for `DefaultAapDeviceProfile.decodeDeviceInfo`. The three + * `AirPods*AapSessionTest` classes hold the authoritative per-model golden + * captures; this file covers the parser's behaviour when payloads deviate + * from the full 15-segment schema. + */ +class DefaultAapDeviceProfileDeviceInfoTest : BaseAapSessionTest() { + + override val podModel = PodModel.AIRPODS_PRO3 + + private fun infoMessage(vararg hexParts: String): AapMessage = + aapMessage("04 00 04 00 1D 00 02 ED 00 04 00", *hexParts) + + @Test + fun `truncated payload with only required system fields decodes without crashing`() { + val msg = infoMessage( + "41 69 72 50 6F 64 73 00", // "AirPods\0" + "41 32 30 38 34 00", // "A2084\0" + "41 70 70 6C 65 00", // "Apple\0" + "53 31 32 33 00", // "S123\0" + ) + val info = profile.decodeDeviceInfo(msg)!! + info.name shouldBe "AirPods" + info.modelNumber shouldBe "A2084" + info.manufacturer shouldBe "Apple" + info.serialNumber shouldBe "S123" + info.firmwareVersion shouldBe "" + info.firmwareVersionPending.shouldBeNull() + info.marketingVersion.shouldBeNull() + info.leftEarbudUuid.shouldBeNull() + info.rightEarbudUuid.shouldBeNull() + info.leftEarbudFirstPaired.shouldBeNull() + info.rightEarbudFirstPaired.shouldBeNull() + } + + @Test + fun `truncated after marketingVersion leaves UUIDs and timestamps null`() { + val msg = infoMessage( + "41 69 72 50 6F 64 73 00", + "41 32 30 38 34 00", + "41 70 70 6C 65 00", + "53 31 32 33 00", + "38 31 00", // firmware active "81\0" + "38 31 00", // firmware pending (same) → should be null + "31 2E 30 2E 30 00", // hardware "1.0.0\0" + "65 61 00", // EA protocol "ea\0" + "4C 31 00", // left bud serial "L1\0" + "52 31 00", // right bud serial "R1\0" + "38 34 35 34 00", // marketing version "8454\0" + // No UUID blob, no timestamps + ) + val info = profile.decodeDeviceInfo(msg)!! + info.marketingVersion shouldBe "8454" + info.firmwareVersion shouldBe "81" + info.firmwareVersionPending.shouldBeNull() + info.hardwareVersion shouldBe "1.0.0" + info.eaProtocolName shouldBe "ea" + info.leftEarbudSerial shouldBe "L1" + info.rightEarbudSerial shouldBe "R1" + info.leftEarbudUuid.shouldBeNull() + info.rightEarbudUuid.shouldBeNull() + info.leftEarbudFirstPaired.shouldBeNull() + info.rightEarbudFirstPaired.shouldBeNull() + } + + @Test + fun `partial UUID present - single left UUID but no right - does not fail`() { + val leftUuidHex = "AA BB CC DD EE FF 11 22 33 44 55 66 77 88 99 00 01" + val msg = infoMessage( + "41 00", "41 00", "41 00", "53 00", + "46 00", "46 00", "48 00", "65 00", + "4C 00", "52 00", "4D 00", + leftUuidHex, + // 17 bytes — enough for left UUID, right UUID read fails + ) + val info = profile.decodeDeviceInfo(msg)!! + info.leftEarbudUuid!!.size shouldBe 17 + info.leftEarbudUuid!![0] shouldBe 0xAA.toByte() + info.leftEarbudUuid!![16] shouldBe 0x01.toByte() + info.rightEarbudUuid.shouldBeNull() + } + + @Test + fun `firmware pending decoded when different from active firmware`() { + val msg = infoMessage( + "41 00", "41 00", "41 00", "53 00", + "38 31 2E 31 00", // firmware active "81.1\0" + "38 31 2E 32 00", // firmware pending "81.2\0" (different) + "31 2E 30 00", + ) + val info = profile.decodeDeviceInfo(msg)!! + info.firmwareVersion shouldBe "81.1" + info.firmwareVersionPending shouldBe "81.2" + } + + @Test + fun `firmware pending suppressed when matching active firmware`() { + val msg = infoMessage( + "41 00", "41 00", "41 00", "53 00", + "38 31 00", + "38 31 00", + ) + val info = profile.decodeDeviceInfo(msg)!! + info.firmwareVersion shouldBe "81" + info.firmwareVersionPending.shouldBeNull() + } + + @Test + fun `UUID with embedded zero bytes is preserved verbatim`() { + val uuidWithZero = "11 00 22 00 33 44 55 66 77 88 99 AA BB CC DD EE FF" + val msg = infoMessage( + "41 00", "41 00", "41 00", "53 00", + "46 00", "46 00", "48 00", "65 00", + "4C 00", "52 00", "4D 00", + uuidWithZero, + uuidWithZero, + ) + val info = profile.decodeDeviceInfo(msg)!! + // Embedded 0x00 at indices 1 and 3 must be preserved — we must NOT split UUID on NUL + info.leftEarbudUuid!!.size shouldBe 17 + info.leftEarbudUuid!![0] shouldBe 0x11.toByte() + info.leftEarbudUuid!![1] shouldBe 0x00.toByte() + info.leftEarbudUuid!![2] shouldBe 0x22.toByte() + info.leftEarbudUuid!![3] shouldBe 0x00.toByte() + info.rightEarbudUuid!![1] shouldBe 0x00.toByte() + } + + @Test + fun `malformed first-paired timestamp is silently dropped`() { + val uuid = "11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF 00 01" + val msg = infoMessage( + "41 00", "41 00", "41 00", "53 00", + "46 00", "46 00", "48 00", "65 00", + "4C 00", "52 00", "4D 00", + uuid, uuid, + "6E 6F 74 61 64 61 74 65 00", // "notadate\0" + "31 36 39 37 34 38 30 32 31 31 00", // valid right timestamp + ) + val info = profile.decodeDeviceInfo(msg)!! + info.leftEarbudFirstPaired.shouldBeNull() + info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L) + } + + @Test + fun `decodeDeviceInfo returns null for non-Information messages`() { + val msg = aapMessage("04 00 04 00 09 00 0D 02 00 00 00") // Control message, not 0x1D + profile.decodeDeviceInfo(msg).shouldBeNull() + } + + @Test + fun `decodeDeviceInfo returns null for too-short payload`() { + val msg = aapMessage("04 00 04 00 1D 00 02") // just 1 byte of payload after command type + profile.decodeDeviceInfo(msg).shouldBeNull() + } +}