feat(aap): Persist learned ANC settings across reconnects

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent 07b3b95270
commit 384d81be18
19 changed files with 575 additions and 23 deletions
@@ -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<Event>()
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
}
}
@@ -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
@@ -58,6 +58,20 @@ class OverviewViewModel @Inject constructor(
val requestPermissionEvent = SingleEventFlow<Permission>()
sealed interface Event {
data object OffModeRejectedByDevice : Event
}
val events = SingleEventFlow<Event>()
init {
launch {
aapManager.offRejectedEvents.collect {
events.tryEmit(Event.OffModeRejectedByDevice)
}
}
}
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
@@ -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(),
)
}
@@ -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()
@@ -45,6 +45,8 @@ val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
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,
)
}
@@ -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<Unit> = 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<AppleDeviceProfile>()
.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<AapSetting.AllowOffOption>()?.enabled,
cycleMask = setting<AapSetting.ListeningModeCycle>()?.modeMask,
)
companion object {
private val TAG = logTag("Monitor", "AapLearnedSettingsPersister")
}
}
@@ -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(),
)
@@ -64,6 +64,10 @@ class AapConnectionManager @Inject constructor(
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _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" }
@@ -45,6 +45,7 @@ internal class AapConnection(
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
@@ -47,6 +47,10 @@ internal class AapSessionEngine(
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _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)
}
}
@@ -65,9 +65,12 @@ internal class AapSettingsCoordinator(
fun flush(pendingCommands: List<AapCommand>): 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(
@@ -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
+3 -2
View File
@@ -520,8 +520,8 @@
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string>
<string name="device_settings_listening_mode_cycle_transparency">Transparency</string>
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string>
<string name="device_settings_allow_off_label">Include Off</string>
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_allow_off_label">Allow Off mode</string>
<string name="device_settings_allow_off_description">Allow Off as a selectable noise control mode. When disabled, stems skip Off while cycling.</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
<string name="device_settings_rename_label">Rename</string>
@@ -532,6 +532,7 @@
<string name="device_settings_rename_system_unavailable">Android didn\'t let us rename the device here. To update the name in Bluetooth settings, rename it there or re-pair the device.</string>
<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="device_settings_category_connections_label">Connected Devices</string>
<string name="device_settings_connected_devices_description">Other devices currently connected to these AirPods</string>
<string name="device_settings_connected_device_label">Device %d</string>
@@ -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<List<PodDevice>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow<BluetoothAddress>
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<PodDevice>().also {
val syntheticDevice = mockk<PodDevice>(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<UpgradeRepo>().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<DeviceSettingsViewModel.Event.SendFailed>()
}
@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)) }
}
}
@@ -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<kotlin.reflect.KClass<out AapSetting>, 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
}
}
@@ -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<kotlin.reflect.KClass<out AapSetting>, 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<Map<String, AapPodState>>(emptyMap())
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(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<Map<String, AapPodState>>(emptyMap())
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(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<Map<String, AapPodState>>(emptyMap())
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profileWithStored = testProfile.copy(
learnedAllowOffEnabled = true,
lastRequestedListeningModeCycleMask = 0x0F,
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(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()
}
}
@@ -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<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<Unit>()
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<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<Unit>()
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()
}
}
@@ -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<AapCommand.SetAllowOffOption>()
result.commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
result.commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
result.commands shouldHaveSize 4
result.commands[0].shouldBeInstanceOf<AapCommand.SetListeningModeCycle>()
result.commands[1].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
result.commands[2].shouldBeInstanceOf<AapCommand.SetAncMode>()
result.commands[3].shouldBeInstanceOf<AapCommand.SetToneVolume>()
}
@Test