mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -26,7 +26,7 @@ class FossCache @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences> = context.dataStore
|
||||
|
||||
val upgrade = dataStore.createValue<FossUpgrade?>(
|
||||
key = "foss.upgrade1",
|
||||
key = "foss.upgrade",
|
||||
defaultValue = null,
|
||||
json = json,
|
||||
onErrorFallbackToDefault = true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -63,12 +63,15 @@ class PressControlsViewModel @Inject constructor(
|
||||
) { device, profiles, upgrade ->
|
||||
val profile = profiles.filterIsInstance<AppleDeviceProfile>().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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AapSetting.AncMode.Value>,
|
||||
val cycleMask: Int?,
|
||||
val allowOffEnabled: Boolean,
|
||||
)
|
||||
|
||||
fun resolveEffectiveAncState(
|
||||
aapState: AapPodState?,
|
||||
profile: AppleDeviceProfile,
|
||||
): EffectiveAncState? {
|
||||
val anc = aapState?.setting<AapSetting.AncMode>() ?: return null
|
||||
val current = aapState.pendingAncMode ?: anc.current
|
||||
val mask = aapState.setting<AapSetting.ListeningModeCycle>()?.modeMask
|
||||
?: profile.lastRequestedListeningModeCycleMask
|
||||
?: resolvedAncCycleMask(profile.model.features.hasListeningModeCycle, null)
|
||||
val allowOff = aapState.setting<AapSetting.AllowOffOption>()?.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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppleDeviceProfile>()
|
||||
.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<AppleDeviceProfile>()
|
||||
.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 {
|
||||
|
||||
@@ -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<StemAction> = listOf(
|
||||
None,
|
||||
PlayPause, NextTrack, PreviousTrack,
|
||||
VolumeUp, VolumeDown, MuteToggle,
|
||||
Stop, FastForward, Rewind,
|
||||
CycleAnc, ToggleAncTransparency,
|
||||
NoAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,6 +155,11 @@
|
||||
<string name="translators_thanks_description">darken</string>
|
||||
|
||||
<string name="widget_description">A widget showing the last known device status.</string>
|
||||
<string name="anc_widget_description">Control ANC modes directly from your home screen.</string>
|
||||
<string name="anc_widget_config_aap_required_hint">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.</string>
|
||||
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Not connected</string>
|
||||
<string name="anc_widget_aap_connecting_label">Connecting…</string>
|
||||
<string name="widget_config_screen_title">Widget Configuration</string>
|
||||
<string name="widget_configuration_title">Select Device</string>
|
||||
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
|
||||
@@ -561,6 +566,12 @@
|
||||
<string name="press_action_previous_track">Previous Track</string>
|
||||
<string name="press_action_volume_up">Volume Up</string>
|
||||
<string name="press_action_volume_down">Volume Down</string>
|
||||
<string name="press_action_mute_toggle">Mute Music</string>
|
||||
<string name="press_action_stop">Stop</string>
|
||||
<string name="press_action_fast_forward">Fast Forward</string>
|
||||
<string name="press_action_rewind">Rewind</string>
|
||||
<string name="press_action_cycle_anc">Cycle Noise Control</string>
|
||||
<string name="press_action_toggle_anc_transparency">Toggle Transparency</string>
|
||||
<string name="press_controls_reset_label">Reset to defaults</string>
|
||||
<string name="press_controls_reset_confirm_message">Reset all press mappings to defaults?</string>
|
||||
<string name="device_settings_noise_control_open_press_controls_action">Open Press Controls</string>
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
id = testAddress,
|
||||
label = "Test",
|
||||
address = testAddress,
|
||||
stemActions = StemActionsConfig(leftLong = StemAction.PLAY_PAUSE),
|
||||
stemActions = StemActionsConfig(leftLong = StemAction.PlayPause),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+20
-20
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user