fix(device-settings): Fix ANC OFF mode visibility and inference logic

Fix Elvis operator precedence bug in visibleAncModes() where OFF passed the filter unconditionally. Move filtering logic to PodDevice.visibleAncModes extension, unifying DualPodsCard, SinglePodsCard, and DeviceSettingsScreen. Gate AllowOffOption inference on pod-in-ear + 1.5s stability to prevent false positives from in-case OFF reports.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 789c23d24c
commit 14da81d231
8 changed files with 343 additions and 62 deletions
@@ -28,19 +28,3 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
fun visibleAncModes(
supportedModes: List<AapSetting.AncMode.Value>,
currentMode: AapSetting.AncMode.Value,
cycleMask: Int?,
allowOffEnabled: Boolean,
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
val inCycle = cycleMask?.let { (it and mode.cycleBit()) != 0 } ?: mode != AapSetting.AncMode.Value.OFF
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
}
@@ -99,6 +99,8 @@ import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDia
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -516,9 +518,7 @@ fun DeviceSettingsScreen(
val ancMode = device.ancMode
val adaptiveNoise = device.adaptiveAudioNoise
if (features.hasAncControl && ancMode != null) {
val cycleMask = if (features.hasListeningModeCycle) {
(device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask
} else null
val cycleMask = device.resolvedAncCycleMask
val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null
val cycleSubtitle = if (cycleSummary != null) {
buildString {
@@ -543,7 +543,7 @@ fun DeviceSettingsScreen(
NoiseControlCurrentModeControl(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = ancMode.supported,
supportedModes = device.visibleAncModes,
onModeSelected = onAncModeChange,
enabled = enabled,
)
@@ -43,7 +43,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.main.ui.components.visibleAncModes
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
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
@@ -237,15 +237,9 @@ fun DualPodsCard(
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
val visibleModes = visibleAncModes(
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E,
allowOffEnabled = device.allowOffOption?.enabled == true,
)
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
@@ -43,7 +43,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.main.ui.components.visibleAncModes
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.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
@@ -260,15 +260,9 @@ fun SinglePodsCard(
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
val visibleModes = visibleAncModes(
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E,
allowOffEnabled = device.allowOffOption?.enabled == true,
)
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
@@ -0,0 +1,50 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
private fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
fun resolvedAncCycleMask(
hasListeningModeCycle: Boolean,
reportedCycleMask: Int?,
): Int? = if (hasListeningModeCycle) {
reportedCycleMask ?: 0x0E
} else {
null
}
fun visibleAncModes(
supportedModes: List<AapSetting.AncMode.Value>,
currentMode: AapSetting.AncMode.Value,
cycleMask: Int?,
allowOffEnabled: Boolean,
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
val inCycle = if (cycleMask != null) {
(cycleMask and mode.cycleBit()) != 0
} else {
true
}
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
}
val PodDevice.resolvedAncCycleMask: Int?
get() = resolvedAncCycleMask(
hasListeningModeCycle = model.features.hasListeningModeCycle,
reportedCycleMask = listeningModeCycle?.modeMask,
)
val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
get() {
val ancMode = ancMode ?: return emptyList()
return visibleAncModes(
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = resolvedAncCycleMask,
allowOffEnabled = allowOffOption?.enabled == true,
)
}
@@ -54,11 +54,13 @@ internal class AapSessionEngine(
private var scope: CoroutineScope? = null
private var ancDebounceJob: Job? = null
private var allowOffInferenceJob: Job? = null
private var lastSentCommand: AapCommand? = null
private var lastSentAt: Long = 0L
/** Separate tracking for ANC sends — not overwritten by non-ANC commands during flush. */
private var lastAncSentAt: Long = 0L
private var handshakeResponseReceived: Boolean = false
private var latestObservedAncMode: AapSetting.AncMode? = null
/** Stored reference to the socket write callback — set on each [send] / flush call. */
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
@@ -81,6 +83,7 @@ internal class AapSessionEngine(
fun reset() {
ancDebounceJob?.cancel()
ancDebounceJob = null
cancelAllowOffOptionInference()
coordinator.clear()
hidTracker.flush()
hidTracker.reset()
@@ -88,6 +91,7 @@ internal class AapSessionEngine(
lastSentCommand = null
lastSentAt = 0L
lastAncSentAt = 0L
latestObservedAncMode = null
activeSendRaw = null
handshakeResponseReceived = false
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
@@ -163,6 +167,7 @@ internal class AapSessionEngine(
if (outcome is AapSettingsCoordinator.VerificationOutcome.Rejected) {
val command = outcome.command
if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) {
cancelAllowOffOptionInference()
val prev = _state.value.setting<AapSetting.AllowOffOption>()
if (prev == null || prev.enabled) {
_state.value = _state.value.withSetting(
@@ -273,20 +278,20 @@ internal class AapSessionEngine(
// ANC debounce
if (value is AapSetting.AncMode) {
latestObservedAncMode = value
syncAllowOffOptionInference()
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L
if (isFirstAncMode || recentAncSend) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
} else {
ancDebounceJob?.cancel()
ancDebounceJob = scope?.launch {
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
}
}
return
@@ -304,7 +309,7 @@ internal class AapSessionEngine(
}
_state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
applyInferences(value)
if (value is AapSetting.EarDetection) syncAllowOffOptionInference()
// Flush queued commands when pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
@@ -422,20 +427,42 @@ internal class AapSessionEngine(
// ── Inference ───────────────────────────────────────────
private fun applyInferences(trigger: AapSetting) {
val inferred = mutableListOf<Pair<KClass<out AapSetting>, AapSetting>>()
private fun syncAllowOffOptionInference() {
cancelAllowOffOptionInference()
if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) {
val current = _state.value.setting<AapSetting.AllowOffOption>()
if (current == null || !current.enabled) {
inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true)
val observedAncMode = latestObservedAncMode ?: _state.value.setting<AapSetting.AncMode>() ?: return
val earDetection = _state.value.setting<AapSetting.EarDetection>() ?: return
val allowOffOption = _state.value.setting<AapSetting.AllowOffOption>()
if (observedAncMode.current != AapSetting.AncMode.Value.OFF) return
if (!earDetection.isEitherPodInEar) return
if (allowOffOption?.enabled == true) return
val inferenceScope = scope ?: return
allowOffInferenceJob = inferenceScope.launch {
delay(1500L)
val latestAncMode = latestObservedAncMode ?: _state.value.setting<AapSetting.AncMode>()
val latestEarDetection = _state.value.setting<AapSetting.EarDetection>()
val latestAllowOffOption = _state.value.setting<AapSetting.AllowOffOption>()
if (latestAncMode?.current == AapSetting.AncMode.Value.OFF &&
latestEarDetection?.isEitherPodInEar == true &&
latestAllowOffOption?.enabled != true
) {
_state.value = _state.value.withSetting(
AapSetting.AllowOffOption::class,
AapSetting.AllowOffOption(enabled = true),
)
log(TAG) {
"Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)"
}
}
allowOffInferenceJob = null
}
}
for ((key, value) in inferred) {
_state.value = _state.value.withSetting(key, value)
log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" }
}
private fun cancelAllowOffOptionInference() {
allowOffInferenceJob?.cancel()
allowOffInferenceJob = null
}
companion object {
@@ -0,0 +1,95 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodDeviceAncModeTest : BaseTest() {
private val allModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
@Test
fun `cycle mask hides OFF when OFF is not allowed`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = 0x0E,
allowOffEnabled = false,
) shouldContainExactly listOf(
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
}
@Test
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`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.OFF,
cycleMask = 0x0E,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `null cycle mask shows all supported modes`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = null,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `cycle mask with OFF bit set includes OFF`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = 0x0F,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `models with listening mode cycle fall back to default mask when setting is absent`() {
resolvedAncCycleMask(
hasListeningModeCycle = true,
reportedCycleMask = null,
) shouldBe 0x0E
}
@Test
fun `models without listening mode cycle keep cycle mask null`() {
resolvedAncCycleMask(
hasListeningModeCycle = false,
reportedCycleMask = null,
) shouldBe null
}
@Test
fun `reported cycle mask wins over fallback`() {
resolvedAncCycleMask(
hasListeningModeCycle = true,
reportedCycleMask = 0x0A,
) shouldBe 0x0A
}
}
@@ -40,6 +40,10 @@ class AapSessionEngineTest : BaseTest() {
return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0))
}
@Suppress("UNCHECKED_CAST")
private fun settingPair(setting: AapSetting): Pair<KClass<out AapSetting>, AapSetting> =
setting::class as KClass<out AapSetting> to setting
/** Build a profile mock with all decode methods stubbed. Does NOT mock encodeCommand (sealed class). */
private fun mockProfile(block: AapDeviceProfile.() -> Unit = {}): AapDeviceProfile = mockk {
every { decodeStemPress(any()) } returns null
@@ -356,26 +360,159 @@ class AapSessionEngineTest : BaseTest() {
inner class InferenceTests {
@Test
fun `AncMode OFF infers AllowOffOption true`() {
fun `startup OFF while no pod is in ear does not infer 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 { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
))
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.start(this as TestScope)
engine.onHandshakeSent()
// First ANC mode = no debounce, applied immediately
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
}
@Test
fun `stable in-ear OFF infers AllowOffOption true after delay`() = 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.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
}
@Test
fun `later non-OFF ANC update cancels pending AllowOffOption true 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.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
advanceTimeBy(500L)
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
@Test
fun `rejected OFF command infers AllowOffOption false`() = 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.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(2100L)
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe false
sentCommands shouldBe listOf(
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
)
}
}