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 {