Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport

Device: Fix duplicate listening mode commands and silent failures
This commit is contained in:
Matthias Urhahn
2026-08-20 18:16:20 +02:00
committed by GitHub
11 changed files with 300 additions and 26 deletions
@@ -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)
}
@@ -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
}
}
}
@@ -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)
}
}
}
}
@@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor(
sealed interface Event {
data object OffModeRejectedByDevice : Event
data object AncModeNotConfirmedByDevice : Event
}
val events = SingleEventFlow<Event>()
@@ -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)
@@ -18,18 +18,30 @@ fun resolvedAncCycleMask(
null
}
fun visibleAncModes(
supportedModes: List<AapSetting.AncMode.Value>,
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<AapSetting.AncMode.Value> = 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<AapSetting.AncMode.Value>,
cycleMask: Int?,
allowOffEnabled: Boolean,
): List<AapSetting.AncMode.Value> = 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<AapSetting.AncMode.Value>
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,
)
}
@@ -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<EngineTimerAction>()
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
@@ -90,6 +110,7 @@ internal class AapAncController {
val latestEarDetection = podState.setting<AapSetting.EarDetection>()
val latestAllowOffOption = podState.setting<AapSetting.AllowOffOption>()
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<AapSetting.EarDetection>()
val allowOffOption = podState.setting<AapSetting.AllowOffOption>()
return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF &&
!runtimeState.offReportContradicted &&
earDetection?.isEitherPodInEar == true &&
allowOffOption?.enabled != true
) {
@@ -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<AapSetting.EarDetection>()
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"),
)
}
@@ -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<AapCommand>) {
val ancCommand = commands.filterIsInstance<AapCommand.SetAncMode>().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)
}
}
}
+1
View File
@@ -577,6 +577,7 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Settings</string>
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
<string name="device_settings_anc_off_rejected_message">Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control.</string>
<string name="anc_mode_not_confirmed_message">The AirPods didn\'t confirm the listening mode change.</string>
<string name="device_settings_category_battery_label">Battery</string>
<string name="device_settings_charge_cap_label">Optimized Charge Limit</string>
<string name="device_settings_charge_cap_description">Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them.</string>
@@ -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
@@ -412,7 +412,7 @@ class AapSessionEngineTest : BaseTest() {
engine.state.value.setting<AapSetting.AncMode>()!!.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<AapSetting.AncMode>()!!.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<KClass<out AapSetting>, 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<AapSetting.AllowOffOption>().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<KClass<out AapSetting>, 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<AapSetting.AllowOffOption>()?.enabled shouldBe true
}
@Test
fun `unrelated setting report does not prematurely confirm a non-ANC command`() =
runTest(UnconfinedTestDispatcher()) {
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
val rejected = mutableListOf<AapCommand>()
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<AapSetting.AllowOffOption>()?.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()
}