From 14da81d231a1243bbeeebbcb3db9458b97fe69c7 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 16 Apr 2026 05:21:39 +0200 Subject: [PATCH] fix(device-settings): Fix ANC OFF mode visibility and inference logic Fix Elvis operator precedence bug in visibleAncModes() where OFF passed the filter unconditionally. Move filtering logic to PodDevice.visibleAncModes extension, unifying DualPodsCard, SinglePodsCard, and DeviceSettingsScreen. Gate AllowOffOption inference on pod-in-ear + 1.5s stability to prevent false positives from in-case OFF reports. --- .../capod/main/ui/components/AncModeUi.kt | 16 -- .../ui/devicesettings/DeviceSettingsScreen.kt | 8 +- .../main/ui/overview/cards/DualPodsCard.kt | 10 +- .../main/ui/overview/cards/SinglePodsCard.kt | 10 +- .../capod/monitor/core/PodDeviceAncMode.kt | 50 ++++++ .../pods/core/apple/aap/AapSessionEngine.kt | 53 ++++-- .../monitor/core/PodDeviceAncModeTest.kt | 95 ++++++++++ .../core/apple/aap/AapSessionEngineTest.kt | 163 ++++++++++++++++-- 8 files changed, 343 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt index 757dfce5..50fdbbdb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt @@ -28,19 +28,3 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) { AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome } -fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { - AapSetting.AncMode.Value.OFF -> 0x01 - AapSetting.AncMode.Value.ON -> 0x02 - AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 - AapSetting.AncMode.Value.ADAPTIVE -> 0x08 -} - -fun visibleAncModes( - supportedModes: List, - currentMode: AapSetting.AncMode.Value, - cycleMask: Int?, - allowOffEnabled: Boolean, -): List = supportedModes.filter { mode -> - val inCycle = cycleMask?.let { (it and mode.cycleBit()) != 0 } ?: mode != AapSetting.AncMode.Value.OFF - inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode -} 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 214e41e6..1e1f9e49 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 @@ -99,6 +99,8 @@ import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDia import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.monitor.core.lastSeenFormatted +import eu.darken.capod.monitor.core.resolvedAncCycleMask +import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -516,9 +518,7 @@ fun DeviceSettingsScreen( val ancMode = device.ancMode val adaptiveNoise = device.adaptiveAudioNoise if (features.hasAncControl && ancMode != null) { - val cycleMask = if (features.hasListeningModeCycle) { - (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask - } else null + val cycleMask = device.resolvedAncCycleMask val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null val cycleSubtitle = if (cycleSummary != null) { buildString { @@ -543,7 +543,7 @@ fun DeviceSettingsScreen( NoiseControlCurrentModeControl( currentMode = ancMode.current, pendingMode = device.pendingAncMode, - supportedModes = ancMode.supported, + supportedModes = device.visibleAncModes, onModeSelected = onAncModeChange, enabled = enabled, ) 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 58a5f0d7..eb711bb0 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 @@ -43,7 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.darken.capod.R -import eu.darken.capod.main.ui.components.visibleAncModes +import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule import eu.darken.capod.main.ui.overview.cards.components.DebugSection @@ -237,15 +237,9 @@ fun DualPodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(8.dp)) - val visibleModes = visibleAncModes( - supportedModes = ancMode.supported, - currentMode = ancMode.current, - cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, - allowOffEnabled = device.allowOffOption?.enabled == true, - ) AncModeSelector( currentMode = ancMode.current, - supportedModes = visibleModes, + supportedModes = device.visibleAncModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) 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 e910e574..6cc262ab 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 @@ -43,7 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.darken.capod.R -import eu.darken.capod.main.ui.components.visibleAncModes +import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.main.ui.overview.cards.components.DebugSection import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge @@ -260,15 +260,9 @@ fun SinglePodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) - val visibleModes = visibleAncModes( - supportedModes = ancMode.supported, - currentMode = ancMode.current, - cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, - allowOffEnabled = device.allowOffOption?.enabled == true, - ) AncModeSelector( currentMode = ancMode.current, - supportedModes = visibleModes, + supportedModes = device.visibleAncModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) 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 new file mode 100644 index 00000000..5d27eb14 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt @@ -0,0 +1,50 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +private fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { + AapSetting.AncMode.Value.OFF -> 0x01 + AapSetting.AncMode.Value.ON -> 0x02 + AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 + AapSetting.AncMode.Value.ADAPTIVE -> 0x08 +} + +fun resolvedAncCycleMask( + hasListeningModeCycle: Boolean, + reportedCycleMask: Int?, +): Int? = if (hasListeningModeCycle) { + reportedCycleMask ?: 0x0E +} else { + null +} + +fun visibleAncModes( + supportedModes: List, + currentMode: AapSetting.AncMode.Value, + cycleMask: Int?, + allowOffEnabled: Boolean, +): List = supportedModes.filter { mode -> + val inCycle = if (cycleMask != null) { + (cycleMask and mode.cycleBit()) != 0 + } else { + true + } + inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode +} + +val PodDevice.resolvedAncCycleMask: Int? + get() = resolvedAncCycleMask( + hasListeningModeCycle = model.features.hasListeningModeCycle, + reportedCycleMask = listeningModeCycle?.modeMask, + ) + +val PodDevice.visibleAncModes: List + get() { + val ancMode = ancMode ?: return emptyList() + return visibleAncModes( + supportedModes = ancMode.supported, + currentMode = ancMode.current, + cycleMask = resolvedAncCycleMask, + allowOffEnabled = allowOffOption?.enabled == true, + ) + } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt index dd1ca1b7..7c1a6123 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt @@ -54,11 +54,13 @@ internal class AapSessionEngine( private var scope: CoroutineScope? = null private var ancDebounceJob: Job? = null + private var allowOffInferenceJob: Job? = null private var lastSentCommand: AapCommand? = null private var lastSentAt: Long = 0L /** Separate tracking for ANC sends — not overwritten by non-ANC commands during flush. */ private var lastAncSentAt: Long = 0L private var handshakeResponseReceived: Boolean = false + private var latestObservedAncMode: AapSetting.AncMode? = null /** Stored reference to the socket write callback — set on each [send] / flush call. */ private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null @@ -81,6 +83,7 @@ internal class AapSessionEngine( fun reset() { ancDebounceJob?.cancel() ancDebounceJob = null + cancelAllowOffOptionInference() coordinator.clear() hidTracker.flush() hidTracker.reset() @@ -88,6 +91,7 @@ internal class AapSessionEngine( lastSentCommand = null lastSentAt = 0L lastAncSentAt = 0L + latestObservedAncMode = null activeSendRaw = null handshakeResponseReceived = false _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) @@ -163,6 +167,7 @@ internal class AapSessionEngine( if (outcome is AapSettingsCoordinator.VerificationOutcome.Rejected) { val command = outcome.command if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { + cancelAllowOffOptionInference() val prev = _state.value.setting() if (prev == null || prev.enabled) { _state.value = _state.value.withSetting( @@ -273,20 +278,20 @@ internal class AapSessionEngine( // ANC debounce if (value is AapSetting.AncMode) { + latestObservedAncMode = value + syncAllowOffOptionInference() val isFirstAncMode = _state.value.setting() == null val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L if (isFirstAncMode || recentAncSend) { ancDebounceJob?.cancel() _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" } - applyInferences(value) } else { ancDebounceJob?.cancel() ancDebounceJob = scope?.launch { delay(1500L) _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" } - applyInferences(value) } } return @@ -304,7 +309,7 @@ internal class AapSessionEngine( } _state.value = newState log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } - applyInferences(value) + if (value is AapSetting.EarDetection) syncAllowOffOptionInference() // Flush queued commands when pod goes in ear if (value is AapSetting.EarDetection && value.isEitherPodInEar) { @@ -422,20 +427,42 @@ internal class AapSessionEngine( // ── Inference ─────────────────────────────────────────── - private fun applyInferences(trigger: AapSetting) { - val inferred = mutableListOf, AapSetting>>() + private fun syncAllowOffOptionInference() { + cancelAllowOffOptionInference() - if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) { - val current = _state.value.setting() - if (current == null || !current.enabled) { - inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true) + val observedAncMode = latestObservedAncMode ?: _state.value.setting() ?: return + val earDetection = _state.value.setting() ?: return + val allowOffOption = _state.value.setting() + if (observedAncMode.current != AapSetting.AncMode.Value.OFF) return + if (!earDetection.isEitherPodInEar) return + if (allowOffOption?.enabled == true) return + + val inferenceScope = scope ?: return + allowOffInferenceJob = inferenceScope.launch { + delay(1500L) + + val latestAncMode = latestObservedAncMode ?: _state.value.setting() + val latestEarDetection = _state.value.setting() + val latestAllowOffOption = _state.value.setting() + if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && + latestEarDetection?.isEitherPodInEar == true && + latestAllowOffOption?.enabled != true + ) { + _state.value = _state.value.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = true), + ) + log(TAG) { + "Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)" + } } + allowOffInferenceJob = null } + } - for ((key, value) in inferred) { - _state.value = _state.value.withSetting(key, value) - log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" } - } + private fun cancelAllowOffOptionInference() { + allowOffInferenceJob?.cancel() + allowOffInferenceJob = null } companion object { 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 new file mode 100644 index 00000000..3913f556 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt @@ -0,0 +1,95 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class PodDeviceAncModeTest : BaseTest() { + + private val allModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + + @Test + fun `cycle mask hides OFF when OFF is not allowed`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = 0x0E, + allowOffEnabled = false, + ) shouldContainExactly listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + } + + @Test + 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`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.OFF, + cycleMask = 0x0E, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `null cycle mask shows all supported modes`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = null, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `cycle mask with OFF bit set includes OFF`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = 0x0F, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `models with listening mode cycle fall back to default mask when setting is absent`() { + resolvedAncCycleMask( + hasListeningModeCycle = true, + reportedCycleMask = null, + ) shouldBe 0x0E + } + + @Test + fun `models without listening mode cycle keep cycle mask null`() { + resolvedAncCycleMask( + hasListeningModeCycle = false, + reportedCycleMask = null, + ) shouldBe null + } + + @Test + fun `reported cycle mask wins over fallback`() { + resolvedAncCycleMask( + hasListeningModeCycle = true, + reportedCycleMask = 0x0A, + ) shouldBe 0x0A + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt index 74a22c95..f9bd0e05 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt @@ -40,6 +40,10 @@ class AapSessionEngineTest : BaseTest() { return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0)) } + @Suppress("UNCHECKED_CAST") + private fun settingPair(setting: AapSetting): Pair, AapSetting> = + setting::class as KClass to setting + /** Build a profile mock with all decode methods stubbed. Does NOT mock encodeCommand (sealed class). */ private fun mockProfile(block: AapDeviceProfile.() -> Unit = {}): AapDeviceProfile = mockk { every { decodeStemPress(any()) } returns null @@ -356,26 +360,159 @@ class AapSessionEngineTest : BaseTest() { inner class InferenceTests { @Test - fun `AncMode OFF infers AllowOffOption true`() { + fun `startup OFF while no pod is in ear does not infer 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 { decodeStemPress(any()) } returns null - every { decodeBattery(any()) } returns null - every { decodePrivateKeyResponse(any()) } returns null - every { decodeDeviceInfo(any()) } returns null - every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode( - current = AapSetting.AncMode.Value.OFF, - supported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON), - )) + every { decodeSetting(any()) } answers { nextSetting } } val engine = AapSessionEngine(profile, timeSource) - val scope = TestScope(UnconfinedTestDispatcher()) - engine.start(scope) + engine.start(this as TestScope) engine.onHandshakeSent() - // First ANC mode = no debounce, applied immediately + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + engine.state.value.setting().shouldBeNull() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + )) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + } + + @Test + fun `stable in-ear OFF infers AllowOffOption true after delay`() = 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.start(this as TestScope) + engine.onHandshakeSent() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + engine.state.value.setting().shouldBeNull() + + advanceTimeBy(1600L) + engine.state.value.setting()?.enabled shouldBe true + } + + @Test + fun `later non-OFF ANC update cancels pending AllowOffOption true 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.start(this as TestScope) + engine.onHandshakeSent() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + )) engine.processMessage(dummyMessage()) - engine.state.value.setting()?.enabled shouldBe true + advanceTimeBy(500L) + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + } + + @Test + fun `rejected OFF command infers AllowOffOption false`() = 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.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true)) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it } + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + advanceTimeBy(2100L) + engine.state.value.setting()?.enabled shouldBe false + sentCommands shouldBe listOf( + AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), + AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), + ) } }