From 384d81be1893e87d9aab1773405fc264178b9ccb Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 17 Apr 2026 11:37:33 +0200 Subject: [PATCH] feat(aap): Persist learned ANC settings across reconnects --- .../devicesettings/DeviceSettingsViewModel.kt | 38 +++++- .../capod/main/ui/overview/OverviewScreen.kt | 18 +++ .../main/ui/overview/OverviewViewModel.kt | 14 +++ .../capod/monitor/core/DeviceMonitor.kt | 6 + .../eu/darken/capod/monitor/core/PodDevice.kt | 12 ++ .../capod/monitor/core/PodDeviceAncMode.kt | 4 +- .../core/aap/AapLearnedSettingsPersister.kt | 70 +++++++++++ .../monitor/core/aap/AapLifecycleManager.kt | 2 + .../core/apple/aap/AapConnectionManager.kt | 11 ++ .../core/apple/aap/engine/AapConnection.kt | 1 + .../core/apple/aap/engine/AapSessionEngine.kt | 5 + .../aap/engine/AapSettingsCoordinator.kt | 9 +- .../capod/profiles/core/AppleDeviceProfile.kt | 12 ++ app/src/main/res/values/strings.xml | 5 +- .../DeviceSettingsViewModelTest.kt | 89 +++++++++++++- .../monitor/core/PodDeviceAncModeTest.kt | 94 +++++++++++++++ .../aap/AapLearnedSettingsPersisterTest.kt | 114 ++++++++++++++++++ .../apple/aap/engine/AapSessionEngineTest.kt | 80 ++++++++++++ .../aap/engine/AapSettingsCoordinatorTest.kt | 14 ++- 19 files changed, 575 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersister.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersisterTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index f9e4b1d3..a4c18e41 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -17,6 +17,7 @@ import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.resolvedAncCycleMask import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.isPro @@ -79,10 +80,21 @@ class DeviceSettingsViewModel @Inject constructor( data object OpenBluetoothSettings : Event data class SendFailed(val command: AapCommand, val message: String?) : Event data object SystemRenameUnavailable : Event + data object OffModeRejectedByDevice : Event } val events = SingleEventFlow() + init { + launch { + aapManager.offRejectedEvents.collect { address -> + if (address == currentAddress()) { + events.tryEmit(Event.OffModeRejectedByDevice) + } + } + } + } + val state = targetProfileId.flatMapLatest { profileId -> if (profileId == null) return@flatMapLatest flowOf(State(device = null)) combine( @@ -253,16 +265,25 @@ class DeviceSettingsViewModel @Inject constructor( fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask)) - fun setListeningModeOffVisibility(enabled: Boolean, currentCycleMask: Int) = launch { + fun setAllowOffOption(enabled: Boolean) = launch { if (!upgradeRepo.isPro()) { navTo(Nav.Main.Upgrade) return@launch } - // Keep in sync with cycleBit(OFF) in DeviceSettingsScreen. - val offBit = 0x01 - val newMask = if (enabled) currentCycleMask or offBit else currentCycleMask and offBit.inv() - if (sendInternal(AapCommand.SetListeningModeCycle(newMask))) { - sendInternal(AapCommand.SetAllowOffOption(enabled)) + if (enabled) { + sendInternal(AapCommand.SetAllowOffOption(enabled = true)) + } else { + val profileId = targetProfileId.value + val currentMask = profileId + ?.let { deviceMonitor.getDeviceForProfile(it) } + ?.resolvedAncCycleMask + ?: DEFAULT_CYCLE_MASK_WITH_OFF + // Always send the cycle-mask update first — local state can diverge from the + // device's actual mask since 0x1A is never echoed. Stripping OFF unconditionally + // keeps the stem cycle consistent with the disabled capability. + val newMask = currentMask and OFF_BIT.inv() + sendInternal(AapCommand.SetListeningModeCycle(newMask)) + sendInternal(AapCommand.SetAllowOffOption(enabled = false)) } } @@ -416,5 +437,10 @@ class DeviceSettingsViewModel @Inject constructor( companion object { private val TAG = logTag("DeviceSettings", "VM") + private const val OFF_BIT = 0x01 + // Apple's factory-default listening-mode cycle mask: ON | TRANSPARENCY | ADAPTIVE. + // Used as a conservative fallback when disabling Allow Off and we don't have a + // live/persisted mask to mutate. + private const val DEFAULT_CYCLE_MASK_WITH_OFF = 0x0F } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 7fbd1ebb..dbe7b2cb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -20,12 +20,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -70,12 +73,24 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { NavigationEventHandler(vm) val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) // Collect workerAutolaunch passively to keep it active LaunchedEffect(Unit) { vm.workerAutolaunch.collect {} } + LaunchedEffect(Unit) { + vm.events.collect { event -> + when (event) { + OverviewViewModel.Event.OffModeRejectedByDevice -> { + snackbarHostState.showSnackbar(offRejectedMessage) + } + } + } + } + // Permission handling var awaitingPermission by rememberSaveable { mutableStateOf(false) } @@ -129,6 +144,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { OverviewScreen( state = currentState, + snackbarHostState = snackbarHostState, onRequestPermission = { vm.requestPermission(it) }, onBluetoothSettings = { try { @@ -153,6 +169,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { @Composable fun OverviewScreen( state: OverviewViewModel.State, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onRequestPermission: (Permission) -> Unit, onBluetoothSettings: () -> Unit, onManageDevices: () -> Unit, @@ -234,6 +251,7 @@ fun OverviewScreen( }, ) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> LazyColumn( modifier = Modifier diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 8e9c96f3..b3822e91 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -58,6 +58,20 @@ class OverviewViewModel @Inject constructor( val requestPermissionEvent = SingleEventFlow() + sealed interface Event { + data object OffModeRejectedByDevice : Event + } + + val events = SingleEventFlow() + + init { + launch { + aapManager.offRejectedEvents.collect { + events.tryEmit(Event.OffModeRejectedByDevice) + } + } + } + private val showUnmatchedDevices = MutableStateFlow(false) private val userExpansionOverrides = MutableStateFlow>(emptySet()) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index edcf1b43..159bca84 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -72,6 +72,8 @@ class DeviceMonitor @Inject constructor( profileAddress = profile?.address, profileModel = profile?.model, profileKeyState = profile.toBleKeyState(), + profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, + profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, reactions = profile.toReactionConfig(), isSystemConnected = profile?.address in connectedAddresses, ) @@ -145,6 +147,8 @@ class DeviceMonitor @Inject constructor( profileAddress = profile.address, profileModel = profile.model, profileKeyState = profile.toBleKeyState(), + profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, + profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, reactions = profile.toReactionConfig(), isSystemConnected = profile.address in connectedAddresses, ) @@ -224,6 +228,8 @@ class DeviceMonitor @Inject constructor( profileAddress = profile.address, profileModel = profile.model, profileKeyState = profile.toBleKeyState(), + profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, + profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, reactions = profile.toReactionConfig(), ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 1e940233..66d288ca 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -47,6 +47,16 @@ data class PodDevice( * every time the BLE scanner misses the next advertisement batch. */ internal val profileKeyState: BleKeyState = BleKeyState.NONE, + /** + * Last-known AllowOffOption value persisted on the profile. Used as a fallback when the + * live AAP state has no AllowOffOption setting (fresh session, device not pushing 0x34). + */ + internal val profileLearnedAllowOffEnabled: Boolean? = null, + /** + * Last-known ListeningModeCycle mask persisted on the profile. Used as a fallback when + * the live AAP state has no ListeningModeCycle setting (device never echoes 0x1A back). + */ + internal val profileLastRequestedListeningModeCycleMask: Int? = null, /** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */ val reactions: ReactionConfig = ReactionConfig(), /** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */ @@ -308,9 +318,11 @@ data class PodDevice( val listeningModeCycle: AapSetting.ListeningModeCycle? get() = aap?.setting() + ?: profileLastRequestedListeningModeCycleMask?.let { AapSetting.ListeningModeCycle(modeMask = it) } val allowOffOption: AapSetting.AllowOffOption? get() = aap?.setting() + ?: profileLearnedAllowOffEnabled?.let { AapSetting.AllowOffOption(enabled = it) } val stemConfig: AapSetting.StemConfig? get() = aap?.setting() diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt index 5d27eb14..9fb4cbc5 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt @@ -45,6 +45,8 @@ val PodDevice.visibleAncModes: List supportedModes = ancMode.supported, currentMode = ancMode.current, cycleMask = resolvedAncCycleMask, - allowOffEnabled = allowOffOption?.enabled == true, + // 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, ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersister.kt new file mode 100644 index 00000000..a4eb6cbf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersister.kt @@ -0,0 +1,70 @@ +package eu.darken.capod.monitor.core.aap + +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.profiles.core.AppleDeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists the learned AllowOffOption and ListeningModeCycle values to [AppleDeviceProfile] + * whenever they change in AAP state. AAP state is dropped on disconnect and neither setting + * is proactively echoed by the device, so without persistence every reconnect would force the + * UI back to defaults (OFF hidden, cycle mask 0x0E). + */ +@Singleton +class AapLearnedSettingsPersister @Inject constructor( + private val aapManager: AapConnectionManager, + private val profilesRepo: DeviceProfilesRepo, +) { + private data class LearnedSnapshot( + val allowOffEnabled: Boolean?, + val cycleMask: Int?, + ) + + fun monitor(): Flow = aapManager.allStates + .map { states -> states.mapValues { (_, state) -> state.snapshot() } } + .distinctUntilChanged() + .onEach { addressToSnapshot -> + addressToSnapshot.forEach { (address, snapshot) -> + if (snapshot.allowOffEnabled == null && snapshot.cycleMask == null) return@forEach + val profile = profilesRepo.profiles.first() + .filterIsInstance() + .firstOrNull { it.address == address } ?: return@forEach + val allowOffChanged = snapshot.allowOffEnabled != null && + profile.learnedAllowOffEnabled != snapshot.allowOffEnabled + val cycleChanged = snapshot.cycleMask != null && + profile.lastRequestedListeningModeCycleMask != snapshot.cycleMask + if (!allowOffChanged && !cycleChanged) return@forEach + profilesRepo.updateAppleProfile(profile.id) { + it.copy( + learnedAllowOffEnabled = if (allowOffChanged) snapshot.allowOffEnabled else it.learnedAllowOffEnabled, + lastRequestedListeningModeCycleMask = if (cycleChanged) snapshot.cycleMask else it.lastRequestedListeningModeCycleMask, + ) + } + if (allowOffChanged) log(TAG) { "Persisted learnedAllowOffEnabled=${snapshot.allowOffEnabled} for $address" } + if (cycleChanged) log(TAG) { "Persisted lastRequestedListeningModeCycleMask=0x%02X for $address".format(snapshot.cycleMask) } + } + } + .map { } + .setupCommonEventHandlers(TAG) { "learnedSettingsPersister" } + + private fun AapPodState.snapshot(): LearnedSnapshot = LearnedSnapshot( + allowOffEnabled = setting()?.enabled, + cycleMask = setting()?.modeMask, + ) + + companion object { + private val TAG = logTag("Monitor", "AapLearnedSettingsPersister") + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt index f2e9040b..f399a506 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt @@ -24,6 +24,7 @@ class AapLifecycleManager @Inject constructor( @AppScope private val appScope: CoroutineScope, private val aapAutoConnect: AapAutoConnect, private val aapKeyPersister: AapKeyPersister, + private val aapLearnedSettingsPersister: AapLearnedSettingsPersister, private val stemConfigSender: StemConfigSender, private val stemPressReaction: StemPressReaction, ) { @@ -32,6 +33,7 @@ class AapLifecycleManager @Inject constructor( merge( aapAutoConnect.monitor(), aapKeyPersister.monitor(), + aapLearnedSettingsPersister.monitor(), stemConfigSender.monitor(), stemPressReaction.monitor(), ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index 28fbbff0..0dbbe124 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -64,6 +64,10 @@ class AapConnectionManager @Inject constructor( private val _stemPressEvents = MutableSharedFlow>(extraBufferCapacity = 32) val stemPressEvents: SharedFlow> = _stemPressEvents.asSharedFlow() + /** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */ + private val _offRejectedEvents = MutableSharedFlow(extraBufferCapacity = 16) + val offRejectedEvents: SharedFlow = _offRejectedEvents.asSharedFlow() + fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] } suspend fun connect( @@ -108,6 +112,13 @@ class AapConnectionManager @Inject constructor( } } + // Forward OFF-rejection events from this connection (child coroutine) + launch { + connection.offRejected.collect { + _offRejectedEvents.tryEmit(address) + } + } + connection.state.collect { podState -> if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) { log(TAG) { "Connection to $address disconnected" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index e53fd8e2..37505004 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -45,6 +45,7 @@ internal class AapConnection( val state: StateFlow get() = engine.state val keysReceived: SharedFlow get() = engine.keysReceived val stemPressEvents: SharedFlow get() = engine.stemPressEvents + val offRejected: SharedFlow get() = engine.offRejected private var socket: BluetoothSocket? = null private var readerJob: Job? = null diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index 90b51cfe..6775b88b 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -47,6 +47,10 @@ internal class AapSessionEngine( MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() + private val _offRejected = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val offRejected: SharedFlow = _offRejected.asSharedFlow() + private val hidTracker = HidTracker { msg -> log(TAG) { msg } } private val inboundInterpreter = AapInboundInterpreter(profile) private val ancController = AapAncController() @@ -359,6 +363,7 @@ internal class AapSessionEngine( private fun handleRejectedCommand(command: AapCommand?) { if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc)) + _offRejected.tryEmit(Unit) } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt index 88c7f45c..8a2bc240 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt @@ -65,9 +65,12 @@ internal class AapSettingsCoordinator( fun flush(pendingCommands: List): FlushResult { val sorted = pendingCommands.sortedBy { when (it) { - is AapCommand.SetAllowOffOption -> 0 - is AapCommand.SetAncMode -> 1 - else -> 2 + // Cycle mask must go before AllowOffOption(false) so we don't leave the device + // with OFF still in the stem cycle but no longer permitted as a mode. + is AapCommand.SetListeningModeCycle -> 0 + is AapCommand.SetAllowOffOption -> 1 + is AapCommand.SetAncMode -> 2 + else -> 3 } } return FlushResult( diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index fce63a56..34403f64 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -29,6 +29,18 @@ data class AppleDeviceProfile( @SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN, @SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false, @SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false, + /** + * Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor + * the learned value across sessions — AAP state is dropped on disconnect, but whether OFF + * mode is allowed on the device is effectively sticky until the owner toggles it. + */ + @SerialName("learnedAllowOffEnabled") val learnedAllowOffEnabled: Boolean? = null, + /** + * Last-known device-side ListeningModeCycle mask (AAP setting 0x1A). The device never + * echoes this back as a push setting, so without persistence every reconnect resets the + * UI to the default 0x0E (no OFF bit) even if the real cycle on-device includes OFF. + */ + @SerialName("learnedListeningModeCycleMask") val lastRequestedListeningModeCycleMask: Int? = null, ) : DeviceProfile, HasReactionConfig { override val reactionConfig: ReactionConfig diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0dc52e40..5a3a14a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,8 +520,8 @@ Noise Cancellation Transparency Adaptive - Include Off - Show Off as an option when cycling noise control modes + Allow Off mode + Allow Off as a selectable noise control mode. When disabled, stems skip Off while cycling. Sleep Detection Automatically pause audio when you fall asleep Rename @@ -532,6 +532,7 @@ Android didn\'t let us rename the device here. To update the name in Bluetooth settings, rename it there or re-pair the device. Bluetooth Settings Could not apply setting: %1$s + Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control. Connected Devices Other devices currently connected to these AirPods Device %d diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index abf8afc0..dd03eff2 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -16,12 +16,10 @@ import eu.darken.capod.reaction.core.stem.StemAction import eu.darken.capod.reaction.core.stem.StemActionSettings import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.Called import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation @@ -70,6 +68,7 @@ class DeviceSettingsViewModelTest : BaseTest() { private lateinit var devicesFlow: MutableStateFlow> private lateinit var upgradeInfoFlow: MutableStateFlow private lateinit var connectedDevicesFlow: MutableStateFlow> + private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk { every { this@mockk.address } returns address @@ -84,7 +83,7 @@ class DeviceSettingsViewModelTest : BaseTest() { every { it.isPro } returns false }) - val syntheticDevice = mockk().also { + val syntheticDevice = mockk(relaxed = true).also { every { it.profileId } returns testAddress every { it.address } returns testAddress } @@ -92,7 +91,10 @@ class DeviceSettingsViewModelTest : BaseTest() { every { it.devices } returns devicesFlow coEvery { it.getDeviceForProfile(testAddress) } returns syntheticDevice } - aapManager = mockk(relaxed = true) + offRejectedFlow = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) + aapManager = mockk(relaxed = true) { + every { offRejectedEvents } returns offRejectedFlow + } upgradeRepo = mockk().also { every { it.upgradeInfo } returns upgradeInfoFlow } @@ -282,7 +284,7 @@ class DeviceSettingsViewModelTest : BaseTest() { vm.setDeviceName("NewName") - verify { aapManager wasNot Called } + coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetDeviceName("NewName")) } } @Test @@ -391,4 +393,81 @@ class DeviceSettingsViewModelTest : BaseTest() { sendFailed.command shouldBe AapCommand.SetNcWithOneAirPod(true) sendFailed.message shouldBe "socket closed" } + + @Test + fun `offRejectedEvents for current address emits OffModeRejectedByDevice`() = runVmTest { + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + offRejectedFlow.emit(testAddress) + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OffModeRejectedByDevice + } + + @Test + fun `offRejectedEvents for other address is ignored`() = runVmTest { + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + offRejectedFlow.emit("11:22:33:44:55:66") + // No event should have been emitted — send another recognized event afterward + // so we can assert that the first emission from vm.events is the later one. + coEvery { + aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) + } throws IllegalStateException("socket closed") + vm.setNcWithOneAirPod(true) + + val event = vm.events.first() + event.shouldBeInstanceOf() + } + + @Test + fun `setAllowOffOption(true) as Pro sends only SetAllowOffOption`() = runVmTest { + every { upgradeInfoFlow.value.isPro } returns true + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setAllowOffOption(true) + + coVerify(exactly = 1) { aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(true)) } + coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) } + } + + @Test + fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() = runVmTest { + every { upgradeInfoFlow.value.isPro } returns true + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setAllowOffOption(false) + + // Fallback cycle mask (0x0F) with OFF bit stripped = 0x0E. + coVerify(ordering = io.mockk.Ordering.ORDERED) { + aapManager.sendCommand(testAddress, AapCommand.SetListeningModeCycle(0x0E)) + aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(false)) + } + } + + @Test + fun `setAllowOffOption as non-Pro sends no commands`() = runVmTest { + every { upgradeInfoFlow.value.isPro } returns false + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setAllowOffOption(true) + vm.setAllowOffOption(false) + + coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(true)) } + coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(false)) } + coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) } + } } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt index 3913f556..03715159 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt @@ -1,5 +1,7 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe @@ -92,4 +94,96 @@ class PodDeviceAncModeTest : BaseTest() { reportedCycleMask = 0x0A, ) shouldBe 0x0A } + + // -- PodDevice.visibleAncModes extension: the null-coalesce lives here -- + + private fun deviceWith( + allowOffSetting: AapSetting.AllowOffOption? = null, + learnedAllowOffEnabled: Boolean? = null, + currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON, + ): PodDevice { + val ancSetting = AapSetting.AncMode(current = currentMode, supported = allModes) + val settings: Map, AapSetting> = buildMap { + put(AapSetting.AncMode::class, ancSetting) + if (allowOffSetting != null) put(AapSetting.AllowOffOption::class, allowOffSetting) + } + return PodDevice( + profileId = null, + ble = null, + aap = AapPodState(settings = settings), + profileModel = PodModel.AIRPODS_PRO, + profileLearnedAllowOffEnabled = learnedAllowOffEnabled, + ) + } + + @Test + fun `unknown AllowOffOption is treated as allowed — OFF visible by default`() { + val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = null) + device.visibleAncModes shouldContainExactly allModes + } + + @Test + fun `confirmed AllowOffOption=false hides OFF`() { + val device = deviceWith( + allowOffSetting = AapSetting.AllowOffOption(enabled = false), + learnedAllowOffEnabled = null, + ) + device.visibleAncModes shouldContainExactly listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + } + + @Test + fun `profile-learned AllowOffEnabled=false acts as fallback and hides OFF`() { + val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = false) + device.visibleAncModes shouldContainExactly listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + } + + @Test + fun `live AAP AllowOffOption overrides profile fallback`() { + val device = deviceWith( + allowOffSetting = AapSetting.AllowOffOption(enabled = true), + learnedAllowOffEnabled = false, + ) + device.visibleAncModes shouldContainExactly allModes + } + + @Test + fun `profile-learned ListeningModeCycle mask is used when AAP state has none`() { + val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes) + val device = PodDevice( + profileId = null, + ble = null, + aap = AapPodState(settings = mapOf(AapSetting.AncMode::class to ancSetting)), + profileModel = PodModel.AIRPODS_PRO, + profileLearnedAllowOffEnabled = true, + profileLastRequestedListeningModeCycleMask = 0x0F, + ) + device.listeningModeCycle?.modeMask shouldBe 0x0F + device.resolvedAncCycleMask shouldBe 0x0F + } + + @Test + fun `live AAP ListeningModeCycle overrides profile fallback`() { + val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes) + val device = PodDevice( + profileId = null, + ble = null, + aap = AapPodState( + settings = mapOf( + AapSetting.AncMode::class to ancSetting, + AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = 0x0A), + ), + ), + profileModel = PodModel.AIRPODS_PRO, + profileLastRequestedListeningModeCycleMask = 0x0F, + ) + device.listeningModeCycle?.modeMask shouldBe 0x0A + } } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersisterTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersisterTest.kt new file mode 100644 index 00000000..68cc54d6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/AapLearnedSettingsPersisterTest.kt @@ -0,0 +1,114 @@ +package eu.darken.capod.monitor.core.aap + +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.profiles.core.AppleDeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AapLearnedSettingsPersisterTest : BaseTest() { + + private val testAddress = "AA:BB:CC:DD:EE:FF" + private val testProfile = AppleDeviceProfile( + label = "Test AirPods", + model = PodModel.AIRPODS_PRO, + address = testAddress, + ) + + private fun stateWithSettings( + allowOffEnabled: Boolean? = null, + cycleMask: Int? = null, + ): AapPodState { + val settings = buildMap, AapSetting> { + allowOffEnabled?.let { put(AapSetting.AllowOffOption::class, AapSetting.AllowOffOption(it)) } + cycleMask?.let { put(AapSetting.ListeningModeCycle::class, AapSetting.ListeningModeCycle(it)) } + } + return AapPodState(settings = settings) + } + + @Test + fun `persists AllowOffOption value to matching profile`() = runTest(UnconfinedTestDispatcher()) { + val allStates = MutableStateFlow>(emptyMap()) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profilesRepo = mockk(relaxUnitFun = true) { + every { profiles } returns flowOf(listOf(testProfile)) + } + val persister = AapLearnedSettingsPersister(aapManager, profilesRepo) + + val job = launch { persister.monitor().collect {} } + + allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = false)) + advanceUntilIdle() + + val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>() + coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) } + val updated = transform.captured(testProfile) + assert(updated.learnedAllowOffEnabled == false) { "Expected learnedAllowOffEnabled=false" } + + job.cancel() + } + + @Test + fun `persists ListeningModeCycle mask to matching profile`() = runTest(UnconfinedTestDispatcher()) { + val allStates = MutableStateFlow>(emptyMap()) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profilesRepo = mockk(relaxUnitFun = true) { + every { profiles } returns flowOf(listOf(testProfile)) + } + val persister = AapLearnedSettingsPersister(aapManager, profilesRepo) + + val job = launch { persister.monitor().collect {} } + + allStates.value = mapOf(testAddress to stateWithSettings(cycleMask = 0x0F)) + advanceUntilIdle() + + val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>() + coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) } + val updated = transform.captured(testProfile) + assert(updated.lastRequestedListeningModeCycleMask == 0x0F) { "Expected lastRequestedListeningModeCycleMask=0x0F" } + + job.cancel() + } + + @Test + fun `does not write when settings match already-persisted values`() = runTest(UnconfinedTestDispatcher()) { + val allStates = MutableStateFlow>(emptyMap()) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profileWithStored = testProfile.copy( + learnedAllowOffEnabled = true, + lastRequestedListeningModeCycleMask = 0x0F, + ) + val profilesRepo = mockk(relaxUnitFun = true) { + every { profiles } returns flowOf(listOf(profileWithStored)) + } + val persister = AapLearnedSettingsPersister(aapManager, profilesRepo) + + val job = launch { persister.monitor().collect {} } + + allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = true, cycleMask = 0x0F)) + advanceUntilIdle() + + coVerify(exactly = 0) { profilesRepo.updateAppleProfile(any(), any()) } + + job.cancel() + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 0a47765c..b7561e19 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -588,6 +588,86 @@ class AapSessionEngineTest : BaseTest() { AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), ) } + + @Test + fun `rejected OFF command emits offRejected event`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + val rejected = mutableListOf() + val collectJob = launch { engine.offRejected.collect { rejected += it } } + + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { } + + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(2100L) + rejected.size shouldBe 1 + collectJob.cancel() + } + + @Test + fun `rejected non-OFF command does not emit offRejected`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + val rejected = mutableListOf() + val collectJob = launch { engine.offRejected.collect { rejected += it } } + + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON)) { } + + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + + advanceTimeBy(2100L) + rejected shouldBe emptyList() + collectJob.cancel() + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt index ee822bce..25a09b9b 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt @@ -147,7 +147,7 @@ class AapSettingsCoordinatorTest : BaseTest() { } @Test - fun `flush sorts AllowOffOption before AncMode before others`() { + fun `flush sorts ListeningModeCycle before AllowOffOption before AncMode before others`() { val coord = createCoordinator() val state = stateWithSetting( AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), @@ -157,12 +157,14 @@ class AapSettingsCoordinatorTest : BaseTest() { val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state) val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state) - val result = coord.flush(third.pendingCommands) + val fourth = coord.enqueue(third.pendingCommands, AapCommand.SetListeningModeCycle(0x0F), state) + val result = coord.flush(fourth.pendingCommands) - result.commands shouldHaveSize 3 - result.commands[0].shouldBeInstanceOf() - result.commands[1].shouldBeInstanceOf() - result.commands[2].shouldBeInstanceOf() + result.commands shouldHaveSize 4 + result.commands[0].shouldBeInstanceOf() + result.commands[1].shouldBeInstanceOf() + result.commands[2].shouldBeInstanceOf() + result.commands[3].shouldBeInstanceOf() } @Test