mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
feat(device-settings): Split stem actions into dedicated Press Controls screen
Move press timing, call controls, and stem mappings out of the Controls card into a new Press Controls screen. The screen name is device-agnostic so it applies to both stemmed AirPods and the AirPods Max's Digital Crown/noise button. Stem mappings are now per-device (stored on the AppleDeviceProfile) rather than a single app-global DataStore. The Pro gate for mappings moves from screen entry to per-change with an Upgrade badge on the mappings card header, so free users can still access the non-Pro press timing and call-control settings that live on the same screen.
This commit is contained in:
+32
-24
@@ -11,9 +11,11 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.coEvery
|
||||
@@ -60,14 +62,12 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
private lateinit var profilesRepo: DeviceProfilesRepo
|
||||
private lateinit var generalSettings: GeneralSettings
|
||||
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
|
||||
private lateinit var stemActionSettings: StemActionSettings
|
||||
private lateinit var fakeLeftLongStemAction: FakeDataStoreValue<StemAction>
|
||||
private lateinit var fakeRightLongStemAction: FakeDataStoreValue<StemAction>
|
||||
private val timeSource: TimeSource = TestTimeSource()
|
||||
|
||||
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
|
||||
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
|
||||
private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow<BluetoothAddress>
|
||||
|
||||
private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk {
|
||||
@@ -104,17 +104,18 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
every { bondedDevices() } returns flowOf(emptySet())
|
||||
every { connectedDevices } returns connectedDevicesFlow
|
||||
}
|
||||
profilesRepo = mockk(relaxed = true)
|
||||
profilesFlow = MutableStateFlow(
|
||||
listOf(
|
||||
AppleDeviceProfile(id = testAddress, label = "Test", address = testAddress)
|
||||
)
|
||||
)
|
||||
profilesRepo = mockk(relaxed = true) {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
|
||||
generalSettings = mockk<GeneralSettings>().also {
|
||||
every { it.monitorMode } returns fakeMonitorMode.mock
|
||||
}
|
||||
fakeLeftLongStemAction = FakeDataStoreValue(StemAction.NONE)
|
||||
fakeRightLongStemAction = FakeDataStoreValue(StemAction.NONE)
|
||||
stemActionSettings = mockk<StemActionSettings>().also {
|
||||
every { it.leftLong } returns fakeLeftLongStemAction.mock
|
||||
every { it.rightLong } returns fakeRightLongStemAction.mock
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -132,7 +133,6 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
bluetoothManager = bluetoothManager,
|
||||
profilesRepo = profilesRepo,
|
||||
generalSettings = generalSettings,
|
||||
stemActionSettings = stemActionSettings,
|
||||
timeSource = timeSource,
|
||||
webpageTool = mockk(relaxed = true),
|
||||
).also { vm = it }
|
||||
@@ -341,7 +341,14 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `hasCustomLongPressStemAction is true when either long-press action is assigned`() = runVmTest {
|
||||
fakeLeftLongStemAction.value = StemAction.PLAY_PAUSE
|
||||
profilesFlow.value = listOf(
|
||||
AppleDeviceProfile(
|
||||
id = testAddress,
|
||||
label = "Test",
|
||||
address = testAddress,
|
||||
stemActions = StemActionsConfig(leftLong = StemAction.PLAY_PAUSE),
|
||||
)
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
@@ -439,21 +446,22 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
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()
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(false)
|
||||
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))
|
||||
// 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 {
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
package eu.darken.capod.main.ui.presscontrols
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.TestDispatcherProvider
|
||||
import testhelpers.livedata.InstantExecutorExtension
|
||||
|
||||
@ExtendWith(InstantExecutorExtension::class)
|
||||
class PressControlsViewModelTest : BaseTest() {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val testProfileId: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private var vm: PressControlsViewModel? = null
|
||||
|
||||
private lateinit var deviceMonitor: DeviceMonitor
|
||||
private lateinit var aapManager: AapConnectionManager
|
||||
private lateinit var upgradeRepo: UpgradeRepo
|
||||
private lateinit var profilesRepo: DeviceProfilesRepo
|
||||
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
|
||||
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
|
||||
private fun makeProfile(stemActions: StemActionsConfig = StemActionsConfig()) =
|
||||
AppleDeviceProfile(id = testProfileId, label = "Test", address = testProfileId, stemActions = stemActions)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
devicesFlow = MutableStateFlow(emptyList())
|
||||
val syntheticDevice = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testProfileId
|
||||
every { it.address } returns testProfileId
|
||||
every { it.isAapReady } returns true
|
||||
}
|
||||
deviceMonitor = mockk {
|
||||
every { devices } returns devicesFlow
|
||||
coEvery { getDeviceForProfile(testProfileId) } returns syntheticDevice
|
||||
}
|
||||
aapManager = mockk(relaxed = true)
|
||||
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true).also {
|
||||
every { it.isPro } returns false
|
||||
})
|
||||
upgradeRepo = mockk {
|
||||
every { upgradeInfo } returns upgradeInfoFlow
|
||||
}
|
||||
profilesFlow = MutableStateFlow(listOf(makeProfile()))
|
||||
profilesRepo = mockk(relaxed = true) {
|
||||
every { profiles } returns profilesFlow
|
||||
// Make updateAppleProfile actually mutate the in-memory profile flow.
|
||||
coEvery { updateAppleProfile(testProfileId, any()) } coAnswers {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val transform = arg<(AppleDeviceProfile) -> AppleDeviceProfile>(1)
|
||||
val current = profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first { it.id == testProfileId }
|
||||
profilesFlow.value = profilesFlow.value.map {
|
||||
if (it.id == testProfileId) transform(current) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
vm?.vmScope?.cancel()
|
||||
vm = null
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
|
||||
try {
|
||||
testBody()
|
||||
} finally {
|
||||
vm?.vmScope?.cancel()
|
||||
vm = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel() = PressControlsViewModel(
|
||||
dispatcherProvider = TestDispatcherProvider(testDispatcher),
|
||||
deviceMonitor = deviceMonitor,
|
||||
aapManager = aapManager,
|
||||
upgradeRepo = upgradeRepo,
|
||||
profilesRepo = profilesRepo,
|
||||
).also { vm = it }
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle as Pro persists action to profile`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.PLAY_PAUSE)
|
||||
|
||||
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
|
||||
profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions.leftSingle shouldBe StemAction.PLAY_PAUSE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle to non-NONE as free user navigates to Upgrade and does not mutate`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.PLAY_PAUSE)
|
||||
|
||||
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(testProfileId, any()) }
|
||||
profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions.leftSingle shouldBe StemAction.NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle to NONE as free user is allowed and clears existing mapping`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
profilesFlow.value = listOf(makeProfile(StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)))
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.NONE)
|
||||
|
||||
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
|
||||
profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions.leftSingle shouldBe StemAction.NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle to current value as free user is a no-op`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.NONE) // already NONE
|
||||
|
||||
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(testProfileId, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle assigns NO_ACTION to right side when right was NONE`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.PLAY_PAUSE)
|
||||
|
||||
val updated = profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions
|
||||
updated.leftSingle shouldBe StemAction.PLAY_PAUSE
|
||||
updated.rightSingle shouldBe StemAction.NO_ACTION
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLeftSingle to NONE clears the opposite side too`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
profilesFlow.value = listOf(
|
||||
makeProfile(StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE, rightSingle = StemAction.NO_ACTION))
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setLeftSingle(StemAction.NONE)
|
||||
|
||||
val updated = profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions
|
||||
updated.leftSingle shouldBe StemAction.NONE
|
||||
updated.rightSingle shouldBe StemAction.NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetAll wipes stemActions on the profile`() = runVmTest {
|
||||
profilesFlow.value = listOf(
|
||||
makeProfile(StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE, rightLong = StemAction.VOLUME_UP))
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.resetAll()
|
||||
|
||||
profilesFlow.value
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.first().stemActions shouldBe StemActionsConfig()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setPressSpeed sends AAP command and is not Pro-gated`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setPressSpeed(AapSetting.PressSpeed.Value.SLOWER)
|
||||
|
||||
coVerify {
|
||||
aapManager.sendCommand(testProfileId, AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWER))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setPressSpeed failure emits SendFailed event`() = runVmTest {
|
||||
coEvery {
|
||||
aapManager.sendCommand(testProfileId, AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWEST))
|
||||
} throws IllegalStateException("socket closed")
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
vm.state.first()
|
||||
|
||||
vm.setPressSpeed(AapSetting.PressSpeed.Value.SLOWEST)
|
||||
|
||||
val event = vm.events.first()
|
||||
val sendFailed = event.shouldBeInstanceOf<PressControlsViewModel.Event.SendFailed>()
|
||||
sendFailed.command shouldBe AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWEST)
|
||||
sendFailed.message shouldBe "socket closed"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state isAapReady reflects device readiness`() = runVmTest {
|
||||
val notReady = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testProfileId
|
||||
every { it.address } returns testProfileId
|
||||
every { it.isAapReady } returns false
|
||||
}
|
||||
coEvery { deviceMonitor.getDeviceForProfile(testProfileId) } returns notReady
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testProfileId)
|
||||
|
||||
vm.state.first().isAapReady shouldBe false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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.AapCommand
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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 StemConfigSenderTest : BaseTest() {
|
||||
|
||||
private val addressA = "AA:AA:AA:AA:AA:AA"
|
||||
private val addressB = "BB:BB:BB:BB:BB:BB"
|
||||
|
||||
private fun profile(address: String, stemActions: StemActionsConfig) = AppleDeviceProfile(
|
||||
label = "Test",
|
||||
model = PodModel.AIRPODS_PRO2,
|
||||
address = address,
|
||||
stemActions = stemActions,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `sends per-profile mask to each AAP-ready device`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(
|
||||
mapOf(
|
||||
addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY),
|
||||
addressB to AapPodState(connectionState = AapPodState.ConnectionState.READY),
|
||||
)
|
||||
)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
// A has SINGLE only → mask 0x01. B has LONG only → mask 0x08.
|
||||
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(
|
||||
listOf(
|
||||
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
|
||||
profile(addressB, StemActionsConfig(rightLong = StemAction.VOLUME_UP)),
|
||||
)
|
||||
)
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
val sender = StemConfigSender(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { sender.monitor().collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x01)) }
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(addressB, AapCommand.SetStemConfig(0x08)) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips devices that are not AAP-ready`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(
|
||||
mapOf(
|
||||
addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY),
|
||||
addressB to AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED),
|
||||
)
|
||||
)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(
|
||||
listOf(
|
||||
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
|
||||
profile(addressB, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
|
||||
)
|
||||
)
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
val sender = StemConfigSender(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { sender.monitor().collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x01)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(addressB, any<AapCommand.SetStemConfig>()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-NONE config sends mask 0x00`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(
|
||||
mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY))
|
||||
)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { profiles } returns MutableStateFlow<List<DeviceProfile>>(
|
||||
listOf(profile(addressA, StemActionsConfig()))
|
||||
)
|
||||
}
|
||||
val sender = StemConfigSender(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { sender.monitor().collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x00)) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all four press types contribute independent bits to the mask`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(
|
||||
mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY))
|
||||
)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { profiles } returns MutableStateFlow<List<DeviceProfile>>(
|
||||
listOf(
|
||||
profile(
|
||||
addressA,
|
||||
StemActionsConfig(
|
||||
leftSingle = StemAction.PLAY_PAUSE,
|
||||
rightDouble = StemAction.NEXT_TRACK,
|
||||
leftTriple = StemAction.VOLUME_UP,
|
||||
rightLong = StemAction.VOLUME_DOWN,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
val sender = StemConfigSender(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { sender.monitor().collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x0F)) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import android.view.KeyEvent
|
||||
import eu.darken.capod.common.MediaControl
|
||||
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.protocol.StemPressEvent
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
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 StemPressReactionTest : BaseTest() {
|
||||
|
||||
private val addressA = "AA:AA:AA:AA:AA:AA"
|
||||
private val addressB = "BB:BB:BB:BB:BB:BB"
|
||||
|
||||
private fun profile(address: String, stemActions: StemActionsConfig) = AppleDeviceProfile(
|
||||
label = "Test",
|
||||
model = PodModel.AIRPODS_PRO2,
|
||||
address = address,
|
||||
stemActions = stemActions,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `resolves action per profile address`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { stemPressEvents } returns events
|
||||
}
|
||||
val profiles = listOf<DeviceProfile>(
|
||||
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
|
||||
profile(addressB, StemActionsConfig(leftSingle = StemAction.NEXT_TRACK)),
|
||||
)
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { this@mockk.profiles } returns flowOf(profiles)
|
||||
}
|
||||
val mediaControl = mockk<MediaControl>(relaxed = true)
|
||||
val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl)
|
||||
|
||||
val job = launch { reaction.monitor().collect {} }
|
||||
|
||||
events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
|
||||
advanceUntilIdle()
|
||||
events.emit(addressB to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { mediaControl.sendPlayPause() }
|
||||
coVerify(exactly = 1) { mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores events from unknown address`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { stemPressEvents } returns events
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { this@mockk.profiles } returns flowOf(
|
||||
listOf<DeviceProfile>(profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)))
|
||||
)
|
||||
}
|
||||
val mediaControl = mockk<MediaControl>(relaxed = true)
|
||||
val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl)
|
||||
|
||||
val job = launch { reaction.monitor().collect {} }
|
||||
|
||||
events.emit("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ" to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { mediaControl.sendPlayPause() }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NO_ACTION and NONE do not execute anything`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { stemPressEvents } returns events
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
|
||||
every { this@mockk.profiles } returns flowOf(
|
||||
listOf<DeviceProfile>(
|
||||
profile(
|
||||
addressA,
|
||||
StemActionsConfig(
|
||||
leftSingle = StemAction.NONE,
|
||||
rightSingle = StemAction.NO_ACTION,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
val mediaControl = mockk<MediaControl>(relaxed = true)
|
||||
val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl)
|
||||
|
||||
val job = launch { reaction.monitor().collect {} }
|
||||
|
||||
events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
|
||||
events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.RIGHT))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { mediaControl.sendPlayPause() }
|
||||
coVerify(exactly = 0) { mediaControl.sendKey(any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user