feat: Add device settings screen for AAP controls

This commit is contained in:
darken
2026-04-03 13:09:31 +02:00
committed by Matthias Urhahn
parent c609b03148
commit adfedc1cce
15 changed files with 876 additions and 8 deletions
@@ -21,6 +21,9 @@ object Nav {
@Serializable
data object Upgrade : Main
@Serializable
data class DeviceSettings(val address: String) : Main
}
sealed interface Settings : NavigationDestination {
@@ -26,6 +26,7 @@ fun SettingsSliderItem(
valueRange: ClosedFloatingPointRange<Float> = 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,
@@ -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<NavKey>.setup() {
entry<Nav.Main.DeviceSettings> { key ->
DeviceSettingsScreenHost(address = key.address)
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry
}
}
@@ -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 <T> SegmentedSettingRow(
icon: ImageVector,
title: String,
options: List<Pair<String, T>>,
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),
)
}
}
}
@@ -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<BluetoothAddress?>(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")
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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))
@@ -248,13 +248,16 @@ fun AncModeSelector(
supportedModes: List<AapSetting.AncMode.Value>,
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(
@@ -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))
@@ -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
@@ -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,
@@ -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
}
@@ -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<KClass<out AapSetting>, AapSetting> = when (command) {
is AapCommand.SetAncMode -> return // Handled in send()
is AapCommand.SetConversationalAwareness -> {
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
}
is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
}
is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
}
is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
}
is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: 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 {
+41
View File
@@ -420,4 +420,45 @@
<string name="support_contact_sent_title">Email sent?</string>
<string name="support_contact_sent_message">Was the email sent successfully? The attached debug log will be deleted.</string>
<!-- Device Settings -->
<string name="device_settings_title">Device Settings</string>
<string name="device_settings_info_name_label">Name</string>
<string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_category_sound_label">Sound</string>
<string name="device_settings_category_controls_label">Controls</string>
<string name="device_settings_nc_one_airpod_label">Noise Cancellation with One AirPod</string>
<string name="device_settings_nc_one_airpod_description">Keep noise cancellation active with only one AirPod</string>
<string name="device_settings_personalized_volume_label">Personalized Volume</string>
<string name="device_settings_personalized_volume_description">Adjust media volume based on environment</string>
<string name="device_settings_conversation_awareness_description">Lower media volume and reduce noise when someone speaks to you</string>
<string name="device_settings_tone_volume_label">Chime Volume</string>
<string name="device_settings_tone_volume_description">Volume of confirmation sounds and ringtones</string>
<string name="device_settings_adaptive_noise_label">Adaptive Audio Noise</string>
<string name="device_settings_adaptive_noise_description">How much environmental noise is allowed through</string>
<string name="device_settings_press_speed_label">Press Speed</string>
<string name="device_settings_press_speed_description">How quickly you need to press for multi-press gestures</string>
<string name="device_settings_press_speed_default">Default</string>
<string name="device_settings_press_speed_slower">Slower</string>
<string name="device_settings_press_speed_slowest">Slowest</string>
<string name="device_settings_press_hold_label">Press and Hold Duration</string>
<string name="device_settings_press_hold_description">How long to press and hold to switch noise control modes</string>
<string name="device_settings_press_hold_default">Default</string>
<string name="device_settings_press_hold_shorter">Shorter</string>
<string name="device_settings_press_hold_shortest">Shortest</string>
<string name="device_settings_volume_swipe_label">Volume Swipe</string>
<string name="device_settings_volume_swipe_description">Swipe to adjust volume</string>
<string name="device_settings_volume_swipe_length_label">Volume Swipe Length</string>
<string name="device_settings_volume_swipe_length_description">How far to swipe on the stem to change volume</string>
<string name="device_settings_volume_swipe_length_default">Default</string>
<string name="device_settings_volume_swipe_length_longer">Longer</string>
<string name="device_settings_volume_swipe_length_longest">Longest</string>
<string name="device_settings_end_call_mute_mic_label">Call Controls</string>
<string name="device_settings_end_call_mute_mic_description">Choose what pressing the stem does during calls</string>
<string name="device_settings_end_call_mute_mic_option_a_title">Single press to end call</string>
<string name="device_settings_end_call_mute_mic_option_a_subtitle">Double press to mute microphone</string>
<string name="device_settings_end_call_mute_mic_option_b_title">Single press to mute microphone</string>
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double press to end call</string>
<string name="device_settings_open_cd">Open device settings</string>
</resources>