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 + } +}