From e356344ba650293dac5a07fd8a8715474584a638 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 3 Apr 2026 19:58:47 +0200 Subject: [PATCH] feat(aap): Add new device settings, stem actions, and noise control UI Add 8 new AAP writable settings (microphone mode, ear detection toggle, listening mode cycle, allow off, stem config, sleep detection, in-case tone, device rename) and 4 new data reception features (stem press events, connected devices, audio source, EQ data). Implement stem press action system with per-bud configurable Android actions (play/pause, next/prev track, volume), auto-sent stem config on connection, and dedicated config screen. Combine ANC mode selector with listening mode cycle visibility into a unified noise control component. Eye icons control which modes appear in the dashboard card and settings. Pro gating with stars icon for upgrade-required features. Add 64 unit tests covering all new decoders, model feature flags, malformed payloads, and rename byte-length validation. --- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 35 +- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 34 +- .../eu/darken/capod/common/MediaControl.kt | 2 +- .../eu/darken/capod/common/navigation/Nav.kt | 3 + .../common/settings/SettingsSliderItem.kt | 43 +- .../ui/devicesettings/DeviceSettingsScreen.kt | 467 +++++++++++++++++- .../devicesettings/DeviceSettingsViewModel.kt | 44 +- .../main/ui/overview/cards/DualPodsCard.kt | 13 +- .../main/ui/overview/cards/SinglePodsCard.kt | 13 +- .../stemactions/StemActionConfigNavigation.kt | 28 ++ .../ui/stemactions/StemActionConfigScreen.kt | 261 ++++++++++ .../stemactions/StemActionConfigViewModel.kt | 58 +++ .../eu/darken/capod/monitor/core/PodDevice.kt | 30 ++ .../monitor/core/aap/AapLifecycleManager.kt | 4 + .../monitor/core/aap/StemConfigSender.kt | 81 +++ .../monitor/core/aap/StemPressReaction.kt | 64 +++ .../darken/capod/pods/core/apple/PodModel.kt | 45 ++ .../pods/core/apple/aap/AapConnection.kt | 49 +- .../core/apple/aap/AapConnectionManager.kt | 12 + .../core/apple/aap/protocol/AapCommand.kt | 8 + .../apple/aap/protocol/AapDeviceProfile.kt | 6 + .../core/apple/aap/protocol/AapSetting.kt | 63 +++ .../aap/protocol/DefaultAapDeviceProfile.kt | 119 ++++- .../core/apple/aap/protocol/StemPressEvent.kt | 22 + .../capod/reaction/core/stem/StemAction.kt | 13 + .../reaction/core/stem/StemActionSettings.kt | 32 ++ app/src/main/res/values/strings.xml | 49 ++ .../DefaultAapDeviceProfileNewSettingsTest.kt | 313 ++++++++++++ .../devices/DefaultAapDeviceProfileTest.kt | 12 +- .../airpods/AirPodsPro2UsbcAapSessionTest.kt | 3 +- .../airpods/AirPodsPro3AapSessionTest.kt | 3 +- .../airpods/AirPodsProAapSessionTest.kt | 1 + 32 files changed, 1845 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/StemPressEvent.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 31f70d39..c559aa42 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -5,14 +5,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -36,6 +33,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -117,18 +115,27 @@ fun UpgradeScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, ) { paddingValues -> - Box(modifier = Modifier - .windowInsetsPadding(WindowInsets.systemBars) - .padding(paddingValues) - ) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) + .padding(paddingValues) .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.height(16.dp)) Box(contentAlignment = Alignment.Center) { Surface( @@ -238,18 +245,6 @@ fun UpgradeScreen( Spacer(modifier = Modifier.height(24.dp)) } - - IconButton( - onClick = onNavigateUp, - modifier = Modifier.padding(4.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } } } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 1d2689d2..450a6474 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -7,14 +7,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,8 +34,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -128,14 +127,28 @@ fun UpgradeScreen( Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support), ) - Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { paddingValues -> Column( modifier = Modifier .verticalScroll(rememberScrollState()) + .padding(paddingValues) .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.height(16.dp)) Box(contentAlignment = Alignment.Center) { Surface( @@ -231,17 +244,6 @@ fun UpgradeScreen( Spacer(modifier = Modifier.height(24.dp)) } - - IconButton( - onClick = onNavigateUp, - modifier = Modifier.padding(4.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - } } } diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 8361e548..40d83521 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -45,7 +45,7 @@ class MediaControl @Inject constructor( } } - private suspend fun sendKey(keyCode: Int) { + internal suspend fun sendKey(keyCode: Int) { log(TAG) { "Sending up+down KeyEvent: $keyCode" } val eventTime = SystemClock.uptimeMillis() audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0)) 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 81fffbb8..6949a1a0 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 @@ -24,6 +24,9 @@ object Nav { @Serializable data class DeviceSettings(val address: String) : Main + + @Serializable + data object StemActionConfig : 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 3bfff43f..6906037e 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 @@ -1,8 +1,11 @@ package eu.darken.capod.common.settings +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text @@ -36,29 +39,33 @@ fun SettingsSliderItem( subtitle = subtitle, onClick = {}, enabled = enabled, - trailingContent = if (valueLabel != null) { - { - Text( - text = valueLabel(value), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - modifier = Modifier.padding(start = 16.dp) - ) - } - } else null, ) - Slider( - value = value, - onValueChange = onValueChange, - onValueChangeFinished = onValueChangeFinished, - valueRange = valueRange, - steps = steps, - enabled = enabled, + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 56.dp) .padding(bottom = 8.dp), - ) + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Slider( + value = value, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished, + valueRange = valueRange, + steps = steps, + enabled = enabled, + modifier = Modifier.weight(1f), + ) + if (valueLabel != null) { + Text( + text = valueLabel(value), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + modifier = Modifier.width(42.dp), + ) + } + } } } 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 7dd4e2b3..abec0725 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 @@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.devicesettings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,7 +19,18 @@ 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.foundation.layout.width +import androidx.compose.material.icons.twotone.Check +import androidx.compose.material.icons.twotone.DevicesOther +import androidx.compose.material.icons.twotone.Edit +import androidx.compose.material.icons.twotone.Mic +import androidx.compose.material.icons.twotone.Nightlight +import androidx.compose.material.icons.twotone.NotificationsActive import androidx.compose.material.icons.twotone.TouchApp +import androidx.compose.material.icons.twotone.Stars +import androidx.compose.material.icons.twotone.Visibility +import androidx.compose.material.icons.twotone.VisibilityOff +import androidx.compose.material.icons.twotone.Tune import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -36,6 +48,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -54,7 +67,9 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MOCK_NOW import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler +import eu.darken.capod.common.settings.SettingsBaseItem import eu.darken.capod.common.settings.SettingsCategoryHeader +import eu.darken.capod.common.settings.SettingsPreferenceItem import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem import eu.darken.capod.main.ui.overview.cards.AncModeSelector @@ -94,6 +109,14 @@ fun DeviceSettingsScreenHost( onVolumeSwipeChange = { vm.setVolumeSwipe(it) }, onVolumeSwipeLengthChange = { vm.setVolumeSwipeLength(it) }, onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) }, + onMicrophoneModeChange = { vm.setMicrophoneMode(it) }, + onEarDetectionEnabledChange = { vm.setEarDetectionEnabled(it) }, + onListeningModeCycleChange = { vm.setListeningModeCycle(it) }, + onAllowOffOptionChange = { vm.setAllowOffOption(it) }, + onSleepDetectionChange = { vm.setSleepDetection(it) }, + onInCaseToneChange = { vm.setInCaseTone(it) }, + onDeviceNameChange = { vm.setDeviceName(it) }, + onStemActionsClick = { vm.navToStemConfig() }, ) } @@ -113,10 +136,19 @@ fun DeviceSettingsScreen( onVolumeSwipeChange: (Boolean) -> Unit = {}, onVolumeSwipeLengthChange: (AapSetting.VolumeSwipeLength.Value) -> Unit = {}, onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> }, + onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {}, + onEarDetectionEnabledChange: (Boolean) -> Unit = {}, + onListeningModeCycleChange: (Int) -> Unit = {}, + onAllowOffOptionChange: (Boolean) -> Unit = {}, + onSleepDetectionChange: (Boolean) -> Unit = {}, + onInCaseToneChange: (Boolean) -> Unit = {}, + onDeviceNameChange: (String) -> Unit = {}, + onStemActionsClick: () -> Unit = {}, ) { val device = state.device val features = device?.model?.features val enabled = device?.isAapReady == true + val isPro = state.isPro Scaffold( topBar = { @@ -169,6 +201,8 @@ fun DeviceSettingsScreen( connectionStateLabel = stateDetection?.state?.getLabel(context), lastSeen = device.lastSeenFormatted(state.now), firstSeen = firstSeen, + canRename = device.isAapConnected, + onRename = onDeviceNameChange, ) } } @@ -181,16 +215,20 @@ fun DeviceSettingsScreen( val ancMode = device.ancMode if (features.hasAncControl && ancMode != null) { - item("anc_mode") { - AncModeSelector( + val cycleMask = if (features.hasListeningModeCycle) { + (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask + } else null + + item("noise_control") { + NoiseControlCombined( currentMode = ancMode.current, + pendingMode = device.pendingAncMode, supportedModes = ancMode.supported, onModeSelected = onAncModeChange, - pendingMode = device.pendingAncMode, - modifier = Modifier.padding(horizontal = 16.dp), + cycleMask = cycleMask, + onCycleMaskChange = onListeningModeCycleChange, enabled = enabled, ) - Spacer(modifier = Modifier.height(8.dp)) } } @@ -344,6 +382,118 @@ fun DeviceSettingsScreen( ) } } + + if (features.hasStemConfig) { + item("stem_actions") { + SettingsPreferenceItem( + icon = Icons.TwoTone.TouchApp, + title = stringResource(R.string.stem_actions_title), + subtitle = stringResource(R.string.stem_actions_nav_description), + onClick = onStemActionsClick, + enabled = enabled, + ) + } + } + + // Additional settings section + item("additional_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_additional_label)) + } + + if (features.hasMicrophoneMode) { + val micMode = device.microphoneMode + ?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO) + item("microphone_mode") { + SegmentedSettingRow( + icon = Icons.TwoTone.Mic, + title = stringResource(R.string.device_settings_microphone_mode_label), + subtitle = stringResource(R.string.device_settings_microphone_mode_description), + options = listOf( + stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO, + stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT, + stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT, + ), + selected = micMode.mode, + onSelected = onMicrophoneModeChange, + enabled = enabled, + ) + } + } + + if (features.hasEarDetectionToggle) { + val earDetection = device.earDetectionEnabled + ?: AapSetting.EarDetectionEnabled(enabled = true) + item("ear_detection_toggle") { + SettingsSwitchItem( + icon = Icons.TwoTone.Hearing, + title = stringResource(R.string.device_settings_ear_detection_label), + subtitle = stringResource(R.string.device_settings_ear_detection_description), + checked = earDetection.enabled, + onCheckedChange = onEarDetectionEnabledChange, + enabled = enabled, + ) + } + } + + if (features.hasSleepDetection) { + val sleepDet = device.sleepDetection + ?: AapSetting.SleepDetection(enabled = true) + item("sleep_detection") { + ProGatedSwitchItem( + icon = Icons.TwoTone.Nightlight, + title = stringResource(R.string.device_settings_sleep_detection_label), + subtitle = stringResource(R.string.device_settings_sleep_detection_description), + checked = sleepDet.enabled, + onCheckedChange = onSleepDetectionChange, + enabled = enabled, + isPro = isPro, + ) + } + } + + if (features.hasInCaseTone) { + val inCaseTone = device.inCaseTone + ?: AapSetting.InCaseTone(enabled = true) + item("in_case_tone") { + ProGatedSwitchItem( + icon = Icons.TwoTone.NotificationsActive, + title = stringResource(R.string.device_settings_in_case_tone_label), + subtitle = stringResource(R.string.device_settings_in_case_tone_description), + checked = inCaseTone.enabled, + onCheckedChange = onInCaseToneChange, + enabled = enabled, + isPro = isPro, + ) + } + } + + // Connections section + val connectedDevices = device.connectedDevices + if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { + item("connections_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_connections_label)) + } + item("connected_devices") { + ConnectedDevicesList( + devices = connectedDevices.devices, + audioSource = device.audioSource, + ) + } + } + + // EQ visualization (debug only — internal adaptive calibration data, not user-actionable) + val eqBands = device.eqBands + if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) { + item("eq_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_eq_label)) + } + item("eq_bars") { + EqBarsChart( + sets = eqBands.sets, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } } item("bottom_spacer") { @@ -359,7 +509,22 @@ private fun DeviceInfoCard( connectionStateLabel: String?, lastSeen: String?, firstSeen: String?, + canRename: Boolean = false, + onRename: (String) -> Unit = {}, ) { + var showRenameDialog by remember { mutableStateOf(false) } + + if (showRenameDialog && deviceInfo != null) { + RenameDialog( + currentName = deviceInfo.name, + onConfirm = { newName -> + onRename(newName) + showRenameDialog = false + }, + onDismiss = { showRenameDialog = false }, + ) + } + ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -369,10 +534,26 @@ private fun DeviceInfoCard( Column(modifier = Modifier.padding(16.dp)) { if (deviceInfo != null) { if (deviceInfo.name.isNotBlank()) { - InfoRow( - label = stringResource(R.string.device_settings_info_name_label), - value = deviceInfo.name, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + InfoRow( + label = stringResource(R.string.device_settings_info_name_label), + value = deviceInfo.name, + modifier = Modifier.weight(1f), + ) + if (canRename) { + IconButton(onClick = { showRenameDialog = true }) { + Icon( + imageVector = Icons.TwoTone.Edit, + contentDescription = stringResource(R.string.device_settings_rename_label), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } if (deviceInfo.serialNumber.isNotBlank()) { InfoRow( @@ -410,11 +591,9 @@ private fun DeviceInfoCard( } @Composable -private fun InfoRow(label: String, value: String) { +private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), + modifier = modifier.padding(vertical = 2.dp), ) { Text( text = label, @@ -620,6 +799,268 @@ private fun CallControlOption( } } +@Composable +private fun EqBarsChart( + sets: List>, + modifier: Modifier = Modifier, +) { + val primary = MaterialTheme.colorScheme.primary + val outline = MaterialTheme.colorScheme.outlineVariant + + // Use the first EQ set (main) + val bands = sets.firstOrNull() ?: return + if (bands.size != 8) return + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(80.dp), + ) { + val barCount = bands.size + val spacing = 4.dp.toPx() + val cornerRadius = 4.dp.toPx() + val barWidth = (size.width - spacing * (barCount - 1)) / barCount + + for (i in bands.indices) { + val x = i * (barWidth + spacing) + // Fixed 0-100 scale — values are typically 0-100 from the device + val normalized = (bands[i] / 100f).coerceIn(0f, 1f) + val barHeight = (normalized * size.height).coerceAtLeast(2.dp.toPx()) + + // Background bar (rounded rect) + drawRoundRect( + color = outline, + topLeft = androidx.compose.ui.geometry.Offset(x, 0f), + size = androidx.compose.ui.geometry.Size(barWidth, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius), + ) + + // Filled bar from bottom (rounded rect) + drawRoundRect( + color = primary, + topLeft = androidx.compose.ui.geometry.Offset(x, size.height - barHeight), + size = androidx.compose.ui.geometry.Size(barWidth, barHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius), + ) + } + } +} + +@Composable +private fun ConnectedDevicesList( + devices: List, + audioSource: AapSetting.AudioSource?, +) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + text = stringResource(R.string.device_settings_connected_devices_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + + for ((index, device) in devices.withIndex()) { + val isSource = audioSource?.sourceMac == device.mac + val statusLabel = if (isSource) { + when (audioSource?.type) { + AapSetting.AudioSource.AudioSourceType.CALL -> stringResource(R.string.device_settings_connected_device_call) + AapSetting.AudioSource.AudioSourceType.MEDIA -> stringResource(R.string.device_settings_connected_device_media) + else -> "" + } + } else "" + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.TwoTone.DevicesOther, + contentDescription = null, + tint = if (isSource) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Column { + Text( + text = stringResource(R.string.device_settings_connected_device_label, index + 1), + style = MaterialTheme.typography.bodyMedium, + color = if (isSource) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + Text( + text = if (statusLabel.isNotEmpty()) statusLabel else device.mac, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ProGatedSwitchItem( + icon: ImageVector, + title: String, + subtitle: String?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean, + isPro: Boolean, +) { + if (isPro) { + SettingsSwitchItem( + icon = icon, + title = title, + subtitle = subtitle, + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } else { + SettingsBaseItem( + icon = icon, + title = title, + subtitle = subtitle, + onClick = { onCheckedChange(!checked) }, + enabled = enabled, + trailingContent = { + Icon( + imageVector = Icons.TwoTone.Stars, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } +} + +@Composable +private fun RenameDialog( + currentName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var textValue by remember { mutableStateOf(currentName) } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.device_settings_rename_label)) }, + text = { + androidx.compose.material3.OutlinedTextField( + value = textValue, + onValueChange = { if (it.length <= 32) textValue = it }, + singleLine = true, + label = { Text(stringResource(R.string.device_settings_rename_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { if (textValue.isNotBlank()) onConfirm(textValue) }, + enabled = textValue.isNotBlank() && textValue != currentName, + ) { + Text(stringResource(R.string.device_settings_rename_confirm)) + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun NoiseControlCombined( + currentMode: AapSetting.AncMode.Value, + pendingMode: AapSetting.AncMode.Value?, + supportedModes: List, + onModeSelected: (AapSetting.AncMode.Value) -> Unit, + cycleMask: Int?, + onCycleMaskChange: (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(horizontal = 16.dp, vertical = 8.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() + .clickable(enabled = enabled) { onModeSelected(mode) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Visibility toggle for cycle (only if device supports listening mode cycle) + if (cycleMask != null) { + IconButton( + onClick = { + if (inCycle == true && canRemoveFromCycle) { + onCycleMaskChange(cycleMask xor bit) + } else if (inCycle != true) { + onCycleMaskChange((cycleMask ?: 0) 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 label + 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) androidx.compose.ui.text.font.FontWeight.Bold else null, + modifier = Modifier.weight(1f), + ) + + // Selection indicator + RadioButton( + selected = isSelected, + onClick = { onModeSelected(mode) }, + enabled = enabled, + ) + } + } + } +} + +@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) +} + @Preview2 @Composable private fun DeviceSettingsFullPreview() = PreviewWrapper { 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 57039300..002c2f5d 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 @@ -8,6 +8,9 @@ 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.common.navigation.Nav +import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -26,6 +29,7 @@ class DeviceSettingsViewModel @Inject constructor( dispatcherProvider: DispatcherProvider, private val deviceMonitor: DeviceMonitor, private val aapManager: AapConnectionManager, + private val upgradeRepo: UpgradeRepo, ) : ViewModel4(dispatcherProvider) { private val targetAddress = MutableStateFlow(null) @@ -46,10 +50,11 @@ class DeviceSettingsViewModel @Inject constructor( val state = targetAddress.flatMapLatest { address -> if (address == null) return@flatMapLatest flowOf(State(device = null)) - combine(updateTicker, deviceMonitor.devices) { _, devices -> + combine(updateTicker, deviceMonitor.devices, upgradeRepo.upgradeInfo) { _, devices, upgrade -> State( device = devices.firstOrNull { it.address == address }, now = Instant.now(), + isPro = upgrade.isPro, ) } }.asLiveState() @@ -57,6 +62,7 @@ class DeviceSettingsViewModel @Inject constructor( data class State( val device: PodDevice?, val now: Instant = Instant.now(), + val isPro: Boolean = false, ) private fun send(command: AapCommand) { @@ -71,6 +77,20 @@ class DeviceSettingsViewModel @Inject constructor( } } + private fun sendProGated(command: AapCommand) = launch { + if (upgradeRepo.isPro()) { + 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}" } + } + } else { + navTo(Nav.Main.Upgrade) + } + } + fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode)) fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled)) @@ -96,6 +116,28 @@ class DeviceSettingsViewModel @Inject constructor( endCall: AapSetting.EndCallMuteMic.EndCallMode, ) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall)) + fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = send(AapCommand.SetMicrophoneMode(mode)) + + fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled)) + + fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask)) + + fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled)) + + fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled)) + + fun setInCaseTone(enabled: Boolean) = sendProGated(AapCommand.SetInCaseTone(enabled)) + + fun setDeviceName(name: String) = send(AapCommand.SetDeviceName(name)) + + fun navToStemConfig() = launch { + if (upgradeRepo.isPro()) { + navTo(Nav.Main.StemActionConfig) + } else { + navTo(Nav.Main.Upgrade) + } + } + companion object { private val TAG = logTag("DeviceSettings", "VM") } 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 d8b7b27b..792e45e9 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 @@ -207,9 +207,20 @@ 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 = ancMode.supported.filter { mode -> + val bit = cycleBits[mode] ?: return@filter true + (cycleMask and bit) != 0 + } AncModeSelector( currentMode = ancMode.current, - supportedModes = ancMode.supported, + supportedModes = visibleModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) 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 205df211..19dc337d 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 @@ -232,9 +232,20 @@ 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 = ancMode.supported.filter { mode -> + val bit = cycleBits[mode] ?: return@filter true + (cycleMask and bit) != 0 + } AncModeSelector( currentMode = ancMode.current, - supportedModes = ancMode.supported, + supportedModes = visibleModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt new file mode 100644 index 00000000..35de43e0 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigNavigation.kt @@ -0,0 +1,28 @@ +package eu.darken.capod.main.ui.stemactions + +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 StemActionConfigNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { + StemActionConfigScreenHost() + } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt new file mode 100644 index 00000000..7690a627 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigScreen.kt @@ -0,0 +1,261 @@ +package eu.darken.capod.main.ui.stemactions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.twotone.ArrowBack +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import eu.darken.capod.R +import eu.darken.capod.common.navigation.NavigationEventHandler +import eu.darken.capod.common.settings.SettingsCategoryHeader +import eu.darken.capod.reaction.core.stem.StemAction + +@Composable +fun StemActionConfigScreenHost( + vm: StemActionConfigViewModel = hiltViewModel(), +) { + NavigationEventHandler(vm) + + val state by vm.state.collectAsStateWithLifecycle(initialValue = null) + val currentState = state ?: return + + StemActionConfigScreen( + state = currentState, + onNavigateUp = { vm.navUp() }, + onLeftSingle = { vm.setLeftSingle(it) }, + onLeftDouble = { vm.setLeftDouble(it) }, + onLeftTriple = { vm.setLeftTriple(it) }, + onLeftLong = { vm.setLeftLong(it) }, + onRightSingle = { vm.setRightSingle(it) }, + onRightDouble = { vm.setRightDouble(it) }, + onRightTriple = { vm.setRightTriple(it) }, + onRightLong = { vm.setRightLong(it) }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StemActionConfigScreen( + state: StemActionConfigViewModel.State, + onNavigateUp: () -> Unit, + onLeftSingle: (StemAction) -> Unit = {}, + onLeftDouble: (StemAction) -> Unit = {}, + onLeftTriple: (StemAction) -> Unit = {}, + onLeftLong: (StemAction) -> Unit = {}, + onRightSingle: (StemAction) -> Unit = {}, + onRightDouble: (StemAction) -> Unit = {}, + onRightTriple: (StemAction) -> Unit = {}, + onRightLong: (StemAction) -> Unit = {}, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.stem_actions_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { + item("description") { + Text( + text = stringResource(R.string.stem_actions_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + // Press type rows with left/right dropdowns + item("single_header") { + SettingsCategoryHeader(text = stringResource(R.string.stem_actions_single_press)) + } + item("single_row") { + StemActionRow( + leftAction = state.leftSingle, + rightAction = state.rightSingle, + onLeftChange = onLeftSingle, + onRightChange = onRightSingle, + ) + } + + item("double_header") { + SettingsCategoryHeader(text = stringResource(R.string.stem_actions_double_press)) + } + item("double_row") { + StemActionRow( + leftAction = state.leftDouble, + rightAction = state.rightDouble, + onLeftChange = onLeftDouble, + onRightChange = onRightDouble, + ) + } + + item("triple_header") { + SettingsCategoryHeader(text = stringResource(R.string.stem_actions_triple_press)) + } + item("triple_row") { + StemActionRow( + leftAction = state.leftTriple, + rightAction = state.rightTriple, + onLeftChange = onLeftTriple, + onRightChange = onRightTriple, + ) + } + + item("long_header") { + SettingsCategoryHeader(text = stringResource(R.string.stem_actions_long_press)) + } + item("long_row") { + StemActionRow( + leftAction = state.leftLong, + rightAction = state.rightLong, + onLeftChange = onLeftLong, + onRightChange = onRightLong, + ) + } + + item("bottom_spacer") { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun StemActionRow( + leftAction: StemAction, + rightAction: StemAction, + onLeftChange: (StemAction) -> Unit, + onRightChange: (StemAction) -> Unit, +) { + // Enforce: if one side is non-NONE, the other can't be NONE + val leftIsLocked = rightAction != StemAction.NONE + val rightIsLocked = leftAction != StemAction.NONE + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.stem_actions_left), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StemActionDropdown( + selected = leftAction, + onSelected = onLeftChange, + disableNone = leftIsLocked, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.stem_actions_right), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StemActionDropdown( + selected = rightAction, + onSelected = onRightChange, + disableNone = rightIsLocked, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StemActionDropdown( + selected: StemAction, + onSelected: (StemAction) -> Unit, + disableNone: Boolean, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = selected.label(), + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + for (action in StemAction.entries) { + val enabled = !(action == StemAction.NONE && disableNone) + DropdownMenuItem( + text = { + Text( + text = action.label(), + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + }, + onClick = { + if (enabled) { + onSelected(action) + expanded = false + } + }, + enabled = enabled, + ) + } + } + } +} + +@Composable +private fun StemAction.label(): String = when (this) { + StemAction.NONE -> stringResource(R.string.stem_action_none) + StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause) + StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track) + StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track) + StemAction.VOLUME_UP -> stringResource(R.string.stem_action_volume_up) + StemAction.VOLUME_DOWN -> stringResource(R.string.stem_action_volume_down) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt new file mode 100644 index 00000000..4ec992fd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/stemactions/StemActionConfigViewModel.kt @@ -0,0 +1,58 @@ +package eu.darken.capod.main.ui.stemactions + +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.uix.ViewModel4 +import eu.darken.capod.reaction.core.stem.StemAction +import eu.darken.capod.reaction.core.stem.StemActionSettings +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +class StemActionConfigViewModel @Inject constructor( + dispatcherProvider: DispatcherProvider, + private val stemActionSettings: StemActionSettings, +) : ViewModel4(dispatcherProvider) { + + val state = combine( + stemActionSettings.leftSingle.flow, + stemActionSettings.leftDouble.flow, + stemActionSettings.leftTriple.flow, + stemActionSettings.leftLong.flow, + stemActionSettings.rightSingle.flow, + stemActionSettings.rightDouble.flow, + stemActionSettings.rightTriple.flow, + stemActionSettings.rightLong.flow, + ) { values -> + State( + leftSingle = values[0], + leftDouble = values[1], + leftTriple = values[2], + leftLong = values[3], + rightSingle = values[4], + rightDouble = values[5], + rightTriple = values[6], + rightLong = values[7], + ) + }.asLiveState() + + data class State( + val leftSingle: StemAction = StemAction.NONE, + val leftDouble: StemAction = StemAction.NONE, + val leftTriple: StemAction = StemAction.NONE, + val leftLong: StemAction = StemAction.NONE, + val rightSingle: StemAction = StemAction.NONE, + val rightDouble: StemAction = StemAction.NONE, + val rightTriple: StemAction = StemAction.NONE, + val rightLong: StemAction = StemAction.NONE, + ) + + fun setLeftSingle(action: StemAction) = launch { stemActionSettings.leftSingle.update { action } } + fun setLeftDouble(action: StemAction) = launch { stemActionSettings.leftDouble.update { action } } + fun setLeftTriple(action: StemAction) = launch { stemActionSettings.leftTriple.update { action } } + fun setLeftLong(action: StemAction) = launch { stemActionSettings.leftLong.update { action } } + fun setRightSingle(action: StemAction) = launch { stemActionSettings.rightSingle.update { action } } + fun setRightDouble(action: StemAction) = launch { stemActionSettings.rightDouble.update { action } } + fun setRightTriple(action: StemAction) = launch { stemActionSettings.rightTriple.update { action } } + fun setRightLong(action: StemAction) = launch { stemActionSettings.rightLong.update { action } } +} 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 c9922003..44e4505c 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 @@ -253,6 +253,36 @@ data class PodDevice( val endCallMuteMic: AapSetting.EndCallMuteMic? get() = aap?.setting() + val microphoneMode: AapSetting.MicrophoneMode? + get() = aap?.setting() + + val earDetectionEnabled: AapSetting.EarDetectionEnabled? + get() = aap?.setting() + + val listeningModeCycle: AapSetting.ListeningModeCycle? + get() = aap?.setting() + + val allowOffOption: AapSetting.AllowOffOption? + get() = aap?.setting() + + val stemConfig: AapSetting.StemConfig? + get() = aap?.setting() + + val sleepDetection: AapSetting.SleepDetection? + get() = aap?.setting() + + val inCaseTone: AapSetting.InCaseTone? + get() = aap?.setting() + + val connectedDevices: AapSetting.ConnectedDevices? + get() = aap?.setting() + + val audioSource: AapSetting.AudioSource? + get() = aap?.setting() + + val eqBands: AapSetting.EqBands? + get() = aap?.setting() + val deviceInfo: AapDeviceInfo? get() = aap?.deviceInfo ?: cached?.deviceInfo diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt index 746b65d4..f2e9040b 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt @@ -24,12 +24,16 @@ class AapLifecycleManager @Inject constructor( @AppScope private val appScope: CoroutineScope, private val aapAutoConnect: AapAutoConnect, private val aapKeyPersister: AapKeyPersister, + private val stemConfigSender: StemConfigSender, + private val stemPressReaction: StemPressReaction, ) { fun start() { log(TAG) { "start()" } merge( aapAutoConnect.monitor(), aapKeyPersister.monitor(), + stemConfigSender.monitor(), + stemPressReaction.monitor(), ) .catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } } .setupCommonEventHandlers(TAG) { "aapActive" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt new file mode 100644 index 00000000..47587355 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemConfigSender.kt @@ -0,0 +1,81 @@ +package eu.darken.capod.monitor.core.aap + +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand +import eu.darken.capod.profiles.core.AppleDeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import eu.darken.capod.reaction.core.stem.StemAction +import eu.darken.capod.reaction.core.stem.StemActionSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StemConfigSender @Inject constructor( + private val aapManager: AapConnectionManager, + private val stemActionSettings: StemActionSettings, + private val profilesRepo: DeviceProfilesRepo, +) { + fun monitor(): Flow = combine( + aapManager.allStates, + stemActionMask(), + ) { states, mask -> + states.entries + .filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY } + .mapNotNull { (address, _) -> + val profile = profilesRepo.profiles.first() + .filterIsInstance() + .firstOrNull { it.address == address } + if (profile != null && profile.model.features.hasStemConfig) { + address to mask + } else { + null + } + } + } + .distinctUntilChanged() + .onEach { commands -> + for ((address, mask) in commands) { + try { + aapManager.sendCommand(address, AapCommand.SetStemConfig(mask)) + log(TAG) { "Sent stem config 0x${"%02X".format(mask)} to $address" } + } catch (e: Exception) { + log(TAG, WARN) { "StemConfig send failed for $address: $e" } + } + } + } + .map { } + .setupCommonEventHandlers(TAG) { "stemConfig" } + + private fun stemActionMask(): Flow = combine( + stemActionSettings.leftSingle.flow, + stemActionSettings.leftDouble.flow, + stemActionSettings.leftTriple.flow, + stemActionSettings.leftLong.flow, + stemActionSettings.rightSingle.flow, + stemActionSettings.rightDouble.flow, + stemActionSettings.rightTriple.flow, + stemActionSettings.rightLong.flow, + ) { values -> + var mask = 0 + if (values[0] != StemAction.NONE || values[4] != StemAction.NONE) mask = mask or 0x01 // single + if (values[1] != StemAction.NONE || values[5] != StemAction.NONE) mask = mask or 0x02 // double + if (values[2] != StemAction.NONE || values[6] != StemAction.NONE) mask = mask or 0x04 // triple + if (values[3] != StemAction.NONE || values[7] != StemAction.NONE) mask = mask or 0x08 // long + mask + } + + companion object { + private val TAG = logTag("Monitor", "StemConfigSender") + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt new file mode 100644 index 00000000..977b1e3b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/StemPressReaction.kt @@ -0,0 +1,64 @@ +package eu.darken.capod.monitor.core.aap + +import android.view.KeyEvent +import eu.darken.capod.common.MediaControl +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent +import eu.darken.capod.reaction.core.stem.StemAction +import eu.darken.capod.reaction.core.stem.StemActionSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StemPressReaction @Inject constructor( + private val aapManager: AapConnectionManager, + private val stemActionSettings: StemActionSettings, + private val mediaControl: MediaControl, +) { + fun monitor(): Flow = aapManager.stemPressEvents + .onEach { (_, event) -> + val action = resolveAction(event) + log(TAG) { "Stem ${event.bud} ${event.pressType} -> $action" } + executeAction(action) + } + .map { } + .setupCommonEventHandlers(TAG) { "stemReaction" } + + private suspend fun resolveAction(event: StemPressEvent): StemAction { + val setting = when (event.bud) { + StemPressEvent.Bud.LEFT -> when (event.pressType) { + StemPressEvent.PressType.SINGLE -> stemActionSettings.leftSingle + StemPressEvent.PressType.DOUBLE -> stemActionSettings.leftDouble + StemPressEvent.PressType.TRIPLE -> stemActionSettings.leftTriple + StemPressEvent.PressType.LONG -> stemActionSettings.leftLong + } + StemPressEvent.Bud.RIGHT -> when (event.pressType) { + StemPressEvent.PressType.SINGLE -> stemActionSettings.rightSingle + StemPressEvent.PressType.DOUBLE -> stemActionSettings.rightDouble + StemPressEvent.PressType.TRIPLE -> stemActionSettings.rightTriple + StemPressEvent.PressType.LONG -> stemActionSettings.rightLong + } + } + return setting.flow.first() + } + + private suspend fun executeAction(action: StemAction) = when (action) { + StemAction.NONE -> Unit + StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause() + StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT) + StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS) + StemAction.VOLUME_UP -> mediaControl.sendKey(KeyEvent.KEYCODE_VOLUME_UP) + StemAction.VOLUME_DOWN -> mediaControl.sendKey(KeyEvent.KEYCODE_VOLUME_DOWN) + } + + companion object { + private val TAG = logTag("Monitor", "StemPressReaction") + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index dfc8e175..1bf68c02 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -65,6 +65,10 @@ enum class PodModel( hasDualPods = true, hasCase = true, hasEarDetection = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasSleepDetection = true, + hasInCaseTone = true, ), modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, @@ -90,6 +94,13 @@ enum class PodModel( hasToneVolume = true, hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, + hasStemConfig = true, + hasSleepDetection = true, + hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones @@ -112,6 +123,10 @@ enum class PodModel( hasPressHoldDuration = true, hasToneVolume = true, hasEndCallMuteMic = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, ), modelNumbers = setOf("A2083", "A2084"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, @@ -139,6 +154,13 @@ enum class PodModel( hasToneVolume = true, hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, + hasStemConfig = true, + hasSleepDetection = true, + hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones @@ -167,6 +189,13 @@ enum class PodModel( hasToneVolume = true, hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, + hasStemConfig = true, + hasSleepDetection = true, + hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones @@ -195,6 +224,13 @@ enum class PodModel( hasToneVolume = true, hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true, + hasMicrophoneMode = true, + hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, + hasStemConfig = true, + hasSleepDetection = true, + hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones @@ -213,6 +249,7 @@ enum class PodModel( hasPressSpeed = true, hasPressHoldDuration = true, hasToneVolume = true, + hasEarDetectionToggle = true, ), modelNumbers = setOf("A2096"), // headphones ), @@ -227,6 +264,7 @@ enum class PodModel( hasPressSpeed = true, hasPressHoldDuration = true, hasToneVolume = true, + hasEarDetectionToggle = true, ), modelNumbers = setOf("A3184"), // headphones ), @@ -487,6 +525,13 @@ enum class PodModel( val hasToneVolume: Boolean = false, val hasEndCallMuteMic: Boolean = false, val hasAdaptiveAudioNoise: Boolean = false, + val hasMicrophoneMode: Boolean = false, + val hasEarDetectionToggle: Boolean = false, + val hasListeningModeCycle: Boolean = false, + val hasAllowOffOption: Boolean = false, + val hasStemConfig: Boolean = false, + val hasSleepDetection: Boolean = false, + val hasInCaseTone: Boolean = false, // Protocol val needsInitExt: Boolean = false, ) 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 b47de239..1f73e365 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 @@ -15,6 +15,8 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult +import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent +import kotlinx.coroutines.channels.BufferOverflow import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -44,9 +46,6 @@ internal class AapConnection( private val socketFactory: L2capSocketFactory, private val psm: Int = 0x1001, ) { - companion object { - private val TAG = logTag("AapConnection") - } private val _state = MutableStateFlow(AapPodState()) val state: StateFlow = _state.asStateFlow() @@ -54,6 +53,9 @@ internal class AapConnection( private val _keysReceived = MutableSharedFlow(extraBufferCapacity = 1) val keysReceived: SharedFlow = _keysReceived.asSharedFlow() + private val _stemPressEvents = MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() + private var socket: BluetoothSocket? = null private var readerJob: Job? = null private val writeMutex = Mutex() @@ -218,6 +220,28 @@ internal class AapConnection( baseState.setting() ?: return AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall) } + is AapCommand.SetMicrophoneMode -> { + AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode) + } + is AapCommand.SetEarDetectionEnabled -> { + AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled) + } + is AapCommand.SetListeningModeCycle -> { + AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask) + } + is AapCommand.SetAllowOffOption -> { + AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled) + } + is AapCommand.SetStemConfig -> { + AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask) + } + is AapCommand.SetSleepDetection -> { + AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled) + } + is AapCommand.SetInCaseTone -> { + AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled = command.enabled) + } + is AapCommand.SetDeviceName -> return // No optimistic state — name comes from deviceInfo } _state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now()) } @@ -282,9 +306,22 @@ internal class AapConnection( val hex = message.raw.joinToString(" ") { "%02X".format(it) } log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" } + // Try stem press event (transient — emitted via SharedFlow, not stored in state) + profile.decodeStemPress(message)?.let { event -> + _stemPressEvents.tryEmit(event) + log(TAG) { "Stem press: ${event.pressType} ${event.bud}" } + return + } + // Try battery profile.decodeBattery(message)?.let { batteries -> - _state.value = _state.value.copy(batteries = batteries, lastMessageAt = Instant.now()) + // Filter DISCONNECTED entries (e.g. case reports 0% DISCONNECTED when pods are removed). + // Merge with existing state so previously-known values are preserved for absent slots. + val valid = batteries.filterValues { it.charging != AapPodState.ChargingState.DISCONNECTED } + _state.value = _state.value.copy( + batteries = _state.value.batteries + valid, + lastMessageAt = Instant.now(), + ) log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } return } @@ -386,4 +423,8 @@ internal class AapConnection( } socket = null } + + companion object { + private val TAG = logTag("AapConnection") + } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index 00ad1f56..a3a176cb 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel 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.KeyExchangeResult +import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -56,6 +57,10 @@ class AapConnectionManager @Inject constructor( private val _keysReceived = MutableSharedFlow>(extraBufferCapacity = 16) val keysReceived: SharedFlow> = _keysReceived.asSharedFlow() + /** Emits transient stem press events from any connected device. */ + private val _stemPressEvents = MutableSharedFlow>(extraBufferCapacity = 32) + val stemPressEvents: SharedFlow> = _stemPressEvents.asSharedFlow() + fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] } suspend fun connect( @@ -93,6 +98,13 @@ class AapConnectionManager @Inject constructor( } } + // Forward stem press events from this connection (child coroutine) + launch { + connection.stemPressEvents.collect { event -> + _stemPressEvents.tryEmit(address to event) + } + } + connection.state.collect { podState -> if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) { log(TAG) { "Connection to $address disconnected" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt index ff53ea01..71d00b48 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt @@ -16,4 +16,12 @@ sealed class AapCommand { data class SetVolumeSwipe(val enabled: Boolean) : AapCommand() data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand() data class SetAdaptiveAudioNoise(val level: Int) : AapCommand() + data class SetMicrophoneMode(val mode: AapSetting.MicrophoneMode.Mode) : AapCommand() + data class SetEarDetectionEnabled(val enabled: Boolean) : AapCommand() + data class SetListeningModeCycle(val modeMask: Int) : AapCommand() + data class SetAllowOffOption(val enabled: Boolean) : AapCommand() + data class SetStemConfig(val claimedPressMask: Int) : AapCommand() + data class SetSleepDetection(val enabled: Boolean) : AapCommand() + data class SetInCaseTone(val enabled: Boolean) : AapCommand() + data class SetDeviceName(val name: String) : AapCommand() } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt index e941f20c..54ebc8f5 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt @@ -67,6 +67,12 @@ interface AapDeviceProfile { */ fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? + /** + * Decode a stem press event (command 0x19). + * Returns null if the message is not a stem press event. + */ + fun decodeStemPress(message: AapMessage): StemPressEvent? + companion object { fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index 61b920a2..e81d328a 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -102,6 +102,69 @@ sealed class AapSetting { val speaking: Boolean, ) : AapSetting() + data class MicrophoneMode( + val mode: Mode, + ) : AapSetting() { + enum class Mode(val wireValue: Int) { + AUTO(0x00), ALWAYS_RIGHT(0x01), ALWAYS_LEFT(0x02); + + companion object { + fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value } + } + } + } + + data class EarDetectionEnabled( + val enabled: Boolean, + ) : AapSetting() + + data class ListeningModeCycle( + val modeMask: Int, + ) : AapSetting() { + val includesOff: Boolean get() = (modeMask and 0x01) != 0 + val includesAnc: Boolean get() = (modeMask and 0x02) != 0 + val includesTransparency: Boolean get() = (modeMask and 0x04) != 0 + val includesAdaptive: Boolean get() = (modeMask and 0x08) != 0 + } + + data class AllowOffOption( + val enabled: Boolean, + ) : AapSetting() + + data class StemConfig( + val claimedPressMask: Int, + ) : AapSetting() { + val claimsSinglePress: Boolean get() = (claimedPressMask and 0x01) != 0 + val claimsDoublePress: Boolean get() = (claimedPressMask and 0x02) != 0 + val claimsTriplePress: Boolean get() = (claimedPressMask and 0x04) != 0 + val claimsLongPress: Boolean get() = (claimedPressMask and 0x08) != 0 + } + + data class SleepDetection( + val enabled: Boolean, + ) : AapSetting() + + data class InCaseTone( + val enabled: Boolean, + ) : AapSetting() + + data class ConnectedDevices( + val devices: List, + ) : AapSetting() { + data class ConnectedDevice(val mac: String, val type: Int) + } + + data class AudioSource( + val sourceMac: String?, + val type: AudioSourceType, + ) : AapSetting() { + enum class AudioSourceType { NONE, CALL, MEDIA } + } + + data class EqBands( + val sets: List>, + ) : AapSetting() + /** Per-pod placement reported by the device (command 0x06). */ data class EarDetection( val primaryPod: PodPlacement, 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 16dd2d17..a2810000 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 @@ -37,6 +37,20 @@ class DefaultAapDeviceProfile( const val SETTING_PERSONALIZED_VOLUME = 0x26 const val SETTING_CONVERSATIONAL_AWARENESS = 0x28 const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E + const val SETTING_MICROPHONE_MODE = 0x01 + const val SETTING_EAR_DETECTION_ENABLED = 0x0A + const val SETTING_LISTENING_MODE_CYCLE = 0x1A + const val SETTING_IN_CASE_TONE = 0x31 + const val SETTING_ALLOW_OFF_OPTION = 0x34 + const val SETTING_SLEEP_DETECTION = 0x35 + const val SETTING_STEM_CONFIG = 0x39 + + // Command types for non-settings messages + const val CMD_RENAME = 0x001E + const val CMD_STEM_PRESS = 0x0019 + const val CMD_CONNECTED_DEVICES = 0x002E + const val CMD_AUDIO_SOURCE = 0x000E + const val CMD_EQ_DATA = 0x0053 // ANC mode wire values const val ANC_WIRE_OFF = 0x01 @@ -49,8 +63,8 @@ class DefaultAapDeviceProfile( val features = model.features when { !features.hasAncControl -> emptyList() - features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE) - else -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY) + features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE) + else -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY) } } @@ -84,6 +98,14 @@ class DefaultAapDeviceProfile( is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled)) is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, command.level.coerceIn(0, 100)) is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall) + is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue) + is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled)) + is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F) + is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled)) + is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F) + is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled)) + is AapCommand.SetInCaseTone -> buildSettingsMessage(SETTING_IN_CASE_TONE, encodeAppleBool(command.enabled)) + is AapCommand.SetDeviceName -> buildRenameMessage(command.name) } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { @@ -113,6 +135,55 @@ class DefaultAapDeviceProfile( ) } + // Connected devices list (push-only from device) + if (message.commandType == CMD_CONNECTED_DEVICES) { + if (message.payload.size < 3) return null + val count = message.payload[2].toInt() and 0xFF + val devices = mutableListOf() + var offset = 3 + for (i in 0 until count) { + if (offset + 8 > message.payload.size) break + val mac = (0 until 6).map { "%02X".format(message.payload[offset + 5 - it]) }.joinToString(":") + val type = message.payload[offset + 6].toInt() and 0xFF + devices.add(AapSetting.ConnectedDevices.ConnectedDevice(mac, type)) + offset += 8 + } + return AapSetting.ConnectedDevices::class to AapSetting.ConnectedDevices(devices) + } + + // Audio source tracking (push-only from device) + if (message.commandType == CMD_AUDIO_SOURCE) { + if (message.payload.size < 7) return null + val mac = (0 until 6).map { "%02X".format(message.payload[5 - it]) }.joinToString(":") + val typeValue = message.payload[6].toInt() and 0xFF + val type = when (typeValue) { + 0x01 -> AapSetting.AudioSource.AudioSourceType.CALL + 0x02 -> AapSetting.AudioSource.AudioSourceType.MEDIA + else -> AapSetting.AudioSource.AudioSourceType.NONE + } + return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type) + } + + // EQ data (push-only from device) + if (message.commandType == CMD_EQ_DATA) { + if (message.payload.size < 6 + 128) return null + val sets = mutableListOf>() + var offset = 6 // skip header + for (s in 0 until 4) { + val bands = mutableListOf() + for (b in 0 until 8) { + val bits = (message.payload[offset].toInt() and 0xFF) or + ((message.payload[offset + 1].toInt() and 0xFF) shl 8) or + ((message.payload[offset + 2].toInt() and 0xFF) shl 16) or + ((message.payload[offset + 3].toInt() and 0xFF) shl 24) + bands.add(Float.fromBits(bits)) + offset += 4 + } + sets.add(bands) + } + return AapSetting.EqBands::class to AapSetting.EqBands(sets) + } + // Conversation Awareness State is a separate command type (push-only) if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { if (message.payload.isEmpty()) return null @@ -169,6 +240,32 @@ class DefaultAapDeviceProfile( SETTING_ADAPTIVE_AUDIO_NOISE -> { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = value) } + SETTING_MICROPHONE_MODE -> { + val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null + AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode) + } + SETTING_EAR_DETECTION_ENABLED -> { + val enabled = decodeAppleBool(value) ?: return null + AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled) + } + SETTING_LISTENING_MODE_CYCLE -> { + AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value) + } + SETTING_ALLOW_OFF_OPTION -> { + val enabled = decodeAppleBool(value) ?: return null + AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled) + } + SETTING_STEM_CONFIG -> { + AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value) + } + SETTING_SLEEP_DETECTION -> { + val enabled = decodeAppleBool(value) ?: return null + AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled) + } + SETTING_IN_CASE_TONE -> { + val enabled = decodeAppleBool(value) ?: return null + AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) + } else -> null } } @@ -339,6 +436,24 @@ class DefaultAapDeviceProfile( } } + override fun decodeStemPress(message: AapMessage): StemPressEvent? { + if (message.commandType != CMD_STEM_PRESS) return null + if (message.payload.size < 2) return null + val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null + val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null + return StemPressEvent(pressType, bud) + } + + private fun buildRenameMessage(name: String): ByteArray { + val nameBytes = name.toByteArray(Charsets.UTF_8) + require(nameBytes.size <= 127) { "Device name too long: ${nameBytes.size} bytes (max 127)" } + return byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x1E, 0x00, + nameBytes.size.toByte(), 0x00, + ) + nameBytes + } + private fun parseNullTerminatedStrings(data: ByteArray): List { val strings = mutableListOf() var start = 0 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/StemPressEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/StemPressEvent.kt new file mode 100644 index 00000000..264e3df8 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/StemPressEvent.kt @@ -0,0 +1,22 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +data class StemPressEvent( + val pressType: PressType, + val bud: Bud, +) { + enum class PressType(val wireValue: Int) { + SINGLE(0x05), DOUBLE(0x06), TRIPLE(0x07), LONG(0x08); + + companion object { + fun fromWire(value: Int): PressType? = entries.firstOrNull { it.wireValue == value } + } + } + + enum class Bud(val wireValue: Int) { + LEFT(0x01), RIGHT(0x02); + + companion object { + fun fromWire(value: Int): Bud? = entries.firstOrNull { it.wireValue == value } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt new file mode 100644 index 00000000..7536ed61 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemAction.kt @@ -0,0 +1,13 @@ +package eu.darken.capod.reaction.core.stem + +import kotlinx.serialization.Serializable + +@Serializable +enum class StemAction { + NONE, + PLAY_PAUSE, + NEXT_TRACK, + PREVIOUS_TRACK, + VOLUME_UP, + VOLUME_DOWN, +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt new file mode 100644 index 00000000..97ed17f9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/stem/StemActionSettings.kt @@ -0,0 +1,32 @@ +package eu.darken.capod.reaction.core.stem + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.SerializationCapod +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StemActionSettings @Inject constructor( + @ApplicationContext private val context: Context, + @SerializationCapod json: Json, +) { + + private val Context.dataStore by preferencesDataStore(name = "settings_stem_actions") + + private val dataStore: DataStore get() = context.dataStore + + val leftSingle = dataStore.createValue("stem.left.single", StemAction.NONE, json, onErrorFallbackToDefault = true) + val leftDouble = dataStore.createValue("stem.left.double", StemAction.NONE, json, onErrorFallbackToDefault = true) + val leftTriple = dataStore.createValue("stem.left.triple", StemAction.NONE, json, onErrorFallbackToDefault = true) + val leftLong = dataStore.createValue("stem.left.long", StemAction.NONE, json, onErrorFallbackToDefault = true) + val rightSingle = dataStore.createValue("stem.right.single", StemAction.NONE, json, onErrorFallbackToDefault = true) + val rightDouble = dataStore.createValue("stem.right.double", StemAction.NONE, json, onErrorFallbackToDefault = true) + val rightTriple = dataStore.createValue("stem.right.triple", StemAction.NONE, json, onErrorFallbackToDefault = true) + val rightLong = dataStore.createValue("stem.right.long", StemAction.NONE, json, onErrorFallbackToDefault = true) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a0b9ea6..b276259a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,4 +464,53 @@ Double press to end call Open device settings + + Additional + Microphone + Which AirPod is used as the microphone + Auto + Right + Left + Automatic Ear Detection + Pause audio when AirPods are removed from ears + Noise Control Cycle + Select which modes cycle when pressing and holding the stem + Off + Noise Cancellation + Transparency + Adaptive + Include Off in Noise Control + Show Off as an option when cycling noise control modes + Sleep Detection + Automatically pause audio when you fall asleep + Charging Sounds + Play a sound when connected to a charger + Rename + Device name + Rename + Connected Devices + Other devices currently connected to these AirPods + Device %d + In a call + Playing media + + Equalizer + + + Stem Actions + Map stem presses to Android actions + Choose what happens when you press the stem on each AirPod. When an action is assigned, the app intercepts the press instead of the AirPods handling it natively. + Single Press + Double Press + Triple Press + Long Press + Left + Right + None + Play/Pause + Next Track + Previous Track + Volume Up + Volume Down + \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt new file mode 100644 index 00000000..59dfdd08 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -0,0 +1,313 @@ +package eu.darken.capod.pods.core.apple.aap.devices + +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand +import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.aap.protocol.BaseAapSessionTest +import eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile +import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { + + override val podModel = PodModel.AIRPODS_PRO2 + + // ── Microphone Mode (0x01) ────────────────────────────── + + @Nested + inner class MicrophoneModeTests { + @Test fun `encode auto`() { profile.encodeCommand(AapCommand.SetMicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO))[7] shouldBe 0x00.toByte() } + @Test fun `encode always right`() { profile.encodeCommand(AapCommand.SetMicrophoneMode(AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT))[7] shouldBe 0x01.toByte() } + @Test fun `encode always left`() { profile.encodeCommand(AapCommand.SetMicrophoneMode(AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT))[7] shouldBe 0x02.toByte() } + @Test fun `decode auto`() { decodeSetting(settingsMessage(0x01, 0x00)).mode shouldBe AapSetting.MicrophoneMode.Mode.AUTO } + @Test fun `decode always right`() { decodeSetting(settingsMessage(0x01, 0x01)).mode shouldBe AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT } + @Test fun `decode always left`() { decodeSetting(settingsMessage(0x01, 0x02)).mode shouldBe AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x01, 0x99)).shouldBeNull() } + + @Test + fun `round-trip all modes`() { + for (mode in AapSetting.MicrophoneMode.Mode.entries) { + val encoded = profile.encodeCommand(AapCommand.SetMicrophoneMode(mode)) + decodeSetting(AapMessage.parse(encoded)!!).mode shouldBe mode + } + } + } + + // ── Ear Detection Toggle (0x0A) ───────────────────────── + + @Nested + inner class EarDetectionToggleTests { + @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetEarDetectionEnabled(true))[7] shouldBe 0x01.toByte() } + @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetEarDetectionEnabled(false))[7] shouldBe 0x02.toByte() } + @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x0A, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x0A, 0x02)).enabled shouldBe false } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x0A, 0x00)).shouldBeNull() } + } + + // ── Listening Mode Cycle (0x1A) ───────────────────────── + + @Nested + inner class ListeningModeCycleTests { + @Test fun `encode mask 0x0F`() { profile.encodeCommand(AapCommand.SetListeningModeCycle(0x0F))[7] shouldBe 0x0F.toByte() } + @Test fun `encode mask 0x06`() { profile.encodeCommand(AapCommand.SetListeningModeCycle(0x06))[7] shouldBe 0x06.toByte() } + @Test fun `decode mask`() { decodeSetting(settingsMessage(0x1A, 0x0E)).modeMask shouldBe 0x0E } + + @Test fun `decode mask helpers`() { + val cycle = decodeSetting(settingsMessage(0x1A, 0x0D)) + cycle.includesOff shouldBe true + cycle.includesAnc shouldBe false + cycle.includesTransparency shouldBe true + cycle.includesAdaptive shouldBe true + } + + @Test fun `encode single mode`() { + profile.encodeCommand(AapCommand.SetListeningModeCycle(0x01))[7] shouldBe 0x01.toByte() + } + + @Test fun `encode zero mask`() { + profile.encodeCommand(AapCommand.SetListeningModeCycle(0x00))[7] shouldBe 0x00.toByte() + } + + @Test fun `encode masks out high bits`() { + profile.encodeCommand(AapCommand.SetListeningModeCycle(0xFF))[7] shouldBe 0x0F.toByte() + } + } + + // ── Allow Off Option (0x34) ───────────────────────────── + + @Nested + inner class AllowOffOptionTests { + @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetAllowOffOption(true))[7] shouldBe 0x01.toByte() } + @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetAllowOffOption(false))[7] shouldBe 0x02.toByte() } + @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x34, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x34, 0x02)).enabled shouldBe false } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x34, 0x00)).shouldBeNull() } + } + + // ── Stem Config (0x39) ────────────────────────────────── + + @Nested + inner class StemConfigTests { + @Test fun `encode full claim`() { profile.encodeCommand(AapCommand.SetStemConfig(0x0F))[7] shouldBe 0x0F.toByte() } + @Test fun `encode no claim`() { profile.encodeCommand(AapCommand.SetStemConfig(0x00))[7] shouldBe 0x00.toByte() } + @Test fun `encode masks high bits`() { profile.encodeCommand(AapCommand.SetStemConfig(0xFF))[7] shouldBe 0x0F.toByte() } + + @Test fun `decode claim mask`() { + val sc = decodeSetting(settingsMessage(0x39, 0x05)) + sc.claimsSinglePress shouldBe true + sc.claimsDoublePress shouldBe false + sc.claimsTriplePress shouldBe true + sc.claimsLongPress shouldBe false + } + } + + // ── Sleep Detection (0x35) ────────────────────────────── + + @Nested + inner class SleepDetectionTests { + @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetSleepDetection(true))[7] shouldBe 0x01.toByte() } + @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetSleepDetection(false))[7] shouldBe 0x02.toByte() } + @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x35, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x35, 0x02)).enabled shouldBe false } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x35, 0x00)).shouldBeNull() } + } + + // ── In-Case Tone (0x31) ───────────────────────────────── + + @Nested + inner class InCaseToneTests { + @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetInCaseTone(true))[7] shouldBe 0x01.toByte() } + @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetInCaseTone(false))[7] shouldBe 0x02.toByte() } + @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x31, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x31, 0x02)).enabled shouldBe false } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x31, 0x00)).shouldBeNull() } + } + + // ── Device Rename (0x1E) ──────────────────────────────── + + @Nested + inner class DeviceRenameTests { + @Test + fun `encode simple ASCII name`() { + val bytes = profile.encodeCommand(AapCommand.SetDeviceName("MyPods")) + bytes[4] shouldBe 0x1E.toByte() + bytes[6] shouldBe 6.toByte() // length + String(bytes, 8, 6, Charsets.UTF_8) shouldBe "MyPods" + } + + @Test + fun `encode multibyte UTF-8 name`() { + val name = "AirPods \uD83C\uDFA7" // headphone emoji + val nameBytes = name.toByteArray(Charsets.UTF_8) + val bytes = profile.encodeCommand(AapCommand.SetDeviceName(name)) + bytes[6] shouldBe nameBytes.size.toByte() + String(bytes, 8, nameBytes.size, Charsets.UTF_8) shouldBe name + } + + @Test + fun `encode rejects name exceeding 127 bytes`() { + val longName = "A".repeat(128) // 128 ASCII bytes + assertThrows { profile.encodeCommand(AapCommand.SetDeviceName(longName)) } + } + + @Test + fun `encode accepts 127 byte name`() { + val name = "A".repeat(127) + val bytes = profile.encodeCommand(AapCommand.SetDeviceName(name)) + bytes[6] shouldBe 127.toByte() + } + } + + // ── Stem Press Events (0x19) ──────────────────────────── + + @Nested + inner class StemPressEventTests { + @Test fun `decode single left`() { + val event = profile.decodeStemPress(aapMessage("04 00 04 00 19 00 05 01"))!! + event.pressType shouldBe StemPressEvent.PressType.SINGLE + event.bud shouldBe StemPressEvent.Bud.LEFT + } + + @Test fun `decode double right`() { + val event = profile.decodeStemPress(aapMessage("04 00 04 00 19 00 06 02"))!! + event.pressType shouldBe StemPressEvent.PressType.DOUBLE + event.bud shouldBe StemPressEvent.Bud.RIGHT + } + + @Test fun `decode triple left`() { + val event = profile.decodeStemPress(aapMessage("04 00 04 00 19 00 07 01"))!! + event.pressType shouldBe StemPressEvent.PressType.TRIPLE + event.bud shouldBe StemPressEvent.Bud.LEFT + } + + @Test fun `decode long right`() { + val event = profile.decodeStemPress(aapMessage("04 00 04 00 19 00 08 02"))!! + event.pressType shouldBe StemPressEvent.PressType.LONG + event.bud shouldBe StemPressEvent.Bud.RIGHT + } + + @Test fun `unknown press type returns null`() { + profile.decodeStemPress(aapMessage("04 00 04 00 19 00 99 01")).shouldBeNull() + } + + @Test fun `unknown bud returns null`() { + profile.decodeStemPress(aapMessage("04 00 04 00 19 00 05 03")).shouldBeNull() + } + + @Test fun `payload too short returns null`() { + profile.decodeStemPress(aapMessage("04 00 04 00 19 00 05")).shouldBeNull() + } + + @Test fun `wrong command type returns null`() { + profile.decodeStemPress(settingsMessage(0x0D, 0x02)).shouldBeNull() + } + } + + // ── Connected Devices (0x2E) ──────────────────────────── + + @Nested + inner class ConnectedDevicesTests { + @Test fun `decode single device`() { + // payload: 2 unknown bytes, count=1, then 6-byte MAC reversed + 2 flags + val msg = aapMessage("04 00 04 00 2E 00 00 00 01 06 05 04 03 02 01 00 00") + val cd = decodeSetting(msg) + cd.devices.size shouldBe 1 + cd.devices[0].mac shouldBe "01:02:03:04:05:06" + } + + @Test fun `decode empty list`() { + val msg = aapMessage("04 00 04 00 2E 00 00 00 00") + val cd = decodeSetting(msg) + cd.devices shouldBe emptyList() + } + + @Test fun `payload too short returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 2E 00 00 00")).shouldBeNull() + } + } + + // ── Audio Source (0x0E) ────────────────────────────────── + + @Nested + inner class AudioSourceTests { + @Test fun `decode media source`() { + val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 02") + val as_ = decodeSetting(msg) + as_.sourceMac shouldBe "01:02:03:04:05:06" + as_.type shouldBe AapSetting.AudioSource.AudioSourceType.MEDIA + } + + @Test fun `decode call source`() { + val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 01") + val as_ = decodeSetting(msg) + as_.type shouldBe AapSetting.AudioSource.AudioSourceType.CALL + } + + @Test fun `decode unknown type maps to NONE`() { + val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 00") + val as_ = decodeSetting(msg) + as_.type shouldBe AapSetting.AudioSource.AudioSourceType.NONE + } + + @Test fun `payload too short returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01")).shouldBeNull() + } + } + + // ── Model Feature Flags ───────────────────────────────── + + @Nested + inner class ModelFeatureFlags { + @Test fun `Pro 2 has all new flags`() { + val f = PodModel.AIRPODS_PRO2.features + f.hasMicrophoneMode shouldBe true + f.hasEarDetectionToggle shouldBe true + f.hasListeningModeCycle shouldBe true + f.hasAllowOffOption shouldBe true + f.hasStemConfig shouldBe true + f.hasSleepDetection shouldBe true + f.hasInCaseTone shouldBe true + } + + @Test fun `Pro 1 has mic and ear detection but no stem config`() { + val f = PodModel.AIRPODS_PRO.features + f.hasMicrophoneMode shouldBe true + f.hasEarDetectionToggle shouldBe true + f.hasListeningModeCycle shouldBe true + f.hasAllowOffOption shouldBe true + f.hasStemConfig shouldBe false + f.hasSleepDetection shouldBe false + f.hasInCaseTone shouldBe false + } + + @Test fun `Gen 4 has mic, ear detection, sleep, in-case but no stem config`() { + val f = PodModel.AIRPODS_GEN4.features + f.hasMicrophoneMode shouldBe true + f.hasEarDetectionToggle shouldBe true + f.hasListeningModeCycle shouldBe false + f.hasStemConfig shouldBe false + f.hasSleepDetection shouldBe true + f.hasInCaseTone shouldBe true + } + + @Test fun `Max has ear detection toggle only`() { + val f = PodModel.AIRPODS_MAX.features + f.hasMicrophoneMode shouldBe false + f.hasEarDetectionToggle shouldBe true + f.hasStemConfig shouldBe false + } + + @Test fun `Gen 1 has no new flags`() { + val f = PodModel.AIRPODS_GEN1.features + f.hasMicrophoneMode shouldBe false + f.hasEarDetectionToggle shouldBe false + f.hasListeningModeCycle shouldBe false + f.hasStemConfig shouldBe false + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index 3fdc3cbe..405079ae 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -84,16 +84,16 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { } @Test - fun `Pro 2 supports ON, TRANSPARENCY, ADAPTIVE`() { + fun `Pro 2 supports OFF, ON, TRANSPARENCY, ADAPTIVE`() { ancModesFor(PodModel.AIRPODS_PRO2) shouldContainExactly listOf( - AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE, + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE, ) } @Test - fun `Pro 1 supports ON, TRANSPARENCY only`() { + fun `Pro 1 supports OFF, ON, TRANSPARENCY`() { ancModesFor(PodModel.AIRPODS_PRO) shouldContainExactly listOf( - AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, ) } @@ -103,9 +103,9 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { } @Test - fun `Max supports ON, TRANSPARENCY only`() { + fun `Max supports OFF, ON, TRANSPARENCY`() { ancModesFor(PodModel.AIRPODS_MAX) shouldContainExactly listOf( - AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, ) } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt index de098925..0b6e6417 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt @@ -113,6 +113,7 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { val anc = decodeSetting("04 00 04 00 09 00 0D 02 00 00 00") anc.current shouldBe AapSetting.AncMode.Value.ON anc.supported shouldContainExactly listOf( + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE @@ -242,7 +243,7 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { @Test fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x35, 0x3E) + val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x3E) for (id in unknownIds) { profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index e67f41fc..616af54d 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -106,6 +106,7 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { val anc = decodeSetting("04 00 04 00 09 00 0D 02 00 00 00") anc.current shouldBe AapSetting.AncMode.Value.ON anc.supported shouldContainExactly listOf( + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE @@ -236,7 +237,7 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { @Test fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B) + val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x3E, 0x37, 0x38, 0x3B) for (id in unknownIds) { profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt index 70a10f4b..272c0c33 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt @@ -113,6 +113,7 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() { val anc = decodeSetting("04 00 04 00 09 00 0D 01 00 00 00") anc.current shouldBe AapSetting.AncMode.Value.OFF anc.supported shouldContainExactly listOf( + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, )