From bba9155573d327613e2101c9a015b82baac6d819 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 17 Apr 2026 17:53:23 +0200 Subject: [PATCH] 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. --- .../capod/common/upgrade/core/FossCache.kt | 2 +- .../eu/darken/capod/common/MediaControl.kt | 9 + .../devicesettings/DeviceSettingsViewModel.kt | 3 +- .../ui/presscontrols/PressControlsScreen.kt | 10 +- .../presscontrols/PressControlsViewModel.kt | 15 +- .../ui/presscontrols/PressMappingsCard.kt | 70 ++++++-- .../monitor/core/aap/AncGestureResolver.kt | 51 ++++++ .../monitor/core/aap/StemConfigSender.kt | 8 +- .../monitor/core/aap/StemPressReaction.kt | 69 ++++++-- .../capod/reaction/core/stem/StemAction.kt | 36 +++- .../reaction/core/stem/StemActionsConfig.kt | 16 +- app/src/main/res/values/strings.xml | 11 ++ .../DeviceSettingsViewModelTest.kt | 2 +- .../PressControlsViewModelTest.kt | 40 ++--- .../core/aap/NextGestureAncModeTest.kt | 75 ++++++++ .../monitor/core/aap/StemConfigSenderTest.kt | 16 +- .../monitor/core/aap/StemPressReactionTest.kt | 163 +++++++++++++++++- .../core/stem/StemActionSerializationTest.kt | 50 ++++++ 18 files changed, 554 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/aap/AncGestureResolver.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/aap/NextGestureAncModeTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/stem/StemActionSerializationTest.kt diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt index fe50a4fe..30f693bf 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt @@ -26,7 +26,7 @@ class FossCache @Inject constructor( private val dataStore: DataStore = context.dataStore val upgrade = dataStore.createValue( - key = "foss.upgrade1", + key = "foss.upgrade", defaultValue = null, json = json, onErrorFallbackToDefault = true, diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 0055ead8..d12fcaf4 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -81,6 +81,15 @@ class MediaControl @Inject constructor( ) } + fun toggleMuteMusic() { + log(TAG, INFO) { "toggleMuteMusic()" } + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_TOGGLE_MUTE, + AudioManager.FLAG_SHOW_UI, + ) + } + private fun markRecentCapPause() { capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 2f8fe8ec..74758c3f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -137,7 +137,8 @@ class DeviceSettingsViewModel @Inject constructor( monitorMode = monitorMode, systemBluetoothName = systemBtName, hasCustomLongPressStemAction = stemActions?.let { - it.leftLong != StemAction.NONE || it.rightLong != StemAction.NONE + (it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) || + (it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc) } == true, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt index af4e0878..48d02950 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt @@ -208,6 +208,8 @@ fun PressControlsScreen( PressMappingsCard( stemActions = state.stemActions, isPro = state.isPro, + supportsAncCycle = state.supportsAncCycle, + supportsAncToggle = state.supportsAncToggle, onLeftSingle = onLeftSingle, onLeftDouble = onLeftDouble, onLeftTriple = onLeftTriple, @@ -262,6 +264,8 @@ internal fun previewPressControlsState( stemActions = stemActions, isPro = isPro, isAapReady = true, + supportsAncCycle = model.features.hasListeningModeCycle, + supportsAncToggle = model.features.hasAncControl, ) } @@ -272,9 +276,9 @@ private fun PressControlsScreenProPreview() = PreviewWrapper { state = previewPressControlsState( isPro = true, stemActions = StemActionsConfig( - leftSingle = StemAction.PLAY_PAUSE, - rightSingle = StemAction.NO_ACTION, - leftLong = StemAction.NEXT_TRACK, + leftSingle = StemAction.PlayPause, + rightSingle = StemAction.NoAction, + leftLong = StemAction.NextTrack, ), ), onNavigateUp = {}, diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt index 80a9635c..b54b8a61 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt @@ -63,12 +63,15 @@ class PressControlsViewModel @Inject constructor( ) { device, profiles, upgrade -> val profile = profiles.filterIsInstance().firstOrNull { it.id == profileId } val stemActions = profile?.stemActions ?: StemActionsConfig() + val model = device?.model ?: profile?.model State( device = device, profile = profile, stemActions = stemActions, isPro = upgrade.isPro, isAapReady = device?.isAapReady == true, + supportsAncCycle = model?.features?.hasListeningModeCycle == true, + supportsAncToggle = model?.features?.hasAncControl == true, ) } }.asLiveState() @@ -85,6 +88,8 @@ class PressControlsViewModel @Inject constructor( val stemActions: StemActionsConfig = StemActionsConfig(), val isPro: Boolean = false, val isAapReady: Boolean = false, + val supportsAncCycle: Boolean = false, + val supportsAncToggle: Boolean = false, ) // ── Stem-action mapping setters (profile-scoped, Pro-gated on non-NONE assignment) ─────── @@ -136,9 +141,9 @@ class PressControlsViewModel @Inject constructor( val current = currentProfile.stemActions.getSide(bud) // 1. No-op short-circuit. if (action == current) return@launch - // 2. Free-clear allowance — always allow clearing to NONE. + // 2. Free-clear allowance — always allow clearing to None. // 3. Otherwise Pro is required. - if (action != StemAction.NONE && !upgradeRepo.isPro()) { + if (action != StemAction.None && !upgradeRepo.isPro()) { navTo(Nav.Main.Upgrade) return@launch } @@ -158,9 +163,9 @@ class PressControlsViewModel @Inject constructor( otherBud: Side, otherCurrent: StemAction, ): StemActionsConfig = when (selected) { - StemAction.NONE -> withSide(otherBud, StemAction.NONE) - StemAction.NO_ACTION -> this - else -> if (otherCurrent == StemAction.NONE) withSide(otherBud, StemAction.NO_ACTION) else this + is StemAction.None -> withSide(otherBud, StemAction.None) + is StemAction.NoAction -> this + else -> if (otherCurrent is StemAction.None) withSide(otherBud, StemAction.NoAction) else this } fun setLeftSingle(action: StemAction) = setSide(Side.LEFT_SINGLE, action) diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt index 54324909..f01d4a78 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt @@ -46,6 +46,8 @@ import eu.darken.capod.reaction.core.stem.StemActionsConfig fun PressMappingsCard( stemActions: StemActionsConfig, isPro: Boolean, + supportsAncCycle: Boolean = false, + supportsAncToggle: Boolean = false, onLeftSingle: (StemAction) -> Unit = {}, onLeftDouble: (StemAction) -> Unit = {}, onLeftTriple: (StemAction) -> Unit = {}, @@ -64,6 +66,8 @@ fun PressMappingsCard( StemActionRow( leftAction = stemActions.leftSingle, rightAction = stemActions.rightSingle, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, onLeftChange = onLeftSingle, onRightChange = onRightSingle, ) @@ -74,6 +78,8 @@ fun PressMappingsCard( StemActionRow( leftAction = stemActions.leftDouble, rightAction = stemActions.rightDouble, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, onLeftChange = onLeftDouble, onRightChange = onRightDouble, ) @@ -84,6 +90,8 @@ fun PressMappingsCard( StemActionRow( leftAction = stemActions.leftTriple, rightAction = stemActions.rightTriple, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, onLeftChange = onLeftTriple, onRightChange = onRightTriple, ) @@ -94,10 +102,12 @@ fun PressMappingsCard( StemActionRow( leftAction = stemActions.leftLong, rightAction = stemActions.rightLong, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, onLeftChange = onLeftLong, onRightChange = onRightLong, ) - if (stemActions.leftLong != StemAction.NONE || stemActions.rightLong != StemAction.NONE) { + if (longPressOverridesAncCycle(stemActions)) { SettingsInfoBox( text = stringResource(R.string.press_controls_long_press_anc_cycle_info), type = InfoBoxType.INFO, @@ -106,6 +116,14 @@ fun PressMappingsCard( } } +private fun longPressOverridesAncCycle(stemActions: StemActionsConfig): Boolean { + val left = stemActions.leftLong + val right = stemActions.rightLong + val leftOverrides = left !is StemAction.None && left !is StemAction.CycleAnc + val rightOverrides = right !is StemAction.None && right !is StemAction.CycleAnc + return leftOverrides || rightOverrides +} + @Composable private fun MappingsCardHeader(isPro: Boolean) { Row( @@ -155,6 +173,8 @@ private fun PressTypeHeader(icon: ImageVector, text: String) { private fun StemActionRow( leftAction: StemAction, rightAction: StemAction, + supportsAncCycle: Boolean, + supportsAncToggle: Boolean, onLeftChange: (StemAction) -> Unit, onRightChange: (StemAction) -> Unit, ) { @@ -173,6 +193,8 @@ private fun StemActionRow( selected = leftAction, onSelected = onLeftChange, otherSideAction = rightAction, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, modifier = Modifier.fillMaxWidth(), ) } @@ -190,6 +212,8 @@ private fun StemActionRow( selected = rightAction, onSelected = onRightChange, otherSideAction = leftAction, + supportsAncCycle = supportsAncCycle, + supportsAncToggle = supportsAncToggle, modifier = Modifier.fillMaxWidth(), ) } @@ -202,14 +226,20 @@ private fun StemActionDropdown( selected: StemAction, onSelected: (StemAction) -> Unit, otherSideAction: StemAction, + supportsAncCycle: Boolean, + supportsAncToggle: Boolean, modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } - val options = if (otherSideAction == StemAction.NONE) { - StemAction.entries.filter { it != StemAction.NO_ACTION } - } else { - StemAction.entries.toList() + val options = StemAction.all.filter { candidate -> + when { + candidate === selected -> true + candidate is StemAction.NoAction && otherSideAction is StemAction.None -> false + candidate is StemAction.CycleAnc && !supportsAncCycle -> false + candidate is StemAction.ToggleAncTransparency && !supportsAncToggle -> false + else -> true + } } ExposedDropdownMenuBox( @@ -246,13 +276,19 @@ private fun StemActionDropdown( @Composable private fun StemAction.label(): String = when (this) { - StemAction.NONE -> stringResource(R.string.press_action_none) - StemAction.NO_ACTION -> stringResource(R.string.press_action_no_action) - StemAction.PLAY_PAUSE -> stringResource(R.string.press_action_play_pause) - StemAction.NEXT_TRACK -> stringResource(R.string.press_action_next_track) - StemAction.PREVIOUS_TRACK -> stringResource(R.string.press_action_previous_track) - StemAction.VOLUME_UP -> stringResource(R.string.press_action_volume_up) - StemAction.VOLUME_DOWN -> stringResource(R.string.press_action_volume_down) + is StemAction.None -> stringResource(R.string.press_action_none) + is StemAction.NoAction -> stringResource(R.string.press_action_no_action) + is StemAction.PlayPause -> stringResource(R.string.press_action_play_pause) + is StemAction.NextTrack -> stringResource(R.string.press_action_next_track) + is StemAction.PreviousTrack -> stringResource(R.string.press_action_previous_track) + is StemAction.VolumeUp -> stringResource(R.string.press_action_volume_up) + is StemAction.VolumeDown -> stringResource(R.string.press_action_volume_down) + is StemAction.Stop -> stringResource(R.string.press_action_stop) + is StemAction.FastForward -> stringResource(R.string.press_action_fast_forward) + is StemAction.Rewind -> stringResource(R.string.press_action_rewind) + is StemAction.MuteToggle -> stringResource(R.string.press_action_mute_toggle) + is StemAction.CycleAnc -> stringResource(R.string.press_action_cycle_anc) + is StemAction.ToggleAncTransparency -> stringResource(R.string.press_action_toggle_anc_transparency) } @Preview2 @@ -260,11 +296,13 @@ private fun StemAction.label(): String = when (this) { private fun PressMappingsCardProPreview() = PreviewWrapper { PressMappingsCard( stemActions = StemActionsConfig( - leftSingle = StemAction.PLAY_PAUSE, - rightSingle = StemAction.NO_ACTION, - leftLong = StemAction.NEXT_TRACK, + leftSingle = StemAction.PlayPause, + rightSingle = StemAction.NoAction, + leftLong = StemAction.NextTrack, ), isPro = true, + supportsAncCycle = true, + supportsAncToggle = true, ) } @@ -274,5 +312,7 @@ private fun PressMappingsCardNonProPreview() = PreviewWrapper { PressMappingsCard( stemActions = StemActionsConfig(), isPro = false, + supportsAncCycle = true, + supportsAncToggle = true, ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/AncGestureResolver.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AncGestureResolver.kt new file mode 100644 index 00000000..7b10a260 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AncGestureResolver.kt @@ -0,0 +1,51 @@ +package eu.darken.capod.monitor.core.aap + +import eu.darken.capod.monitor.core.resolvedAncCycleMask +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 + +data class EffectiveAncState( + val current: AapSetting.AncMode.Value, + val supported: List, + val cycleMask: Int?, + val allowOffEnabled: Boolean, +) + +fun resolveEffectiveAncState( + aapState: AapPodState?, + profile: AppleDeviceProfile, +): EffectiveAncState? { + val anc = aapState?.setting() ?: return null + val current = aapState.pendingAncMode ?: anc.current + val mask = aapState.setting()?.modeMask + ?: profile.lastRequestedListeningModeCycleMask + ?: resolvedAncCycleMask(profile.model.features.hasListeningModeCycle, null) + val allowOff = aapState.setting()?.enabled + ?: profile.learnedAllowOffEnabled + ?: true + return EffectiveAncState( + current = current, + supported = anc.supported, + cycleMask = mask, + allowOffEnabled = allowOff, + ) +} + +private fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { + AapSetting.AncMode.Value.OFF -> 0x01 + AapSetting.AncMode.Value.ON -> 0x02 + AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 + AapSetting.AncMode.Value.ADAPTIVE -> 0x08 +} + +fun nextGestureAncMode(state: EffectiveAncState): AapSetting.AncMode.Value? { + val mask = state.cycleMask ?: return null + val cycleModes = state.supported.filter { mode -> + val bit = mode.cycleBit() + (mask and bit) != 0 && (mode != AapSetting.AncMode.Value.OFF || state.allowOffEnabled) + } + if (cycleModes.size < 2) return null + val idx = cycleModes.indexOf(state.current) + return if (idx < 0) cycleModes.first() else cycleModes[(idx + 1) % cycleModes.size] +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt index 6bbde5ca..ee776073 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt @@ -53,10 +53,10 @@ class StemConfigSender @Inject constructor( private fun StemActionsConfig.toMask(): Int { var mask = 0 - if (leftSingle != StemAction.NONE || rightSingle != StemAction.NONE) mask = mask or 0x01 - if (leftDouble != StemAction.NONE || rightDouble != StemAction.NONE) mask = mask or 0x02 - if (leftTriple != StemAction.NONE || rightTriple != StemAction.NONE) mask = mask or 0x04 - if (leftLong != StemAction.NONE || rightLong != StemAction.NONE) mask = mask or 0x08 + if (leftSingle != StemAction.None || rightSingle != StemAction.None) mask = mask or 0x01 + if (leftDouble != StemAction.None || rightDouble != StemAction.None) mask = mask or 0x02 + if (leftTriple != StemAction.None || rightTriple != StemAction.None) mask = mask or 0x04 + if (leftLong != StemAction.None || rightLong != StemAction.None) mask = mask or 0x08 return mask } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt index 31e7b545..468380ce 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt @@ -2,10 +2,13 @@ package eu.darken.capod.monitor.core.aap import android.view.KeyEvent import eu.darken.capod.common.MediaControl +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN 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.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.DeviceProfilesRepo @@ -28,16 +31,13 @@ class StemPressReaction @Inject constructor( .onEach { (address, event) -> val action = resolveAction(address, event) log(TAG) { "Stem ${event.bud} ${event.pressType} @ $address -> $action" } - executeAction(action) + executeAction(address, action) } .map { } .setupCommonEventHandlers(TAG) { "stemReaction" } private suspend fun resolveAction(address: String, event: StemPressEvent): StemAction { - val profile = profilesRepo.profiles.first() - .filterIsInstance() - .firstOrNull { it.address == address } - ?: return StemAction.NONE + val profile = profileFor(address) ?: return StemAction.None return profile.stemActions.actionFor(event.bud, event.pressType) } @@ -58,13 +58,58 @@ class StemPressReaction @Inject constructor( } } - private suspend fun executeAction(action: StemAction) = when (action) { - StemAction.NONE, StemAction.NO_ACTION -> Unit - StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause() - StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT) - StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS) - StemAction.VOLUME_UP -> mediaControl.adjustVolumeUp() - StemAction.VOLUME_DOWN -> mediaControl.adjustVolumeDown() + private suspend fun executeAction(address: String, action: StemAction) { + when (action) { + is StemAction.None, is StemAction.NoAction -> Unit + is StemAction.PlayPause -> mediaControl.sendPlayPause() + is StemAction.NextTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT) + is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS) + is StemAction.VolumeUp -> mediaControl.adjustVolumeUp() + is StemAction.VolumeDown -> mediaControl.adjustVolumeDown() + is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP) + is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) + is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND) + is StemAction.MuteToggle -> mediaControl.toggleMuteMusic() + is StemAction.CycleAnc -> cycleAnc(address) + is StemAction.ToggleAncTransparency -> toggleAncTransparency(address) + } + } + + private suspend fun profileFor(address: String): AppleDeviceProfile? = profilesRepo.profiles.first() + .filterIsInstance() + .firstOrNull { it.address == address } + + private suspend fun cycleAnc(address: String) { + val profile = profileFor(address) ?: return + if (!profile.model.features.hasListeningModeCycle) return + val aapState = aapManager.allStates.first()[address] ?: return + val state = resolveEffectiveAncState(aapState, profile) ?: return + val next = nextGestureAncMode(state) ?: return + sendAncCommand(address, next) + } + + private suspend fun toggleAncTransparency(address: String) { + val profile = profileFor(address) ?: return + val aapState = aapManager.allStates.first()[address] ?: return + val state = resolveEffectiveAncState(aapState, profile) ?: return + val supported = state.supported + val target = if (state.current == AapSetting.AncMode.Value.TRANSPARENCY) { + supported.firstOrNull { it == AapSetting.AncMode.Value.ON } + ?: supported.firstOrNull { it == AapSetting.AncMode.Value.ADAPTIVE } + ?: supported.firstOrNull { it == AapSetting.AncMode.Value.OFF } + ?: return + } else { + if (AapSetting.AncMode.Value.TRANSPARENCY in supported) AapSetting.AncMode.Value.TRANSPARENCY else return + } + sendAncCommand(address, target) + } + + private suspend fun sendAncCommand(address: String, mode: AapSetting.AncMode.Value) { + try { + aapManager.sendCommand(address, AapCommand.SetAncMode(mode)) + } catch (e: Exception) { + log(TAG, WARN) { "SetAncMode($mode) failed for $address: $e" } + } } companion object { diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt index cccede41..485d135e 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt @@ -1,14 +1,34 @@ package eu.darken.capod.reaction.core.stem +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -enum class StemAction { - NONE, - NO_ACTION, - PLAY_PAUSE, - NEXT_TRACK, - PREVIOUS_TRACK, - VOLUME_UP, - VOLUME_DOWN, +sealed interface StemAction : Parcelable { + @Parcelize @Serializable @SerialName("NONE") data object None : StemAction + @Parcelize @Serializable @SerialName("NO_ACTION") data object NoAction : StemAction + @Parcelize @Serializable @SerialName("PLAY_PAUSE") data object PlayPause : StemAction + @Parcelize @Serializable @SerialName("NEXT_TRACK") data object NextTrack : StemAction + @Parcelize @Serializable @SerialName("PREVIOUS_TRACK") data object PreviousTrack : StemAction + @Parcelize @Serializable @SerialName("VOLUME_UP") data object VolumeUp : StemAction + @Parcelize @Serializable @SerialName("VOLUME_DOWN") data object VolumeDown : StemAction + @Parcelize @Serializable @SerialName("STOP") data object Stop : StemAction + @Parcelize @Serializable @SerialName("FAST_FORWARD") data object FastForward : StemAction + @Parcelize @Serializable @SerialName("REWIND") data object Rewind : StemAction + @Parcelize @Serializable @SerialName("MUTE_TOGGLE") data object MuteToggle : StemAction + @Parcelize @Serializable @SerialName("CYCLE_ANC") data object CycleAnc : StemAction + @Parcelize @Serializable @SerialName("TOGGLE_ANC_TRANSPARENCY") data object ToggleAncTransparency : StemAction + + companion object { + val all: List = listOf( + None, + PlayPause, NextTrack, PreviousTrack, + VolumeUp, VolumeDown, MuteToggle, + Stop, FastForward, Rewind, + CycleAnc, ToggleAncTransparency, + NoAction, + ) + } } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt index a541bd17..9e93ffe9 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt @@ -7,12 +7,12 @@ import kotlinx.serialization.Serializable @Parcelize @Serializable data class StemActionsConfig( - @SerialName("leftSingle") val leftSingle: StemAction = StemAction.NONE, - @SerialName("leftDouble") val leftDouble: StemAction = StemAction.NONE, - @SerialName("leftTriple") val leftTriple: StemAction = StemAction.NONE, - @SerialName("leftLong") val leftLong: StemAction = StemAction.NONE, - @SerialName("rightSingle") val rightSingle: StemAction = StemAction.NONE, - @SerialName("rightDouble") val rightDouble: StemAction = StemAction.NONE, - @SerialName("rightTriple") val rightTriple: StemAction = StemAction.NONE, - @SerialName("rightLong") val rightLong: StemAction = StemAction.NONE, + @SerialName("leftSingle") val leftSingle: StemAction = StemAction.None, + @SerialName("leftDouble") val leftDouble: StemAction = StemAction.None, + @SerialName("leftTriple") val leftTriple: StemAction = StemAction.None, + @SerialName("leftLong") val leftLong: StemAction = StemAction.None, + @SerialName("rightSingle") val rightSingle: StemAction = StemAction.None, + @SerialName("rightDouble") val rightDouble: StemAction = StemAction.None, + @SerialName("rightTriple") val rightTriple: StemAction = StemAction.None, + @SerialName("rightLong") val rightLong: StemAction = StemAction.None, ) : android.os.Parcelable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92cd6bc8..0e8e4fdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -155,6 +155,11 @@ darken A widget showing the last known device status. + Control ANC modes directly from your home screen. + This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget. + This device does not support noise control + Not connected + Connecting… Widget Configuration Select Device Choose which device profile this widget should display. @@ -561,6 +566,12 @@ Previous Track Volume Up Volume Down + Mute Music + Stop + Fast Forward + Rewind + Cycle Noise Control + Toggle Transparency Reset to defaults Reset all press mappings to defaults? Open Press Controls diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index ede74e0d..6ea193fc 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -346,7 +346,7 @@ class DeviceSettingsViewModelTest : BaseTest() { id = testAddress, label = "Test", address = testAddress, - stemActions = StemActionsConfig(leftLong = StemAction.PLAY_PAUSE), + stemActions = StemActionsConfig(leftLong = StemAction.PlayPause), ) ) diff --git a/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt index f6e41d02..5ec3ea33 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt @@ -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() - .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() - .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() - .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() .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() .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() diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/NextGestureAncModeTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/NextGestureAncModeTest.kt new file mode 100644 index 00000000..94c33d4f --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/NextGestureAncModeTest.kt @@ -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 = 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 + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt index 28f596d2..253302c5 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt @@ -46,8 +46,8 @@ class StemConfigSenderTest : BaseTest() { // A has SINGLE only → mask 0x01. B has LONG only → mask 0x08. val profilesFlow = MutableStateFlow>( 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(relaxed = true) { @@ -77,8 +77,8 @@ class StemConfigSenderTest : BaseTest() { } val profilesFlow = MutableStateFlow>( 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(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, ), ) ) diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt index 4e027c33..8d9ed35b 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt @@ -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( - 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(relaxed = true) { every { this@mockk.profiles } returns flowOf(profiles) @@ -71,7 +75,7 @@ class StemPressReactionTest : BaseTest() { } val profilesRepo = mockk(relaxed = true) { every { this@mockk.profiles } returns flowOf( - listOf(profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE))) + listOf(profile(addressA, StemActionsConfig(leftSingle = StemAction.PlayPause))) ) } val mediaControl = mockk(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>(extraBufferCapacity = 8) val aapManager = mockk(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>(extraBufferCapacity = 8) + val aapManager = mockk(relaxed = true) { + every { stemPressEvents } returns events + } + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf( + listOf( + profile( + addressA, + StemActionsConfig( + leftSingle = StemAction.Stop, + leftDouble = StemAction.FastForward, + leftTriple = StemAction.Rewind, + leftLong = StemAction.MuteToggle, + ), + ) + ) + ) + } + val mediaControl = mockk(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>(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(relaxed = true) { + every { stemPressEvents } returns events + every { allStates } returns MutableStateFlow(mapOf(addressA to aapState)) + } + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf( + listOf( + profile(addressA, StemActionsConfig(leftSingle = StemAction.CycleAnc)) + ) + ) + } + val mediaControl = mockk(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>(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(relaxed = true) { + every { stemPressEvents } returns events + every { allStates } returns MutableStateFlow(mapOf(addressA to aapState)) + } + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf( + listOf( + profile(addressA, StemActionsConfig(leftSingle = StemAction.ToggleAncTransparency)) + ) + ) + } + val mediaControl = mockk(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>(extraBufferCapacity = 8) + val aapManager = mockk(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(relaxed = true) { + every { this@mockk.profiles } returns flowOf(listOf(noCycleProfile)) + } + val mediaControl = mockk(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()) } + + job.cancel() + } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/stem/StemActionSerializationTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/stem/StemActionSerializationTest.kt new file mode 100644 index 00000000..fb5d41a5 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/stem/StemActionSerializationTest.kt @@ -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 + } +}