diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt index ef6ef9f9..7d5fc960 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt @@ -8,6 +8,12 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting internal data class VerificationState( val command: AapCommand, val attempt: Int = 0, + /** + * The listening mode the device was in when the write went out. Used to tell a refusal (the + * device echoes the mode it is staying in) apart from an unusable report (a third mode). Null + * for non-ANC commands. + */ + val previousAncMode: AapSetting.AncMode.Value? = null, ) internal data class OutboundRuntimeState( @@ -86,7 +92,13 @@ internal class AapOutboundController( return OutboundDecision( podState = updatedPodState, runtimeState = updatedRuntimeState.copy( - verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) } + verification = verificationCheck?.let { + VerificationState( + command = command, + attempt = 0, + previousAncMode = podState.setting()?.current, + ) + } ?: updatedRuntimeState.verification, ), commandsToSend = listOf(command), @@ -118,7 +130,11 @@ internal class AapOutboundController( runtimeState = runtimeState.copy( pendingCommands = result.pendingCommands, verification = if (verificationCheck != null) { - VerificationState(command = checkNotNull(toVerify), attempt = 0) + VerificationState( + command = checkNotNull(toVerify), + attempt = 0, + previousAncMode = podState.setting()?.current, + ) } else { runtimeState.verification }, @@ -179,6 +195,8 @@ internal class AapOutboundController( ) } + unusableAncReport(podState, runtimeState, verification)?.let { return it } + val ear = podState.setting() if (ear != null && !ear.isEitherPodInEar) { // Drop the pending mode too: nothing is going to confirm it now, and leaving it set @@ -208,6 +226,45 @@ internal class AapOutboundController( ) } + /** + * Distinguish a refusal from a report we cannot act on. + * + * A device that refuses a listening mode write echoes the mode it is staying in, and does so + * quickly (25-267ms in captures). AirPods Pro 3 have instead been seen answering an ADAPTIVE + * write with OFF at normal change latency (815-1010ms) while audibly switching to Adaptive: + * a third mode, neither the one requested nor the one it was in. + * + * Retrying that write is pointless (it already took effect) and reporting it as rejected is + * wrong. Treat the echo as noise, record the mode we asked for as current, and stop verifying. + * The raw frame is still logged upstream; nothing is suppressed at the protocol layer. + * + * Deliberately engine-local: it uses only the requested mode, the previous mode and the echo. + * Which modes a device permits is app-level knowledge and stays out of the session engine. + */ + private fun unusableAncReport( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + verification: VerificationState, + ): OutboundDecision? { + val command = verification.command as? AapCommand.SetAncMode ?: return null + val previous = verification.previousAncMode ?: return null + val ancMode = podState.setting() ?: return null + val reported = ancMode.current + if (reported == command.mode || reported == previous) return null + + return OutboundDecision( + podState = clearPendingForCommand( + podState.withSetting(AapSetting.AncMode::class, ancMode.copy(current = command.mode)), + command, + ), + runtimeState = runtimeState.copy(verification = null), + logs = listOf( + "Unusable ANC echo for ${command.mode} (reported=$reported, was=$previous), " + + "not a refusal: keeping ${command.mode}" + ), + ) + } + private fun clearPendingForCommand( podState: AapPodState, command: AapCommand, 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 8298b8ba..a4cf4256 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 @@ -724,6 +724,108 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE } + @Test + fun `third-mode echo is treated as an unusable report, not a refusal`() = + runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + val rejected = mutableListOf() + val collectJob = launch { engine.settingRejected.collect { rejected += it } } + + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it } + + // AirPods Pro 3 answering an ADAPTIVE write with OFF: neither the requested mode + // nor the one it was in. The write did take effect, so this must not be retried + // or reported as a rejection. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + + sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) + rejected shouldBe emptyList() + engine.state.value.setting()!!.current shouldBe + AapSetting.AncMode.Value.ADAPTIVE + engine.state.value.pendingAncMode.shouldBeNull() + collectJob.cancel() + } + + @Test + fun `echo of the previous mode is still treated as a refusal`() = + runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + val rejected = mutableListOf() + val collectJob = launch { engine.settingRejected.collect { rejected += it } } + + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it } + + // A real refusal echoes the mode the device is staying in. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + + sentCommands.size shouldBe 2 + rejected shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) + engine.state.value.setting()!!.current shouldBe + AapSetting.AncMode.Value.ON + collectJob.cancel() + } + @Test fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() = runTest(UnconfinedTestDispatcher()) {