From edf84248ab651eae28820e604194a850292607a0 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 19 Aug 2026 19:09:20 +0200 Subject: [PATCH] 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() }