mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport
Device: Fix duplicate listening mode commands and silent failures
This commit is contained in:
@@ -90,6 +90,7 @@ fun DeviceSettingsScreenHost(
|
||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||
|
||||
@@ -125,6 +126,10 @@ fun DeviceSettingsScreenHost(
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||
}
|
||||
|
||||
@@ -109,7 +110,11 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
when (command) {
|
||||
is AapCommand.SetDynamicEndOfCharge ->
|
||||
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
is AapCommand.SetAncMode -> if (command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
|
||||
// Collect workerAutolaunch passively to keep it active
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -108,6 +109,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
OverviewViewModel.Event.OffModeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
OverviewViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
sealed interface Event {
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
@@ -84,6 +85,14 @@ class OverviewViewModel @Inject constructor(
|
||||
events.tryEmit(Event.OffModeRejectedByDevice)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
aapManager.settingRejectedEvents.collect { (_, command) ->
|
||||
if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
|
||||
@@ -18,18 +18,30 @@ fun resolvedAncCycleMask(
|
||||
null
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
/**
|
||||
* Whether the device is expected to be able to sit in [mode] at all, based on the listening mode
|
||||
* cycle and the Allow Off option. Both of those are inferred rather than device-reported: AirPods
|
||||
* never push 0x1A/0x34, so this is a belief, not ground truth.
|
||||
*/
|
||||
fun isAncModePermitted(
|
||||
mode: AapSetting.AncMode.Value,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
): Boolean {
|
||||
val inCycle = if (cycleMask != null) {
|
||||
(cycleMask and mode.cycleBit()) != 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
|
||||
return inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled)
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
isAncModePermitted(mode, cycleMask, allowOffEnabled)
|
||||
}
|
||||
|
||||
val PodDevice.resolvedAncCycleMask: Int?
|
||||
@@ -38,15 +50,17 @@ val PodDevice.resolvedAncCycleMask: Int?
|
||||
reportedCycleMask = listeningModeCycle?.modeMask,
|
||||
)
|
||||
|
||||
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
|
||||
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
|
||||
private val PodDevice.resolvedAllowOffEnabled: Boolean
|
||||
get() = allowOffOption?.enabled != false
|
||||
|
||||
val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
|
||||
get() {
|
||||
val ancMode = ancMode ?: return emptyList()
|
||||
return visibleAncModes(
|
||||
supportedModes = ancMode.supported,
|
||||
currentMode = ancMode.current,
|
||||
cycleMask = resolvedAncCycleMask,
|
||||
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
|
||||
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
|
||||
allowOffEnabled = allowOffOption?.enabled != false,
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ import kotlin.reflect.KClass
|
||||
internal data class AncRuntimeState(
|
||||
val latestObservedAncMode: AapSetting.AncMode? = null,
|
||||
val pendingDebouncedAnc: PendingDebouncedAnc? = null,
|
||||
/**
|
||||
* Set when the device reported OFF while we had a request for a different mode outstanding.
|
||||
*
|
||||
* AirPods Pro 3 have been seen answering a listening mode write with OFF while actually
|
||||
* switching to the requested mode. Such a report must not be fed to the Allow Off inference,
|
||||
* or a single glitch permanently teaches CAPod that OFF is a permitted mode. Cleared as soon
|
||||
* as any non-OFF mode is reported, so an unsolicited switch into OFF (stem press, another
|
||||
* phone) still trains the inference normally.
|
||||
*/
|
||||
val offReportContradicted: Boolean = false,
|
||||
)
|
||||
|
||||
internal data class PendingDebouncedAnc(
|
||||
@@ -34,7 +44,17 @@ internal class AapAncController {
|
||||
now: Instant,
|
||||
): AncDecision {
|
||||
val previous = podState.settings[key]
|
||||
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
|
||||
val pendingMode = podState.pendingAncMode
|
||||
// An OFF report with no competing request of our own is taken at face value, which both
|
||||
// clears any earlier contradiction and keeps the Allow Off self-heal working after a
|
||||
// glitch (stem press / another phone switching the pods into OFF for real).
|
||||
val contradicted = value.current == AapSetting.AncMode.Value.OFF &&
|
||||
pendingMode != null &&
|
||||
pendingMode != AapSetting.AncMode.Value.OFF
|
||||
val updatedRuntime = runtimeState.copy(
|
||||
latestObservedAncMode = value,
|
||||
offReportContradicted = contradicted,
|
||||
)
|
||||
val timerActions = mutableListOf<EngineTimerAction>()
|
||||
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
|
||||
|
||||
@@ -90,6 +110,7 @@ internal class AapAncController {
|
||||
val latestEarDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val latestAllowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
if (latestAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
latestEarDetection?.isEitherPodInEar == true &&
|
||||
latestAllowOffOption?.enabled != true
|
||||
) {
|
||||
@@ -153,6 +174,7 @@ internal class AapAncController {
|
||||
val earDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val allowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
earDetection?.isEitherPodInEar == true &&
|
||||
allowOffOption?.enabled != true
|
||||
) {
|
||||
|
||||
+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,30 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `allow off keeps OFF visible even when cycle mask excludes it`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = true,
|
||||
) shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current OFF stays visible even when OFF is otherwise hidden`() {
|
||||
fun `current OFF is NOT re-admitted when OFF is otherwise hidden`() {
|
||||
// Regression: a device reporting a mode outside its own cycle used to conjure an extra
|
||||
// selector button. AirPods Pro 3 do exactly that, answering an Adaptive write with OFF.
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.OFF,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
) shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null cycle mask shows all supported modes`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = null,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
@@ -65,7 +67,6 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `cycle mask with OFF bit set includes OFF`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0F,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
|
||||
+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