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