From ec087e4b07e92cd806a5fcffd75583478f09364a Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 17 Apr 2026 15:20:13 +0200 Subject: [PATCH] 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. --- .../eu/darken/capod/common/navigation/Nav.kt | 2 +- .../ui/devicesettings/DeviceSettingsScreen.kt | 97 ++++-- .../devicesettings/DeviceSettingsViewModel.kt | 60 ++-- .../ui/devicesettings/cards/ControlsCard.kt | 49 +-- .../devicesettings/cards/NoiseControlCard.kt | 8 +- .../CallControlSettings.kt | 8 +- .../PressControlsNavigation.kt} | 10 +- .../ui/presscontrols/PressControlsScreen.kt | 301 ++++++++++++++++++ .../presscontrols/PressControlsViewModel.kt | 211 ++++++++++++ .../PressHoldDurationSetting.kt | 4 +- .../ui/presscontrols/PressMappingsCard.kt | 278 ++++++++++++++++ .../PressSpeedSetting.kt | 4 +- .../ui/stemactions/StemActionConfigScreen.kt | 300 ----------------- .../stemactions/StemActionConfigViewModel.kt | 125 -------- .../monitor/core/aap/StemConfigSender.kt | 47 +-- .../monitor/core/aap/StemPressReaction.kt | 47 +-- .../capod/profiles/core/AppleDeviceProfile.kt | 2 + .../reaction/core/stem/StemActionSettings.kt | 37 --- .../reaction/core/stem/StemActionsConfig.kt | 18 ++ app/src/main/res/values/strings.xml | 43 +-- .../DeviceSettingsViewModelTest.kt | 56 ++-- .../PressControlsViewModelTest.kt | 281 ++++++++++++++++ .../monitor/core/aap/StemConfigSenderTest.kt | 153 +++++++++ .../monitor/core/aap/StemPressReactionTest.kt | 123 +++++++ 24 files changed, 1592 insertions(+), 672 deletions(-) rename app/src/main/java/eu/darken/capod/main/ui/{devicesettings/components => presscontrols}/CallControlSettings.kt (95%) rename app/src/main/java/eu/darken/capod/main/ui/{stemactions/StemActionConfigNavigation.kt => presscontrols/PressControlsNavigation.kt} (65%) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt rename app/src/main/java/eu/darken/capod/main/ui/{devicesettings/components => presscontrols}/PressHoldDurationSetting.kt (92%) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt rename app/src/main/java/eu/darken/capod/main/ui/{devicesettings/components => presscontrols}/PressSpeedSetting.kt (92%) delete mode 100644 app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt delete mode 100644 app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt delete mode 100644 app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt index a9429a0f..f7b6dba6 100644 --- a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt +++ b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index fa20f950..50f26eb7 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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 { 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, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index a4c18e41..2f8fe8ec 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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 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 + val stemActions = profiles.filterIsInstance() + .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. diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt index fdcc9907..c3ba5590 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt @@ -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, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt index 54f8af62..7db62955 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt @@ -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)) } }, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/CallControlSettings.kt similarity index 95% rename from app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt rename to app/src/main/java/eu/darken/capod/main/ui/presscontrols/CallControlSettings.kt index ddc3cdd1..9c6309ee 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/CallControlSettings.kt @@ -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), diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsNavigation.kt similarity index 65% rename from app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt rename to app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsNavigation.kt index 35de43e0..c81f2a1f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsNavigation.kt @@ -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.setup() { - entry { - StemActionConfigScreenHost() + entry { 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 } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt new file mode 100644 index 00000000..af4e0878 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsScreen.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt new file mode 100644 index 00000000..80a9635c --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt @@ -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(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() + + 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().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 = + deviceMonitor.devices.flatMapLatest { devices -> + val live = devices.firstOrNull { it.profileId == profileId } + flow { 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() + .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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressHoldDurationSetting.kt similarity index 92% rename from app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt rename to app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressHoldDurationSetting.kt index d49469af..c808392a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressHoldDurationSetting.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt new file mode 100644 index 00000000..54324909 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressMappingsCard.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressSpeedSetting.kt similarity index 92% rename from app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt rename to app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressSpeedSetting.kt index 5ec7d6f6..f210b41c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressSpeedSetting.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt deleted file mode 100644 index c80ebd57..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt +++ /dev/null @@ -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) -} diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt deleted file mode 100644 index 29602404..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt +++ /dev/null @@ -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, - ) { - 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") - } -} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt index 47587355..6bbde5ca 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt @@ -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 = 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() - .firstOrNull { it.address == address } - if (profile != null && profile.model.features.hasStemConfig) { - address to mask - } else { - null - } - } + .map { (address, _) -> address } + .toSet() + profiles + .filterIsInstance() + .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 = 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 { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt index 96e10017..31e7b545 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt @@ -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 = 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() + .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 diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index 34403f64..ebc4c506 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt deleted file mode 100644 index da76dbbd..00000000 --- a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt +++ /dev/null @@ -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 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() } - } -} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt new file mode 100644 index 00000000..a541bd17 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionsConfig.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a3a14a8..92cd6bc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -542,26 +542,27 @@ Noise Control Equalizer - - Stem Actions - Map stem presses to Android actions - 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. - Single Press - Double Press - Triple Press - Long Press - Assigning a long-press action also disables cycling Noise Control modes (e.g. Noise Cancellation, Transparency) by holding the stem on the AirPods. - Left - Right - Default - No Action - Play/Pause - Next Track - Previous Track - Volume Up - Volume Down - Reset to defaults - Reset all stem actions to defaults? - Open Stem Actions + + Press Controls + Tune press behaviour and map presses to actions + 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. + Single Press + Double Press + Triple Press + Long Press + Assigning a long-press action also disables cycling Noise Control modes (e.g. Noise Cancellation, Transparency) by pressing and holding on the AirPods. + Press Mappings + Left + Right + Default + No Action + Play/Pause + Next Track + Previous Track + Volume Up + Volume Down + Reset to defaults + Reset all press mappings to defaults? + Open Press Controls diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index dd03eff2..ede74e0d 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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 - private lateinit var stemActionSettings: StemActionSettings - private lateinit var fakeLeftLongStemAction: FakeDataStoreValue - private lateinit var fakeRightLongStemAction: FakeDataStoreValue private val timeSource: TimeSource = TestTimeSource() private lateinit var devicesFlow: MutableStateFlow> private lateinit var upgradeInfoFlow: MutableStateFlow private lateinit var connectedDevicesFlow: MutableStateFlow> + private lateinit var profilesFlow: MutableStateFlow> private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow 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().also { every { it.monitorMode } returns fakeMonitorMode.mock } - fakeLeftLongStemAction = FakeDataStoreValue(StemAction.NONE) - fakeRightLongStemAction = FakeDataStoreValue(StemAction.NONE) - stemActionSettings = mockk().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 { diff --git a/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt new file mode 100644 index 00000000..f6e41d02 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt @@ -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> + private lateinit var devicesFlow: MutableStateFlow> + private lateinit var upgradeInfoFlow: MutableStateFlow + + 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(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(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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + 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(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 + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt new file mode 100644 index 00000000..28f596d2 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemConfigSenderTest.kt @@ -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>( + mapOf( + addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY), + addressB to AapPodState(connectionState = AapPodState.ConnectionState.READY), + ) + ) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + // A has SINGLE only → mask 0x01. B has LONG only → mask 0x08. + val profilesFlow = MutableStateFlow>( + listOf( + profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)), + profile(addressB, StemActionsConfig(rightLong = StemAction.VOLUME_UP)), + ) + ) + val profilesRepo = mockk(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>( + mapOf( + addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY), + addressB to AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED), + ) + ) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profilesFlow = MutableStateFlow>( + listOf( + profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)), + profile(addressB, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)), + ) + ) + val profilesRepo = mockk(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()) } + + job.cancel() + } + + @Test + fun `all-NONE config sends mask 0x00`() = runTest(UnconfinedTestDispatcher()) { + val allStates = MutableStateFlow>( + mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY)) + ) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profilesRepo = mockk(relaxed = true) { + every { profiles } returns MutableStateFlow>( + 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>( + mapOf(addressA to AapPodState(connectionState = AapPodState.ConnectionState.READY)) + ) + val aapManager = mockk(relaxed = true) { + every { this@mockk.allStates } returns allStates + } + val profilesRepo = mockk(relaxed = true) { + every { profiles } returns MutableStateFlow>( + 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() + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt new file mode 100644 index 00000000..4e027c33 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/aap/StemPressReactionTest.kt @@ -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>(extraBufferCapacity = 8) + val aapManager = mockk(relaxed = true) { + every { stemPressEvents } returns events + } + val profiles = listOf( + profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE)), + profile(addressB, StemActionsConfig(leftSingle = StemAction.NEXT_TRACK)), + ) + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf(profiles) + } + val mediaControl = mockk(relaxed = true) + val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl) + + val job = launch { reaction.monitor().collect {} } + + events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT)) + advanceUntilIdle() + 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>(extraBufferCapacity = 8) + val aapManager = mockk(relaxed = true) { + every { stemPressEvents } returns events + } + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf( + listOf(profile(addressA, StemActionsConfig(leftSingle = StemAction.PLAY_PAUSE))) + ) + } + val mediaControl = mockk(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>(extraBufferCapacity = 8) + val aapManager = mockk(relaxed = true) { + every { stemPressEvents } returns events + } + val profilesRepo = mockk(relaxed = true) { + every { this@mockk.profiles } returns flowOf( + listOf( + profile( + addressA, + StemActionsConfig( + leftSingle = StemAction.NONE, + rightSingle = StemAction.NO_ACTION, + ), + ) + ) + ) + } + val mediaControl = mockk(relaxed = true) + val reaction = StemPressReaction(aapManager, profilesRepo, mediaControl) + + val job = launch { reaction.monitor().collect {} } + + events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT)) + events.emit(addressA to StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.RIGHT)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPlayPause() } + coVerify(exactly = 0) { mediaControl.sendKey(any()) } + + job.cancel() + } +}