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 a2387dd0..d3b2c617 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 @@ -90,6 +90,7 @@ fun DeviceSettingsScreenHost( var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) } val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) + val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message) val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message) val pendingInfoMessage = stringResource(R.string.device_settings_pending_info) @@ -125,6 +126,10 @@ fun DeviceSettingsScreenHost( snackbarHostState.showSnackbar(offRejectedMessage) } + DeviceSettingsViewModel.Event.AncModeNotConfirmedByDevice -> { + snackbarHostState.showSnackbar(ancNotConfirmedMessage) + } + DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> { snackbarHostState.showSnackbar(chargeCapRejectedMessage) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index c0f759ce..bcfbbe46 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -90,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor( data class SendFailed(val command: AapCommand, val message: String?) : Event data object SystemRenameUnavailable : Event data object OffModeRejectedByDevice : Event + data object AncModeNotConfirmedByDevice : Event data object DynamicEndOfChargeRejectedByDevice : Event } @@ -109,7 +110,11 @@ class DeviceSettingsViewModel @Inject constructor( when (command) { is AapCommand.SetDynamicEndOfCharge -> events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice) - else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF) + // OFF has its own, more specific message via offRejectedEvents. + is AapCommand.SetAncMode -> if (command.mode != AapSetting.AncMode.Value.OFF) { + events.tryEmit(Event.AncModeNotConfirmedByDevice) + } + else -> Unit } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 72714786..7a515d26 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -96,6 +96,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) + val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message) // Collect workerAutolaunch passively to keep it active LaunchedEffect(Unit) { @@ -108,6 +109,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { OverviewViewModel.Event.OffModeRejectedByDevice -> { snackbarHostState.showSnackbar(offRejectedMessage) } + + OverviewViewModel.Event.AncModeNotConfirmedByDevice -> { + snackbarHostState.showSnackbar(ancNotConfirmedMessage) + } } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index ce92d1c7..5dc93922 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor( sealed interface Event { data object OffModeRejectedByDevice : Event + data object AncModeNotConfirmedByDevice : Event } val events = SingleEventFlow() @@ -84,6 +85,14 @@ class OverviewViewModel @Inject constructor( events.tryEmit(Event.OffModeRejectedByDevice) } } + launch { + // OFF has its own, more specific message via offRejectedEvents. + aapManager.settingRejectedEvents.collect { (_, command) -> + if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.OFF) { + events.tryEmit(Event.AncModeNotConfirmedByDevice) + } + } + } } private val showUnmatchedDevices = MutableStateFlow(false) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt index 9fb4cbc5..51089eb6 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt @@ -18,18 +18,30 @@ fun resolvedAncCycleMask( null } -fun visibleAncModes( - supportedModes: List, - currentMode: AapSetting.AncMode.Value, +/** + * Whether the device is expected to be able to sit in [mode] at all, based on the listening mode + * cycle and the Allow Off option. Both of those are inferred rather than device-reported: AirPods + * never push 0x1A/0x34, so this is a belief, not ground truth. + */ +fun isAncModePermitted( + mode: AapSetting.AncMode.Value, cycleMask: Int?, allowOffEnabled: Boolean, -): List = supportedModes.filter { mode -> +): Boolean { val inCycle = if (cycleMask != null) { (cycleMask and mode.cycleBit()) != 0 } else { true } - inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode + return inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) +} + +fun visibleAncModes( + supportedModes: List, + cycleMask: Int?, + allowOffEnabled: Boolean, +): List = supportedModes.filter { mode -> + isAncModePermitted(mode, cycleMask, allowOffEnabled) } val PodDevice.resolvedAncCycleMask: Int? @@ -38,15 +50,17 @@ val PodDevice.resolvedAncCycleMask: Int? reportedCycleMask = listeningModeCycle?.modeMask, ) +// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a +// confirmed enabled=false (direct device report or inferred rejection) hides OFF. +private val PodDevice.resolvedAllowOffEnabled: Boolean + get() = allowOffOption?.enabled != false + val PodDevice.visibleAncModes: List get() { val ancMode = ancMode ?: return emptyList() return visibleAncModes( supportedModes = ancMode.supported, - currentMode = ancMode.current, cycleMask = resolvedAncCycleMask, - // Unknown (null) is treated as allowed so OFF is visible optimistically. Only a - // confirmed enabled=false (direct device report or inferred rejection) hides OFF. - allowOffEnabled = allowOffOption?.enabled != false, + allowOffEnabled = resolvedAllowOffEnabled, ) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt index 963145d9..27a4e0aa 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt @@ -8,6 +8,16 @@ import kotlin.reflect.KClass internal data class AncRuntimeState( val latestObservedAncMode: AapSetting.AncMode? = null, val pendingDebouncedAnc: PendingDebouncedAnc? = null, + /** + * Set when the device reported OFF while we had a request for a different mode outstanding. + * + * AirPods Pro 3 have been seen answering a listening mode write with OFF while actually + * switching to the requested mode. Such a report must not be fed to the Allow Off inference, + * or a single glitch permanently teaches CAPod that OFF is a permitted mode. Cleared as soon + * as any non-OFF mode is reported, so an unsolicited switch into OFF (stem press, another + * phone) still trains the inference normally. + */ + val offReportContradicted: Boolean = false, ) internal data class PendingDebouncedAnc( @@ -34,7 +44,17 @@ internal class AapAncController { now: Instant, ): AncDecision { val previous = podState.settings[key] - val updatedRuntime = runtimeState.copy(latestObservedAncMode = value) + val pendingMode = podState.pendingAncMode + // An OFF report with no competing request of our own is taken at face value, which both + // clears any earlier contradiction and keeps the Allow Off self-heal working after a + // glitch (stem press / another phone switching the pods into OFF for real). + val contradicted = value.current == AapSetting.AncMode.Value.OFF && + pendingMode != null && + pendingMode != AapSetting.AncMode.Value.OFF + val updatedRuntime = runtimeState.copy( + latestObservedAncMode = value, + offReportContradicted = contradicted, + ) val timerActions = mutableListOf() timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime)) @@ -90,6 +110,7 @@ internal class AapAncController { val latestEarDetection = podState.setting() val latestAllowOffOption = podState.setting() if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && + !runtimeState.offReportContradicted && latestEarDetection?.isEitherPodInEar == true && latestAllowOffOption?.enabled != true ) { @@ -153,6 +174,7 @@ internal class AapAncController { val earDetection = podState.setting() val allowOffOption = podState.setting() return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF && + !runtimeState.offReportContradicted && earDetection?.isEitherPodInEar == true && allowOffOption?.enabled != true ) { 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 cdcda4e6..ef6ef9f9 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 @@ -30,6 +30,23 @@ internal class AapOutboundController( private val coordinator = AapSettingsCoordinator(timeSource) + companion object { + /** + * How long to wait for the device to confirm a setting write before treating it as diverged. + * + * AirPods Pro 3 answer a listening mode write in roughly 0.8-1.1s (measured: 833ms, 887ms, + * 956ms, 1008ms). A 1000ms deadline sits inside that spread, so a perfectly healthy reply + * could land just after the timer and trigger a bogus "Divergence detected" plus a + * redundant re-send. The deadline is only a backstop now: [onStateObserved] resolves the + * verification as soon as a matching report arrives, so raising this does not slow the + * success path, only how long a genuinely unanswered write waits. + * + * Kept at roughly 2x the worst measured reply rather than higher, because a real rejection + * still costs two full deadlines before the user is told about it. + */ + const val VERIFICATION_TIMEOUT_MS = 2000L + } + fun onCommandRequested( podState: AapPodState, runtimeState: OutboundRuntimeState, @@ -74,7 +91,7 @@ internal class AapOutboundController( ), commandsToSend = listOf(command), timerActions = if (verificationCheck != null) { - listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)) } else { emptyList() }, @@ -108,7 +125,7 @@ internal class AapOutboundController( ), commandsToSend = result.commands, timerActions = if (verificationCheck != null) { - listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)) } else { emptyList() }, @@ -116,6 +133,34 @@ internal class AapOutboundController( ) } + /** + * Re-check the outstanding verification against freshly applied device state, so a confirmation + * is honoured the moment it arrives instead of waiting out [VERIFICATION_TIMEOUT_MS] and racing + * it. + * + * Deliberately limited to [AapCommand.SetAncMode]. Every other verified command gets an + * optimistic write into state when it is queued (see AapSettingsCoordinator.optimisticUpdate), + * which satisfies its own verification predicate straight away - only the device's contradicting + * echo later makes it fail. Reconciling those on arbitrary inbound frames would cancel the + * verification before that echo lands and silently swallow the rejection. SetAncMode is exempt + * from the optimistic write, so its predicate only becomes true once the device really confirms. + */ + fun onStateObserved( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState) + if (verification.command !is AapCommand.SetAncMode) return OutboundDecision(podState, runtimeState) + val check = coordinator.verificationFor(verification.command) + ?: return OutboundDecision(podState, runtimeState) + if (!check(podState)) return OutboundDecision(podState, runtimeState) + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.Verification)), + ) + } + fun onVerificationTimerFired( podState: AapPodState, runtimeState: OutboundRuntimeState, @@ -136,8 +181,10 @@ internal class AapOutboundController( 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 + // would keep the UI showing a mode the device never reached. return OutboundDecision( - podState = podState, + podState = clearPendingForCommand(podState, verification.command), runtimeState = runtimeState.copy(verification = null), logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"), ) @@ -148,7 +195,7 @@ internal class AapOutboundController( podState = podState, runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), commandsToSend = listOf(verification.command), - timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)), + timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)), logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), ) } 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 f73c3642..7118ac65 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 @@ -295,6 +295,7 @@ internal class AapSessionEngine( now = timeSource.now(), ) applyAncDecision(decision) + reconcileVerification() return } @@ -313,6 +314,8 @@ internal class AapSessionEngine( "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } + reconcileVerification() + if (value is AapSetting.EarDetection) { applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc)) if (value.isEitherPodInEar) { @@ -361,6 +364,21 @@ internal class AapSessionEngine( applyTimerActions(decision.timerActions) } + /** + * Settle an outstanding verification against state we just applied. Confirmations are honoured + * the moment the device's report lands rather than at the verification deadline, so a reply + * arriving close to that deadline can't be misread as a divergence. + */ + private fun reconcileVerification() { + if (runtimeState.outbound.verification == null) return + val decision = outboundController.onStateObserved(_state.value, runtimeState.outbound) + if (decision.runtimeState.verification != null) return + _state.value = decision.podState + runtimeState = runtimeState.copy(outbound = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + applyTimerActions(decision.timerActions) + } + /** * Apply the non-send side of a decision (state, runtime, logs) and prepare the send context. * @@ -397,6 +415,16 @@ internal class AapSessionEngine( runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification)) } + /** + * A send that threw never reached the device, so an optimistically stored pending ANC mode has + * nothing left to confirm it. Clearing it stops the UI from showing a mode we failed to request. + */ + private fun clearPendingAncAfterFailedSend(commands: List) { + val ancCommand = commands.filterIsInstance().lastOrNull() ?: return + if (_state.value.pendingAncMode != ancCommand.mode) return + _state.value = _state.value.copy(pendingAncMode = null) + } + /** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */ private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) { val ctx = applyDecisionStateAndPrepareSend(decision) ?: return @@ -406,6 +434,7 @@ internal class AapSessionEngine( handleRejectedCommand(ctx.decision.rejectedCommand) } catch (e: Exception) { restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) throw e } } @@ -417,6 +446,7 @@ internal class AapSessionEngine( if (currentScope == null) { log(TAG, ERROR) { "No scope available for outbound async send" } restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) return } currentScope.launch { @@ -426,6 +456,7 @@ internal class AapSessionEngine( handleRejectedCommand(ctx.decision.rejectedCommand) } catch (_: Exception) { restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4aea814d..2e99e670 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -577,6 +577,7 @@ Bluetooth Settings Could not apply setting: %1$s Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control. + The AirPods didn\'t confirm the listening mode change. Battery Optimized Charge Limit Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them. diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt index 03715159..6ec38c90 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt @@ -21,7 +21,6 @@ class PodDeviceAncModeTest : BaseTest() { fun `cycle mask hides OFF when OFF is not allowed`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = 0x0E, allowOffEnabled = false, ) shouldContainExactly listOf( @@ -35,27 +34,30 @@ class PodDeviceAncModeTest : BaseTest() { fun `allow off keeps OFF visible even when cycle mask excludes it`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = 0x0E, allowOffEnabled = true, ) shouldContainExactly allModes } @Test - fun `current OFF stays visible even when OFF is otherwise hidden`() { + fun `current OFF is NOT re-admitted when OFF is otherwise hidden`() { + // Regression: a device reporting a mode outside its own cycle used to conjure an extra + // selector button. AirPods Pro 3 do exactly that, answering an Adaptive write with OFF. visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.OFF, cycleMask = 0x0E, allowOffEnabled = false, - ) shouldContainExactly allModes + ) shouldContainExactly listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) } @Test fun `null cycle mask shows all supported modes`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = null, allowOffEnabled = false, ) shouldContainExactly allModes @@ -65,7 +67,6 @@ class PodDeviceAncModeTest : BaseTest() { fun `cycle mask with OFF bit set includes OFF`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = 0x0F, allowOffEnabled = false, ) shouldContainExactly allModes 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 66f86d53..8298b8ba 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 @@ -412,7 +412,7 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE - advanceTimeBy(1100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L) sentCommands.size shouldBe 3 sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) @@ -724,6 +724,140 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE } + @Test + fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() = + 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) + + 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()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { } + + // AirPods Pro 3 firmware answering an ADAPTIVE write with OFF. Must not be taken as + // evidence that OFF is a permitted mode. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + } + + @Test + fun `unsolicited OFF after a contradicted one still infers AllowOffOption true`() = + 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) + + 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()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { } + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + // Let the request finish failing, so nothing of ours is outstanding any more. + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + engine.state.value.pendingAncMode.shouldBeNull() + + // Now a genuine switch into OFF (stem press / another phone) must still train it. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting()?.enabled shouldBe true + } + + @Test + fun `unrelated setting report does not prematurely confirm a non-ANC command`() = + runTest(UnconfinedTestDispatcher()) { + 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.ConversationalAwareness(enabled = false)) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetConversationalAwareness(true)) { } + + // An unrelated frame must not settle the outstanding verification: the optimistic + // write already satisfies its predicate, so doing so would swallow the rejection. + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + // The device then contradicts the write. + nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false)) + engine.processMessage(dummyMessage()) + + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + rejected shouldBe listOf(AapCommand.SetConversationalAwareness(true)) + collectJob.cancel() + } + @Test fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) { val supportedModes = listOf( @@ -768,7 +902,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) engine.state.value.pendingAncMode.shouldBeNull() engine.state.value.setting()?.enabled shouldBe false sentCommands shouldBe listOf( @@ -812,7 +946,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) rejected.size shouldBe 1 collectJob.cancel() } @@ -852,7 +986,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) rejected shouldBe emptyList() collectJob.cancel() }