diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt index 75b674fe..860a0a3d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt @@ -49,7 +49,6 @@ import eu.darken.capod.main.ui.devicesettings.components.SettingsCompoundHeader import eu.darken.capod.main.ui.devicesettings.previewFullState import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.monitor.core.PodDevice -import eu.darken.capod.monitor.core.effectiveAncMode import eu.darken.capod.monitor.core.resolvedAncCycleMask import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.PodModel @@ -97,7 +96,7 @@ internal fun NoiseControlCard( SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) { NoiseControlCurrentModeControl( - currentMode = device.effectiveAncMode ?: ancMode.current, + currentMode = ancMode.current, pendingMode = device.pendingAncMode, supportedModes = device.visibleAncModes, onModeSelected = onAncModeChange, @@ -174,7 +173,7 @@ internal fun NoiseControlCard( level = adaptiveNoise.level, onLevelChange = onAdaptiveAudioNoiseChange, enabled = enabled, - isAdaptiveMode = device.effectiveAncMode == AapSetting.AncMode.Value.ADAPTIVE + isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE || device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE, ) } 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 d91e2d84..b3ca9b78 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 @@ -45,7 +45,6 @@ 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.monitor.core.effectiveAncMode 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 @@ -287,7 +286,7 @@ private fun ColumnScope.DualPodsCardExpanded( if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) AncModeSelector( - currentMode = device.effectiveAncMode ?: ancMode.current, + currentMode = ancMode.current, 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 04ef737b..9d544e85 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 @@ -44,7 +44,6 @@ 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.monitor.core.effectiveAncMode 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.CompactBatterySummary @@ -335,7 +334,7 @@ private fun ColumnScope.SinglePodsCardExpanded( if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) AncModeSelector( - currentMode = device.effectiveAncMode ?: ancMode.current, + currentMode = ancMode.current, supportedModes = device.visibleAncModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, diff --git a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt index 7e014ba1..2e6618dc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt @@ -3,7 +3,6 @@ package eu.darken.capod.main.ui.tile import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.permissions.Permission import eu.darken.capod.monitor.core.PodDevice -import eu.darken.capod.monitor.core.effectiveAncMode import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -37,7 +36,7 @@ object AncTileStateMapper { if (visible.isEmpty()) return AncTileState.Connecting return AncTileState.Active( - current = device.effectiveAncMode ?: ancMode.current, + current = ancMode.current, pending = device.pendingAncMode, visible = visible, deviceLabel = device.label, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt index e7d6a0df..195a7cfe 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt @@ -5,7 +5,6 @@ import eu.darken.capod.R import eu.darken.capod.main.ui.components.iconDrawableRes import eu.darken.capod.main.ui.components.shortLabel import eu.darken.capod.monitor.core.PodDevice -import eu.darken.capod.monitor.core.effectiveAncMode import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -92,7 +91,7 @@ object AncWidgetRenderStateMapper { primaryText = context.getString(R.string.anc_widget_aap_connecting_label), ) - val currentMode = device.effectiveAncMode ?: ancMode.current + val currentMode = ancMode.current val pendingMode = device.pendingAncMode val filteredModes = device.visibleAncModes diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt index acd28069..4e3b1781 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt @@ -1,7 +1,6 @@ package eu.darken.capod.main.ui.widget import eu.darken.capod.monitor.core.PodDevice -import eu.darken.capod.monitor.core.effectiveAncMode import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -54,7 +53,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey( isAapConnected = isAapConnected, isAapReady = isAapReady, hasBleAdvertisement = ble != null, - ancMode = effectiveAncMode, + ancMode = ancMode?.current, pendingAncMode = pendingAncMode, visibleAncModes = visibleAncModes, ) 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 514199c7..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 @@ -44,30 +44,6 @@ fun visibleAncModes( isAncModePermitted(mode, cycleMask, allowOffEnabled) } -/** - * The mode to display. Normally whatever the device reported. - * - * AirPods Pro 3 have been observed answering a listening mode write with a mode they cannot - * actually be in - reporting OFF (wire 0x01) while audibly switching to Adaptive, on a device - * where OFF is outside the cycle and Allow Off is disabled. Adopting that verbatim shows the - * wrong mode as selected. While our own request is still outstanding, a reported mode that the - * device should not be able to reach is treated as noise and the requested mode is shown instead. - * - * Once the request is resolved (confirmed or rejected) [pendingMode] is null and the reported - * value is shown again - out-of-cycle it will simply not match any selectable entry. - */ -fun effectiveAncMode( - reportedMode: AapSetting.AncMode.Value, - pendingMode: AapSetting.AncMode.Value?, - cycleMask: Int?, - allowOffEnabled: Boolean, -): AapSetting.AncMode.Value = when { - pendingMode == null -> reportedMode - reportedMode == pendingMode -> reportedMode - isAncModePermitted(reportedMode, cycleMask, allowOffEnabled) -> reportedMode - else -> pendingMode -} - val PodDevice.resolvedAncCycleMask: Int? get() = resolvedAncCycleMask( hasListeningModeCycle = model.features.hasListeningModeCycle, @@ -88,15 +64,3 @@ val PodDevice.visibleAncModes: List allowOffEnabled = resolvedAllowOffEnabled, ) } - -/** Display-facing listening mode. See [effectiveAncMode]. */ -val PodDevice.effectiveAncMode: AapSetting.AncMode.Value? - get() { - val ancMode = ancMode ?: return null - return effectiveAncMode( - reportedMode = ancMode.current, - pendingMode = pendingAncMode, - cycleMask = resolvedAncCycleMask, - allowOffEnabled = resolvedAllowOffEnabled, - ) - } 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 bd39eeb3..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 @@ -8,24 +8,6 @@ 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, - /** Monotonic-ish timestamp of the write, used to measure how long the device took to answer. */ - val sentAtMs: Long = 0L, - /** The first listening mode report seen since the write, with how long it took to arrive. */ - val observedAncEcho: AncEcho? = null, - /** True once another listening mode write was issued while this one was still outstanding. */ - val superseded: Boolean = false, -) - -/** A listening mode report attributed to an outstanding write. */ -internal data class AncEcho( - val mode: AapSetting.AncMode.Value, - val latencyMs: Long, ) internal data class OutboundRuntimeState( @@ -43,7 +25,7 @@ internal data class OutboundDecision( ) internal class AapOutboundController( - private val timeSource: TimeSource, + timeSource: TimeSource, ) { private val coordinator = AapSettingsCoordinator(timeSource) @@ -63,14 +45,6 @@ internal class AapOutboundController( * still costs two full deadlines before the user is told about it. */ const val VERIFICATION_TIMEOUT_MS = 2000L - - /** - * Below this, a listening mode report is a refusal rather than the result of a change. - * - * Captured refusals answer in 25-267ms; a real mode change answers in 815-1010ms. Only an - * answer slow enough to be a change is eligible to be read as an unusable report. - */ - const val ANC_CHANGE_LATENCY_MIN_MS = 500L } fun onCommandRequested( @@ -112,18 +86,7 @@ internal class AapOutboundController( return OutboundDecision( podState = updatedPodState, runtimeState = updatedRuntimeState.copy( - verification = verificationCheck?.let { - VerificationState( - command = command, - attempt = 0, - previousAncMode = podState.setting()?.current, - sentAtMs = timeSource.elapsedRealtime(), - // A second listening mode write while one is outstanding makes the echoes - // ambiguous: we can no longer say which write any given report answers. - superseded = command is AapCommand.SetAncMode && - updatedRuntimeState.verification?.command is AapCommand.SetAncMode, - ) - } + verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) } ?: updatedRuntimeState.verification, ), commandsToSend = listOf(command), @@ -155,13 +118,7 @@ internal class AapOutboundController( runtimeState = runtimeState.copy( pendingCommands = result.pendingCommands, verification = if (verificationCheck != null) { - VerificationState( - command = checkNotNull(toVerify), - attempt = 0, - previousAncMode = podState.setting()?.current, - sentAtMs = timeSource.elapsedRealtime(), - superseded = runtimeState.verification?.command is AapCommand.SetAncMode, - ) + VerificationState(command = checkNotNull(toVerify), attempt = 0) } else { runtimeState.verification }, @@ -176,43 +133,6 @@ internal class AapOutboundController( ) } - /** - * Attribute a listening mode report to the outstanding write and remember how long it took. - * - * Only the first report after the write is kept: that is the one the device sent in answer. - * Classification later uses this recorded frame rather than whatever happens to be current at - * the deadline, so an unrelated concurrent report cannot be mistaken for our echo. - */ - fun onAncReportObserved( - runtimeState: OutboundRuntimeState, - mode: AapSetting.AncMode.Value, - nowMs: Long, - ): OutboundRuntimeState { - val verification = runtimeState.verification ?: return runtimeState - if (verification.command !is AapCommand.SetAncMode) return runtimeState - if (verification.observedAncEcho != null) return runtimeState - return runtimeState.copy( - verification = verification.copy( - observedAncEcho = AncEcho(mode = mode, latencyMs = nowMs - verification.sentAtMs), - ), - ) - } - - /** - * Mark an outstanding listening mode write ambiguous because the user changed the mode on the - * device itself. Any report arriving now could answer either, and the two cannot be told apart. - * - * This only covers changes CAPod can see. A switch made from iOS or another paired phone is - * invisible here, which is why a misattributed echo is only ever allowed to affect the current - * reading and is never learned from. - */ - fun onExternalAncChange(runtimeState: OutboundRuntimeState): OutboundRuntimeState { - val verification = runtimeState.verification ?: return runtimeState - if (verification.command !is AapCommand.SetAncMode) return runtimeState - if (verification.superseded) return runtimeState - return runtimeState.copy(verification = verification.copy(superseded = true)) - } - /** * 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 @@ -259,8 +179,6 @@ 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 @@ -275,15 +193,7 @@ internal class AapOutboundController( if (verification.attempt == 0) { return OutboundDecision( podState = podState, - runtimeState = runtimeState.copy( - // A re-send is a fresh question: the previous attempt's echo and send time - // must not be carried over, or the retry gets judged on stale evidence. - verification = verification.copy( - attempt = 1, - sentAtMs = timeSource.elapsedRealtime(), - observedAncEcho = null, - ), - ), + runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), commandsToSend = listOf(verification.command), timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)), logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), @@ -298,54 +208,6 @@ 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 answers - * 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. - * - * Every condition below exists to keep a wrong conclusion out of the session: - * - the recorded echo is used, never whatever is current at the deadline, so an unrelated - * concurrent report cannot be mistaken for the answer to our write - * - a write that was superseded by another listening mode write is never classified, because - * its echoes can no longer be attributed - * - an answer fast enough to be a refusal is never read as a change - * - * Deliberately engine-local: it uses only the requested mode, the previous mode, and the echo - * we recorded. Which modes a device permits is app-level knowledge and stays out of the engine. - */ - private fun unusableAncReport( - podState: AapPodState, - runtimeState: OutboundRuntimeState, - verification: VerificationState, - ): OutboundDecision? { - val command = verification.command as? AapCommand.SetAncMode ?: return null - if (verification.superseded) return null - val previous = verification.previousAncMode ?: return null - val echo = verification.observedAncEcho ?: return null - if (echo.mode == command.mode || echo.mode == previous) return null - if (echo.latencyMs < ANC_CHANGE_LATENCY_MIN_MS) return null - val ancMode = podState.setting() ?: 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=${echo.mode}, was=$previous, " + - "after ${echo.latencyMs}ms), not a refusal: keeping ${command.mode}" - ), - ) - } - private fun clearPendingForCommand( podState: AapPodState, command: AapCommand, 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 f7df880b..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 @@ -231,9 +231,6 @@ internal class AapSessionEngine( private fun handleInboundUpdate(update: AapInboundUpdate) { when (update) { is AapInboundUpdate.StemPress -> { - runtimeState = runtimeState.copy( - outbound = outboundController.onExternalAncChange(runtimeState.outbound), - ) _stemPressEvents.tryEmit(update.event) log(TAG) { "Stem press: ${update.event.pressType} ${update.event.bud}" } } @@ -289,13 +286,6 @@ internal class AapSessionEngine( private fun handleSettingUpdate(key: KClass, value: AapSetting) { if (value is AapSetting.AncMode) { - runtimeState = runtimeState.copy( - outbound = outboundController.onAncReportObserved( - runtimeState = runtimeState.outbound, - mode = value.current, - nowMs = timeSource.elapsedRealtime(), - ), - ) val decision = ancController.onAncSetting( podState = _state.value, runtimeState = runtimeState.anc, 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 78262081..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 @@ -54,49 +54,6 @@ class PodDeviceAncModeTest : BaseTest() { ) } - // -- effectiveAncMode: distrusting an impossible report while a request is in flight -- - - @Test - fun `impossible reported mode is ignored while our request is pending`() { - effectiveAncMode( - reportedMode = AapSetting.AncMode.Value.OFF, - pendingMode = AapSetting.AncMode.Value.ADAPTIVE, - cycleMask = 0x0E, - allowOffEnabled = false, - ) shouldBe AapSetting.AncMode.Value.ADAPTIVE - } - - @Test - fun `permitted reported mode is adopted even while pending`() { - // A genuine refusal echoes the mode the device is actually in; that must win. - effectiveAncMode( - reportedMode = AapSetting.AncMode.Value.ON, - pendingMode = AapSetting.AncMode.Value.ADAPTIVE, - cycleMask = 0x0E, - allowOffEnabled = false, - ) shouldBe AapSetting.AncMode.Value.ON - } - - @Test - fun `reported mode is adopted verbatim when nothing is pending`() { - effectiveAncMode( - reportedMode = AapSetting.AncMode.Value.OFF, - pendingMode = null, - cycleMask = 0x0E, - allowOffEnabled = false, - ) shouldBe AapSetting.AncMode.Value.OFF - } - - @Test - fun `OFF report is adopted while pending when OFF is actually allowed`() { - effectiveAncMode( - reportedMode = AapSetting.AncMode.Value.OFF, - pendingMode = AapSetting.AncMode.Value.ADAPTIVE, - cycleMask = 0x0E, - allowOffEnabled = true, - ) shouldBe AapSetting.AncMode.Value.OFF - } - @Test fun `null cycle mask shows all supported modes`() { visibleAncModes( 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 10e7de31..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 @@ -31,22 +31,9 @@ import kotlin.reflect.KClass class AapSessionEngineTest : BaseTest() { - /** - * Wall clock the engine reads. Advanceable because classification of a listening mode echo - * depends on how long the device took to answer, so tests must be able to simulate a reply - * that is slow enough to be a real mode change rather than a refusal. - */ - private var fakeNowMs = 1000L - private val timeSource = mockk { - every { now() } answers { Instant.ofEpochMilli(fakeNowMs) } - every { currentTimeMillis() } answers { fakeNowMs } - every { elapsedRealtime() } answers { fakeNowMs } - } - - /** Simulate the device taking a realistic amount of time to answer a mode change. */ - private fun elapseChangeLatency() { - fakeNowMs += AapOutboundController.ANC_CHANGE_LATENCY_MIN_MS + 400L + every { now() } returns Instant.ofEpochMilli(1000L) + every { currentTimeMillis() } returns 1000L } private fun dummyMessage(commandType: Int = 0x0009): AapMessage { @@ -737,314 +724,6 @@ 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. - elapseChangeLatency() - 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 `a fast third-mode echo is a refusal, not an unusable report`() = - runTest(UnconfinedTestDispatcher()) { - // A refusal that settles in some third mode still answers at refusal speed. Without - // the latency check this was misread as a misreport and poisoned the session. - 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) - - 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.OFF)) { } - fakeNowMs += 40L // refusal speed - nextSetting = settingPair( - AapSetting.AncMode( - current = AapSetting.AncMode.Value.TRANSPARENCY, - supported = supportedModes, - ) - ) - engine.processMessage(dummyMessage()) - advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) - - // Must NOT have rewritten state to OFF, and must NOT have learned a mapping. - engine.state.value.setting()!!.current shouldBe - AapSetting.AncMode.Value.TRANSPARENCY - } - - @Test - fun `a superseded ANC write is never classified as an unusable report`() = - runTest(UnconfinedTestDispatcher()) { - // Two writes in flight make the echoes unattributable: a delayed answer to the - // first looks like a third mode to the second. - 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) - - 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)) { } - engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.TRANSPARENCY)) { } - - // Delayed answer to the FIRST write arrives while the second is outstanding. - elapseChangeLatency() - nextSetting = settingPair( - AapSetting.AncMode( - current = AapSetting.AncMode.Value.ADAPTIVE, - supported = supportedModes, - ) - ) - engine.processMessage(dummyMessage()) - advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) - - // Without supersession the classifier would call this ADAPTIVE report the answer to - // the TRANSPARENCY write and force TRANSPARENCY into state. State must instead stay - // on what the device actually reported. - engine.state.value.setting()!!.current shouldBe - AapSetting.AncMode.Value.ADAPTIVE - } - - @Test - fun `a fast refusal on the retry is not inflated by the first attempt's timestamp`() = - runTest(UnconfinedTestDispatcher()) { - // Attempt 0 draws no answer at all. Without restamping on re-send, a quick refusal - // to attempt 1 measures from the original write and sails past the latency gate. - 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) - - 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.OFF)) { } - - // Silence through the first deadline, so a re-send goes out. - fakeNowMs += AapOutboundController.VERIFICATION_TIMEOUT_MS - advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L) - - // Refusal speed, relative to the re-send. - fakeNowMs += 40L - nextSetting = settingPair( - AapSetting.AncMode( - current = AapSetting.AncMode.Value.TRANSPARENCY, - supported = supportedModes, - ) - ) - engine.processMessage(dummyMessage()) - advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L) - - // Must be read as a refusal: state stays where the device says it is. - engine.state.value.setting()!!.current shouldBe - AapSetting.AncMode.Value.TRANSPARENCY - } - - @Test - fun `a stem press makes an outstanding ANC write ambiguous`() = - 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 - var nextStemPress: StemPressEvent? = null - val profile = mockProfile { - every { decodeSetting(any()) } answers { nextSetting } - every { decodeStemPress(any()) } answers { nextStemPress } - } - 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)) { } - - // The user changes the mode on the pods themselves mid-request. - nextSetting = null - nextStemPress = StemPressEvent( - pressType = StemPressEvent.PressType.SINGLE, - bud = StemPressEvent.Bud.LEFT, - ) - engine.processMessage(dummyMessage()) - nextStemPress = null - - elapseChangeLatency() - nextSetting = settingPair( - AapSetting.AncMode( - current = AapSetting.AncMode.Value.TRANSPARENCY, - supported = supportedModes, - ) - ) - engine.processMessage(dummyMessage()) - advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) - - // Must NOT have been claimed as our own write's result. - engine.state.value.setting()!!.current shouldBe - AapSetting.AncMode.Value.TRANSPARENCY - } - - @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()) { @@ -1086,6 +765,54 @@ class AapSessionEngineTest : BaseTest() { 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()) {