refactor(aap): Split the echo classifier out of this change

Everything that reinterprets what the device reported moves to its own branch
(anc-echo-classifier), leaving only changes that stand on their own.

The classifier addresses a fault that has never been observed being handled: it
reproduced on two of four sessions and none since it was written. It also adds a
failure mode that did not exist before, where a report misattributed to our own
write makes CAPod show a mode the device is not in. That is a poor trade to carry
into main on the strength of tests alone, so it waits until it can be seen working
against a live fault.

What remains does not depend on the misreport:

- the verification deadline was 1000ms while the device answers in 833-1008ms, so
  a healthy reply could land just after the timer and trigger a bogus divergence
  plus a redundant re-send; this was captured live
- a listening mode request the device did not confirm produced no feedback at all
  for any mode except Off, which was a gap in the event plumbing rather than a
  timing artifact
- a mode outside the device's listening mode cycle was rendered as an ordinary
  selectable button whenever it happened to be the current mode
- an Off report arriving while a different mode was requested could teach the Allow
  Off inference, persisting "Off is permitted" into the device profile

Also drops effectiveAncMode, which only had an effect while the classifier was
present.
This commit is contained in:
darken
2026-08-20 12:38:30 +02:00
parent 0b83e86c5e
commit dbd1d422c6
11 changed files with 61 additions and 567 deletions
@@ -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,
)
}
@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -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,
)
@@ -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<AapSetting.AncMode.Value>
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,
)
}
@@ -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<AapSetting.AncMode>()?.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<AapSetting.AncMode>()?.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<AapSetting.EarDetection>()
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<AapSetting.AncMode>() ?: 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,
@@ -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<out AapSetting>, 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,
@@ -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(
@@ -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<TimeSource> {
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<AapSetting.AncMode>()!!.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<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.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
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<AapSetting.AncMode>()!!.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<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.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<AapSetting.AncMode>()!!.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<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)) { }
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<AapSetting.AncMode>()!!.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<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.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<AapSetting.AncMode>()!!.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<KClass<out AapSetting>, 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<AapSetting.AncMode>()!!.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<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.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
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<AapSetting.AncMode>()!!.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<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()) {