From edf84248ab651eae28820e604194a850292607a0 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 19 Aug 2026 19:09:20 +0200 Subject: [PATCH 1/5] fix(anc): Don't show a phantom Off mode when AirPods misreport the listening mode AirPods Pro 3 can answer a listening mode write with 0x0D 0x01 (Off) while audibly switching to the requested mode. Seen on firmware 81.2675000075000000.6503, intermittently, and not reproducible on demand. CAPod took that report at face value: it surfaced an Off button that isn't even in the device's listening mode cycle, selected it, and said nothing about the request not having been confirmed. - visibleAncModes no longer re-admits a mode purely because it is the current one. That escape clause was what conjured the extra button. - effectiveAncMode keeps showing the requested mode while our own request is outstanding and the device reports a mode it should not be able to reach. - A rejected listening mode request now surfaces a message for every mode, not only Off. Other modes were dropped silently. - The Allow Off inference ignores an Off report that arrived while a different mode was pending, so a single glitch cannot permanently persist "Off is allowed" into the device profile. An unsolicited Off still trains it, which is what keeps the option discoverable after it is enabled elsewhere. - Verification deadline moved from a hardcoded 1000ms to 2000ms, and a matching device report now settles the verification when it arrives. Measured reply latency is 833-1008ms, so the old deadline sat inside the device's normal spread and could fire a bogus divergence plus a redundant re-send. Settling verification on arrival is limited to SetAncMode deliberately. Every other verified command is optimistically written into state when it is queued, so its predicate is satisfied immediately and only the device's contradicting echo makes it fail. Settling those early would swallow the rejection. Note that AirPods never report AllowOffOption (0x34) or ListeningModeCycle (0x1A), so which modes are permitted is always inferred, never device truth. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 5 + .../devicesettings/DeviceSettingsViewModel.kt | 7 +- .../devicesettings/cards/NoiseControlCard.kt | 5 +- .../capod/main/ui/overview/OverviewScreen.kt | 5 + .../main/ui/overview/OverviewViewModel.kt | 9 ++ .../main/ui/overview/cards/DualPodsCard.kt | 3 +- .../main/ui/overview/cards/SinglePodsCard.kt | 3 +- .../capod/main/ui/tile/AncTileStateMapper.kt | 3 +- .../ui/widget/AncWidgetRenderStateMapper.kt | 3 +- .../capod/main/ui/widget/WidgetDeviceKey.kt | 3 +- .../capod/monitor/core/PodDeviceAncMode.kt | 68 +++++++-- .../core/apple/aap/engine/AapAncController.kt | 24 ++- .../apple/aap/engine/AapOutboundController.kt | 55 ++++++- .../core/apple/aap/engine/AapSessionEngine.kt | 31 ++++ app/src/main/res/values/strings.xml | 1 + .../monitor/core/PodDeviceAncModeTest.kt | 58 ++++++- .../apple/aap/engine/AapSessionEngineTest.kt | 142 +++++++++++++++++- 17 files changed, 392 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index a2387dd0..d3b2c617 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -90,6 +90,7 @@ fun DeviceSettingsScreenHost( var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) } val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) + val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message) val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message) val pendingInfoMessage = stringResource(R.string.device_settings_pending_info) @@ -125,6 +126,10 @@ fun DeviceSettingsScreenHost( snackbarHostState.showSnackbar(offRejectedMessage) } + DeviceSettingsViewModel.Event.AncModeNotConfirmedByDevice -> { + snackbarHostState.showSnackbar(ancNotConfirmedMessage) + } + DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> { snackbarHostState.showSnackbar(chargeCapRejectedMessage) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index c0f759ce..bcfbbe46 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -90,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor( data class SendFailed(val command: AapCommand, val message: String?) : Event data object SystemRenameUnavailable : Event data object OffModeRejectedByDevice : Event + data object AncModeNotConfirmedByDevice : Event data object DynamicEndOfChargeRejectedByDevice : Event } @@ -109,7 +110,11 @@ class DeviceSettingsViewModel @Inject constructor( when (command) { is AapCommand.SetDynamicEndOfCharge -> events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice) - else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF) + // OFF has its own, more specific message via offRejectedEvents. + is AapCommand.SetAncMode -> if (command.mode != AapSetting.AncMode.Value.OFF) { + events.tryEmit(Event.AncModeNotConfirmedByDevice) + } + else -> Unit } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt index 860a0a3d..75b674fe 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,6 +49,7 @@ 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 @@ -96,7 +97,7 @@ internal fun NoiseControlCard( SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) { NoiseControlCurrentModeControl( - currentMode = ancMode.current, + currentMode = device.effectiveAncMode ?: ancMode.current, pendingMode = device.pendingAncMode, supportedModes = device.visibleAncModes, onModeSelected = onAncModeChange, @@ -173,7 +174,7 @@ internal fun NoiseControlCard( level = adaptiveNoise.level, onLevelChange = onAdaptiveAudioNoiseChange, enabled = enabled, - isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE + isAdaptiveMode = device.effectiveAncMode == AapSetting.AncMode.Value.ADAPTIVE || device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 72714786..7a515d26 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -96,6 +96,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) + val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message) // Collect workerAutolaunch passively to keep it active LaunchedEffect(Unit) { @@ -108,6 +109,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { OverviewViewModel.Event.OffModeRejectedByDevice -> { snackbarHostState.showSnackbar(offRejectedMessage) } + + OverviewViewModel.Event.AncModeNotConfirmedByDevice -> { + snackbarHostState.showSnackbar(ancNotConfirmedMessage) + } } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index ce92d1c7..5dc93922 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor( sealed interface Event { data object OffModeRejectedByDevice : Event + data object AncModeNotConfirmedByDevice : Event } val events = SingleEventFlow() @@ -84,6 +85,14 @@ class OverviewViewModel @Inject constructor( events.tryEmit(Event.OffModeRejectedByDevice) } } + launch { + // OFF has its own, more specific message via offRejectedEvents. + aapManager.settingRejectedEvents.collect { (_, command) -> + if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.OFF) { + events.tryEmit(Event.AncModeNotConfirmedByDevice) + } + } + } } private val showUnmatchedDevices = MutableStateFlow(false) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index b3ca9b78..d91e2d84 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,6 +45,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.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 @@ -286,7 +287,7 @@ private fun ColumnScope.DualPodsCardExpanded( if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) AncModeSelector( - currentMode = ancMode.current, + currentMode = device.effectiveAncMode ?: 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 9d544e85..04ef737b 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,6 +44,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.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 @@ -334,7 +335,7 @@ private fun ColumnScope.SinglePodsCardExpanded( if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) AncModeSelector( - currentMode = ancMode.current, + currentMode = device.effectiveAncMode ?: 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 2e6618dc..7e014ba1 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,6 +3,7 @@ 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 @@ -36,7 +37,7 @@ object AncTileStateMapper { if (visible.isEmpty()) return AncTileState.Connecting return AncTileState.Active( - current = ancMode.current, + current = device.effectiveAncMode ?: 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 195a7cfe..e7d6a0df 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,6 +5,7 @@ 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 @@ -91,7 +92,7 @@ object AncWidgetRenderStateMapper { primaryText = context.getString(R.string.anc_widget_aap_connecting_label), ) - val currentMode = ancMode.current + val currentMode = device.effectiveAncMode ?: 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 4e3b1781..acd28069 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,6 +1,7 @@ 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 @@ -53,7 +54,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey( isAapConnected = isAapConnected, isAapReady = isAapReady, hasBleAdvertisement = ble != null, - ancMode = ancMode?.current, + ancMode = effectiveAncMode, 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 9fb4cbc5..514199c7 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt @@ -18,18 +18,54 @@ fun resolvedAncCycleMask( null } -fun visibleAncModes( - supportedModes: List, - currentMode: AapSetting.AncMode.Value, +/** + * Whether the device is expected to be able to sit in [mode] at all, based on the listening mode + * cycle and the Allow Off option. Both of those are inferred rather than device-reported: AirPods + * never push 0x1A/0x34, so this is a belief, not ground truth. + */ +fun isAncModePermitted( + mode: AapSetting.AncMode.Value, cycleMask: Int?, allowOffEnabled: Boolean, -): List = supportedModes.filter { mode -> +): Boolean { val inCycle = if (cycleMask != null) { (cycleMask and mode.cycleBit()) != 0 } else { true } - inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode + return inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) +} + +fun visibleAncModes( + supportedModes: List, + cycleMask: Int?, + allowOffEnabled: Boolean, +): List = supportedModes.filter { mode -> + isAncModePermitted(mode, cycleMask, allowOffEnabled) +} + +/** + * 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? @@ -38,15 +74,29 @@ val PodDevice.resolvedAncCycleMask: Int? reportedCycleMask = listeningModeCycle?.modeMask, ) +// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a +// confirmed enabled=false (direct device report or inferred rejection) hides OFF. +private val PodDevice.resolvedAllowOffEnabled: Boolean + get() = allowOffOption?.enabled != false + val PodDevice.visibleAncModes: List get() { val ancMode = ancMode ?: return emptyList() return visibleAncModes( supportedModes = ancMode.supported, - currentMode = ancMode.current, cycleMask = resolvedAncCycleMask, - // Unknown (null) is treated as allowed so OFF is visible optimistically. Only a - // confirmed enabled=false (direct device report or inferred rejection) hides OFF. - allowOffEnabled = allowOffOption?.enabled != false, + allowOffEnabled = resolvedAllowOffEnabled, + ) + } + +/** 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/AapAncController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt index 963145d9..27a4e0aa 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt @@ -8,6 +8,16 @@ import kotlin.reflect.KClass internal data class AncRuntimeState( val latestObservedAncMode: AapSetting.AncMode? = null, val pendingDebouncedAnc: PendingDebouncedAnc? = null, + /** + * Set when the device reported OFF while we had a request for a different mode outstanding. + * + * AirPods Pro 3 have been seen answering a listening mode write with OFF while actually + * switching to the requested mode. Such a report must not be fed to the Allow Off inference, + * or a single glitch permanently teaches CAPod that OFF is a permitted mode. Cleared as soon + * as any non-OFF mode is reported, so an unsolicited switch into OFF (stem press, another + * phone) still trains the inference normally. + */ + val offReportContradicted: Boolean = false, ) internal data class PendingDebouncedAnc( @@ -34,7 +44,17 @@ internal class AapAncController { now: Instant, ): AncDecision { val previous = podState.settings[key] - val updatedRuntime = runtimeState.copy(latestObservedAncMode = value) + val pendingMode = podState.pendingAncMode + // An OFF report with no competing request of our own is taken at face value, which both + // clears any earlier contradiction and keeps the Allow Off self-heal working after a + // glitch (stem press / another phone switching the pods into OFF for real). + val contradicted = value.current == AapSetting.AncMode.Value.OFF && + pendingMode != null && + pendingMode != AapSetting.AncMode.Value.OFF + val updatedRuntime = runtimeState.copy( + latestObservedAncMode = value, + offReportContradicted = contradicted, + ) val timerActions = mutableListOf() timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime)) @@ -90,6 +110,7 @@ internal class AapAncController { val latestEarDetection = podState.setting() val latestAllowOffOption = podState.setting() if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && + !runtimeState.offReportContradicted && latestEarDetection?.isEitherPodInEar == true && latestAllowOffOption?.enabled != true ) { @@ -153,6 +174,7 @@ internal class AapAncController { val earDetection = podState.setting() val allowOffOption = podState.setting() return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF && + !runtimeState.offReportContradicted && earDetection?.isEitherPodInEar == true && allowOffOption?.enabled != true ) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt index cdcda4e6..ef6ef9f9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt @@ -30,6 +30,23 @@ internal class AapOutboundController( private val coordinator = AapSettingsCoordinator(timeSource) + companion object { + /** + * How long to wait for the device to confirm a setting write before treating it as diverged. + * + * AirPods Pro 3 answer a listening mode write in roughly 0.8-1.1s (measured: 833ms, 887ms, + * 956ms, 1008ms). A 1000ms deadline sits inside that spread, so a perfectly healthy reply + * could land just after the timer and trigger a bogus "Divergence detected" plus a + * redundant re-send. The deadline is only a backstop now: [onStateObserved] resolves the + * verification as soon as a matching report arrives, so raising this does not slow the + * success path, only how long a genuinely unanswered write waits. + * + * Kept at roughly 2x the worst measured reply rather than higher, because a real rejection + * still costs two full deadlines before the user is told about it. + */ + const val VERIFICATION_TIMEOUT_MS = 2000L + } + fun onCommandRequested( podState: AapPodState, runtimeState: OutboundRuntimeState, @@ -74,7 +91,7 @@ internal class AapOutboundController( ), commandsToSend = listOf(command), timerActions = if (verificationCheck != null) { - listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)) } else { emptyList() }, @@ -108,7 +125,7 @@ internal class AapOutboundController( ), commandsToSend = result.commands, timerActions = if (verificationCheck != null) { - listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)) } else { emptyList() }, @@ -116,6 +133,34 @@ internal class AapOutboundController( ) } + /** + * Re-check the outstanding verification against freshly applied device state, so a confirmation + * is honoured the moment it arrives instead of waiting out [VERIFICATION_TIMEOUT_MS] and racing + * it. + * + * Deliberately limited to [AapCommand.SetAncMode]. Every other verified command gets an + * optimistic write into state when it is queued (see AapSettingsCoordinator.optimisticUpdate), + * which satisfies its own verification predicate straight away - only the device's contradicting + * echo later makes it fail. Reconciling those on arbitrary inbound frames would cancel the + * verification before that echo lands and silently swallow the rejection. SetAncMode is exempt + * from the optimistic write, so its predicate only becomes true once the device really confirms. + */ + fun onStateObserved( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState) + if (verification.command !is AapCommand.SetAncMode) return OutboundDecision(podState, runtimeState) + val check = coordinator.verificationFor(verification.command) + ?: return OutboundDecision(podState, runtimeState) + if (!check(podState)) return OutboundDecision(podState, runtimeState) + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.Verification)), + ) + } + fun onVerificationTimerFired( podState: AapPodState, runtimeState: OutboundRuntimeState, @@ -136,8 +181,10 @@ internal class AapOutboundController( val ear = podState.setting() if (ear != null && !ear.isEitherPodInEar) { + // Drop the pending mode too: nothing is going to confirm it now, and leaving it set + // would keep the UI showing a mode the device never reached. return OutboundDecision( - podState = podState, + podState = clearPendingForCommand(podState, verification.command), runtimeState = runtimeState.copy(verification = null), logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"), ) @@ -148,7 +195,7 @@ internal class AapOutboundController( podState = podState, runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), commandsToSend = listOf(verification.command), - timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)), + timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)), logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), ) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index f73c3642..7118ac65 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -295,6 +295,7 @@ internal class AapSessionEngine( now = timeSource.now(), ) applyAncDecision(decision) + reconcileVerification() return } @@ -313,6 +314,8 @@ internal class AapSessionEngine( "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } + reconcileVerification() + if (value is AapSetting.EarDetection) { applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc)) if (value.isEitherPodInEar) { @@ -361,6 +364,21 @@ internal class AapSessionEngine( applyTimerActions(decision.timerActions) } + /** + * Settle an outstanding verification against state we just applied. Confirmations are honoured + * the moment the device's report lands rather than at the verification deadline, so a reply + * arriving close to that deadline can't be misread as a divergence. + */ + private fun reconcileVerification() { + if (runtimeState.outbound.verification == null) return + val decision = outboundController.onStateObserved(_state.value, runtimeState.outbound) + if (decision.runtimeState.verification != null) return + _state.value = decision.podState + runtimeState = runtimeState.copy(outbound = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + applyTimerActions(decision.timerActions) + } + /** * Apply the non-send side of a decision (state, runtime, logs) and prepare the send context. * @@ -397,6 +415,16 @@ internal class AapSessionEngine( runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification)) } + /** + * A send that threw never reached the device, so an optimistically stored pending ANC mode has + * nothing left to confirm it. Clearing it stops the UI from showing a mode we failed to request. + */ + private fun clearPendingAncAfterFailedSend(commands: List) { + val ancCommand = commands.filterIsInstance().lastOrNull() ?: return + if (_state.value.pendingAncMode != ancCommand.mode) return + _state.value = _state.value.copy(pendingAncMode = null) + } + /** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */ private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) { val ctx = applyDecisionStateAndPrepareSend(decision) ?: return @@ -406,6 +434,7 @@ internal class AapSessionEngine( handleRejectedCommand(ctx.decision.rejectedCommand) } catch (e: Exception) { restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) throw e } } @@ -417,6 +446,7 @@ internal class AapSessionEngine( if (currentScope == null) { log(TAG, ERROR) { "No scope available for outbound async send" } restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) return } currentScope.launch { @@ -426,6 +456,7 @@ internal class AapSessionEngine( handleRejectedCommand(ctx.decision.rejectedCommand) } catch (_: Exception) { restoreVerification(ctx.previousVerification) + clearPendingAncAfterFailedSend(ctx.decision.commandsToSend) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4aea814d..2e99e670 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -577,6 +577,7 @@ Bluetooth Settings Could not apply setting: %1$s Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control. + The AirPods didn\'t confirm the listening mode change. Battery Optimized Charge Limit Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them. diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt index 03715159..78262081 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt @@ -21,7 +21,6 @@ class PodDeviceAncModeTest : BaseTest() { fun `cycle mask hides OFF when OFF is not allowed`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = 0x0E, allowOffEnabled = false, ) shouldContainExactly listOf( @@ -35,27 +34,73 @@ 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, + ) + } + + // -- 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( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = null, allowOffEnabled = false, ) shouldContainExactly allModes @@ -65,7 +110,6 @@ class PodDeviceAncModeTest : BaseTest() { fun `cycle mask with OFF bit set includes OFF`() { visibleAncModes( supportedModes = allModes, - currentMode = AapSetting.AncMode.Value.ON, cycleMask = 0x0F, allowOffEnabled = false, ) shouldContainExactly allModes diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 66f86d53..8298b8ba 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -412,7 +412,7 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE - advanceTimeBy(1100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L) sentCommands.size shouldBe 3 sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) @@ -724,6 +724,140 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE } + @Test + fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() = + runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { } + + // AirPods Pro 3 firmware answering an ADAPTIVE write with OFF. Must not be taken as + // evidence that OFF is a permitted mode. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + } + + @Test + fun `unsolicited OFF after a contradicted one still infers AllowOffOption true`() = + runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { } + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + // Let the request finish failing, so nothing of ours is outstanding any more. + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + engine.state.value.pendingAncMode.shouldBeNull() + + // Now a genuine switch into OFF (stem press / another phone) must still train it. + nextSetting = settingPair( + AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting()?.enabled shouldBe true + } + + @Test + fun `unrelated setting report does not prematurely confirm a non-ANC command`() = + runTest(UnconfinedTestDispatcher()) { + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + val rejected = mutableListOf() + val collectJob = launch { engine.settingRejected.collect { rejected += it } } + + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false)) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetConversationalAwareness(true)) { } + + // An unrelated frame must not settle the outstanding verification: the optimistic + // write already satisfies its predicate, so doing so would swallow the rejection. + nextSetting = settingPair( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + ) + ) + engine.processMessage(dummyMessage()) + + // The device then contradicts the write. + nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false)) + engine.processMessage(dummyMessage()) + + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) + rejected shouldBe listOf(AapCommand.SetConversationalAwareness(true)) + collectJob.cancel() + } + @Test fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) { val supportedModes = listOf( @@ -768,7 +902,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) engine.state.value.pendingAncMode.shouldBeNull() engine.state.value.setting()?.enabled shouldBe false sentCommands shouldBe listOf( @@ -812,7 +946,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) rejected.size shouldBe 1 collectJob.cancel() } @@ -852,7 +986,7 @@ class AapSessionEngineTest : BaseTest() { ) engine.processMessage(dummyMessage()) - advanceTimeBy(2100L) + advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) rejected shouldBe emptyList() collectJob.cancel() } From 734e15c94ddb8dcf0e7057741e640c30859f7406 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 19 Aug 2026 20:33:32 +0200 Subject: [PATCH 2/5] fix(aap): Tell a listening mode refusal apart from an unusable echo Follow-up to the previous commit, which left the wrong half of this in place. Distrusting the report only while the request was outstanding meant that four seconds later the rejection cleared the pending mode, the bogus value came back, and the user was shown an error for a mode change that had actually worked. The two cases have different signatures, both readable from state the engine already holds: - A refusal echoes the mode the device is staying in, quickly. Captured Off refusals answer in 25-267ms with the previous mode. - The Pro 3 misreport answers with a third mode, neither the one requested nor the one it was in, at normal change latency (815-1010ms). So an echo that is neither the requested nor the previous mode is treated as an unusable report rather than a refusal: no re-send of a write that already took effect, no rejection, no error, and the requested mode is recorded as current. Refusals still work, which is what the Off rejection message and the Allow Off learning depend on. This is deliberately engine-local. Seeding the cycle mask and Allow Off belief from the device profile into the session would have encoded the rule directly, but it inverts the current engine-to-profile data flow and creates a belief that no device report can ever correct, since AirPods never report 0x1A or 0x34. librepods keeps the same knowledge in its service layer and preferences, not in its protocol manager. The fault is per-session rather than per-request: across four sessions today the pods either misreported every Adaptive write or none of them. The heuristic is covered by unit tests but has not yet been observed handling a live bad session. --- .../apple/aap/engine/AapOutboundController.kt | 61 ++++++++++- .../apple/aap/engine/AapSessionEngineTest.kt | 102 ++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) 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 ef6ef9f9..7d5fc960 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,6 +8,12 @@ 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, ) internal data class OutboundRuntimeState( @@ -86,7 +92,13 @@ internal class AapOutboundController( return OutboundDecision( podState = updatedPodState, runtimeState = updatedRuntimeState.copy( - verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) } + verification = verificationCheck?.let { + VerificationState( + command = command, + attempt = 0, + previousAncMode = podState.setting()?.current, + ) + } ?: updatedRuntimeState.verification, ), commandsToSend = listOf(command), @@ -118,7 +130,11 @@ internal class AapOutboundController( runtimeState = runtimeState.copy( pendingCommands = result.pendingCommands, verification = if (verificationCheck != null) { - VerificationState(command = checkNotNull(toVerify), attempt = 0) + VerificationState( + command = checkNotNull(toVerify), + attempt = 0, + previousAncMode = podState.setting()?.current, + ) } else { runtimeState.verification }, @@ -179,6 +195,8 @@ 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 @@ -208,6 +226,45 @@ 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 does so + * 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. + * + * Deliberately engine-local: it uses only the requested mode, the previous mode and the echo. + * Which modes a device permits is app-level knowledge and stays out of the session engine. + */ + private fun unusableAncReport( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + verification: VerificationState, + ): OutboundDecision? { + val command = verification.command as? AapCommand.SetAncMode ?: return null + val previous = verification.previousAncMode ?: return null + val ancMode = podState.setting() ?: return null + val reported = ancMode.current + if (reported == command.mode || reported == previous) 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=$reported, was=$previous), " + + "not a refusal: keeping ${command.mode}" + ), + ) + } + private fun clearPendingForCommand( podState: AapPodState, command: AapCommand, 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 8298b8ba..a4cf4256 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 @@ -724,6 +724,108 @@ 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. + 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 `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()) { From b396ce457d2e63e889230224b26b42785ce5b6d1 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 19 Aug 2026 23:52:52 +0200 Subject: [PATCH 3/5] fix(aap): Attribute a listening mode echo before drawing conclusions from it Extends the previous commit's classifier so it only judges evidence it can actually attribute to our own write. The classifier judged whatever mode happened to be current when the deadline fired, and never looked at timing, despite the refusal-versus-misreport distinction resting on it. A delayed answer to an earlier write, or a mode change made on the pods themselves mid-request, could be taken for the answer to the outstanding write. Now the first report after a write is recorded with its latency, and that recorded frame is what gets classified. A write is left alone entirely when it was superseded by another listening mode write or by a stem press, since its echoes can no longer be attributed. An answer arriving faster than 500ms is a refusal, never a change: captures put refusals at 25-267ms and real changes at 815-1010ms. A re-send restamps its own send time and drops the previous attempt's echo, so a retry is never judged on stale evidence. Latency is measured with the monotonic clock, so a wall clock correction cannot turn a fast refusal into an apparent change. A misattribution is still possible, because AAP reports carry no correlation id and a change made from iOS or another paired phone is invisible here. That is why nothing is learned or persisted from this: the worst case is one wrong reading that the device's next report corrects. An earlier version of this work also carried a session-scoped remap, so that a stem-initiated switch could be read correctly after our own write had proven the device mislabels a value (issue #594). It is not included. The same unattributability that bounds the classifier to a single wrong reading would have let one misattribution rewrite every later report in the session, and a wrongly resolved Off could persist AllowOffOption into the device profile, outliving the session that produced it. Stem-initiated switches on an affected session are therefore still not shown correctly. Refs #594 --- .../apple/aap/engine/AapOutboundController.kt | 99 ++++++- .../core/apple/aap/engine/AapSessionEngine.kt | 12 +- .../apple/aap/engine/AapSessionEngineTest.kt | 279 ++++++++++++++---- 3 files changed, 330 insertions(+), 60 deletions(-) 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 7d5fc960..bd39eeb3 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 @@ -14,6 +14,18 @@ internal data class VerificationState( * 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( @@ -31,7 +43,7 @@ internal data class OutboundDecision( ) internal class AapOutboundController( - timeSource: TimeSource, + private val timeSource: TimeSource, ) { private val coordinator = AapSettingsCoordinator(timeSource) @@ -51,6 +63,14 @@ 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( @@ -97,6 +117,11 @@ internal class AapOutboundController( 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, ) } ?: updatedRuntimeState.verification, @@ -134,6 +159,8 @@ internal class AapOutboundController( command = checkNotNull(toVerify), attempt = 0, previousAncMode = podState.setting()?.current, + sentAtMs = timeSource.elapsedRealtime(), + superseded = runtimeState.verification?.command is AapCommand.SetAncMode, ) } else { runtimeState.verification @@ -149,6 +176,43 @@ 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 @@ -211,7 +275,15 @@ internal class AapOutboundController( if (verification.attempt == 0) { return OutboundDecision( podState = podState, - runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), + 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, + ), + ), commandsToSend = listOf(verification.command), timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)), logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), @@ -229,7 +301,7 @@ 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 does so + * 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. @@ -238,8 +310,15 @@ internal class AapOutboundController( * 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. * - * Deliberately engine-local: it uses only the requested mode, the previous mode and the echo. - * Which modes a device permits is app-level knowledge and stays out of the session engine. + * 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, @@ -247,10 +326,12 @@ internal class AapOutboundController( 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 - val reported = ancMode.current - if (reported == command.mode || reported == previous) return null return OutboundDecision( podState = clearPendingForCommand( @@ -259,8 +340,8 @@ internal class AapOutboundController( ), runtimeState = runtimeState.copy(verification = null), logs = listOf( - "Unusable ANC echo for ${command.mode} (reported=$reported, was=$previous), " + - "not a refusal: keeping ${command.mode}" + "Unusable ANC echo for ${command.mode} (reported=${echo.mode}, was=$previous, " + + "after ${echo.latencyMs}ms), not a refusal: keeping ${command.mode}" ), ) } 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 7118ac65..8eac67f3 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,6 +231,9 @@ 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}" } } @@ -286,6 +289,13 @@ 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, @@ -351,7 +361,7 @@ internal class AapSessionEngine( EngineTimerKey.Verification -> { applyOutboundDecisionAsync( - outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), +outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), ) } } 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 a4cf4256..d8341ff7 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 @@ -14,6 +14,7 @@ import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.first @@ -31,9 +32,22 @@ 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() } returns Instant.ofEpochMilli(1000L) - every { currentTimeMillis() } returns 1000L + 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 } private fun dummyMessage(commandType: Int = 0x0009): AapMessage { @@ -762,6 +776,7 @@ class AapSessionEngineTest : BaseTest() { // 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) ) @@ -777,6 +792,218 @@ class AapSessionEngineTest : BaseTest() { 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) + + // Must not have learned ADAPTIVE -> TRANSPARENCY. A later genuine ADAPTIVE report + // therefore still reads as ADAPTIVE. + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + 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()) { @@ -867,54 +1094,6 @@ 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()) { From 0b83e86c5ea684fe46e4b674bac3cb446f344a9d Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 20 Aug 2026 00:06:03 +0200 Subject: [PATCH 4/5] fix(aap): Correct an overstated safety claim and a test that proved nothing Review follow-ups on the previous commit. Its claim that "nothing is learned or persisted" was wrong. BatteryEstimator buckets drain samples by the current listening mode and force-persists the accumulated window whenever that mode changes, so a misattributed mode can write a drain rate to disk under the wrong bucket, and the corrective report does not remove it. This is not new: the misreport being fixed here already mis-buckets in the same way, and more often, since the device claims OFF while the pods play Adaptive. Classifying corrects the common case and only gets it wrong on the rarer misattribution. The design stands, the claim does not. Two other claims were also too strong. The recorded echo is the first report after the verification was installed, which is not exactly the wire write, so write contention above the latency boundary can still inflate a fast refusal. And a superseded write is not left alone entirely: classification is skipped, but it still falls through to the ordinary retry path. The supersession regression test asserted nothing: it fed a fresh ADAPTIVE report in before its only assertion, overwriting either outcome, so it passed whether or not the guard existed. It now asserts on the state left by the delayed echo, and fails with the guard removed. Also drops an unused import and restores an indent lost when the remap argument was removed. --- .../pods/core/apple/aap/engine/AapSessionEngine.kt | 2 +- .../core/apple/aap/engine/AapSessionEngineTest.kt | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) 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 8eac67f3..f7df880b 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 @@ -361,7 +361,7 @@ internal class AapSessionEngine( EngineTimerKey.Verification -> { applyOutboundDecisionAsync( -outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), + outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), ) } } 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 d8341ff7..10e7de31 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 @@ -14,7 +14,6 @@ import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.first @@ -882,16 +881,9 @@ class AapSessionEngineTest : BaseTest() { engine.processMessage(dummyMessage()) advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L) - // Must not have learned ADAPTIVE -> TRANSPARENCY. A later genuine ADAPTIVE report - // therefore still reads as ADAPTIVE. - nextSetting = settingPair( - AapSetting.AncMode( - current = AapSetting.AncMode.Value.ADAPTIVE, - supported = supportedModes, - ) - ) - engine.processMessage(dummyMessage()) - advanceTimeBy(1600L) + // 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 } From dbd1d422c69316b9c8ab43e5c13dbd68b634650c Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 20 Aug 2026 12:38:30 +0200 Subject: [PATCH 5/5] 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. --- .../devicesettings/cards/NoiseControlCard.kt | 5 +- .../main/ui/overview/cards/DualPodsCard.kt | 3 +- .../main/ui/overview/cards/SinglePodsCard.kt | 3 +- .../capod/main/ui/tile/AncTileStateMapper.kt | 3 +- .../ui/widget/AncWidgetRenderStateMapper.kt | 3 +- .../capod/main/ui/widget/WidgetDeviceKey.kt | 3 +- .../capod/monitor/core/PodDeviceAncMode.kt | 36 -- .../apple/aap/engine/AapOutboundController.kt | 146 +------ .../core/apple/aap/engine/AapSessionEngine.kt | 10 - .../monitor/core/PodDeviceAncModeTest.kt | 43 -- .../apple/aap/engine/AapSessionEngineTest.kt | 373 +++--------------- 11 files changed, 61 insertions(+), 567 deletions(-) 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()) {