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
@@ -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