mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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.
This commit is contained in:
@@ -90,6 +90,7 @@ fun DeviceSettingsScreenHost(
|
||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||
|
||||
@@ -125,6 +126,10 @@ fun DeviceSettingsScreenHost(
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||
}
|
||||
|
||||
@@ -109,7 +110,11 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
when (command) {
|
||||
is AapCommand.SetDynamicEndOfCharge ->
|
||||
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
is AapCommand.SetAncMode -> if (command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
|
||||
// Collect workerAutolaunch passively to keep it active
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -108,6 +109,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
OverviewViewModel.Event.OffModeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
OverviewViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
sealed interface Event {
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
@@ -84,6 +85,14 @@ class OverviewViewModel @Inject constructor(
|
||||
events.tryEmit(Event.OffModeRejectedByDevice)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
aapManager.settingRejectedEvents.collect { (_, command) ->
|
||||
if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -18,18 +18,54 @@ fun resolvedAncCycleMask(
|
||||
null
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
/**
|
||||
* Whether the device is expected to be able to sit in [mode] at all, based on the listening mode
|
||||
* cycle and the Allow Off option. Both of those are inferred rather than device-reported: AirPods
|
||||
* never push 0x1A/0x34, so this is a belief, not ground truth.
|
||||
*/
|
||||
fun isAncModePermitted(
|
||||
mode: AapSetting.AncMode.Value,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
): Boolean {
|
||||
val inCycle = if (cycleMask != null) {
|
||||
(cycleMask and mode.cycleBit()) != 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
|
||||
return inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled)
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
isAncModePermitted(mode, cycleMask, allowOffEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AapSetting.AncMode.Value>
|
||||
get() {
|
||||
val ancMode = ancMode ?: return emptyList()
|
||||
return visibleAncModes(
|
||||
supportedModes = ancMode.supported,
|
||||
currentMode = ancMode.current,
|
||||
cycleMask = resolvedAncCycleMask,
|
||||
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
|
||||
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
|
||||
allowOffEnabled = allowOffOption?.enabled != false,
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
/** Display-facing listening mode. See [effectiveAncMode]. */
|
||||
val PodDevice.effectiveAncMode: AapSetting.AncMode.Value?
|
||||
get() {
|
||||
val ancMode = ancMode ?: return null
|
||||
return effectiveAncMode(
|
||||
reportedMode = ancMode.current,
|
||||
pendingMode = pendingAncMode,
|
||||
cycleMask = resolvedAncCycleMask,
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ import kotlin.reflect.KClass
|
||||
internal data class AncRuntimeState(
|
||||
val latestObservedAncMode: AapSetting.AncMode? = null,
|
||||
val pendingDebouncedAnc: PendingDebouncedAnc? = null,
|
||||
/**
|
||||
* Set when the device reported OFF while we had a request for a different mode outstanding.
|
||||
*
|
||||
* AirPods Pro 3 have been seen answering a listening mode write with OFF while actually
|
||||
* switching to the requested mode. Such a report must not be fed to the Allow Off inference,
|
||||
* or a single glitch permanently teaches CAPod that OFF is a permitted mode. Cleared as soon
|
||||
* as any non-OFF mode is reported, so an unsolicited switch into OFF (stem press, another
|
||||
* phone) still trains the inference normally.
|
||||
*/
|
||||
val offReportContradicted: Boolean = false,
|
||||
)
|
||||
|
||||
internal data class PendingDebouncedAnc(
|
||||
@@ -34,7 +44,17 @@ internal class AapAncController {
|
||||
now: Instant,
|
||||
): AncDecision {
|
||||
val previous = podState.settings[key]
|
||||
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
|
||||
val pendingMode = podState.pendingAncMode
|
||||
// An OFF report with no competing request of our own is taken at face value, which both
|
||||
// clears any earlier contradiction and keeps the Allow Off self-heal working after a
|
||||
// glitch (stem press / another phone switching the pods into OFF for real).
|
||||
val contradicted = value.current == AapSetting.AncMode.Value.OFF &&
|
||||
pendingMode != null &&
|
||||
pendingMode != AapSetting.AncMode.Value.OFF
|
||||
val updatedRuntime = runtimeState.copy(
|
||||
latestObservedAncMode = value,
|
||||
offReportContradicted = contradicted,
|
||||
)
|
||||
val timerActions = mutableListOf<EngineTimerAction>()
|
||||
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
|
||||
|
||||
@@ -90,6 +110,7 @@ internal class AapAncController {
|
||||
val latestEarDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val latestAllowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
if (latestAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
latestEarDetection?.isEitherPodInEar == true &&
|
||||
latestAllowOffOption?.enabled != true
|
||||
) {
|
||||
@@ -153,6 +174,7 @@ internal class AapAncController {
|
||||
val earDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val allowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
earDetection?.isEitherPodInEar == true &&
|
||||
allowOffOption?.enabled != true
|
||||
) {
|
||||
|
||||
+51
-4
@@ -30,6 +30,23 @@ internal class AapOutboundController(
|
||||
|
||||
private val coordinator = AapSettingsCoordinator(timeSource)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long to wait for the device to confirm a setting write before treating it as diverged.
|
||||
*
|
||||
* AirPods Pro 3 answer a listening mode write in roughly 0.8-1.1s (measured: 833ms, 887ms,
|
||||
* 956ms, 1008ms). A 1000ms deadline sits inside that spread, so a perfectly healthy reply
|
||||
* could land just after the timer and trigger a bogus "Divergence detected" plus a
|
||||
* redundant re-send. The deadline is only a backstop now: [onStateObserved] resolves the
|
||||
* verification as soon as a matching report arrives, so raising this does not slow the
|
||||
* success path, only how long a genuinely unanswered write waits.
|
||||
*
|
||||
* Kept at roughly 2x the worst measured reply rather than higher, because a real rejection
|
||||
* still costs two full deadlines before the user is told about it.
|
||||
*/
|
||||
const val VERIFICATION_TIMEOUT_MS = 2000L
|
||||
}
|
||||
|
||||
fun onCommandRequested(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
@@ -74,7 +91,7 @@ internal class AapOutboundController(
|
||||
),
|
||||
commandsToSend = listOf(command),
|
||||
timerActions = if (verificationCheck != null) {
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L))
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
@@ -108,7 +125,7 @@ internal class AapOutboundController(
|
||||
),
|
||||
commandsToSend = result.commands,
|
||||
timerActions = if (verificationCheck != null) {
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L))
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
@@ -116,6 +133,34 @@ internal class AapOutboundController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the outstanding verification against freshly applied device state, so a confirmation
|
||||
* is honoured the moment it arrives instead of waiting out [VERIFICATION_TIMEOUT_MS] and racing
|
||||
* it.
|
||||
*
|
||||
* Deliberately limited to [AapCommand.SetAncMode]. Every other verified command gets an
|
||||
* optimistic write into state when it is queued (see AapSettingsCoordinator.optimisticUpdate),
|
||||
* which satisfies its own verification predicate straight away - only the device's contradicting
|
||||
* echo later makes it fail. Reconciling those on arbitrary inbound frames would cancel the
|
||||
* verification before that echo lands and silently swallow the rejection. SetAncMode is exempt
|
||||
* from the optimistic write, so its predicate only becomes true once the device really confirms.
|
||||
*/
|
||||
fun onStateObserved(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
): OutboundDecision {
|
||||
val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState)
|
||||
if (verification.command !is AapCommand.SetAncMode) return OutboundDecision(podState, runtimeState)
|
||||
val check = coordinator.verificationFor(verification.command)
|
||||
?: return OutboundDecision(podState, runtimeState)
|
||||
if (!check(podState)) return OutboundDecision(podState, runtimeState)
|
||||
return OutboundDecision(
|
||||
podState = clearPendingForCommand(podState, verification.command),
|
||||
runtimeState = runtimeState.copy(verification = null),
|
||||
timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.Verification)),
|
||||
)
|
||||
}
|
||||
|
||||
fun onVerificationTimerFired(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
@@ -136,8 +181,10 @@ internal class AapOutboundController(
|
||||
|
||||
val ear = podState.setting<AapSetting.EarDetection>()
|
||||
if (ear != null && !ear.isEitherPodInEar) {
|
||||
// Drop the pending mode too: nothing is going to confirm it now, and leaving it set
|
||||
// would keep the UI showing a mode the device never reached.
|
||||
return OutboundDecision(
|
||||
podState = podState,
|
||||
podState = clearPendingForCommand(podState, verification.command),
|
||||
runtimeState = runtimeState.copy(verification = null),
|
||||
logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"),
|
||||
)
|
||||
@@ -148,7 +195,7 @@ internal class AapOutboundController(
|
||||
podState = podState,
|
||||
runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)),
|
||||
commandsToSend = listOf(verification.command),
|
||||
timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)),
|
||||
timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)),
|
||||
logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ internal class AapSessionEngine(
|
||||
now = timeSource.now(),
|
||||
)
|
||||
applyAncDecision(decision)
|
||||
reconcileVerification()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,6 +314,8 @@ internal class AapSessionEngine(
|
||||
"Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]"
|
||||
}
|
||||
|
||||
reconcileVerification()
|
||||
|
||||
if (value is AapSetting.EarDetection) {
|
||||
applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc))
|
||||
if (value.isEitherPodInEar) {
|
||||
@@ -361,6 +364,21 @@ internal class AapSessionEngine(
|
||||
applyTimerActions(decision.timerActions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle an outstanding verification against state we just applied. Confirmations are honoured
|
||||
* the moment the device's report lands rather than at the verification deadline, so a reply
|
||||
* arriving close to that deadline can't be misread as a divergence.
|
||||
*/
|
||||
private fun reconcileVerification() {
|
||||
if (runtimeState.outbound.verification == null) return
|
||||
val decision = outboundController.onStateObserved(_state.value, runtimeState.outbound)
|
||||
if (decision.runtimeState.verification != null) return
|
||||
_state.value = decision.podState
|
||||
runtimeState = runtimeState.copy(outbound = decision.runtimeState)
|
||||
decision.logs.forEach { log(TAG) { it } }
|
||||
applyTimerActions(decision.timerActions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the non-send side of a decision (state, runtime, logs) and prepare the send context.
|
||||
*
|
||||
@@ -397,6 +415,16 @@ internal class AapSessionEngine(
|
||||
runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification))
|
||||
}
|
||||
|
||||
/**
|
||||
* A send that threw never reached the device, so an optimistically stored pending ANC mode has
|
||||
* nothing left to confirm it. Clearing it stops the UI from showing a mode we failed to request.
|
||||
*/
|
||||
private fun clearPendingAncAfterFailedSend(commands: List<AapCommand>) {
|
||||
val ancCommand = commands.filterIsInstance<AapCommand.SetAncMode>().lastOrNull() ?: return
|
||||
if (_state.value.pendingAncMode != ancCommand.mode) return
|
||||
_state.value = _state.value.copy(pendingAncMode = null)
|
||||
}
|
||||
|
||||
/** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */
|
||||
private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) {
|
||||
val ctx = applyDecisionStateAndPrepareSend(decision) ?: return
|
||||
@@ -406,6 +434,7 @@ internal class AapSessionEngine(
|
||||
handleRejectedCommand(ctx.decision.rejectedCommand)
|
||||
} catch (e: Exception) {
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -417,6 +446,7 @@ internal class AapSessionEngine(
|
||||
if (currentScope == null) {
|
||||
log(TAG, ERROR) { "No scope available for outbound async send" }
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
return
|
||||
}
|
||||
currentScope.launch {
|
||||
@@ -426,6 +456,7 @@ internal class AapSessionEngine(
|
||||
handleRejectedCommand(ctx.decision.rejectedCommand)
|
||||
} catch (_: Exception) {
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +577,7 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Settings</string>
|
||||
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control.</string>
|
||||
<string name="anc_mode_not_confirmed_message">The AirPods didn\'t confirm the listening mode change.</string>
|
||||
<string name="device_settings_category_battery_label">Battery</string>
|
||||
<string name="device_settings_charge_cap_label">Optimized Charge Limit</string>
|
||||
<string name="device_settings_charge_cap_description">Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them.</string>
|
||||
|
||||
@@ -21,7 +21,6 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `cycle mask hides OFF when OFF is not allowed`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly listOf(
|
||||
@@ -35,27 +34,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
|
||||
|
||||
+138
-4
@@ -412,7 +412,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
|
||||
advanceTimeBy(1100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L)
|
||||
|
||||
sentCommands.size shouldBe 3
|
||||
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
||||
@@ -724,6 +724,140 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
|
||||
// AirPods Pro 3 firmware answering an ADAPTIVE write with OFF. Must not be taken as
|
||||
// evidence that OFF is a permitted mode.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited OFF after a contradicted one still infers AllowOffOption true`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// Let the request finish failing, so nothing of ours is outstanding any more.
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
|
||||
// Now a genuine switch into OFF (stem press / another phone) must still train it.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated setting report does not prematurely confirm a non-ANC command`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<AapCommand>()
|
||||
val collectJob = launch { engine.settingRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false))
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetConversationalAwareness(true)) { }
|
||||
|
||||
// An unrelated frame must not settle the outstanding verification: the optimistic
|
||||
// write already satisfies its predicate, so doing so would swallow the rejection.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// The device then contradicts the write.
|
||||
nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false))
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected shouldBe listOf(AapCommand.SetConversationalAwareness(true))
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
@@ -768,7 +902,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe false
|
||||
sentCommands shouldBe listOf(
|
||||
@@ -812,7 +946,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected.size shouldBe 1
|
||||
collectJob.cancel()
|
||||
}
|
||||
@@ -852,7 +986,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected shouldBe emptyList()
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user