From 71638c13ab4a656c77468e9373421cf3bf58cd48 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 31 Mar 2026 15:00:53 +0200 Subject: [PATCH] feat: Decode AAP ear detection and stabilize ANC mode changes Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit. Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment. Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback. --- .../main/ui/overview/cards/DualPodsCard.kt | 1 + .../ui/overview/cards/PodCardComponents.kt | 12 +- .../main/ui/overview/cards/SinglePodsCard.kt | 1 + .../eu/darken/capod/monitor/core/PodDevice.kt | 43 +++++- .../pods/core/apple/aap/AapConnection.kt | 82 ++++++++++- .../capod/pods/core/apple/aap/AapPodState.kt | 8 ++ .../core/apple/aap/protocol/AapSetting.kt | 13 ++ .../aap/protocol/DefaultAapDeviceProfile.kt | 17 +++ .../capod/monitor/core/PodDeviceTest.kt | 130 ++++++++++++++++++ .../pods/core/apple/aap/AapPodStateTest.kt | 80 +++++++++++ .../devices/DefaultAapDeviceProfileTest.kt | 54 ++++++++ .../airpods/AirPodsPro3AapSessionTest.kt | 32 ++++- 12 files changed, 462 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 3f1a2941..7b7cd489 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -210,6 +210,7 @@ fun DualPodsCard( currentMode = ancMode.current, supportedModes = ancMode.supported, onModeSelected = { onAncModeChange?.invoke(it) }, + pendingMode = device.pendingAncMode, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index 6c7b8b34..d37ba160 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -236,13 +236,23 @@ fun AncModeSelector( currentMode: AapSetting.AncMode.Value, supportedModes: List, onModeSelected: (AapSetting.AncMode.Value) -> Unit, + pendingMode: AapSetting.AncMode.Value? = null, ) { + val displayMode = pendingMode ?: currentMode SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { supportedModes.forEachIndexed { index, mode -> SegmentedButton( - selected = mode == currentMode, + selected = mode == displayMode, onClick = { onModeSelected(mode) }, shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size), + colors = if (pendingMode != null && mode == displayMode) { + SegmentedButtonDefaults.colors( + activeContainerColor = MaterialTheme.colorScheme.secondaryContainer, + activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + SegmentedButtonDefaults.colors() + }, label = { Text( text = when (mode) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 4dbd2cbc..cc7f3386 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -222,6 +222,7 @@ fun SinglePodsCard( currentMode = ancMode.current, supportedModes = ancMode.supported, onModeSelected = { onAncModeChange?.invoke(it) }, + pendingMode = device.pendingAncMode, ) } 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 23f72f1d..45e39621 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 @@ -93,18 +93,48 @@ data class PodDevice( val isHeadsetBeingCharged: Boolean? get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged - // Ear detection + // Ear detection — AAP preferred (lower latency), BLE fallback. + // AAP reports primary/secondary; BLE bit 5 tells us which physical pod is primary. val isLeftInEar: Boolean? - get() = (ble as? HasEarDetectionDual)?.isLeftPodInEar + get() { + val earDetection = aap?.aapEarDetection + val primary = (ble as? DualApplePods)?.primaryPod + if (earDetection != null && primary != null) { + return if (primary == DualBlePodSnapshot.Pod.LEFT) { + earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + } else { + earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + } + } + return (ble as? HasEarDetectionDual)?.isLeftPodInEar + } val isRightInEar: Boolean? - get() = (ble as? HasEarDetectionDual)?.isRightPodInEar + get() { + val earDetection = aap?.aapEarDetection + val primary = (ble as? DualApplePods)?.primaryPod + if (earDetection != null && primary != null) { + return if (primary == DualBlePodSnapshot.Pod.RIGHT) { + earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + } else { + earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + } + } + return (ble as? HasEarDetectionDual)?.isRightPodInEar + } val isBeingWorn: Boolean? - get() = (ble as? HasEarDetection)?.isBeingWorn + get() { + val earDetection = aap?.aapEarDetection + if (earDetection != null) { + return earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + && earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + } + return (ble as? HasEarDetection)?.isBeingWorn + } val isEitherPodInEar: Boolean? - get() = (ble as? HasEarDetectionDual)?.isEitherPodInEar + get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar val caseLidState: DualApplePods.LidState? get() = (ble as? DualApplePods)?.caseLidState @@ -137,6 +167,9 @@ data class PodDevice( val ancMode: AapSetting.AncMode? get() = aap?.setting() + val pendingAncMode: AapSetting.AncMode.Value? + get() = aap?.pendingAncMode + val conversationalAwareness: AapSetting.ConversationalAwareness? get() = aap?.setting() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 261642a6..7b9dbb14 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -12,11 +12,13 @@ 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.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -56,6 +58,11 @@ internal class AapConnection( private val writeMutex = Mutex() private val framer = AapFramer() + private var pendingAncMode: AapSetting.AncMode.Value? = null + private var ancDebounceJob: Job? = null + private var connectionScope: CoroutineScope? = null + private var lastAncCommandSentAt: Long = 0L + /** * Opens the L2CAP socket, sends the handshake, and launches the read loop. * Returns after the handshake is sent — the read loop runs in [scope] independently. @@ -65,6 +72,7 @@ internal class AapConnection( throw IllegalStateException("connect() called in state ${_state.value.connectionState}") } + connectionScope = scope _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) try { @@ -116,6 +124,10 @@ internal class AapConnection( log(TAG) { "Disconnecting" } readerJob?.cancel() readerJob = null + ancDebounceJob?.cancel() + ancDebounceJob = null + pendingAncMode = null + connectionScope = null cleanupSocket() framer.reset() _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) @@ -127,12 +139,37 @@ internal class AapConnection( throw IllegalStateException("Cannot send command in state ${currentState.connectionState}") } + if (command is AapCommand.SetAncMode) { + val earDetection = currentState.setting() + if (earDetection != null && !earDetection.isEitherPodInEar) { + log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" } + pendingAncMode = command.mode + _state.value = currentState.copy(pendingAncMode = command.mode) + return + } + pendingAncMode = null + // Optimistically update UI — don't wait for device echo + val currentAnc = currentState.setting() + if (currentAnc != null) { + _state.value = currentState + .withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode)) + .copy(pendingAncMode = null, lastMessageAt = Instant.now()) + } else { + _state.value = currentState.copy(pendingAncMode = null) + } + } + + sendRaw(command) + } + + private suspend fun sendRaw(command: AapCommand) { val bytes = profile.encodeCommand(command) writeMutex.withLock { withContext(Dispatchers.IO) { val sock = socket ?: throw IOException("Socket is null") sock.outputStream.write(bytes) sock.outputStream.flush() + if (command is AapCommand.SetAncMode) lastAncCommandSentAt = System.currentTimeMillis() log(TAG) { "Sent command: $command (${bytes.size} bytes)" } } } @@ -169,8 +206,13 @@ internal class AapConnection( } catch (e: IOException) { if (isActive) log(TAG, ERROR) { "Read error: $e" } } finally { + ancDebounceJob?.cancel() + pendingAncMode = null cleanupSocket() - _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.DISCONNECTED) + _state.value = _state.value.copy( + connectionState = AapPodState.ConnectionState.DISCONNECTED, + pendingAncMode = null, + ) } } @@ -202,8 +244,46 @@ internal class AapConnection( // Try setting update (merge into existing state) profile.decodeSetting(message)?.let { (key, value) -> + // Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions). + // Skip debounce for: first ANC mode (initial setup), echoes after our own command. + if (value is AapSetting.AncMode) { + val isFirstAncMode = _state.value.setting() == null + val sinceLastCommand = System.currentTimeMillis() - lastAncCommandSentAt + if (isFirstAncMode || sinceLastCommand <= 3000L) { + ancDebounceJob?.cancel() + _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) + log(TAG) { "Setting: ${key.simpleName} = $value" } + } else { + ancDebounceJob?.cancel() + ancDebounceJob = connectionScope?.launch { + delay(1500L) + _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) + log(TAG) { "Setting (debounced): ${key.simpleName} = $value" } + } + } + return + } + _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) log(TAG) { "Setting: ${key.simpleName} = $value" } + + // Flush queued ANC command when a pod goes in ear + if (value is AapSetting.EarDetection && value.isEitherPodInEar) { + pendingAncMode?.let { mode -> + pendingAncMode = null + // Optimistic update — show target mode immediately, don't wait for device echo + val currentAnc = _state.value.setting() + if (currentAnc != null) { + _state.value = _state.value + .withSetting(AapSetting.AncMode::class, currentAnc.copy(current = mode)) + .copy(pendingAncMode = null, lastMessageAt = Instant.now()) + } else { + _state.value = _state.value.copy(pendingAncMode = null) + } + log(TAG) { "Pod in ear, sending queued ANC mode: $mode" } + connectionScope?.launch { sendRaw(AapCommand.SetAncMode(mode)) } + } + } return } 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 c9316a4e..0d968670 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 @@ -14,12 +14,20 @@ data class AapPodState( val settings: Map, AapSetting> = emptyMap(), val batteries: Map = emptyMap(), val lastMessageAt: Instant? = null, + val pendingAncMode: AapSetting.AncMode.Value? = null, ) { inline fun setting(): T? = settings[T::class] as? T fun withSetting(key: KClass, value: AapSetting): AapPodState = copy(settings = settings + (key to value)) + // Ear detection — from AAP command 0x06 + val aapEarDetection: AapSetting.EarDetection? + get() = setting() + + val isEitherPodInEar: Boolean? + get() = aapEarDetection?.isEitherPodInEar + // Battery — from AAP command 0x04, 1% granularity val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent 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 c4878fac..5db04766 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 @@ -101,4 +101,17 @@ sealed class AapSetting { data class ConversationalAwarenessState( val speaking: Boolean, ) : AapSetting() + + /** Per-pod placement reported by the device (command 0x06). */ + data class EarDetection( + val primaryPod: PodPlacement, + val secondaryPod: PodPlacement, + ) : AapSetting() { + enum class PodPlacement { + IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED, + } + + val isEitherPodInEar: Boolean + get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR + } } 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 668de891..72dee24b 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 @@ -21,6 +21,7 @@ class DefaultAapDeviceProfile( 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_CONVERSATION_AWARENESS_STATE = 0x004B // Setting IDs (first byte of settings command payload) @@ -85,6 +86,15 @@ class DefaultAapDeviceProfile( } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { + // Ear detection is a separate command type (push-only from device) + if (message.commandType == CMD_EAR_DETECTION) { + if (message.payload.size < 2) return null + return AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF), + secondaryPod = decodePodPlacement(message.payload[1].toInt() and 0xFF), + ) + } + // Conversation Awareness State is a separate command type (push-only) if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { if (message.payload.isEmpty()) return null @@ -237,6 +247,13 @@ class DefaultAapDeviceProfile( ) } + private fun decodePodPlacement(wireValue: Int): AapSetting.EarDetection.PodPlacement = when (wireValue) { + 0x00 -> AapSetting.EarDetection.PodPlacement.IN_EAR + 0x01 -> AapSetting.EarDetection.PodPlacement.NOT_IN_EAR + 0x02 -> AapSetting.EarDetection.PodPlacement.IN_CASE + else -> AapSetting.EarDetection.PodPlacement.DISCONNECTED + } + protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) { AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF AapSetting.AncMode.Value.ON -> ANC_WIRE_ON diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index d0f09c9c..3ad30f0f 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.devices.HasCase import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual import eu.darken.capod.pods.core.apple.PodModel @@ -176,6 +177,135 @@ class PodDeviceTest : BaseTest() { device.isEitherPodInEar shouldBe true } + @Test + fun `AAP ear detection preferred over BLE`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns false + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + AapSetting.EarDetection.PodPlacement.IN_EAR, + AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isEitherPodInEar shouldBe true + } + + @Test + fun `ear detection falls back to BLE when no AAP ear data`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true + } + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice(ble = mock, aap = aap) + device.isEitherPodInEar shouldBe true + } + + @Test + fun `AAP left in ear maps via BLE primaryPod LEFT`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { primaryPod } returns DualBlePodSnapshot.Pod.LEFT + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftInEar shouldBe true + device.isRightInEar shouldBe false + } + + @Test + fun `AAP left in ear maps via BLE primaryPod RIGHT`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { primaryPod } returns DualBlePodSnapshot.Pod.RIGHT + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftInEar shouldBe false + device.isRightInEar shouldBe true + } + + @Test + fun `AAP isBeingWorn true when both pods in ear`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + ), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isBeingWorn shouldBe true + } + + @Test + fun `AAP isBeingWorn false when one pod not in ear`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isBeingWorn shouldBe false + } + + @Test + fun `pendingAncMode exposed from AAP state`() { + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE, + ) + val device = PodDevice(ble = mockDualPod(), aap = aap) + device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + } + + @Test + fun `pendingAncMode null when no AAP`() { + val device = PodDevice(ble = mockDualPod(), aap = null) + device.pendingAncMode.shouldBeNull() + } + + @Test + fun `pendingAncMode null when not set`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice(ble = mockDualPod(), aap = aap) + device.pendingAncMode.shouldBeNull() + } + @Test fun `icon and label properties delegate to BLE`() { val device = PodDevice( diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt index d2a5dc84..aa3048c9 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt @@ -180,4 +180,84 @@ class AapPodStateTest : BaseTest() { state.isCaseCharging.shouldBeNull() state.isHeadsetCharging.shouldBeNull() } + + // ── Ear Detection ─────────────────────────────────────── + + @Test + fun `ear detection accessor returns typed setting`() { + val state = AapPodState( + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + AapSetting.EarDetection.PodPlacement.IN_EAR, + AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ), + ) + state.aapEarDetection.shouldNotBeNull() + state.aapEarDetection!!.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR + } + + @Test + fun `ear detection accessor null when missing`() { + val state = AapPodState() + state.aapEarDetection.shouldBeNull() + } + + @Test + fun `isEitherPodInEar true when primary in ear`() { + val state = AapPodState( + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + AapSetting.EarDetection.PodPlacement.IN_EAR, + AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ), + ) + state.isEitherPodInEar shouldBe true + } + + @Test + fun `isEitherPodInEar true when secondary in ear`() { + val state = AapPodState( + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + AapSetting.EarDetection.PodPlacement.IN_EAR, + ), + ), + ) + state.isEitherPodInEar shouldBe true + } + + @Test + fun `isEitherPodInEar false when neither in ear`() { + val state = AapPodState( + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + AapSetting.EarDetection.PodPlacement.IN_CASE, + AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ), + ) + state.isEitherPodInEar shouldBe false + } + + @Test + fun `isEitherPodInEar null when no ear detection`() { + val state = AapPodState() + state.isEitherPodInEar.shouldBeNull() + } + + // ── Pending ANC Mode ──────────────────────────────────── + + @Test + fun `pendingAncMode defaults to null`() { + AapPodState().pendingAncMode.shouldBeNull() + } + + @Test + fun `pendingAncMode preserved in copy`() { + val state = AapPodState().copy(pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE) + state.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index 3da8100c..d45a8a08 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -420,6 +420,60 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Test fun `non-key message returns null`() { profile.decodePrivateKeyResponse(settingsMessage(0x0D, 0x02)).shouldBeNull() } } + // ── Ear Detection (0x06) ─────────────────────────────────── + + @Nested + inner class EarDetectionTests { + @Test fun `decode both pods in ear`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 00 00")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR + ed.isEitherPodInEar shouldBe true + } + + @Test fun `decode both pods not in ear`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 01 01")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR + ed.isEitherPodInEar shouldBe false + } + + @Test fun `decode both pods in case`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 02 02")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe false + } + + @Test fun `decode primary in ear, secondary in case`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 00 02")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe true + } + + @Test fun `decode primary not in ear, secondary in case`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 01 02")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe false + } + + @Test fun `decode unknown wire value maps to DISCONNECTED`() { + val ed = decodeSetting(aapMessage("04 00 04 00 06 00 FF 03")) + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.DISCONNECTED + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.DISCONNECTED + } + + @Test fun `payload too short returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 06 00 00")).shouldBeNull() + } + + @Test fun `does not interfere with settings decode`() { + decodeSetting(settingsMessage(0x0D, 0x02)).current shouldBe AapSetting.AncMode.Value.ON + } + } + // ── Edge Cases ─────────────────────────────────────────── @Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 974c2257..55f056f8 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 @@ -179,16 +179,40 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { // ── Unhandled Messages ─────────────────────────────────── + // ── Ear Detection (0x06) — real captures ────────────────── + + @Nested + inner class EarDetectionSessionTests { + @Test fun `both pods in case`() { + val ed = decodeSetting("04 00 04 00 06 00 02 02") + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe false + } + + @Test fun `pod taken from case`() { + val ed = decodeSetting("04 00 04 00 06 00 01 02") + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe false + } + + @Test fun `pod in ear`() { + val ed = decodeSetting("04 00 04 00 06 00 00 02") + ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR + ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE + ed.isEitherPodInEar shouldBe true + } + } + + // ── Unhandled Messages ─────────────────────────────────── + @Nested inner class UnhandledMessageTests { @Test fun `cmd 0x002B init exchange`() { profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 E9 B4 03")).shouldBeNull() } - @Test fun `cmd 0x0006 ear detection`() { - profile.decodeSetting(aapMessage("04 00 04 00 06 00 02 02")).shouldBeNull() - } - @Test fun `unknown settings IDs return null`() { val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B)