feat(device-settings): Split stem actions into dedicated Press Controls screen

Move press timing, call controls, and stem mappings out of the Controls card into a new Press Controls screen. The screen name is device-agnostic so it applies to both stemmed AirPods and the AirPods Max's Digital Crown/noise button.

Stem mappings are now per-device (stored on the AppleDeviceProfile) rather than a single app-global DataStore. The Pro gate for mappings moves from screen entry to per-change with an Upgrade badge on the mappings card header, so free users can still access the non-Pro press timing and call-control settings that live on the same screen.
This commit is contained in:
darken
2026-04-17 15:26:05 +02:00
committed by Matthias Urhahn
parent e7c280cb52
commit ec087e4b07
24 changed files with 1592 additions and 672 deletions
@@ -26,7 +26,7 @@ object Nav {
data class DeviceSettings(val profileId: String) : Main
@Serializable
data object StemActionConfig : Main
data class PressControls(val profileId: String) : Main
}
sealed interface Settings : NavigationDestination {
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -86,14 +85,17 @@ fun DeviceSettingsScreenHost(
DeviceSettingsViewModel.Event.OpenBluetoothSettings -> {
context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS))
}
is DeviceSettingsViewModel.Event.SendFailed -> {
snackbarHostState.showSnackbar(
context.getString(R.string.device_settings_send_failed, event.message ?: ""),
)
}
DeviceSettingsViewModel.Event.SystemRenameUnavailable -> {
showRenameUnavailableDialog = true
}
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
snackbarHostState.showSnackbar(offRejectedMessage)
}
@@ -125,17 +127,14 @@ fun DeviceSettingsScreenHost(
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
onAdaptiveAudioNoiseChange = { vm.setAdaptiveAudioNoise(it) },
onPressSpeedChange = { vm.setPressSpeed(it) },
onPressHoldDurationChange = { vm.setPressHoldDuration(it) },
onVolumeSwipeChange = { vm.setVolumeSwipe(it) },
onVolumeSwipeLengthChange = { vm.setVolumeSwipeLength(it) },
onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) },
onMicrophoneModeChange = { vm.setMicrophoneMode(it) },
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
onSleepDetectionChange = { vm.setSleepDetection(it) },
onDeviceNameChange = { vm.setDeviceName(it) },
onStemActionsClick = { vm.navToStemConfig() },
onPressControlsClick = { vm.navToPressControls() },
onForceConnect = { vm.forceConnect() },
onUpgrade = { vm.launchUpgrade() },
onOnePodModeChange = { vm.setOnePodMode(it) },
@@ -150,7 +149,6 @@ fun DeviceSettingsScreenHost(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceSettingsScreen(
state: DeviceSettingsViewModel.State,
@@ -164,17 +162,14 @@ fun DeviceSettingsScreen(
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
onAdaptiveAudioNoiseChange: (Int) -> Unit = {},
onPressSpeedChange: (AapSetting.PressSpeed.Value) -> Unit = {},
onPressHoldDurationChange: (AapSetting.PressHoldDuration.Value) -> Unit = {},
onVolumeSwipeChange: (Boolean) -> Unit = {},
onVolumeSwipeLengthChange: (AapSetting.VolumeSwipeLength.Value) -> Unit = {},
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {},
onListeningModeCycleChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {},
onStemActionsClick: () -> Unit = {},
onPressControlsClick: () -> Unit = {},
onForceConnect: () -> Unit = {},
onUpgrade: () -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
@@ -245,34 +240,80 @@ fun DeviceSettingsScreen(
val detailItems = buildList<DeviceDetailItem> {
if (info != null) {
if (info.manufacturer.isNotBlank()) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_manufacturer_label), info.manufacturer))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_manufacturer_label),
info.manufacturer
)
)
}
if (info.serialNumber.isNotBlank()) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_serial_label), info.serialNumber))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_serial_label),
info.serialNumber
)
)
}
val hasFirmware = info.firmwareVersion.isNotBlank()
val hasBuild = !info.buildNumber.isNullOrBlank()
if (hasFirmware && hasBuild) {
add(DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_firmware_label), info.firmwareVersion),
end = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_build_label), info.buildNumber!!),
))
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
),
)
)
} else if (hasFirmware) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_firmware_label), info.firmwareVersion))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
)
)
} else if (hasBuild) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_build_label), info.buildNumber!!))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
)
)
}
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
if (hasLeft && hasRight) {
add(DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_left_serial_label), info.leftEarbudSerial!!),
end = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_right_serial_label), info.rightEarbudSerial!!),
))
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
),
)
)
} else if (hasLeft) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_left_serial_label), info.leftEarbudSerial!!))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
)
)
} else if (hasRight) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_right_serial_label), info.rightEarbudSerial!!))
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
)
)
}
}
}
@@ -359,7 +400,7 @@ fun DeviceSettingsScreen(
onAdaptiveAudioNoiseChange = onAdaptiveAudioNoiseChange,
onAllowOffOptionChange = onAllowOffOptionChange,
onListeningModeCycleChange = onListeningModeCycleChange,
onStemActionsClick = onStemActionsClick,
onPressControlsClick = onPressControlsClick,
onUpgrade = onUpgrade,
)
}
@@ -400,12 +441,8 @@ fun DeviceSettingsScreen(
ControlsCard(
device = device,
features = features,
isPro = isPro,
enabled = enabled,
onStemActionsClick = onStemActionsClick,
onEndCallMuteMicChange = onEndCallMuteMicChange,
onPressSpeedChange = onPressSpeedChange,
onPressHoldDurationChange = onPressHoldDurationChange,
onPressControlsClick = onPressControlsClick,
onVolumeSwipeChange = onVolumeSwipeChange,
onVolumeSwipeLengthChange = onVolumeSwipeLengthChange,
)
@@ -1,6 +1,8 @@
package eu.darken.capod.main.ui.devicesettings
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -10,27 +12,24 @@ 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.SingleEventFlow
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
@@ -39,8 +38,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import java.time.Instant
import javax.inject.Inject
@@ -53,7 +52,6 @@ class DeviceSettingsViewModel @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val stemActionSettings: StemActionSettings,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
@@ -106,17 +104,21 @@ class DeviceSettingsViewModel @Inject constructor(
// Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightLong.flow,
profilesRepo.profiles,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as eu.darken.capod.common.upgrade.UpgradeRepo.Info
val upgrade = args[2] as UpgradeRepo.Info
val forcing = args[3] as Boolean
@Suppress("UNCHECKED_CAST")
val connectedDevices = args[4] as Collection<eu.darken.capod.common.bluetooth.BluetoothDevice2>
val monitorMode = args[5] as MonitorMode
val leftLong = args[6] as StemAction
val rightLong = args[7] as StemAction
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
?.stemActions
val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr ->
try {
@@ -134,7 +136,9 @@ class DeviceSettingsViewModel @Inject constructor(
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode,
systemBluetoothName = systemBtName,
hasCustomLongPressStemAction = leftLong != StemAction.NONE || rightLong != StemAction.NONE,
hasCustomLongPressStemAction = stemActions?.let {
it.leftLong != StemAction.NONE || it.rightLong != StemAction.NONE
} == true,
)
}
}.asLiveState()
@@ -246,19 +250,10 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
fun setVolumeSwipe(enabled: Boolean) = send(AapCommand.SetVolumeSwipe(enabled))
fun setVolumeSwipeLength(value: AapSetting.VolumeSwipeLength.Value) = send(AapCommand.SetVolumeSwipeLength(value))
fun setEndCallMuteMic(
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
endCall: AapSetting.EndCallMuteMic.EndCallMode,
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = sendProGated(AapCommand.SetMicrophoneMode(mode))
fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled))
@@ -307,7 +302,10 @@ class DeviceSettingsViewModel @Inject constructor(
}
val aliasOk = bonded?.let { bluetoothManager.setDeviceAlias(it, name) } ?: false
if (!aliasOk) {
log(TAG, WARN) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
log(
TAG,
WARN
) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
events.emit(Event.SystemRenameUnavailable)
}
}
@@ -417,13 +415,10 @@ class DeviceSettingsViewModel @Inject constructor(
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
}
fun navToStemConfig() = launch {
log(TAG, INFO) { "navToStemConfig()" }
if (upgradeRepo.isPro()) {
navTo(Nav.Main.StemActionConfig)
} else {
navTo(Nav.Main.Upgrade)
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch
navTo(Nav.Main.PressControls(profileId = profileId))
}
fun launchUpgrade() {
@@ -438,6 +433,7 @@ class DeviceSettingsViewModel @Inject constructor(
companion object {
private val TAG = logTag("DeviceSettings", "VM")
private const val OFF_BIT = 0x01
// Apple's factory-default listening-mode cycle mask: ON | TRANSPARENCY | ADAPTIVE.
// Used as a conservative fallback when disabling Allow Off and we don't have a
// live/persisted mask to mutate.
@@ -11,9 +11,6 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsPreferenceItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.components.CallControlSettings
import eu.darken.capod.main.ui.devicesettings.components.PressHoldDurationSetting
import eu.darken.capod.main.ui.devicesettings.components.PressSpeedSetting
import eu.darken.capod.main.ui.devicesettings.components.VolumeSwipeLengthSetting
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
@@ -24,50 +21,26 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
internal fun ControlsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
enabled: Boolean,
onStemActionsClick: () -> Unit = {},
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
onPressSpeedChange: (AapSetting.PressSpeed.Value) -> Unit = {},
onPressHoldDurationChange: (AapSetting.PressHoldDuration.Value) -> Unit = {},
onPressControlsClick: () -> Unit = {},
onVolumeSwipeChange: (Boolean) -> Unit = {},
onVolumeSwipeLengthChange: (AapSetting.VolumeSwipeLength.Value) -> Unit = {},
) {
val pressSpd = device.pressSpeed
val pressHold = device.pressHoldDuration
val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength
val endCallMuteMic = device.endCallMuteMic
val showPressControlsNav = features.hasStemConfig ||
features.hasPressSpeed ||
features.hasPressHoldDuration ||
features.hasEndCallMuteMic
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
if (features.hasStemConfig) {
if (showPressControlsNav) {
SettingsPreferenceItem(
icon = Icons.TwoTone.TouchApp,
title = stringResource(R.string.stem_actions_title),
subtitle = stringResource(R.string.stem_actions_nav_description),
onClick = onStemActionsClick,
enabled = enabled,
requiresUpgrade = !isPro,
)
}
if (features.hasEndCallMuteMic && endCallMuteMic != null) {
CallControlSettings(
current = endCallMuteMic,
onChange = onEndCallMuteMicChange,
enabled = enabled,
)
}
if (features.hasPressSpeed && pressSpd != null) {
PressSpeedSetting(
selected = pressSpd.value,
onSelected = onPressSpeedChange,
enabled = enabled,
)
}
if (features.hasPressHoldDuration && pressHold != null) {
PressHoldDurationSetting(
selected = pressHold.value,
onSelected = onPressHoldDurationChange,
title = stringResource(R.string.press_controls_title),
subtitle = stringResource(R.string.press_controls_nav_description),
onClick = onPressControlsClick,
enabled = enabled,
)
}
@@ -99,7 +72,6 @@ private fun ControlsCardPreview() = PreviewWrapper {
ControlsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@@ -112,7 +84,6 @@ private fun ControlsCardNonProPreview() = PreviewWrapper {
ControlsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@@ -68,7 +68,7 @@ internal fun NoiseControlCard(
onAdaptiveAudioNoiseChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {},
onListeningModeCycleChange: (Int) -> Unit = {},
onStemActionsClick: () -> Unit = {},
onPressControlsClick: () -> Unit = {},
onUpgrade: () -> Unit = {},
) {
val context = LocalContext.current
@@ -130,11 +130,11 @@ internal fun NoiseControlCard(
)
if (hasCustomLongPressStemAction) {
SettingsInfoBox(
text = stringResource(R.string.stem_actions_long_press_anc_cycle_info),
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
action = {
TextButton(onClick = onStemActionsClick) {
Text(stringResource(R.string.device_settings_noise_control_open_stem_actions_action))
TextButton(onClick = onPressControlsClick) {
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
}
},
)
@@ -1,4 +1,6 @@
package eu.darken.capod.main.ui.devicesettings.components
package eu.darken.capod.main.ui.presscontrols
import eu.darken.capod.main.ui.devicesettings.components.SettingsCompoundHeader
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -49,7 +51,9 @@ fun CallControlSettings(
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.selectableGroup()) {
Column(modifier = Modifier
.padding(horizontal = 12.dp,)
.selectableGroup()) {
CallControlOption(
title = stringResource(R.string.device_settings_end_call_mute_mic_option_a_title),
subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_a_subtitle),
@@ -1,4 +1,4 @@
package eu.darken.capod.main.ui.stemactions
package eu.darken.capod.main.ui.presscontrols
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
@@ -11,10 +11,10 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class StemActionConfigNavigation @Inject constructor() : NavigationEntry {
class PressControlsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.StemActionConfig> {
StemActionConfigScreenHost()
entry<Nav.Main.PressControls> { key ->
PressControlsScreenHost(profileId = key.profileId)
}
}
@@ -23,6 +23,6 @@ class StemActionConfigNavigation @Inject constructor() : NavigationEntry {
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry
abstract fun bind(entry: PressControlsNavigation): NavigationEntry
}
}
@@ -0,0 +1,301 @@
package eu.darken.capod.main.ui.presscontrols
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
@Composable
fun PressControlsScreenHost(
profileId: String,
vm: PressControlsViewModel = hiltViewModel(),
) {
NavigationEventHandler(vm)
LaunchedEffect(profileId) { vm.initialize(profileId) }
val snackbarHostState = remember { SnackbarHostState() }
val sendFailedTemplate = stringResource(R.string.device_settings_send_failed, "%1\$s")
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
is PressControlsViewModel.Event.SendFailed -> {
snackbarHostState.showSnackbar(
sendFailedTemplate.format(event.message ?: ""),
)
}
}
}
}
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
PressControlsScreen(
state = currentState,
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onReset = { vm.resetAll() },
onLeftSingle = { vm.setLeftSingle(it) },
onLeftDouble = { vm.setLeftDouble(it) },
onLeftTriple = { vm.setLeftTriple(it) },
onLeftLong = { vm.setLeftLong(it) },
onRightSingle = { vm.setRightSingle(it) },
onRightDouble = { vm.setRightDouble(it) },
onRightTriple = { vm.setRightTriple(it) },
onRightLong = { vm.setRightLong(it) },
onPressSpeedChange = { vm.setPressSpeed(it) },
onPressHoldDurationChange = { vm.setPressHoldDuration(it) },
onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PressControlsScreen(
state: PressControlsViewModel.State,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onNavigateUp: () -> Unit,
onReset: () -> Unit = {},
onLeftSingle: (StemAction) -> Unit = {},
onLeftDouble: (StemAction) -> Unit = {},
onLeftTriple: (StemAction) -> Unit = {},
onLeftLong: (StemAction) -> Unit = {},
onRightSingle: (StemAction) -> Unit = {},
onRightDouble: (StemAction) -> Unit = {},
onRightTriple: (StemAction) -> Unit = {},
onRightLong: (StemAction) -> Unit = {},
onPressSpeedChange: (AapSetting.PressSpeed.Value) -> Unit = {},
onPressHoldDurationChange: (AapSetting.PressHoldDuration.Value) -> Unit = {},
onEndCallMuteMicChange: (
AapSetting.EndCallMuteMic.MuteMicMode,
AapSetting.EndCallMuteMic.EndCallMode,
) -> Unit = { _, _ -> },
) {
var showResetDialog by remember { mutableStateOf(false) }
val device = state.device
val features: PodModel.Features? = device?.model?.features
val hasStemConfig = features?.hasStemConfig == true
val hasPressSpeed = features?.hasPressSpeed == true && device.pressSpeed != null
val hasPressHoldDuration = features?.hasPressHoldDuration == true && device.pressHoldDuration != null
val hasEndCallMuteMic = features?.hasEndCallMuteMic == true && device.endCallMuteMic != null
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
confirmButton = {
TextButton(onClick = {
onReset()
showResetDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
text = { Text(stringResource(R.string.press_controls_reset_confirm_message)) },
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.press_controls_title)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
}
},
actions = {
if (hasStemConfig) {
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.TwoTone.RestartAlt,
contentDescription = stringResource(R.string.press_controls_reset_label),
)
}
}
},
)
},
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
item("description") {
Text(
text = stringResource(R.string.press_controls_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// ── Press timing (device-scoped AAP settings) ────────────────────
if (hasPressSpeed) {
item("press_speed") {
PressSpeedSetting(
selected = device.pressSpeed!!.value,
onSelected = onPressSpeedChange,
enabled = state.isAapReady,
)
}
}
if (hasPressHoldDuration) {
item("press_hold") {
PressHoldDurationSetting(
selected = device.pressHoldDuration!!.value,
onSelected = onPressHoldDurationChange,
enabled = state.isAapReady,
)
}
}
if (hasEndCallMuteMic) {
item("call_controls") {
CallControlSettings(
current = device.endCallMuteMic!!,
onChange = onEndCallMuteMicChange,
enabled = state.isAapReady,
)
}
}
// ── Press mappings (profile-scoped, Pro-gated) ────────────────────
if (hasStemConfig) {
item("mappings_card") {
PressMappingsCard(
stemActions = state.stemActions,
isPro = state.isPro,
onLeftSingle = onLeftSingle,
onLeftDouble = onLeftDouble,
onLeftTriple = onLeftTriple,
onLeftLong = onLeftLong,
onRightSingle = onRightSingle,
onRightDouble = onRightDouble,
onRightTriple = onRightTriple,
onRightLong = onRightLong,
)
}
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
internal fun previewPressControlsState(
isPro: Boolean,
hasStemConfig: Boolean = true,
stemActions: StemActionsConfig = StemActionsConfig(),
): PressControlsViewModel.State {
val model = if (hasStemConfig) PodModel.AIRPODS_PRO2 else PodModel.AIRPODS_GEN2
val device = PodDevice(
profileId = "preview",
label = "My AirPods",
ble = MockPodDataProvider.airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = AapDeviceInfo(
name = if (hasStemConfig) "AirPods Pro" else "AirPods",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
settings = mapOf(
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = AapSetting.PressSpeed.Value.DEFAULT),
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = AapSetting.PressHoldDuration.Value.DEFAULT),
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
),
),
),
)
return PressControlsViewModel.State(
device = device,
profile = AppleDeviceProfile(label = "My AirPods", model = model, address = "AA:BB:CC:DD:EE:FF", stemActions = stemActions),
stemActions = stemActions,
isPro = isPro,
isAapReady = true,
)
}
@Preview2
@Composable
private fun PressControlsScreenProPreview() = PreviewWrapper {
PressControlsScreen(
state = previewPressControlsState(
isPro = true,
stemActions = StemActionsConfig(
leftSingle = StemAction.PLAY_PAUSE,
rightSingle = StemAction.NO_ACTION,
leftLong = StemAction.NEXT_TRACK,
),
),
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun PressControlsScreenNonProPreview() = PreviewWrapper {
PressControlsScreen(
state = previewPressControlsState(isPro = false),
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun PressControlsScreenPressOnlyPreview() = PreviewWrapper {
// Device with timing/call settings but no stem mapping support.
PressControlsScreen(
state = previewPressControlsState(isPro = true, hasStemConfig = false),
onNavigateUp = {},
)
}
@@ -0,0 +1,211 @@
package eu.darken.capod.main.ui.presscontrols
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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.SingleEventFlow
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
@HiltViewModel
class PressControlsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val deviceMonitor: DeviceMonitor,
private val aapManager: AapConnectionManager,
private val upgradeRepo: UpgradeRepo,
private val profilesRepo: DeviceProfilesRepo,
) : ViewModel4(dispatcherProvider) {
private val targetProfileId = MutableStateFlow<ProfileId?>(null)
private var initialized = false
fun initialize(profileId: ProfileId) {
if (initialized && targetProfileId.value == profileId) return
initialized = true
targetProfileId.value = profileId
}
sealed interface Event {
data class SendFailed(val command: AapCommand, val message: String?) : Event
}
val events = SingleEventFlow<Event>()
val state = targetProfileId.flatMapLatest { profileId ->
if (profileId == null) return@flatMapLatest flowOf(State())
combine(
deviceForProfile(profileId),
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
) { device, profiles, upgrade ->
val profile = profiles.filterIsInstance<AppleDeviceProfile>().firstOrNull { it.id == profileId }
val stemActions = profile?.stemActions ?: StemActionsConfig()
State(
device = device,
profile = profile,
stemActions = stemActions,
isPro = upgrade.isPro,
isAapReady = device?.isAapReady == true,
)
}
}.asLiveState()
private fun deviceForProfile(profileId: ProfileId): Flow<PodDevice?> =
deviceMonitor.devices.flatMapLatest { devices ->
val live = devices.firstOrNull { it.profileId == profileId }
flow<PodDevice?> { emit(live ?: deviceMonitor.getDeviceForProfile(profileId)) }
}
data class State(
val device: PodDevice? = null,
val profile: AppleDeviceProfile? = null,
val stemActions: StemActionsConfig = StemActionsConfig(),
val isPro: Boolean = false,
val isAapReady: Boolean = false,
)
// ── Stem-action mapping setters (profile-scoped, Pro-gated on non-NONE assignment) ───────
private fun StemActionsConfig.getSide(bud: Side): StemAction = when (bud) {
Side.LEFT_SINGLE -> leftSingle
Side.LEFT_DOUBLE -> leftDouble
Side.LEFT_TRIPLE -> leftTriple
Side.LEFT_LONG -> leftLong
Side.RIGHT_SINGLE -> rightSingle
Side.RIGHT_DOUBLE -> rightDouble
Side.RIGHT_TRIPLE -> rightTriple
Side.RIGHT_LONG -> rightLong
}
private fun StemActionsConfig.withSide(bud: Side, action: StemAction): StemActionsConfig = when (bud) {
Side.LEFT_SINGLE -> copy(leftSingle = action)
Side.LEFT_DOUBLE -> copy(leftDouble = action)
Side.LEFT_TRIPLE -> copy(leftTriple = action)
Side.LEFT_LONG -> copy(leftLong = action)
Side.RIGHT_SINGLE -> copy(rightSingle = action)
Side.RIGHT_DOUBLE -> copy(rightDouble = action)
Side.RIGHT_TRIPLE -> copy(rightTriple = action)
Side.RIGHT_LONG -> copy(rightLong = action)
}
private enum class Side {
LEFT_SINGLE, LEFT_DOUBLE, LEFT_TRIPLE, LEFT_LONG,
RIGHT_SINGLE, RIGHT_DOUBLE, RIGHT_TRIPLE, RIGHT_LONG,
}
private fun otherSideFor(bud: Side): Side = when (bud) {
Side.LEFT_SINGLE -> Side.RIGHT_SINGLE
Side.LEFT_DOUBLE -> Side.RIGHT_DOUBLE
Side.LEFT_TRIPLE -> Side.RIGHT_TRIPLE
Side.LEFT_LONG -> Side.RIGHT_LONG
Side.RIGHT_SINGLE -> Side.LEFT_SINGLE
Side.RIGHT_DOUBLE -> Side.LEFT_DOUBLE
Side.RIGHT_TRIPLE -> Side.LEFT_TRIPLE
Side.RIGHT_LONG -> Side.LEFT_LONG
}
private fun setSide(bud: Side, action: StemAction) = launch {
log(TAG, INFO) { "setSide($bud, $action)" }
val profileId = targetProfileId.value ?: return@launch
val currentProfile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId } ?: return@launch
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.
// 3. Otherwise Pro is required.
if (action != StemAction.NONE && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
// 4. Mutate + cross-side effect.
profilesRepo.updateAppleProfile(profileId) { profile ->
val otherSide = otherSideFor(bud)
val otherCurrent = profile.stemActions.getSide(otherSide)
val newConfig = profile.stemActions
.withSide(bud, action)
.applyCrossSideEffect(action, otherSide, otherCurrent)
profile.copy(stemActions = newConfig)
}
}
private fun StemActionsConfig.applyCrossSideEffect(
selected: StemAction,
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
}
fun setLeftSingle(action: StemAction) = setSide(Side.LEFT_SINGLE, action)
fun setLeftDouble(action: StemAction) = setSide(Side.LEFT_DOUBLE, action)
fun setLeftTriple(action: StemAction) = setSide(Side.LEFT_TRIPLE, action)
fun setLeftLong(action: StemAction) = setSide(Side.LEFT_LONG, action)
fun setRightSingle(action: StemAction) = setSide(Side.RIGHT_SINGLE, action)
fun setRightDouble(action: StemAction) = setSide(Side.RIGHT_DOUBLE, action)
fun setRightTriple(action: StemAction) = setSide(Side.RIGHT_TRIPLE, action)
fun setRightLong(action: StemAction) = setSide(Side.RIGHT_LONG, action)
fun resetAll() = launch {
log(TAG, INFO) { "resetAll()" }
val profileId = targetProfileId.value ?: return@launch
profilesRepo.updateAppleProfile(profileId) { it.copy(stemActions = StemActionsConfig()) }
}
// ── AAP setters (device-scoped, not Pro-gated) ────────────────────────────────────────────
private suspend fun currentAddress(): String? {
val profileId = targetProfileId.value ?: return null
return deviceMonitor.getDeviceForProfile(profileId)?.address
}
private fun send(command: AapCommand) = launch {
val address = currentAddress() ?: return@launch
try {
aapManager.sendCommand(address, command)
log(TAG, INFO) { "Sent $command to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "Failed to send $command: ${e.message}" }
events.emit(Event.SendFailed(command, e.message))
}
}
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
fun setEndCallMuteMic(
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
endCall: AapSetting.EndCallMuteMic.EndCallMode,
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
companion object {
private val TAG = logTag("PressControls", "VM")
}
}
@@ -1,4 +1,6 @@
package eu.darken.capod.main.ui.devicesettings.components
package eu.darken.capod.main.ui.presscontrols
import eu.darken.capod.main.ui.devicesettings.components.SegmentedSettingRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Timer
@@ -0,0 +1,278 @@
package eu.darken.capod.main.ui.presscontrols
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Looks3
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.LooksTwo
import androidx.compose.material.icons.twotone.Timer
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.UpgradeBadge
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
@Composable
fun PressMappingsCard(
stemActions: StemActionsConfig,
isPro: Boolean,
onLeftSingle: (StemAction) -> Unit = {},
onLeftDouble: (StemAction) -> Unit = {},
onLeftTriple: (StemAction) -> Unit = {},
onLeftLong: (StemAction) -> Unit = {},
onRightSingle: (StemAction) -> Unit = {},
onRightDouble: (StemAction) -> Unit = {},
onRightTriple: (StemAction) -> Unit = {},
onRightLong: (StemAction) -> Unit = {},
) {
SettingsSection {
MappingsCardHeader(isPro = isPro)
PressTypeHeader(
icon = Icons.TwoTone.LooksOne,
text = stringResource(R.string.press_controls_single_press),
)
StemActionRow(
leftAction = stemActions.leftSingle,
rightAction = stemActions.rightSingle,
onLeftChange = onLeftSingle,
onRightChange = onRightSingle,
)
PressTypeHeader(
icon = Icons.TwoTone.LooksTwo,
text = stringResource(R.string.press_controls_double_press),
)
StemActionRow(
leftAction = stemActions.leftDouble,
rightAction = stemActions.rightDouble,
onLeftChange = onLeftDouble,
onRightChange = onRightDouble,
)
PressTypeHeader(
icon = Icons.TwoTone.Looks3,
text = stringResource(R.string.press_controls_triple_press),
)
StemActionRow(
leftAction = stemActions.leftTriple,
rightAction = stemActions.rightTriple,
onLeftChange = onLeftTriple,
onRightChange = onRightTriple,
)
PressTypeHeader(
icon = Icons.TwoTone.Timer,
text = stringResource(R.string.press_controls_long_press),
)
StemActionRow(
leftAction = stemActions.leftLong,
rightAction = stemActions.rightLong,
onLeftChange = onLeftLong,
onRightChange = onRightLong,
)
if (stemActions.leftLong != StemAction.NONE || stemActions.rightLong != StemAction.NONE) {
SettingsInfoBox(
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
)
}
}
}
@Composable
private fun MappingsCardHeader(isPro: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.press_controls_mappings_section_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(end = 8.dp),
)
if (!isPro) {
UpgradeBadge()
}
}
}
@Composable
private fun PressTypeHeader(icon: ImageVector, text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@Composable
private fun StemActionRow(
leftAction: StemAction,
rightAction: StemAction,
onLeftChange: (StemAction) -> Unit,
onRightChange: (StemAction) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.press_controls_left),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = leftAction,
onSelected = onLeftChange,
otherSideAction = rightAction,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.End,
) {
Text(
text = stringResource(R.string.press_controls_right),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = rightAction,
onSelected = onRightChange,
otherSideAction = leftAction,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StemActionDropdown(
selected: StemAction,
onSelected: (StemAction) -> Unit,
otherSideAction: StemAction,
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()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selected.label(),
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
for (action in options) {
DropdownMenuItem(
text = { Text(action.label()) },
onClick = {
onSelected(action)
expanded = false
},
)
}
}
}
}
@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)
}
@Preview2
@Composable
private fun PressMappingsCardProPreview() = PreviewWrapper {
PressMappingsCard(
stemActions = StemActionsConfig(
leftSingle = StemAction.PLAY_PAUSE,
rightSingle = StemAction.NO_ACTION,
leftLong = StemAction.NEXT_TRACK,
),
isPro = true,
)
}
@Preview2
@Composable
private fun PressMappingsCardNonProPreview() = PreviewWrapper {
PressMappingsCard(
stemActions = StemActionsConfig(),
isPro = false,
)
}
@@ -1,4 +1,6 @@
package eu.darken.capod.main.ui.devicesettings.components
package eu.darken.capod.main.ui.presscontrols
import eu.darken.capod.main.ui.devicesettings.components.SegmentedSettingRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Speed
@@ -1,300 +0,0 @@
package eu.darken.capod.main.ui.stemactions
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.reaction.core.stem.StemAction
@Composable
fun StemActionConfigScreenHost(
vm: StemActionConfigViewModel = hiltViewModel(),
) {
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
StemActionConfigScreen(
state = currentState,
onNavigateUp = { vm.navUp() },
onReset = { vm.resetAll() },
onLeftSingle = { vm.setLeftSingle(it) },
onLeftDouble = { vm.setLeftDouble(it) },
onLeftTriple = { vm.setLeftTriple(it) },
onLeftLong = { vm.setLeftLong(it) },
onRightSingle = { vm.setRightSingle(it) },
onRightDouble = { vm.setRightDouble(it) },
onRightTriple = { vm.setRightTriple(it) },
onRightLong = { vm.setRightLong(it) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StemActionConfigScreen(
state: StemActionConfigViewModel.State,
onNavigateUp: () -> Unit,
onReset: () -> Unit = {},
onLeftSingle: (StemAction) -> Unit = {},
onLeftDouble: (StemAction) -> Unit = {},
onLeftTriple: (StemAction) -> Unit = {},
onLeftLong: (StemAction) -> Unit = {},
onRightSingle: (StemAction) -> Unit = {},
onRightDouble: (StemAction) -> Unit = {},
onRightTriple: (StemAction) -> Unit = {},
onRightLong: (StemAction) -> Unit = {},
) {
var showResetDialog by remember { mutableStateOf(false) }
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
confirmButton = {
TextButton(onClick = {
onReset()
showResetDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
text = { Text(stringResource(R.string.stem_actions_reset_confirm_message)) },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.stem_actions_title)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.TwoTone.RestartAlt,
contentDescription = stringResource(R.string.stem_actions_reset_label),
)
}
},
)
},
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
item("description") {
Text(
text = stringResource(R.string.stem_actions_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// Press type rows with left/right dropdowns
item("single_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_single_press))
}
item("single_row") {
StemActionRow(
leftAction = state.leftSingle,
rightAction = state.rightSingle,
onLeftChange = onLeftSingle,
onRightChange = onRightSingle,
)
}
item("double_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_double_press))
}
item("double_row") {
StemActionRow(
leftAction = state.leftDouble,
rightAction = state.rightDouble,
onLeftChange = onLeftDouble,
onRightChange = onRightDouble,
)
}
item("triple_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_triple_press))
}
item("triple_row") {
StemActionRow(
leftAction = state.leftTriple,
rightAction = state.rightTriple,
onLeftChange = onLeftTriple,
onRightChange = onRightTriple,
)
}
item("long_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_long_press))
}
item("long_row") {
StemActionRow(
leftAction = state.leftLong,
rightAction = state.rightLong,
onLeftChange = onLeftLong,
onRightChange = onRightLong,
)
}
if (state.leftLong != StemAction.NONE || state.rightLong != StemAction.NONE) {
item("long_anc_cycle_info") {
SettingsInfoBox(
text = stringResource(R.string.stem_actions_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
)
}
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun StemActionRow(
leftAction: StemAction,
rightAction: StemAction,
onLeftChange: (StemAction) -> Unit,
onRightChange: (StemAction) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.stem_actions_left),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = leftAction,
onSelected = onLeftChange,
otherSideAction = rightAction,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.stem_actions_right),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = rightAction,
onSelected = onRightChange,
otherSideAction = leftAction,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StemActionDropdown(
selected: StemAction,
onSelected: (StemAction) -> Unit,
otherSideAction: StemAction,
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()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selected.label(),
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
for (action in options) {
DropdownMenuItem(
text = { Text(action.label()) },
onClick = {
onSelected(action)
expanded = false
},
)
}
}
}
}
@Composable
private fun StemAction.label(): String = when (this) {
StemAction.NONE -> stringResource(R.string.stem_action_none)
StemAction.NO_ACTION -> stringResource(R.string.stem_action_no_action)
StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause)
StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track)
StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track)
StemAction.VOLUME_UP -> stringResource(R.string.stem_action_volume_up)
StemAction.VOLUME_DOWN -> stringResource(R.string.stem_action_volume_down)
}
@@ -1,125 +0,0 @@
package eu.darken.capod.main.ui.stemactions
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import javax.inject.Inject
@HiltViewModel
class StemActionConfigViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val stemActionSettings: StemActionSettings,
) : ViewModel4(dispatcherProvider) {
val state = combine(
stemActionSettings.leftSingle.flow,
stemActionSettings.leftDouble.flow,
stemActionSettings.leftTriple.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightSingle.flow,
stemActionSettings.rightDouble.flow,
stemActionSettings.rightTriple.flow,
stemActionSettings.rightLong.flow,
) { values ->
State(
leftSingle = values[0],
leftDouble = values[1],
leftTriple = values[2],
leftLong = values[3],
rightSingle = values[4],
rightDouble = values[5],
rightTriple = values[6],
rightLong = values[7],
)
}.asLiveState()
data class State(
val leftSingle: StemAction = StemAction.NONE,
val leftDouble: StemAction = StemAction.NONE,
val leftTriple: StemAction = StemAction.NONE,
val leftLong: StemAction = StemAction.NONE,
val rightSingle: StemAction = StemAction.NONE,
val rightDouble: StemAction = StemAction.NONE,
val rightTriple: StemAction = StemAction.NONE,
val rightLong: StemAction = StemAction.NONE,
)
fun setLeftSingle(action: StemAction) = launch {
log(TAG, INFO) { "setLeftSingle($action)" }
stemActionSettings.leftSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.rightSingle)
}
fun setLeftDouble(action: StemAction) = launch {
log(TAG, INFO) { "setLeftDouble($action)" }
stemActionSettings.leftDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.rightDouble)
}
fun setLeftTriple(action: StemAction) = launch {
log(TAG, INFO) { "setLeftTriple($action)" }
stemActionSettings.leftTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.rightTriple)
}
fun setLeftLong(action: StemAction) = launch {
log(TAG, INFO) { "setLeftLong($action)" }
stemActionSettings.leftLong.update { action }
applyCrossSideEffect(action, stemActionSettings.rightLong)
}
fun setRightSingle(action: StemAction) = launch {
log(TAG, INFO) { "setRightSingle($action)" }
stemActionSettings.rightSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.leftSingle)
}
fun setRightDouble(action: StemAction) = launch {
log(TAG, INFO) { "setRightDouble($action)" }
stemActionSettings.rightDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.leftDouble)
}
fun setRightTriple(action: StemAction) = launch {
log(TAG, INFO) { "setRightTriple($action)" }
stemActionSettings.rightTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.leftTriple)
}
fun setRightLong(action: StemAction) = launch {
log(TAG, INFO) { "setRightLong($action)" }
stemActionSettings.rightLong.update { action }
applyCrossSideEffect(action, stemActionSettings.leftLong)
}
private suspend fun applyCrossSideEffect(
selected: StemAction,
otherSide: eu.darken.capod.common.datastore.DataStoreValue<StemAction>,
) {
when (selected) {
StemAction.NONE -> otherSide.update { StemAction.NONE }
StemAction.NO_ACTION -> {} // No side-effect
else -> {
if (otherSide.flow.first() == StemAction.NONE) {
otherSide.update { StemAction.NO_ACTION }
}
}
}
}
fun resetAll() = launch {
log(TAG, INFO) { "resetAll()" }
stemActionSettings.resetAll()
}
companion object {
private val TAG = logTag("StemAction", "Config", "VM")
}
}
@@ -10,11 +10,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@@ -23,25 +22,20 @@ import javax.inject.Singleton
@Singleton
class StemConfigSender @Inject constructor(
private val aapManager: AapConnectionManager,
private val stemActionSettings: StemActionSettings,
private val profilesRepo: DeviceProfilesRepo,
) {
fun monitor(): Flow<Unit> = combine(
aapManager.allStates,
stemActionMask(),
) { states, mask ->
states.entries
profilesRepo.profiles,
) { states, profiles ->
val readyAddresses = states.entries
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
.mapNotNull { (address, _) ->
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
if (profile != null && profile.model.features.hasStemConfig) {
address to mask
} else {
null
}
}
.map { (address, _) -> address }
.toSet()
profiles
.filterIsInstance<AppleDeviceProfile>()
.filter { it.address != null && it.address in readyAddresses && it.model.features.hasStemConfig }
.map { profile -> profile.address!! to profile.stemActions.toMask() }
}
.distinctUntilChanged()
.onEach { commands ->
@@ -57,22 +51,13 @@ class StemConfigSender @Inject constructor(
.map { }
.setupCommonEventHandlers(TAG) { "stemConfig" }
private fun stemActionMask(): Flow<Int> = combine(
stemActionSettings.leftSingle.flow,
stemActionSettings.leftDouble.flow,
stemActionSettings.leftTriple.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightSingle.flow,
stemActionSettings.rightDouble.flow,
stemActionSettings.rightTriple.flow,
stemActionSettings.rightLong.flow,
) { values ->
private fun StemActionsConfig.toMask(): Int {
var mask = 0
if (values[0] != StemAction.NONE || values[4] != StemAction.NONE) mask = mask or 0x01 // single
if (values[1] != StemAction.NONE || values[5] != StemAction.NONE) mask = mask or 0x02 // double
if (values[2] != StemAction.NONE || values[6] != StemAction.NONE) mask = mask or 0x04 // triple
if (values[3] != StemAction.NONE || values[7] != StemAction.NONE) mask = mask or 0x08 // long
mask
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
}
companion object {
@@ -7,8 +7,10 @@ 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.StemPressEvent
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -19,35 +21,42 @@ import javax.inject.Singleton
@Singleton
class StemPressReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val stemActionSettings: StemActionSettings,
private val profilesRepo: DeviceProfilesRepo,
private val mediaControl: MediaControl,
) {
fun monitor(): Flow<Unit> = aapManager.stemPressEvents
.onEach { (_, event) ->
val action = resolveAction(event)
log(TAG) { "Stem ${event.bud} ${event.pressType} -> $action" }
.onEach { (address, event) ->
val action = resolveAction(address, event)
log(TAG) { "Stem ${event.bud} ${event.pressType} @ $address -> $action" }
executeAction(action)
}
.map { }
.setupCommonEventHandlers(TAG) { "stemReaction" }
private suspend fun resolveAction(event: StemPressEvent): StemAction {
val setting = when (event.bud) {
StemPressEvent.Bud.LEFT -> when (event.pressType) {
StemPressEvent.PressType.SINGLE -> stemActionSettings.leftSingle
StemPressEvent.PressType.DOUBLE -> stemActionSettings.leftDouble
StemPressEvent.PressType.TRIPLE -> stemActionSettings.leftTriple
StemPressEvent.PressType.LONG -> stemActionSettings.leftLong
private suspend fun resolveAction(address: String, event: StemPressEvent): StemAction {
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
?: return StemAction.NONE
return profile.stemActions.actionFor(event.bud, event.pressType)
}
private fun StemActionsConfig.actionFor(bud: StemPressEvent.Bud, pressType: StemPressEvent.PressType): StemAction =
when (bud) {
StemPressEvent.Bud.LEFT -> when (pressType) {
StemPressEvent.PressType.SINGLE -> leftSingle
StemPressEvent.PressType.DOUBLE -> leftDouble
StemPressEvent.PressType.TRIPLE -> leftTriple
StemPressEvent.PressType.LONG -> leftLong
}
StemPressEvent.Bud.RIGHT -> when (event.pressType) {
StemPressEvent.PressType.SINGLE -> stemActionSettings.rightSingle
StemPressEvent.PressType.DOUBLE -> stemActionSettings.rightDouble
StemPressEvent.PressType.TRIPLE -> stemActionSettings.rightTriple
StemPressEvent.PressType.LONG -> stemActionSettings.rightLong
StemPressEvent.Bud.RIGHT -> when (pressType) {
StemPressEvent.PressType.SINGLE -> rightSingle
StemPressEvent.PressType.DOUBLE -> rightDouble
StemPressEvent.PressType.TRIPLE -> rightTriple
StemPressEvent.PressType.LONG -> rightLong
}
}
return setting.flow.first()
}
private suspend fun executeAction(action: StemAction) = when (action) {
StemAction.NONE, StemAction.NO_ACTION -> Unit
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -41,6 +42,7 @@ data class AppleDeviceProfile(
* UI to the default 0x0E (no OFF bit) even if the real cycle on-device includes OFF.
*/
@SerialName("learnedListeningModeCycleMask") val lastRequestedListeningModeCycleMask: Int? = null,
@SerialName("stemActions") val stemActions: StemActionsConfig = StemActionsConfig(),
) : DeviceProfile, HasReactionConfig {
override val reactionConfig: ReactionConfig
@@ -1,37 +0,0 @@
package eu.darken.capod.reaction.core.stem
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.SerializationCapod
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StemActionSettings @Inject constructor(
@ApplicationContext private val context: Context,
@SerializationCapod json: Json,
) {
private val Context.dataStore by preferencesDataStore(name = "settings_stem_actions")
private val dataStore: DataStore<Preferences> get() = context.dataStore
val leftSingle = dataStore.createValue("stem.left.single", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftDouble = dataStore.createValue("stem.left.double", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftTriple = dataStore.createValue("stem.left.triple", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftLong = dataStore.createValue("stem.left.long", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightSingle = dataStore.createValue("stem.right.single", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightDouble = dataStore.createValue("stem.right.double", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightTriple = dataStore.createValue("stem.right.triple", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightLong = dataStore.createValue("stem.right.long", StemAction.NONE, json, onErrorFallbackToDefault = true)
suspend fun resetAll() {
context.dataStore.edit { it.clear() }
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.reaction.core.stem
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
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,
) : android.os.Parcelable
+22 -21
View File
@@ -542,26 +542,27 @@
<string name="device_settings_noise_control_label">Noise Control</string>
<string name="device_settings_eq_label">Equalizer</string>
<!-- Stem Actions -->
<string name="stem_actions_title">Stem Actions</string>
<string name="stem_actions_nav_description">Map stem presses to Android actions</string>
<string name="stem_actions_description">Choose what happens when you press the stem on each AirPod. When an action is assigned, the app intercepts the press instead of the AirPods handling it natively.</string>
<string name="stem_actions_single_press">Single Press</string>
<string name="stem_actions_double_press">Double Press</string>
<string name="stem_actions_triple_press">Triple Press</string>
<string name="stem_actions_long_press">Long Press</string>
<string name="stem_actions_long_press_anc_cycle_info">Assigning a long-press action also disables cycling Noise Control modes (e.g. Noise Cancellation, Transparency) by holding the stem on the AirPods.</string>
<string name="stem_actions_left">Left</string>
<string name="stem_actions_right">Right</string>
<string name="stem_action_none">Default</string>
<string name="stem_action_no_action">No Action</string>
<string name="stem_action_play_pause">Play/Pause</string>
<string name="stem_action_next_track">Next Track</string>
<string name="stem_action_previous_track">Previous Track</string>
<string name="stem_action_volume_up">Volume Up</string>
<string name="stem_action_volume_down">Volume Down</string>
<string name="stem_actions_reset_label">Reset to defaults</string>
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
<string name="device_settings_noise_control_open_stem_actions_action">Open Stem Actions</string>
<!-- Press Controls -->
<string name="press_controls_title">Press Controls</string>
<string name="press_controls_nav_description">Tune press behaviour and map presses to actions</string>
<string name="press_controls_description">Tune how presses are detected, choose call-button behaviour, and map presses to actions. Mapped actions apply to this device only and let the app intercept the press instead of the AirPods handling it natively.</string>
<string name="press_controls_single_press">Single Press</string>
<string name="press_controls_double_press">Double Press</string>
<string name="press_controls_triple_press">Triple Press</string>
<string name="press_controls_long_press">Long Press</string>
<string name="press_controls_long_press_anc_cycle_info">Assigning a long-press action also disables cycling Noise Control modes (e.g. Noise Cancellation, Transparency) by pressing and holding on the AirPods.</string>
<string name="press_controls_mappings_section_title">Press Mappings</string>
<string name="press_controls_left">Left</string>
<string name="press_controls_right">Right</string>
<string name="press_action_none">Default</string>
<string name="press_action_no_action">No Action</string>
<string name="press_action_play_pause">Play/Pause</string>
<string name="press_action_next_track">Next Track</string>
<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_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>
</resources>
@@ -11,9 +11,11 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
@@ -60,14 +62,12 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var generalSettings: GeneralSettings
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private lateinit var stemActionSettings: StemActionSettings
private lateinit var fakeLeftLongStemAction: FakeDataStoreValue<StemAction>
private lateinit var fakeRightLongStemAction: FakeDataStoreValue<StemAction>
private val timeSource: TimeSource = TestTimeSource()
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow<BluetoothAddress>
private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk {
@@ -104,17 +104,18 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { bondedDevices() } returns flowOf(emptySet())
every { connectedDevices } returns connectedDevicesFlow
}
profilesRepo = mockk(relaxed = true)
profilesFlow = MutableStateFlow(
listOf(
AppleDeviceProfile(id = testAddress, label = "Test", address = testAddress)
)
)
profilesRepo = mockk(relaxed = true) {
every { profiles } returns profilesFlow
}
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
fakeLeftLongStemAction = FakeDataStoreValue(StemAction.NONE)
fakeRightLongStemAction = FakeDataStoreValue(StemAction.NONE)
stemActionSettings = mockk<StemActionSettings>().also {
every { it.leftLong } returns fakeLeftLongStemAction.mock
every { it.rightLong } returns fakeRightLongStemAction.mock
}
}
@AfterEach
@@ -132,7 +133,6 @@ class DeviceSettingsViewModelTest : BaseTest() {
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
generalSettings = generalSettings,
stemActionSettings = stemActionSettings,
timeSource = timeSource,
webpageTool = mockk(relaxed = true),
).also { vm = it }
@@ -341,7 +341,14 @@ class DeviceSettingsViewModelTest : BaseTest() {
@Test
fun `hasCustomLongPressStemAction is true when either long-press action is assigned`() = runVmTest {
fakeLeftLongStemAction.value = StemAction.PLAY_PAUSE
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
stemActions = StemActionsConfig(leftLong = StemAction.PLAY_PAUSE),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
@@ -439,21 +446,22 @@ class DeviceSettingsViewModelTest : BaseTest() {
}
@Test
fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns true
fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() =
runVmTest {
every { upgradeInfoFlow.value.isPro } returns true
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setAllowOffOption(false)
vm.setAllowOffOption(false)
// Fallback cycle mask (0x0F) with OFF bit stripped = 0x0E.
coVerify(ordering = io.mockk.Ordering.ORDERED) {
aapManager.sendCommand(testAddress, AapCommand.SetListeningModeCycle(0x0E))
aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(false))
// Fallback cycle mask (0x0F) with OFF bit stripped = 0x0E.
coVerify(ordering = io.mockk.Ordering.ORDERED) {
aapManager.sendCommand(testAddress, AapCommand.SetListeningModeCycle(0x0E))
aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(false))
}
}
}
@Test
fun `setAllowOffOption as non-Pro sends no commands`() = runVmTest {
@@ -0,0 +1,281 @@
package eu.darken.capod.main.ui.presscontrols
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.livedata.InstantExecutorExtension
@ExtendWith(InstantExecutorExtension::class)
class PressControlsViewModelTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private val testProfileId: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private var vm: PressControlsViewModel? = null
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var aapManager: AapConnectionManager
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private fun makeProfile(stemActions: StemActionsConfig = StemActionsConfig()) =
AppleDeviceProfile(id = testProfileId, label = "Test", address = testProfileId, stemActions = stemActions)
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
devicesFlow = MutableStateFlow(emptyList())
val syntheticDevice = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testProfileId
every { it.address } returns testProfileId
every { it.isAapReady } returns true
}
deviceMonitor = mockk {
every { devices } returns devicesFlow
coEvery { getDeviceForProfile(testProfileId) } returns syntheticDevice
}
aapManager = mockk(relaxed = true)
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true).also {
every { it.isPro } returns false
})
upgradeRepo = mockk {
every { upgradeInfo } returns upgradeInfoFlow
}
profilesFlow = MutableStateFlow(listOf(makeProfile()))
profilesRepo = mockk(relaxed = true) {
every { profiles } returns profilesFlow
// Make updateAppleProfile actually mutate the in-memory profile flow.
coEvery { updateAppleProfile(testProfileId, any()) } coAnswers {
@Suppress("UNCHECKED_CAST")
val transform = arg<(AppleDeviceProfile) -> AppleDeviceProfile>(1)
val current = profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first { it.id == testProfileId }
profilesFlow.value = profilesFlow.value.map {
if (it.id == testProfileId) transform(current) else it
}
}
}
}
@AfterEach
fun teardown() {
vm?.vmScope?.cancel()
vm = null
Dispatchers.resetMain()
}
private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
try {
testBody()
} finally {
vm?.vmScope?.cancel()
vm = null
}
}
private fun createViewModel() = PressControlsViewModel(
dispatcherProvider = TestDispatcherProvider(testDispatcher),
deviceMonitor = deviceMonitor,
aapManager = aapManager,
upgradeRepo = upgradeRepo,
profilesRepo = profilesRepo,
).also { vm = it }
@Test
fun `setLeftSingle as Pro persists action to profile`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns true
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.PLAY_PAUSE)
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.PLAY_PAUSE
}
@Test
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)
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.NONE
}
@Test
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)))
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.NONE)
coVerify { profilesRepo.updateAppleProfile(testProfileId, any()) }
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions.leftSingle shouldBe StemAction.NONE
}
@Test
fun `setLeftSingle to current value as free user is a no-op`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns false
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
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 {
every { upgradeInfoFlow.value.isPro } returns true
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.PLAY_PAUSE)
val updated = profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions
updated.leftSingle shouldBe StemAction.PLAY_PAUSE
updated.rightSingle shouldBe StemAction.NO_ACTION
}
@Test
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))
)
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setLeftSingle(StemAction.NONE)
val updated = profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions
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))
)
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.resetAll()
profilesFlow.value
.filterIsInstance<AppleDeviceProfile>()
.first().stemActions shouldBe StemActionsConfig()
}
@Test
fun `setPressSpeed sends AAP command and is not Pro-gated`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns false
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setPressSpeed(AapSetting.PressSpeed.Value.SLOWER)
coVerify {
aapManager.sendCommand(testProfileId, AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWER))
}
}
@Test
fun `setPressSpeed failure emits SendFailed event`() = runVmTest {
coEvery {
aapManager.sendCommand(testProfileId, AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWEST))
} throws IllegalStateException("socket closed")
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first()
vm.setPressSpeed(AapSetting.PressSpeed.Value.SLOWEST)
val event = vm.events.first()
val sendFailed = event.shouldBeInstanceOf<PressControlsViewModel.Event.SendFailed>()
sendFailed.command shouldBe AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.SLOWEST)
sendFailed.message shouldBe "socket closed"
}
@Test
fun `state isAapReady reflects device readiness`() = runVmTest {
val notReady = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testProfileId
every { it.address } returns testProfileId
every { it.isAapReady } returns false
}
coEvery { deviceMonitor.getDeviceForProfile(testProfileId) } returns notReady
val vm = createViewModel()
vm.initialize(testProfileId)
vm.state.first().isAapReady shouldBe false
}
}
@@ -0,0 +1,153 @@
package eu.darken.capod.monitor.core.aap
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class StemConfigSenderTest : BaseTest() {
private val addressA = "AA:AA:AA:AA:AA:AA"
private val addressB = "BB:BB:BB:BB:BB:BB"
private fun profile(address: String, stemActions: StemActionsConfig) = AppleDeviceProfile(
label = "Test",
model = PodModel.AIRPODS_PRO2,
address = address,
stemActions = stemActions,
)
@Test
fun `sends per-profile mask to each AAP-ready device`() = runTest(UnconfinedTestDispatcher()) {
val allStates = MutableStateFlow<Map<String, AapPodState>>(
mapOf(
addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY),
addressB to AapPodState(connectionState = AapPodState.ConnectionState.READY),
)
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
// 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)),
)
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { profiles } returns profilesFlow
}
val sender = StemConfigSender(aapManager, profilesRepo)
val job = launch { sender.monitor().collect {} }
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x01)) }
coVerify(exactly = 1) { aapManager.sendCommand(addressB, AapCommand.SetStemConfig(0x08)) }
job.cancel()
}
@Test
fun `skips devices that are not AAP-ready`() = runTest(UnconfinedTestDispatcher()) {
val allStates = MutableStateFlow<Map<String, AapPodState>>(
mapOf(
addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY),
addressB to AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED),
)
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(
listOf(
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
)
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { profiles } returns profilesFlow
}
val sender = StemConfigSender(aapManager, profilesRepo)
val job = launch { sender.monitor().collect {} }
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x01)) }
coVerify(exactly = 0) { aapManager.sendCommand(addressB, any<AapCommand.SetStemConfig>()) }
job.cancel()
}
@Test
fun `all-NONE config sends mask 0x00`() = runTest(UnconfinedTestDispatcher()) {
val allStates = MutableStateFlow<Map<String, AapPodState>>(
mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY))
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { profiles } returns MutableStateFlow<List<DeviceProfile>>(
listOf(profile(addressA, StemActionsConfig()))
)
}
val sender = StemConfigSender(aapManager, profilesRepo)
val job = launch { sender.monitor().collect {} }
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x00)) }
job.cancel()
}
@Test
fun `all four press types contribute independent bits to the mask`() = runTest(UnconfinedTestDispatcher()) {
val allStates = MutableStateFlow<Map<String, AapPodState>>(
mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY))
)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { this@mockk.allStates } returns allStates
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { profiles } returns MutableStateFlow<List<DeviceProfile>>(
listOf(
profile(
addressA,
StemActionsConfig(
leftSingle = StemAction.PLAY_PAUSE,
rightDouble = StemAction.NEXT_TRACK,
leftTriple = StemAction.VOLUME_UP,
rightLong = StemAction.VOLUME_DOWN,
),
)
)
)
}
val sender = StemConfigSender(aapManager, profilesRepo)
val job = launch { sender.monitor().collect {} }
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.sendCommand(addressA, AapCommand.SetStemConfig(0x0F)) }
job.cancel()
}
}
@@ -0,0 +1,123 @@
package eu.darken.capod.monitor.core.aap
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.protocol.StemPressEvent
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class StemPressReactionTest : BaseTest() {
private val addressA = "AA:AA:AA:AA:AA:AA"
private val addressB = "BB:BB:BB:BB:BB:BB"
private fun profile(address: String, stemActions: StemActionsConfig) = AppleDeviceProfile(
label = "Test",
model = PodModel.AIRPODS_PRO2,
address = address,
stemActions = stemActions,
)
@Test
fun `resolves action per profile address`() = runTest(UnconfinedTestDispatcher()) {
val events = MutableSharedFlow<Pair<String, StemPressEvent>>(extraBufferCapacity = 8)
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
every { stemPressEvents } returns events
}
val profiles = listOf<DeviceProfile>(
profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)),
profile(addressB, StemActionsConfig(leftSingle = StemAction.NEXT_TRACK)),
)
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(profiles)
}
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()
events.emit(addressB to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
advanceUntilIdle()
coVerify(exactly = 1) { mediaControl.sendPlayPause() }
coVerify(exactly = 1) { mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT) }
job.cancel()
}
@Test
fun `ignores events from unknown address`() = 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.PLAY_PAUSE)))
)
}
val mediaControl = mockk<MediaControl>(relaxed = true)
val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl)
val job = launch { reaction.monitor().collect {} }
events.emit("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ" to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPlayPause() }
job.cancel()
}
@Test
fun `NO_ACTION 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
}
val profilesRepo = mockk<DeviceProfilesRepo>(relaxed = true) {
every { this@mockk.profiles } returns flowOf(
listOf<DeviceProfile>(
profile(
addressA,
StemActionsConfig(
leftSingle = StemAction.NONE,
rightSingle = StemAction.NO_ACTION,
),
)
)
)
}
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.SINGLE, StemPressEvent.Bud.RIGHT))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPlayPause() }
coVerify(exactly = 0) { mediaControl.sendKey(any()) }
job.cancel()
}
}