mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
+43
-1
@@ -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,
|
||||
|
||||
+117
-2
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
+313
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user