diff --git a/.gitignore b/.gitignore index e991220f..0ff87d33 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ .kotlin # Screenshot test reference images (ephemeral, regenerated on demand) app/src/screenshotTest*/reference/ -.codex \ No newline at end of file +.codex +protocol-research/ \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt new file mode 100644 index 00000000..757dfce5 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt @@ -0,0 +1,46 @@ +package eu.darken.capod.main.ui.components + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.AutoAwesome +import androidx.compose.material.icons.twotone.DoNotDisturbOn +import androidx.compose.material.icons.twotone.Headphones +import androidx.compose.material.icons.twotone.Hearing +import androidx.compose.ui.graphics.vector.ImageVector +import eu.darken.capod.R +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +@StringRes +fun AapSetting.AncMode.Value.shortLabelRes(): Int = when (this) { + AapSetting.AncMode.Value.OFF -> R.string.anc_mode_off + AapSetting.AncMode.Value.ON -> R.string.anc_mode_on + AapSetting.AncMode.Value.TRANSPARENCY -> R.string.anc_mode_transparency + AapSetting.AncMode.Value.ADAPTIVE -> R.string.anc_mode_adaptive +} + +fun AapSetting.AncMode.Value.shortLabel(context: Context): String = context.getString(shortLabelRes()) + +fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) { + AapSetting.AncMode.Value.OFF -> Icons.TwoTone.DoNotDisturbOn + AapSetting.AncMode.Value.ON -> Icons.TwoTone.Headphones + AapSetting.AncMode.Value.TRANSPARENCY -> Icons.TwoTone.Hearing + AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome +} + +fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { + AapSetting.AncMode.Value.OFF -> 0x01 + AapSetting.AncMode.Value.ON -> 0x02 + AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 + AapSetting.AncMode.Value.ADAPTIVE -> 0x08 +} + +fun visibleAncModes( + supportedModes: List, + currentMode: AapSetting.AncMode.Value, + cycleMask: Int?, + allowOffEnabled: Boolean, +): List = supportedModes.filter { mode -> + val inCycle = cycleMask?.let { (it and mode.cycleBit()) != 0 } ?: mode != AapSetting.AncMode.Value.OFF + inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 0145e5d8..567ef62c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -34,6 +34,8 @@ import androidx.compose.material.icons.twotone.Swipe import androidx.compose.material.icons.twotone.Timer import androidx.compose.material.icons.twotone.TouchApp import androidx.compose.material.icons.twotone.Workspaces +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -86,8 +88,11 @@ import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard +import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard -import eu.darken.capod.main.ui.devicesettings.components.NoiseControlCombined +import eu.darken.capod.main.ui.components.icon +import eu.darken.capod.main.ui.components.shortLabel +import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog import eu.darken.capod.monitor.core.PodDevice @@ -160,7 +165,6 @@ fun DeviceSettingsScreenHost( onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) }, onMicrophoneModeChange = { vm.setMicrophoneMode(it) }, onListeningModeCycleChange = { vm.setListeningModeCycle(it) }, - onAllowOffOptionChange = { vm.setAllowOffOption(it) }, onOffVisibilityChange = { enabled, mask -> vm.setListeningModeOffVisibility(enabled, mask) }, onSleepDetectionChange = { vm.setSleepDetection(it) }, onDeviceNameChange = { vm.setDeviceName(it) }, @@ -197,7 +201,6 @@ fun DeviceSettingsScreen( onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> }, onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {}, onListeningModeCycleChange: (Int) -> Unit = {}, - onAllowOffOptionChange: (Boolean) -> Unit = {}, onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> }, onSleepDetectionChange: (Boolean) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {}, @@ -214,6 +217,7 @@ fun DeviceSettingsScreen( onFixMonitorMode: () -> Unit = {}, onOpenIssueTracker: () -> Unit = {}, ) { + val context = LocalContext.current val device = state.device val features = device?.model?.features val enabled = device?.isAapReady == true @@ -221,6 +225,7 @@ fun DeviceSettingsScreen( val reactions = state.reactions var showAutoConnectConditionDialog by remember { mutableStateOf(false) } + var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) } Scaffold( topBar = { @@ -235,7 +240,7 @@ fun DeviceSettingsScreen( val profileName = device?.label if (profileName != null) { Text( - text = profileName, + text = stringResource(R.string.device_settings_subtitle_profile_prefix, profileName), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -273,6 +278,8 @@ fun DeviceSettingsScreen( } else null DeviceInfoCard( deviceInfo = device.deviceInfo, + modelLabel = buildModelLabel(device), + systemBluetoothName = state.systemBluetoothName, connectionStateLabel = stateDetection?.state?.getLabel(context), lastSeen = device.lastSeenFormatted(state.now), firstSeen = firstSeen, @@ -467,28 +474,76 @@ fun DeviceSettingsScreen( val cycleMask = if (features.hasListeningModeCycle) { (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask } else null + val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null + val cycleSubtitle = if (cycleSummary != null) { + buildString { + append(cycleSummary) + append('\n') + append( + context.getString( + if (state.hasCustomLongPressStemAction) { + R.string.device_settings_listening_mode_cycle_summary_helper_override + } else { + R.string.device_settings_listening_mode_cycle_summary_helper + } + ), + ) + } + } else { + null + } item("noise_control_section") { SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) { - NoiseControlCombined( + NoiseControlCurrentModeControl( currentMode = ancMode.current, pendingMode = device.pendingAncMode, supportedModes = ancMode.supported, onModeSelected = onAncModeChange, - cycleMask = if (isPro) cycleMask else null, - onCycleMaskChange = onListeningModeCycleChange, - onAllowOffChange = onAllowOffOptionChange, - onOffVisibilityChange = onOffVisibilityChange, enabled = enabled, ) val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) || - (!isPro && features.hasListeningModeCycle) + features.hasListeningModeCycle if (hasNoiseExtras) { HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant, ) } + if (features.hasListeningModeCycle && cycleMask != null && cycleSubtitle != null) { + SettingsPreferenceItem( + icon = Icons.TwoTone.Loop, + title = stringResource(R.string.device_settings_listening_mode_cycle_label), + subtitle = cycleSubtitle, + value = stringResource( + if (isPro) { + R.string.general_edit_action + } else { + R.string.general_upgrade_action + }, + ), + onClick = { + if (isPro) { + showListeningModeCycleDialog = true + } else { + onUpgrade() + } + }, + enabled = if (isPro) enabled else true, + requiresUpgrade = !isPro, + ) + if (state.hasCustomLongPressStemAction) { + SettingsInfoBox( + text = stringResource(R.string.stem_actions_long_press_anc_cycle_info), + type = InfoBoxType.INFO, + action = { + TextButton(onClick = onStemActionsClick) { + Text(stringResource(R.string.device_settings_noise_control_open_stem_actions_action)) + } + }, + ) + } + } if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) { AdaptiveNoiseSlider( level = adaptiveNoise.level, @@ -497,15 +552,6 @@ fun DeviceSettingsScreen( isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE, ) } - if (!isPro && features.hasListeningModeCycle) { - SettingsBaseItem( - icon = Icons.TwoTone.Loop, - title = stringResource(R.string.device_settings_listening_mode_cycle_label), - subtitle = stringResource(R.string.device_settings_listening_mode_cycle_description), - onClick = onUpgrade, - requiresUpgrade = true, - ) - } } } } @@ -713,6 +759,32 @@ fun DeviceSettingsScreen( } } + if ( + showListeningModeCycleDialog && + device != null && + features?.hasListeningModeCycle == true && + device.isAapConnected + ) { + val ancMode = device.ancMode + val currentCycleMask = (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask + if (ancMode != null) { + ListeningModeCycleDialog( + supportedModes = ancMode.supported, + currentCycleMask = currentCycleMask, + onSave = { newMask -> + val supportsOff = ancMode.supported.contains(AapSetting.AncMode.Value.OFF) + if (supportsOff) { + val offBit = cycleBit(AapSetting.AncMode.Value.OFF) + onOffVisibilityChange((newMask and offBit) != 0, newMask and offBit.inv()) + } else { + onListeningModeCycleChange(newMask) + } + }, + onDismiss = { showListeningModeCycleDialog = false }, + ) + } + } + if (showAutoConnectConditionDialog && device != null) { AutoConnectConditionDialog( current = reactions.autoConnectCondition, @@ -736,6 +808,135 @@ private fun ReactionsDivider() { ) } +@Composable +private fun NoiseControlCurrentModeControl( + currentMode: AapSetting.AncMode.Value, + pendingMode: AapSetting.AncMode.Value?, + supportedModes: List, + onModeSelected: (AapSetting.AncMode.Value) -> Unit, + enabled: Boolean, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) { + SettingsCompoundHeader( + icon = Icons.TwoTone.Headphones, + title = stringResource(R.string.device_settings_noise_control_current_mode_label), + subtitle = null, + enabled = enabled, + ) + Spacer(modifier = Modifier.height(12.dp)) + AncModeSelector( + currentMode = currentMode, + supportedModes = supportedModes, + onModeSelected = onModeSelected, + pendingMode = pendingMode, + enabled = enabled, + ) + } + } +} + +@Composable +private fun ListeningModeCycleDialog( + supportedModes: List, + currentCycleMask: Int, + onSave: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val supportedMask = supportedModes.fold(0) { mask, mode -> mask or cycleBit(mode) } + var draftMask by remember(currentCycleMask, supportedMask) { + mutableIntStateOf(currentCycleMask and supportedMask) + } + val selectedCount = Integer.bitCount(draftMask and supportedMask) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.device_settings_listening_mode_cycle_dialog_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.device_settings_listening_mode_cycle_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.selectableGroup()) { + supportedModes.forEach { mode -> + val bit = cycleBit(mode) + val isSelected = (draftMask and bit) != 0 + val canToggle = !isSelected || selectedCount > 2 + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + enabled = canToggle, + role = Role.Checkbox, + onClick = { + draftMask = if (isSelected) { + draftMask and bit.inv() + } else { + draftMask or bit + } + }, + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = null, + enabled = canToggle, + ) + Icon( + imageVector = mode.icon(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (canToggle) 1f else 0.5f), + modifier = Modifier + .padding(start = 12.dp) + .align(Alignment.CenterVertically), + ) + Text( + text = mode.listeningModeCycleDialogLabel(context), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (canToggle) 1f else 0.5f), + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.device_settings_listening_mode_cycle_minimum), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onSave(draftMask) + onDismiss() + }, + enabled = selectedCount >= 2, + ) { + Text(text = stringResource(R.string.general_save_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.general_cancel_action)) + } + }, + ) +} + @Composable private fun ToneVolumeSlider( level: Int, @@ -1053,6 +1254,30 @@ private fun ConnectedDevicesList( } } +private fun cycleBit(mode: AapSetting.AncMode.Value): Int = when (mode) { + AapSetting.AncMode.Value.OFF -> 0x01 + AapSetting.AncMode.Value.ON -> 0x02 + AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 + AapSetting.AncMode.Value.ADAPTIVE -> 0x08 +} + +private fun listeningModeCycleSummary( + context: android.content.Context, + supportedModes: List, + cycleMask: Int, +): String = supportedModes + .filter { mode -> (cycleMask and cycleBit(mode)) != 0 } + .joinToString(separator = " • ") { it.shortLabel(context) } + +private fun AapSetting.AncMode.Value.listeningModeCycleDialogLabel( + context: android.content.Context, +): String = when (this) { + AapSetting.AncMode.Value.OFF -> context.getString(R.string.device_settings_allow_off_label) + AapSetting.AncMode.Value.ON -> context.getString(R.string.device_settings_listening_mode_cycle_anc) + AapSetting.AncMode.Value.TRANSPARENCY -> context.getString(R.string.device_settings_listening_mode_cycle_transparency) + AapSetting.AncMode.Value.ADAPTIVE -> context.getString(R.string.device_settings_listening_mode_cycle_adaptive) +} + private fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State( device = PodDevice( profileId = "preview", @@ -1160,4 +1385,3 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper { onNavigateUp = {}, ) } - diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 1c2425e9..f3332bcb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -28,6 +28,8 @@ 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.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 @@ -50,6 +52,7 @@ 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) { @@ -91,6 +94,8 @@ 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, ) { args -> val device = args[1] as PodDevice? val upgrade = args[2] as eu.darken.capod.common.upgrade.UpgradeRepo.Info @@ -98,7 +103,16 @@ class DeviceSettingsViewModel @Inject constructor( @Suppress("UNCHECKED_CAST") val connectedDevices = args[4] as Collection val monitorMode = args[5] as MonitorMode + val leftLong = args[6] as StemAction + val rightLong = args[7] as StemAction val connectedAddresses = connectedDevices.map { it.address }.toSet() + val systemBtName = device?.address?.let { addr -> + try { + bluetoothManager.bondedDevices().first().firstOrNull { it.address == addr }?.name + } catch (_: Exception) { + null + } + } State( device = device, now = timeSource.now(), @@ -107,6 +121,8 @@ class DeviceSettingsViewModel @Inject constructor( isForceConnecting = forcing, isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true, monitorMode = monitorMode, + systemBluetoothName = systemBtName, + hasCustomLongPressStemAction = leftLong != StemAction.NONE || rightLong != StemAction.NONE, ) } }.asLiveState() @@ -130,6 +146,8 @@ class DeviceSettingsViewModel @Inject constructor( val isForceConnecting: Boolean = false, val isClassicallyConnected: Boolean = false, val monitorMode: MonitorMode = MonitorMode.AUTOMATIC, + val systemBluetoothName: String? = null, + val hasCustomLongPressStemAction: Boolean = false, ) { val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig() } @@ -233,14 +251,12 @@ class DeviceSettingsViewModel @Inject constructor( fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask)) - fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled)) - fun setListeningModeOffVisibility(enabled: Boolean, currentCycleMask: Int) = launch { if (!upgradeRepo.isPro()) { navTo(Nav.Main.Upgrade) return@launch } - // Keep in sync with cycleBits[OFF] in DeviceSettingsScreen.NoiseControlCombined + // Keep in sync with cycleBit(OFF) in DeviceSettingsScreen. val offBit = 0x01 val newMask = if (enabled) currentCycleMask or offBit else currentCycleMask and offBit.inv() if (sendInternal(AapCommand.SetListeningModeCycle(newMask))) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/NoiseControlCombined.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/NoiseControlCombined.kt deleted file mode 100644 index cb8319f8..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/NoiseControlCombined.kt +++ /dev/null @@ -1,150 +0,0 @@ -package eu.darken.capod.main.ui.devicesettings.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Visibility -import androidx.compose.material.icons.twotone.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.runtime.Composable -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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.SettingsSection -import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting - -@Composable -internal fun NoiseControlCombined( - currentMode: AapSetting.AncMode.Value, - pendingMode: AapSetting.AncMode.Value?, - supportedModes: List, - onModeSelected: (AapSetting.AncMode.Value) -> Unit, - cycleMask: Int?, - onCycleMaskChange: (Int) -> Unit, - onAllowOffChange: (Boolean) -> Unit = {}, - onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> }, - enabled: Boolean, -) { - val displayMode = pendingMode ?: currentMode - val cycleBits = mapOf( - AapSetting.AncMode.Value.OFF to 0x01, - AapSetting.AncMode.Value.ON to 0x02, - AapSetting.AncMode.Value.TRANSPARENCY to 0x04, - AapSetting.AncMode.Value.ADAPTIVE to 0x08, - ) - - Column(modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)) { - for (mode in supportedModes) { - val isSelected = mode == displayMode - val bit = cycleBits[mode] ?: continue - val inCycle = cycleMask?.let { (it and bit) != 0 } - val cycleCount = cycleMask?.let { Integer.bitCount(it and 0x0F) } ?: 0 - val canRemoveFromCycle = inCycle != true || cycleCount > 2 - - Row( - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Visibility toggle — independent click target - if (cycleMask != null) { - IconButton( - onClick = { - val isOff = mode == AapSetting.AncMode.Value.OFF - if (isOff) { - onOffVisibilityChange(inCycle != true, cycleMask) - } else if (inCycle == true && canRemoveFromCycle) { - onCycleMaskChange(cycleMask xor bit) - } else if (inCycle != true) { - onCycleMaskChange(cycleMask or bit) - } - }, - enabled = enabled && (inCycle != true || canRemoveFromCycle), - ) { - Icon( - imageVector = if (inCycle == true) Icons.TwoTone.Visibility else Icons.TwoTone.VisibilityOff, - contentDescription = null, - tint = if (inCycle == true) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - }, - ) - } - } - - // Mode selection — label + radio as one click target - Row( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable(enabled = enabled) { onModeSelected(mode) } - .padding(start = if (cycleMask == null) 16.dp else 0.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = mode.label(), - style = MaterialTheme.typography.bodyLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f) - }, - fontWeight = if (isSelected) FontWeight.Bold else null, - modifier = Modifier.weight(1f), - ) - RadioButton( - selected = isSelected, - onClick = null, - enabled = enabled, - modifier = Modifier.padding(16.dp), - ) - } - } - } - } -} - -@Composable -private fun AapSetting.AncMode.Value.label(): String = when (this) { - AapSetting.AncMode.Value.OFF -> stringResource(R.string.device_settings_listening_mode_cycle_off) - AapSetting.AncMode.Value.ON -> stringResource(R.string.device_settings_listening_mode_cycle_anc) - AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.device_settings_listening_mode_cycle_transparency) - AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.device_settings_listening_mode_cycle_adaptive) -} - -private val ALL_MODES = listOf( - AapSetting.AncMode.Value.OFF, - AapSetting.AncMode.Value.ON, - AapSetting.AncMode.Value.TRANSPARENCY, - AapSetting.AncMode.Value.ADAPTIVE, -) - -@Preview2 -@Composable -private fun NoiseControlCombinedProPreview() = PreviewWrapper { - NoiseControlCombined( - currentMode = AapSetting.AncMode.Value.ADAPTIVE, - pendingMode = null, - supportedModes = ALL_MODES, - onModeSelected = {}, - cycleMask = 0x0E, - onCycleMaskChange = {}, - enabled = true, - ) -} 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 2dede7c3..58a5f0d7 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.darken.capod.R +import eu.darken.capod.main.ui.components.visibleAncModes import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule import eu.darken.capod.main.ui.overview.cards.components.DebugSection @@ -236,17 +237,12 @@ fun DualPodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(8.dp)) - val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E) - val cycleBits = mapOf( - AapSetting.AncMode.Value.OFF to 0x01, - AapSetting.AncMode.Value.ON to 0x02, - AapSetting.AncMode.Value.TRANSPARENCY to 0x04, - AapSetting.AncMode.Value.ADAPTIVE to 0x08, + val visibleModes = visibleAncModes( + supportedModes = ancMode.supported, + currentMode = ancMode.current, + cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, + allowOffEnabled = device.allowOffOption?.enabled == true, ) - val visibleModes = ancMode.supported.filter { mode -> - val bit = cycleBits[mode] ?: return@filter true - (cycleMask and bit) != 0 - } AncModeSelector( currentMode = ancMode.current, supportedModes = visibleModes, 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 814c9fbb..e910e574 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.darken.capod.R +import eu.darken.capod.main.ui.components.visibleAncModes import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector import eu.darken.capod.main.ui.overview.cards.components.DebugSection import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge @@ -259,17 +260,12 @@ fun SinglePodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) - val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E) - val cycleBits = mapOf( - AapSetting.AncMode.Value.OFF to 0x01, - AapSetting.AncMode.Value.ON to 0x02, - AapSetting.AncMode.Value.TRANSPARENCY to 0x04, - AapSetting.AncMode.Value.ADAPTIVE to 0x08, + val visibleModes = visibleAncModes( + supportedModes = ancMode.supported, + currentMode = ancMode.current, + cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, + allowOffEnabled = device.allowOffOption?.enabled == true, ) - val visibleModes = ancMode.supported.filter { mode -> - val bit = cycleBits[mode] ?: return@filter true - (cycleMask and bit) != 0 - } AncModeSelector( currentMode = ancMode.current, supportedModes = visibleModes, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/AncModeSelector.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/AncModeSelector.kt index 2b06a661..79690b30 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/AncModeSelector.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/AncModeSelector.kt @@ -1,19 +1,32 @@ package eu.darken.capod.main.ui.overview.cards.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +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.main.ui.components.icon +import eu.darken.capod.main.ui.components.shortLabelRes import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @Composable @@ -34,6 +47,7 @@ fun AncModeSelector( onClick = { onModeSelected(mode) }, enabled = enabled, shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), colors = if (pendingMode != null && mode == displayMode) { SegmentedButtonDefaults.colors( activeContainerColor = MaterialTheme.colorScheme.secondaryContainer, @@ -42,17 +56,37 @@ fun AncModeSelector( } else { SegmentedButtonDefaults.colors() }, + icon = {}, label = { - Text( - text = when (mode) { - AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off) - AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on) - AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency) - AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + val isSelected = mode == displayMode + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding( + start = if (index == 0) 6.dp else 0.dp, + end = if (index == supportedModes.lastIndex) 6.dp else 0.dp, + top = 4.dp, + bottom = 4.dp, + ), + ) { + Icon( + imageVector = mode.icon(), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(mode.shortLabelRes()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + textDecoration = if (isSelected) TextDecoration.Underline else TextDecoration.None, + modifier = Modifier.fillMaxWidth(), + ) + } }, ) } 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 f55dd07e..2cd4e07a 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 @@ -550,6 +550,7 @@ internal class AapConnection( 0x0017, // HID/service descriptors 0x002B, // Session metadata / event history 0x004E, // Unknown (all-zero payload) + 0x0052, // ANC mode change status/rejection 0x0055, // Audio/session state 0x0057, // Connection lifecycle ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index d89916ae..7ddd2235 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -363,8 +363,8 @@ class DefaultAapDeviceProfile( if (message.commandType != CMD_DEVICE_INFO) return null if (message.payload.size < 10) return null - // Device info payload contains null-terminated ASCII strings - // Format: [length prefix] [strings...] + // Device info payload contains null-terminated UTF-8 strings + // Format: [binary header] [NUL-delimited strings...] val strings = parseNullTerminatedStrings(message.payload) if (strings.size < 4) return null diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index d477fecd..bd3831b7 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -12,6 +12,8 @@ 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.DeviceProfilesRepo +import eu.darken.capod.reaction.core.stem.StemAction +import eu.darken.capod.reaction.core.stem.StemActionSettings import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.Called @@ -60,6 +62,9 @@ class DeviceSettingsViewModelTest : BaseTest() { private lateinit var profilesRepo: DeviceProfilesRepo private lateinit var generalSettings: GeneralSettings private lateinit var fakeMonitorMode: FakeDataStoreValue + private lateinit var stemActionSettings: StemActionSettings + private lateinit var fakeLeftLongStemAction: FakeDataStoreValue + private lateinit var fakeRightLongStemAction: FakeDataStoreValue private val timeSource: TimeSource = TestTimeSource() private lateinit var devicesFlow: MutableStateFlow> @@ -102,6 +107,12 @@ class DeviceSettingsViewModelTest : BaseTest() { generalSettings = mockk().also { every { it.monitorMode } returns fakeMonitorMode.mock } + fakeLeftLongStemAction = FakeDataStoreValue(StemAction.NONE) + fakeRightLongStemAction = FakeDataStoreValue(StemAction.NONE) + stemActionSettings = mockk().also { + every { it.leftLong } returns fakeLeftLongStemAction.mock + every { it.rightLong } returns fakeRightLongStemAction.mock + } } @AfterEach @@ -119,6 +130,7 @@ class DeviceSettingsViewModelTest : BaseTest() { bluetoothManager = bluetoothManager, profilesRepo = profilesRepo, generalSettings = generalSettings, + stemActionSettings = stemActionSettings, timeSource = timeSource, webpageTool = mockk(relaxed = true), ).also { vm = it } @@ -324,4 +336,14 @@ class DeviceSettingsViewModelTest : BaseTest() { state.device?.address shouldBe testAddress state.isClassicallyConnected shouldBe false } + + @Test + fun `hasCustomLongPressStemAction is true when either long-press action is assigned`() = runVmTest { + fakeLeftLongStemAction.value = StemAction.PLAY_PAUSE + + val vm = createViewModel() + vm.initialize(testAddress) + + vm.state.first().hasCustomLongPressStemAction shouldBe true + } }