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

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