feat(press-controls): Add media and noise-control actions

Adds Stop, Fast Forward, Rewind, Mute, Cycle Noise Control, and Toggle Transparency as gesture targets. Migrates StemAction from enum to sealed interface with polymorphic serialization to unlock future parameterized actions. ANC actions are gated per-device capability and reuse the existing listening-mode cycle mask.
This commit is contained in:
darken
2026-04-17 17:56:31 +02:00
committed by Matthias Urhahn
parent c19b0b35b6
commit bba9155573
18 changed files with 554 additions and 92 deletions
@@ -346,7 +346,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
id = testAddress,
label = "Test",
address = testAddress,
stemActions = StemActionsConfig(leftLong = StemAction.PLAY_PAUSE),
stemActions = StemActionsConfig(leftLong = StemAction.PlayPause),
)
)
@@ -123,45 +123,45 @@ class PressControlsViewModelTest : BaseTest() {
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.PLAY_PAUSE)
vm.setLeftSingle(StemAction.PlayPause)
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.PLAY_PAUSE
.first().stemActions.leftSingle shouldBe StemAction.PlayPause
}
@Test
fun `setLeftSingle to non-NONE as free user navigates to Upgrade and does not mutate`() = runVmTest {
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)
vm.setLeftSingle(StemAction.PlayPause)
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.NONE
.first().stemActions.leftSingle shouldBe StemAction.None
}
@Test
fun `setLeftSingle to NONE as free user is allowed and clears existing mapping`() = runVmTest {
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)))
profilesFlow.value = listOf(makeProfile(StemActionsConfig(leftSingle = StemAction.PlayPause)))
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.NONE)
vm.setLeftSingle(StemAction.None)
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.NONE
.first().stemActions.leftSingle shouldBe StemAction.None
}
@Test
@@ -172,52 +172,52 @@ class PressControlsViewModelTest : BaseTest() {
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.NONE) // already NONE
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 {
fun `setLeftSingle assigns NoAction 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)
vm.setLeftSingle(StemAction.PlayPause)
val updated = profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions
updated.leftSingle shouldBe StemAction.PLAY_PAUSE
updated.rightSingle shouldBe StemAction.NO_ACTION
updated.leftSingle shouldBe StemAction.PlayPause
updated.rightSingle shouldBe StemAction.NoAction
}
@Test
fun `setLeftSingle to NONE clears the opposite side too`() = runVmTest {
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))
makeProfile(StemActionsConfig(leftSingle = StemAction.PlayPause, rightSingle = StemAction.NoAction))
)
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.NONE)
vm.setLeftSingle(StemAction.None)
val updated = profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions
updated.leftSingle shouldBe StemAction.NONE
updated.rightSingle shouldBe StemAction.NONE
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))
makeProfile(StemActionsConfig(leftSingle = StemAction.PlayPause, rightLong = StemAction.VolumeUp))
)
val vm = createViewModel()
@@ -0,0 +1,75 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting.AncMode.Value
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class NextGestureAncModeTest : BaseTest() {
private val allFour = listOf(Value.OFF, Value.ON, Value.TRANSPARENCY, Value.ADAPTIVE)
private fun state(
current: Value,
supported: List<Value> = allFour,
mask: Int? = 0x0F,
allowOff: Boolean = true,
) = EffectiveAncState(
current = current,
supported = supported,
cycleMask = mask,
allowOffEnabled = allowOff,
)
@Test
fun `null mask is a no-op`() {
nextGestureAncMode(state(current = Value.ON, mask = null)).shouldBeNull()
}
@Test
fun `zero mask is a no-op`() {
nextGestureAncMode(state(current = Value.ON, mask = 0x00)).shouldBeNull()
}
@Test
fun `single-mode mask is a no-op`() {
// Only ON enabled
nextGestureAncMode(state(current = Value.ON, mask = 0x02)).shouldBeNull()
}
@Test
fun `two-mode mask cycles between them`() {
// ON + TRANSPARENCY
nextGestureAncMode(state(current = Value.ON, mask = 0x06)) shouldBe Value.TRANSPARENCY
nextGestureAncMode(state(current = Value.TRANSPARENCY, mask = 0x06)) shouldBe Value.ON
}
@Test
fun `four-mode mask walks the full cycle`() {
nextGestureAncMode(state(current = Value.OFF)) shouldBe Value.ON
nextGestureAncMode(state(current = Value.ON)) shouldBe Value.TRANSPARENCY
nextGestureAncMode(state(current = Value.TRANSPARENCY)) shouldBe Value.ADAPTIVE
nextGestureAncMode(state(current = Value.ADAPTIVE)) shouldBe Value.OFF
}
@Test
fun `allowOff=false strips OFF from the cycle`() {
nextGestureAncMode(state(current = Value.ADAPTIVE, allowOff = false)) shouldBe Value.ON
// Current=OFF is out-of-cycle when allowOff=false → jumps to first cycle mode
nextGestureAncMode(state(current = Value.OFF, allowOff = false)) shouldBe Value.ON
}
@Test
fun `current out of cycle jumps to first cycle mode`() {
// Cycle = TRANSPARENCY + ADAPTIVE only, but current is ON
nextGestureAncMode(state(current = Value.ON, mask = 0x0C)) shouldBe Value.TRANSPARENCY
}
@Test
fun `unsupported modes are excluded even if mask enables them`() {
// Device only supports OFF + ON, but mask says OFF+ON+TRANSPARENCY
val supported = listOf(Value.OFF, Value.ON)
nextGestureAncMode(state(current = Value.ON, supported = supported, mask = 0x07)) shouldBe Value.OFF
}
}
@@ -46,8 +46,8 @@ class StemConfigSenderTest : BaseTest() {
// 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)),
profile(addressA, StemActionsConfig(leftSingle = StemAction.PlayPause)),
profile(addressB, StemActionsConfig(rightLong = StemAction.VolumeUp)),
)
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
@@ -77,8 +77,8 @@ class StemConfigSenderTest : BaseTest() {
}
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(
listOf(
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
profile(addressA, StemActionsConfig(leftSingle = StemAction.PlayPause)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.PlayPause)),
)
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
@@ -132,10 +132,10 @@ class StemConfigSenderTest : BaseTest() {
profile(
addressA,
StemActionsConfig(
leftSingle = StemAction.PLAY_PAUSE,
rightDouble = StemAction.NEXT_TRACK,
leftTriple = StemAction.VOLUME_UP,
rightLong = StemAction.VOLUME_DOWN,
leftSingle = StemAction.PlayPause,
rightDouble = StemAction.NextTrack,
leftTriple = StemAction.VolumeUp,
rightLong = StemAction.VolumeDown,
),
)
)
@@ -4,6 +4,9 @@ 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.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
@@ -14,6 +17,7 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -41,8 +45,8 @@ class StemPressReactionTest : BaseTest() {
every { stemPressEvents } returns events
}
val profiles = listOf<DeviceProfile>(
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.NEXT_TRACK)),
profile(addressA, StemActionsConfig(leftSingle = StemAction.PlayPause)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.NextTrack)),
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(profiles)
@@ -71,7 +75,7 @@ class StemPressReactionTest : BaseTest() {
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(
listOf<DeviceProfile>(profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)))
listOf<DeviceProfile>(profile(addressA, StemActionsConfig(leftSingle = StemAction.PlayPause)))
)
}
val mediaControl = mockk<MediaControl>(relaxed = true)
@@ -88,7 +92,7 @@ class StemPressReactionTest : BaseTest() {
}
@Test
fun `NO_ACTION and NONE do not execute anything`() = runTest(UnconfinedTestDispatcher()) {
fun `NoAction 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
@@ -99,8 +103,8 @@ class StemPressReactionTest : BaseTest() {
profile(
addressA,
StemActionsConfig(
leftSingle = StemAction.NONE,
rightSingle = StemAction.NO_ACTION,
leftSingle = StemAction.None,
rightSingle = StemAction.NoAction,
),
)
)
@@ -120,4 +124,151 @@ class StemPressReactionTest : BaseTest() {
job.cancel()
}
@Test
fun `new media-key actions dispatch correct keycodes`() = 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.Stop,
leftDouble = StemAction.FastForward,
leftTriple = StemAction.Rewind,
leftLong = StemAction.MuteToggle,
),
)
)
)
}
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.DOUBLE, StemPressEvent.Bud.LEFT))
events.emit(addressA to StemPressEvent(StemPressEvent.PressType.TRIPLE, StemPressEvent.Bud.LEFT))
events.emit(addressA to StemPressEvent(StemPressEvent.PressType.LONG, StemPressEvent.Bud.LEFT))
advanceUntilIdle()
coVerify(exactly = 1) { mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP) }
coVerify(exactly = 1) { mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) }
coVerify(exactly = 1) { mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND) }
coVerify(exactly = 1) { mediaControl.toggleMuteMusic() }
job.cancel()
}
@Test
fun `CycleAnc sends SetAncMode with the next cycle mode`() = runTest(UnconfinedTestDispatcher()) {
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
val aapState = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = 0x0E),
),
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { stemPressEvents } returns events
every { allStates } returns MutableStateFlow(mapOf(addressA to aapState))
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(
listOf<DeviceProfile>(
profile(addressA, StemActionsConfig(leftSingle = StemAction.CycleAnc))
)
)
}
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()
coVerify(exactly = 1) {
aapManager.sendCommand(addressA, AapCommand.SetAncMode(AapSetting.AncMode.Value.TRANSPARENCY))
}
job.cancel()
}
@Test
fun `ToggleAncTransparency flips to ON when leaving TRANSPARENCY`() = runTest(UnconfinedTestDispatcher()) {
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
val aapState = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.TRANSPARENCY,
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY),
),
),
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { stemPressEvents } returns events
every { allStates } returns MutableStateFlow(mapOf(addressA to aapState))
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(
listOf<DeviceProfile>(
profile(addressA, StemActionsConfig(leftSingle = StemAction.ToggleAncTransparency))
)
)
}
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()
coVerify(exactly = 1) {
aapManager.sendCommand(addressA, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON))
}
job.cancel()
}
@Test
fun `CycleAnc is a no-op when model lacks listening-mode-cycle`() = runTest(UnconfinedTestDispatcher()) {
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { stemPressEvents } returns events
every { allStates } returns MutableStateFlow(emptyMap())
}
val noCycleProfile = AppleDeviceProfile(
label = "Test",
model = PodModel.AIRPODS_GEN3,
address = addressA,
stemActions = StemActionsConfig(leftSingle = StemAction.CycleAnc),
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(listOf<DeviceProfile>(noCycleProfile))
}
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()
coVerify(exactly = 0) { aapManager.sendCommand(any(), any<AapCommand.SetAncMode>()) }
job.cancel()
}
}
@@ -0,0 +1,50 @@
package eu.darken.capod.reaction.core.stem
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class StemActionSerializationTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
@Test
fun `every action round-trips through Json`() {
for (action in StemAction.all) {
val encoded = json.encodeToString(StemAction.serializer(), action)
val decoded = json.decodeFromString(StemAction.serializer(), encoded)
decoded shouldBe action
}
}
@Test
fun `wire form uses type discriminator with legacy-style names`() {
json.encodeToString(StemAction.serializer(), StemAction.PlayPause) shouldBe """{"type":"PLAY_PAUSE"}"""
json.encodeToString(StemAction.serializer(), StemAction.CycleAnc) shouldBe """{"type":"CYCLE_ANC"}"""
json.encodeToString(StemAction.serializer(), StemAction.ToggleAncTransparency) shouldBe """{"type":"TOGGLE_ANC_TRANSPARENCY"}"""
json.encodeToString(StemAction.serializer(), StemAction.None) shouldBe """{"type":"NONE"}"""
}
@Test
fun `StemActionsConfig round-trips with new actions`() {
val original = StemActionsConfig(
leftSingle = StemAction.PlayPause,
rightSingle = StemAction.NoAction,
leftLong = StemAction.CycleAnc,
rightLong = StemAction.ToggleAncTransparency,
leftDouble = StemAction.Stop,
rightDouble = StemAction.FastForward,
leftTriple = StemAction.Rewind,
rightTriple = StemAction.MuteToggle,
)
val encoded = json.encodeToString(StemActionsConfig.serializer(), original)
val decoded = json.decodeFromString(StemActionsConfig.serializer(), encoded)
decoded shouldBe original
}
}