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.
This commit is contained in:
darken
2026-04-04 11:41:26 +02:00
committed by Matthias Urhahn
parent e014ad8045
commit e356344ba6
32 changed files with 1845 additions and 85 deletions
@@ -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,
)
}
}
}
}
@@ -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,
)
}
}
}
@@ -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))
@@ -24,6 +24,9 @@ object Nav {
@Serializable
data class DeviceSettings(val address: String) : Main
@Serializable
data object StemActionConfig : Main
}
sealed interface Settings : NavigationDestination {
@@ -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),
)
}
}
}
}
@@ -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<List<Float>>,
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<AapSetting.ConnectedDevices.ConnectedDevice>,
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<AapSetting.AncMode.Value>,
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 {
@@ -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<BluetoothAddress?>(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")
}
@@ -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,
)
@@ -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,
)
@@ -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<NavKey>.setup() {
entry<Nav.Main.StemActionConfig> {
StemActionConfigScreenHost()
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry
}
}
@@ -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)
}
@@ -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 } }
}
@@ -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
@@ -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" }
@@ -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<Unit> = combine(
aapManager.allStates,
stemActionMask(),
) { states, mask ->
states.entries
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
.mapNotNull { (address, _) ->
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
if (profile != null && profile.model.features.hasStemConfig) {
address to mask
} else {
null
}
}
}
.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<Int> = combine(
stemActionSettings.leftSingle.flow,
stemActionSettings.leftDouble.flow,
stemActionSettings.leftTriple.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightSingle.flow,
stemActionSettings.rightDouble.flow,
stemActionSettings.rightTriple.flow,
stemActionSettings.rightLong.flow,
) { values ->
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")
}
}
@@ -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<Unit> = 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")
}
}
@@ -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,
)
@@ -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<AapPodState> = _state.asStateFlow()
@@ -54,6 +53,9 @@ internal class AapConnection(
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
private val _stemPressEvents = MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _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<AapSetting.EndCallMuteMic>() ?: 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")
}
}
@@ -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<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
val keysReceived: SharedFlow<Pair<BluetoothAddress, KeyExchangeResult>> = _keysReceived.asSharedFlow()
/** Emits transient stem press events from any connected device. */
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _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" }
@@ -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()
}
@@ -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)
}
@@ -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<ConnectedDevice>,
) : 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<List<Float>>,
) : AapSetting()
/** Per-pod placement reported by the device (command 0x06). */
data class EarDetection(
val primaryPod: PodPlacement,
@@ -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<KClass<out AapSetting>, 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<AapSetting.ConnectedDevices.ConnectedDevice>()
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<List<Float>>()
var offset = 6 // skip header
for (s in 0 until 4) {
val bands = mutableListOf<Float>()
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<String> {
val strings = mutableListOf<String>()
var start = 0
@@ -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 }
}
}
}
@@ -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,
}
@@ -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<Preferences> get() = context.dataStore
val leftSingle = dataStore.createValue("stem.left.single", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftDouble = dataStore.createValue("stem.left.double", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftTriple = dataStore.createValue("stem.left.triple", StemAction.NONE, json, onErrorFallbackToDefault = true)
val leftLong = dataStore.createValue("stem.left.long", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightSingle = dataStore.createValue("stem.right.single", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightDouble = dataStore.createValue("stem.right.double", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightTriple = dataStore.createValue("stem.right.triple", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightLong = dataStore.createValue("stem.right.long", StemAction.NONE, json, onErrorFallbackToDefault = true)
}
+49
View File
@@ -464,4 +464,53 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double press to end call</string>
<string name="device_settings_open_cd">Open device settings</string>
<!-- New device settings -->
<string name="device_settings_category_additional_label">Additional</string>
<string name="device_settings_microphone_mode_label">Microphone</string>
<string name="device_settings_microphone_mode_description">Which AirPod is used as the microphone</string>
<string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string>
<string name="device_settings_ear_detection_label">Automatic Ear Detection</string>
<string name="device_settings_ear_detection_description">Pause audio when AirPods are removed from ears</string>
<string name="device_settings_listening_mode_cycle_label">Noise Control Cycle</string>
<string name="device_settings_listening_mode_cycle_description">Select which modes cycle when pressing and holding the stem</string>
<string name="device_settings_listening_mode_cycle_off">Off</string>
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string>
<string name="device_settings_listening_mode_cycle_transparency">Transparency</string>
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string>
<string name="device_settings_allow_off_label">Include Off in Noise Control</string>
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
<string name="device_settings_in_case_tone_label">Charging Sounds</string>
<string name="device_settings_in_case_tone_description">Play a sound when connected to a charger</string>
<string name="device_settings_rename_label">Rename</string>
<string name="device_settings_rename_hint">Device name</string>
<string name="device_settings_rename_confirm">Rename</string>
<string name="device_settings_category_connections_label">Connected Devices</string>
<string name="device_settings_connected_devices_description">Other devices currently connected to these AirPods</string>
<string name="device_settings_connected_device_label">Device %d</string>
<string name="device_settings_connected_device_call">In a call</string>
<string name="device_settings_connected_device_media">Playing media</string>
<string name="device_settings_eq_label">Equalizer</string>
<!-- Stem Actions -->
<string name="stem_actions_title">Stem Actions</string>
<string name="stem_actions_nav_description">Map stem presses to Android actions</string>
<string name="stem_actions_description">Choose what happens when you press the stem on each AirPod. When an action is assigned, the app intercepts the press instead of the AirPods handling it natively.</string>
<string name="stem_actions_single_press">Single Press</string>
<string name="stem_actions_double_press">Double Press</string>
<string name="stem_actions_triple_press">Triple Press</string>
<string name="stem_actions_long_press">Long Press</string>
<string name="stem_actions_left">Left</string>
<string name="stem_actions_right">Right</string>
<string name="stem_action_none">None</string>
<string name="stem_action_play_pause">Play/Pause</string>
<string name="stem_action_next_track">Next Track</string>
<string name="stem_action_previous_track">Previous Track</string>
<string name="stem_action_volume_up">Volume Up</string>
<string name="stem_action_volume_down">Volume Down</string>
</resources>
@@ -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<AapSetting.MicrophoneMode>(settingsMessage(0x01, 0x00)).mode shouldBe AapSetting.MicrophoneMode.Mode.AUTO }
@Test fun `decode always right`() { decodeSetting<AapSetting.MicrophoneMode>(settingsMessage(0x01, 0x01)).mode shouldBe AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT }
@Test fun `decode always left`() { decodeSetting<AapSetting.MicrophoneMode>(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<AapSetting.MicrophoneMode>(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<AapSetting.EarDetectionEnabled>(settingsMessage(0x0A, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.EarDetectionEnabled>(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<AapSetting.ListeningModeCycle>(settingsMessage(0x1A, 0x0E)).modeMask shouldBe 0x0E }
@Test fun `decode mask helpers`() {
val cycle = decodeSetting<AapSetting.ListeningModeCycle>(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<AapSetting.AllowOffOption>(settingsMessage(0x34, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.AllowOffOption>(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<AapSetting.StemConfig>(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<AapSetting.SleepDetection>(settingsMessage(0x35, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.SleepDetection>(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<AapSetting.InCaseTone>(settingsMessage(0x31, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.InCaseTone>(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<IllegalArgumentException> { 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<AapSetting.ConnectedDevices>(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<AapSetting.ConnectedDevices>(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<AapSetting.AudioSource>(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<AapSetting.AudioSource>(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<AapSetting.AudioSource>(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
}
}
}
@@ -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,
)
}
}
@@ -113,6 +113,7 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
val anc = decodeSetting<AapSetting.AncMode>("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()
}
@@ -106,6 +106,7 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
val anc = decodeSetting<AapSetting.AncMode>("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()
}
@@ -113,6 +113,7 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
val anc = decodeSetting<AapSetting.AncMode>("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,
)