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:
darken
2026-04-17 15:26:05 +02:00
committed by Matthias Urhahn
parent e7c280cb52
commit ec087e4b07
24 changed files with 1592 additions and 672 deletions
@@ -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 {
@@ -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()
}
}