From adfedc1cce4fdc1561aed96e6507500eaa04b449 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 3 Apr 2026 13:03:09 +0200 Subject: [PATCH] feat: Add device settings screen for AAP controls --- .../eu/darken/capod/common/navigation/Nav.kt | 3 + .../common/settings/SettingsSliderItem.kt | 2 + .../DeviceSettingsNavigation.kt | 28 + .../ui/devicesettings/DeviceSettingsScreen.kt | 565 ++++++++++++++++++ .../devicesettings/DeviceSettingsViewModel.kt | 87 +++ .../capod/main/ui/overview/OverviewScreen.kt | 24 +- .../main/ui/overview/OverviewViewModel.kt | 5 + .../main/ui/overview/cards/DualPodsCard.kt | 14 + .../ui/overview/cards/PodCardComponents.kt | 5 +- .../main/ui/overview/cards/SinglePodsCard.kt | 14 + .../eu/darken/capod/monitor/core/PodDevice.kt | 18 + .../monitor/core/cache/CachedDeviceState.kt | 16 + .../core/cache/DeviceStateCacheExtensions.kt | 8 + .../pods/core/apple/aap/AapConnection.kt | 54 ++ app/src/main/res/values/strings.xml | 41 ++ 15 files changed, 876 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsNavigation.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt diff --git a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt index 0b0a3c18..81fffbb8 100644 --- a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt +++ b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt @@ -21,6 +21,9 @@ object Nav { @Serializable data object Upgrade : Main + + @Serializable + data class DeviceSettings(val address: String) : Main } sealed interface Settings : NavigationDestination { diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt index efed7c66..3bfff43f 100644 --- a/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt @@ -26,6 +26,7 @@ fun SettingsSliderItem( valueRange: ClosedFloatingPointRange = 0f..1f, steps: Int = 0, enabled: Boolean = true, + onValueChangeFinished: (() -> Unit)? = null, valueLabel: ((Float) -> String)? = null, ) { Column(modifier = modifier.fillMaxWidth()) { @@ -49,6 +50,7 @@ fun SettingsSliderItem( Slider( value = value, onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished, valueRange = valueRange, steps = steps, enabled = enabled, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsNavigation.kt new file mode 100644 index 00000000..166bae2e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsNavigation.kt @@ -0,0 +1,28 @@ +package eu.darken.capod.main.ui.devicesettings + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import eu.darken.capod.common.navigation.Nav +import eu.darken.capod.common.navigation.NavigationEntry +import javax.inject.Inject + +class DeviceSettingsNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { key -> + DeviceSettingsScreenHost(address = key.address) + } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt new file mode 100644 index 00000000..7290b585 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -0,0 +1,565 @@ +package eu.darken.capod.main.ui.devicesettings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.automirrored.twotone.VolumeUp +import androidx.compose.material.icons.twotone.GraphicEq +import androidx.compose.material.icons.twotone.Headphones +import androidx.compose.material.icons.twotone.Hearing +import androidx.compose.material.icons.twotone.Speed +import androidx.compose.material.icons.twotone.Swipe +import androidx.compose.material.icons.twotone.Timer +import androidx.compose.material.icons.twotone.TouchApp +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.error.ErrorEventHandler +import eu.darken.capod.common.navigation.NavigationEventHandler +import eu.darken.capod.common.settings.SettingsCategoryHeader +import eu.darken.capod.common.settings.SettingsSliderItem +import eu.darken.capod.common.settings.SettingsSwitchItem +import eu.darken.capod.main.ui.overview.cards.AncModeSelector +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +@Composable +fun DeviceSettingsScreenHost( + address: String, + vm: DeviceSettingsViewModel = hiltViewModel(), +) { + ErrorEventHandler(vm) + NavigationEventHandler(vm) + + LaunchedEffect(address) { vm.initialize(address) } + + val state by vm.state.collectAsStateWithLifecycle(initialValue = null) + val currentState = state ?: return + + DeviceSettingsScreen( + state = currentState, + onNavigateUp = { vm.navUp() }, + onAncModeChange = { vm.setAncMode(it) }, + onConversationalAwarenessChange = { vm.setConversationalAwareness(it) }, + onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) }, + 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) }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceSettingsScreen( + state: DeviceSettingsViewModel.State, + onNavigateUp: () -> Unit, + onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {}, + onConversationalAwarenessChange: (Boolean) -> Unit = {}, + onNcWithOneAirPodChange: (Boolean) -> Unit = {}, + 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 = { _, _ -> }, +) { + val device = state.device + val features = device?.model?.features + val enabled = device?.isAapReady == true + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.device_settings_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues), + ) { + // Device Info + val deviceInfo = device?.deviceInfo + if (deviceInfo != null) { + item("device_info") { + DeviceInfoCard(deviceInfo = deviceInfo) + } + } + + // Sound section — only show when AAP is connected (settings come from AAP) + if (features != null && device.isAapConnected) { + item("sound_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_sound_label)) + } + + val ancMode = device.ancMode + if (features.hasAncControl && ancMode != null) { + item("anc_mode") { + AncModeSelector( + currentMode = ancMode.current, + supportedModes = ancMode.supported, + onModeSelected = onAncModeChange, + pendingMode = device.pendingAncMode, + modifier = Modifier.padding(horizontal = 16.dp), + enabled = enabled, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + val convAwareness = device.conversationalAwareness + if (features.hasConversationAwareness && convAwareness != null) { + item("conversation_awareness") { + SettingsSwitchItem( + icon = Icons.TwoTone.Hearing, + title = stringResource(R.string.conversation_awareness_label), + subtitle = stringResource(R.string.device_settings_conversation_awareness_description), + checked = convAwareness.enabled, + onCheckedChange = onConversationalAwarenessChange, + enabled = enabled, + ) + } + } + + val ncOneAirpod = device.ncWithOneAirPod + if (features.hasNcOneAirpod && ncOneAirpod != null) { + item("nc_one_airpod") { + SettingsSwitchItem( + icon = Icons.TwoTone.Headphones, + title = stringResource(R.string.device_settings_nc_one_airpod_label), + subtitle = stringResource(R.string.device_settings_nc_one_airpod_description), + checked = ncOneAirpod.enabled, + onCheckedChange = onNcWithOneAirPodChange, + enabled = enabled, + ) + } + } + + val personalizedVol = device.personalizedVolume + if (features.hasPersonalizedVolume && personalizedVol != null) { + item("personalized_volume") { + SettingsSwitchItem( + icon = Icons.AutoMirrored.TwoTone.VolumeUp, + title = stringResource(R.string.device_settings_personalized_volume_label), + subtitle = stringResource(R.string.device_settings_personalized_volume_description), + checked = personalizedVol.enabled, + onCheckedChange = onPersonalizedVolumeChange, + enabled = enabled, + ) + } + } + + val toneVol = device.toneVolume + if (features.hasToneVolume && toneVol != null) { + item("tone_volume") { + ToneVolumeSlider( + level = toneVol.level, + onLevelChange = onToneVolumeChange, + enabled = enabled, + ) + } + } + + val adaptiveNoise = device.adaptiveAudioNoise + if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) { + item("adaptive_noise") { + AdaptiveNoiseSlider( + level = adaptiveNoise.level, + onLevelChange = onAdaptiveAudioNoiseChange, + enabled = enabled, + ) + } + } + + // Controls section + item("controls_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_controls_label)) + } + + val pressSpd = device.pressSpeed + if (features.hasPressSpeed && pressSpd != null) { + item("press_speed") { + SegmentedSettingRow( + icon = Icons.TwoTone.Speed, + title = stringResource(R.string.device_settings_press_speed_label), + subtitle = stringResource(R.string.device_settings_press_speed_description), + options = listOf( + stringResource(R.string.device_settings_press_speed_default) to AapSetting.PressSpeed.Value.DEFAULT, + stringResource(R.string.device_settings_press_speed_slower) to AapSetting.PressSpeed.Value.SLOWER, + stringResource(R.string.device_settings_press_speed_slowest) to AapSetting.PressSpeed.Value.SLOWEST, + ), + selected = pressSpd.value, + onSelected = onPressSpeedChange, + enabled = enabled, + ) + } + } + + val pressHold = device.pressHoldDuration + if (features.hasPressHoldDuration && pressHold != null) { + item("press_hold") { + SegmentedSettingRow( + icon = Icons.TwoTone.Timer, + title = stringResource(R.string.device_settings_press_hold_label), + subtitle = stringResource(R.string.device_settings_press_hold_description), + options = listOf( + stringResource(R.string.device_settings_press_hold_default) to AapSetting.PressHoldDuration.Value.DEFAULT, + stringResource(R.string.device_settings_press_hold_shorter) to AapSetting.PressHoldDuration.Value.SHORTER, + stringResource(R.string.device_settings_press_hold_shortest) to AapSetting.PressHoldDuration.Value.SHORTEST, + ), + selected = pressHold.value, + onSelected = onPressHoldDurationChange, + enabled = enabled, + ) + } + } + + val volSwipe = device.volumeSwipe + if (features.hasVolumeSwipe && volSwipe != null) { + item("volume_swipe") { + SettingsSwitchItem( + icon = Icons.TwoTone.Swipe, + title = stringResource(R.string.device_settings_volume_swipe_label), + subtitle = stringResource(R.string.device_settings_volume_swipe_description), + checked = volSwipe.enabled, + onCheckedChange = onVolumeSwipeChange, + enabled = enabled, + ) + } + } + + val volSwipeLen = device.volumeSwipeLength + if (features.hasVolumeSwipeLength && volSwipeLen != null) { + item("volume_swipe_length") { + SegmentedSettingRow( + icon = Icons.TwoTone.Swipe, + title = stringResource(R.string.device_settings_volume_swipe_length_label), + subtitle = stringResource(R.string.device_settings_volume_swipe_length_description), + options = listOf( + stringResource(R.string.device_settings_volume_swipe_length_default) to AapSetting.VolumeSwipeLength.Value.DEFAULT, + stringResource(R.string.device_settings_volume_swipe_length_longer) to AapSetting.VolumeSwipeLength.Value.LONGER, + stringResource(R.string.device_settings_volume_swipe_length_longest) to AapSetting.VolumeSwipeLength.Value.LONGEST, + ), + selected = volSwipeLen.value, + onSelected = onVolumeSwipeLengthChange, + enabled = enabled, + ) + } + } + + val endCallMuteMic = device.endCallMuteMic + if (features.hasEndCallMuteMic && endCallMuteMic != null) { + item("end_call_mute_mic") { + EndCallMuteMicControl( + current = endCallMuteMic, + onChange = onEndCallMuteMicChange, + enabled = enabled, + ) + } + } + } + + item("bottom_spacer") { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun DeviceInfoCard(deviceInfo: AapDeviceInfo) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (deviceInfo.name.isNotBlank()) { + InfoRow( + label = stringResource(R.string.device_settings_info_name_label), + value = deviceInfo.name, + ) + } + if (deviceInfo.serialNumber.isNotBlank()) { + InfoRow( + label = stringResource(R.string.device_settings_info_serial_label), + value = deviceInfo.serialNumber, + ) + } + if (deviceInfo.firmwareVersion.isNotBlank()) { + InfoRow( + label = stringResource(R.string.device_settings_info_firmware_label), + value = deviceInfo.firmwareVersion, + ) + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(120.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun ToneVolumeSlider( + level: Int, + onLevelChange: (Int) -> Unit, + enabled: Boolean, +) { + var sliderValue by remember(level) { mutableIntStateOf(level) } + SettingsSliderItem( + icon = Icons.AutoMirrored.TwoTone.VolumeUp, + title = stringResource(R.string.device_settings_tone_volume_label), + subtitle = stringResource(R.string.device_settings_tone_volume_description), + value = sliderValue.toFloat(), + onValueChange = { sliderValue = it.toInt() }, + onValueChangeFinished = { onLevelChange(sliderValue) }, + valueRange = 15f..100f, + steps = 84, + enabled = enabled, + valueLabel = { "${it.toInt()}%" }, + ) +} + +@Composable +private fun AdaptiveNoiseSlider( + level: Int, + onLevelChange: (Int) -> Unit, + enabled: Boolean, +) { + var sliderValue by remember(level) { mutableIntStateOf(level) } + SettingsSliderItem( + icon = Icons.TwoTone.GraphicEq, + title = stringResource(R.string.device_settings_adaptive_noise_label), + subtitle = stringResource(R.string.device_settings_adaptive_noise_description), + value = sliderValue.toFloat(), + onValueChange = { sliderValue = it.toInt() }, + onValueChangeFinished = { onLevelChange(sliderValue) }, + valueRange = 0f..100f, + steps = 99, + enabled = enabled, + valueLabel = { "${it.toInt()}%" }, + ) +} + +@Composable +private fun SegmentedSettingRow( + icon: ImageVector, + title: String, + options: List>, + selected: T, + onSelected: (T) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + modifier = Modifier.padding(end = 16.dp, top = 4.dp), + ) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f), + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f), + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, (label, value) -> + SegmentedButton( + selected = value == selected, + onClick = { if (enabled) onSelected(value) }, + enabled = enabled, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + label = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + } +} + +@Composable +private fun EndCallMuteMicControl( + current: AapSetting.EndCallMuteMic, + onChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit, + enabled: Boolean, +) { + val optionASelected = current.endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = Icons.TwoTone.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + modifier = Modifier.padding(end = 16.dp, top = 4.dp), + ) + Column { + Text( + text = stringResource(R.string.device_settings_end_call_mute_mic_label), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f), + ) + Text( + text = stringResource(R.string.device_settings_end_call_mute_mic_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + 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), + selected = optionASelected, + enabled = enabled, + onClick = { + onChange( + AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, + ) + }, + ) + + CallControlOption( + title = stringResource(R.string.device_settings_end_call_mute_mic_option_b_title), + subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_b_subtitle), + selected = !optionASelected, + enabled = enabled, + onClick = { + onChange( + AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, + ) + }, + ) + } +} + +@Composable +private fun CallControlOption( + title: String, + subtitle: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 4.dp), + ) { + RadioButton( + selected = selected, + onClick = onClick, + enabled = enabled, + ) + Column(modifier = Modifier.padding(start = 4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f), + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f), + ) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt new file mode 100644 index 00000000..5ee48ff2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -0,0 +1,87 @@ +package eu.darken.capod.main.ui.devicesettings + +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.coroutine.DispatcherProvider +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.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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class DeviceSettingsViewModel @Inject constructor( + dispatcherProvider: DispatcherProvider, + private val deviceMonitor: DeviceMonitor, + private val aapManager: AapConnectionManager, +) : ViewModel4(dispatcherProvider) { + + private val targetAddress = MutableStateFlow(null) + private var initialized = false + + fun initialize(address: BluetoothAddress) { + if (initialized && targetAddress.value == address) return + initialized = true + targetAddress.value = address + } + + val state = targetAddress.flatMapLatest { address -> + if (address == null) return@flatMapLatest flowOf(State(device = null)) + deviceMonitor.devices.map { devices -> + State(device = devices.firstOrNull { it.address == address }) + } + }.asLiveState() + + data class State( + val device: PodDevice?, + ) + + private fun send(command: AapCommand) { + val address = targetAddress.value ?: return + launch { + try { + aapManager.sendCommand(address, command) + log(TAG) { "Sent $command to $address" } + } catch (e: Exception) { + log(TAG) { "Failed to send $command: ${e.message}" } + } + } + } + + fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode)) + + fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled)) + + fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled)) + + fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled)) + + fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level)) + + 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)) + + companion object { + private val TAG = logTag("DeviceSettings", "VM") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index f0330987..e37b13ec 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -131,6 +131,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { onToggleUnmatched = { vm.toggleUnmatchedDevices() }, onAncModeChange = { device, mode -> vm.setAncMode(device, mode) }, onConversationAwarenessChange = { device, enabled -> vm.setConversationalAwareness(device, enabled) }, + onDeviceSettings = { device -> vm.goToDeviceSettings(device) }, ) } @@ -144,6 +145,7 @@ fun OverviewScreen( onToggleUnmatched: () -> Unit, onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> }, onConversationAwarenessChange: (PodDevice, Boolean) -> Unit = { _, _ -> }, + onDeviceSettings: (PodDevice) -> Unit = {}, ) { Scaffold( topBar = { @@ -234,6 +236,7 @@ fun OverviewScreen( now = state.now, onAncModeChange = { mode -> onAncModeChange(device, mode) }, onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) }, + onDeviceSettings = { onDeviceSettings(device) }, ) } @@ -271,12 +274,13 @@ fun OverviewScreen( key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" }, ) { device -> PodDeviceCard( - device = device, - showDebug = state.isDebugMode, - now = state.now, - onAncModeChange = { mode -> onAncModeChange(device, mode) }, - onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) }, - ) + device = device, + showDebug = state.isDebugMode, + now = state.now, + onAncModeChange = { mode -> onAncModeChange(device, mode) }, + onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) }, + onDeviceSettings = { onDeviceSettings(device) }, + ) } } } @@ -292,14 +296,20 @@ private fun PodDeviceCard( now: Instant, onAncModeChange: (AapSetting.AncMode.Value) -> Unit, onConversationAwarenessChange: (Boolean) -> Unit = {}, + onDeviceSettings: (() -> Unit)? = null, ) { when { device.hasDualPods -> DualPodsCard( device = device, showDebug = showDebug, now = now, onAncModeChange = onAncModeChange, onConversationAwarenessChange = onConversationAwarenessChange, + onDeviceSettings = onDeviceSettings, + ) + device.model != PodModel.UNKNOWN -> SinglePodsCard( + device = device, showDebug = showDebug, now = now, + onAncModeChange = onAncModeChange, + onDeviceSettings = onDeviceSettings, ) - device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now, onAncModeChange = onAncModeChange) else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 5ec1134f..37e96a1c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -153,6 +153,11 @@ class OverviewViewModel @Inject constructor( navTo(Nav.Main.DeviceManager) } + fun goToDeviceSettings(device: PodDevice) { + val address = device.address ?: return + navTo(Nav.Main.DeviceSettings(address)) + } + fun onUpgrade() { navTo(Nav.Main.Upgrade) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 1ddfe2d1..f9f7af26 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -20,10 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.twotone.BatteryChargingFull import androidx.compose.material.icons.twotone.GridView +import androidx.compose.material.icons.twotone.Tune import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -65,6 +68,7 @@ fun DualPodsCard( now: Instant, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null, onConversationAwarenessChange: ((Boolean) -> Unit)? = null, + onDeviceSettings: (() -> Unit)? = null, ) { val context = LocalContext.current @@ -126,6 +130,16 @@ fun DualPodsCard( isAapConnected = device.isAapConnected, isLive = device.isLive, ) + + if (device.address != null && onDeviceSettings != null) { + IconButton(onClick = onDeviceSettings) { + Icon( + imageVector = Icons.TwoTone.Tune, + contentDescription = stringResource(R.string.device_settings_open_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index 07179062..68df26f7 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -248,13 +248,16 @@ fun AncModeSelector( supportedModes: List, onModeSelected: (AapSetting.AncMode.Value) -> Unit, pendingMode: AapSetting.AncMode.Value? = null, + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { val displayMode = pendingMode ?: currentMode - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { supportedModes.forEachIndexed { index, mode -> SegmentedButton( selected = mode == displayMode, onClick = { onModeSelected(mode) }, + enabled = enabled, shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size), colors = if (pendingMode != null && mode == displayMode) { SegmentedButtonDefaults.colors( diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 6cd9fddd..91851f86 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -21,9 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.twotone.BatteryChargingFull import androidx.compose.material.icons.twotone.Hearing +import androidx.compose.material.icons.twotone.Tune import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -59,6 +62,7 @@ fun SinglePodsCard( showDebug: Boolean, now: Instant, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null, + onDeviceSettings: (() -> Unit)? = null, ) { val context = LocalContext.current @@ -123,6 +127,16 @@ fun SinglePodsCard( isAapConnected = device.isAapConnected, isLive = device.isLive, ) + + if (device.address != null && onDeviceSettings != null) { + IconButton(onClick = onDeviceSettings) { + Icon( + imageVector = Icons.TwoTone.Tune, + contentDescription = stringResource(R.string.device_settings_open_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index fadb3e59..8e873a23 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -7,6 +7,7 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.monitor.core.cache.CachedDeviceState 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.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot @@ -234,8 +235,25 @@ data class PodDevice( val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise? get() = aap?.setting() + val pressSpeed: AapSetting.PressSpeed? + get() = aap?.setting() + + val pressHoldDuration: AapSetting.PressHoldDuration? + get() = aap?.setting() + + val volumeSwipeLength: AapSetting.VolumeSwipeLength? + get() = aap?.setting() + + val endCallMuteMic: AapSetting.EndCallMuteMic? + get() = aap?.setting() + + val deviceInfo: AapDeviceInfo? + get() = aap?.deviceInfo ?: cached?.deviceInfo + val isAapConnected: Boolean get() = aap != null + val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY + val bleKeyState: BleKeyState get() { val applePod = ble as? ApplePods ?: return BleKeyState.NONE diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt index c07e99f3..e6c15682 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt @@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core.cache import eu.darken.capod.common.serialization.InstantEpochMillisSerializer import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.Instant @@ -19,9 +20,24 @@ data class CachedDeviceState( @SerialName("isRightCharging") val isRightCharging: Boolean? = null, @SerialName("isCaseCharging") val isCaseCharging: Boolean? = null, @SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null, + @SerialName("deviceName") val deviceName: String? = null, + @SerialName("serialNumber") val serialNumber: String? = null, + @SerialName("firmwareVersion") val firmwareVersion: String? = null, @Serializable(with = InstantEpochMillisSerializer::class) @SerialName("lastSeenAt") val lastSeenAt: Instant, ) { + val deviceInfo: AapDeviceInfo? + get() { + if (deviceName == null && serialNumber == null && firmwareVersion == null) return null + return AapDeviceInfo( + name = deviceName ?: "", + modelNumber = "", + manufacturer = "", + serialNumber = serialNumber ?: "", + firmwareVersion = firmwareVersion ?: "", + ) + } + @Serializable data class CachedBatterySlot( @SerialName("percent") val percent: Float, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt index 617a3531..08121fcf 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -30,6 +30,8 @@ fun PodDevice.toCachedState( if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null + val liveDeviceInfo = aap?.deviceInfo + val newState = CachedDeviceState( profileId = pid, model = model, @@ -42,6 +44,9 @@ fun PodDevice.toCachedState( isRightCharging = isRightPodCharging, isCaseCharging = isCaseCharging, isHeadsetCharging = isHeadsetBeingCharged, + deviceName = liveDeviceInfo?.name ?: existing?.deviceName, + serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber, + firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, lastSeenAt = seenLastAt ?: now, ) @@ -60,4 +65,7 @@ private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boo || old.isRightCharging != new.isRightCharging || old.isCaseCharging != new.isCaseCharging || old.isHeadsetCharging != new.isHeadsetCharging + || old.deviceName != new.deviceName + || old.serialNumber != new.serialNumber + || old.firmwareVersion != new.firmwareVersion } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index aeac7011..bb0aff74 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -8,6 +8,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag +import kotlin.reflect.KClass import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer @@ -159,9 +160,62 @@ internal class AapConnection( } } + applyOptimisticUpdate(currentState, command) sendRaw(command) } + /** + * Optimistically update state for non-ANC settings so the UI reflects the change immediately. + * ANC is handled separately in [send] due to ear-detection gating. + * If the setting hasn't been reported by the device yet, skip (UI wouldn't show the toggle). + */ + private fun applyOptimisticUpdate(baseState: AapPodState, command: AapCommand) { + val updated: Pair, AapSetting> = when (command) { + is AapCommand.SetAncMode -> return // Handled in send() + is AapCommand.SetConversationalAwareness -> { + val cur = baseState.setting() ?: return + AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetNcWithOneAirPod -> { + val cur = baseState.setting() ?: return + AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetVolumeSwipe -> { + val cur = baseState.setting() ?: return + AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetPersonalizedVolume -> { + val cur = baseState.setting() ?: return + AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetToneVolume -> { + baseState.setting() ?: return + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level) + } + is AapCommand.SetAdaptiveAudioNoise -> { + baseState.setting() ?: return + AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level) + } + is AapCommand.SetPressSpeed -> { + baseState.setting() ?: return + AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value) + } + is AapCommand.SetPressHoldDuration -> { + baseState.setting() ?: return + AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value) + } + is AapCommand.SetVolumeSwipeLength -> { + baseState.setting() ?: return + AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value) + } + is AapCommand.SetEndCallMuteMic -> { + baseState.setting() ?: return + AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall) + } + } + _state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now()) + } + private suspend fun sendRaw(command: AapCommand) { val bytes = profile.encodeCommand(command) writeMutex.withLock { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44347e83..4be263a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -420,4 +420,45 @@ Email sent? Was the email sent successfully? The attached debug log will be deleted. + + Device Settings + Name + Serial Number + Firmware + Sound + Controls + Noise Cancellation with One AirPod + Keep noise cancellation active with only one AirPod + Personalized Volume + Adjust media volume based on environment + Lower media volume and reduce noise when someone speaks to you + Chime Volume + Volume of confirmation sounds and ringtones + Adaptive Audio Noise + How much environmental noise is allowed through + Press Speed + How quickly you need to press for multi-press gestures + Default + Slower + Slowest + Press and Hold Duration + How long to press and hold to switch noise control modes + Default + Shorter + Shortest + Volume Swipe + Swipe to adjust volume + Volume Swipe Length + How far to swipe on the stem to change volume + Default + Longer + Longest + Call Controls + Choose what pressing the stem does during calls + Single press to end call + Double press to mute microphone + Single press to mute microphone + Double press to end call + Open device settings + \ No newline at end of file