mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user