feat(device-settings): Merge one-pod mode settings into a single toggle

Combine reaction one-pod mode (autoplay/autopause) and firmware NC-with-one-AirPod into one setting. The merged toggle always sets the app-local flag and additionally syncs the firmware NC setting via AAP when connected.

Also remove redundant @OptIn(ExperimentalCoroutinesApi::class) annotations from tests, already covered by the global compiler opt-in flag.
This commit is contained in:
darken
2026-04-15 07:37:51 +02:00
committed by Matthias Urhahn
parent 397b32b425
commit f17b6ca99f
11 changed files with 326 additions and 29 deletions
@@ -150,7 +150,6 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
onAdaptiveAudioNoiseChange = { vm.setAdaptiveAudioNoise(it) },
@@ -187,7 +186,6 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
onAdaptiveAudioNoiseChange: (Int) -> Unit = {},
@@ -325,7 +323,8 @@ fun DeviceSettingsScreen(
if (features.hasDualPods) {
val onePodModeActive = reactions.autoPlay ||
reactions.autoPause ||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR) ||
features.hasNcOneAirpod
SettingsBaseItem(
title = stringResource(R.string.settings_onepod_mode_label),
subtitle = stringResource(R.string.settings_onepod_mode_description),
@@ -450,7 +449,6 @@ fun DeviceSettingsScreen(
// ── Noise Control ────────────────────────────
val ancMode = device.ancMode
val ncOneAirpod = device.ncWithOneAirPod
val adaptiveNoise = device.adaptiveAudioNoise
if (features.hasAncControl && ancMode != null) {
val cycleMask = if (features.hasListeningModeCycle) {
@@ -471,7 +469,6 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) ||
(features.hasNcOneAirpod && ncOneAirpod != null) ||
(!isPro && features.hasListeningModeCycle)
if (hasNoiseExtras) {
HorizontalDivider(
@@ -487,16 +484,6 @@ fun DeviceSettingsScreen(
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE,
)
}
if (features.hasNcOneAirpod && ncOneAirpod != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_nc_one_airpod_label),
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
checked = ncOneAirpod.enabled,
onCheckedChange = onNcWithOneAirPodChange,
enabled = enabled,
)
}
if (!isPro && features.hasListeningModeCycle) {
SettingsBaseItem(
icon = Icons.TwoTone.Loop,
@@ -206,8 +206,6 @@ class DeviceSettingsViewModel @Inject constructor(
fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled))
fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled))
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
@@ -299,7 +297,14 @@ class DeviceSettingsViewModel @Inject constructor(
fun setOnePodMode(enabled: Boolean) {
log(TAG, INFO) { "setOnePodMode($enabled)" }
launch { updateProfileNow { it.copy(onePodMode = enabled) } }
launch {
updateProfileNow { it.copy(onePodMode = enabled) }
// Opportunistic immediate sync — NcOnePodSender handles deferred/reconnect
val device = deviceMonitor.getDeviceForProfile(targetProfileId.value ?: return@launch)
if (device != null && device.isAapConnected && device.model.features.hasNcOneAirpod) {
sendInternal(AapCommand.SetNcWithOneAirPod(enabled))
}
}
}
fun setAutoPlay(enabled: Boolean) = launch {
@@ -26,6 +26,7 @@ class AapLifecycleManager @Inject constructor(
private val aapKeyPersister: AapKeyPersister,
private val stemConfigSender: StemConfigSender,
private val stemPressReaction: StemPressReaction,
private val ncOnePodSender: NcOnePodSender,
) {
fun start() {
log(TAG) { "start()" }
@@ -34,6 +35,7 @@ class AapLifecycleManager @Inject constructor(
aapKeyPersister.monitor(),
stemConfigSender.monitor(),
stemPressReaction.monitor(),
ncOnePodSender.monitor(),
)
.catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } }
.setupCommonEventHandlers(TAG) { "aapActive" }
@@ -0,0 +1,58 @@
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NcOnePodSender @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
) {
fun monitor(): Flow<Unit> = combine(
aapManager.allStates,
profilesRepo.profiles,
) { states, profiles ->
val appleProfiles = profiles.filterIsInstance<AppleDeviceProfile>()
states.entries
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
.mapNotNull { (address, _) ->
val profile = appleProfiles.firstOrNull { it.address == address }
if (profile != null && profile.model.features.hasNcOneAirpod) {
address to profile.onePodMode
} else {
null
}
}
}
.distinctUntilChanged()
.onEach { commands ->
for ((address, enabled) in commands) {
try {
aapManager.sendCommand(address, AapCommand.SetNcWithOneAirPod(enabled))
log(TAG) { "Sent SetNcWithOneAirPod($enabled) to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "SetNcWithOneAirPod send failed for $address: $e" }
}
}
}
.map { }
.setupCommonEventHandlers(TAG) { "ncOnePod" }
companion object {
private val TAG = logTag("Monitor", "NcOnePodSender")
}
}
+1 -1
View File
@@ -81,7 +81,7 @@
<string name="settings_compat_offloaded_batching_disabled_summary">Don\'t let the system group collected BLE data before forwarding it to us.</string>
<string name="settings_onepod_mode_label">One pod mode</string>
<string name="settings_onepod_mode_description">Treat a single in-ear pod as worn. Auto play/pause and &quot;In ear&quot; auto-connect react to either pod instead of requiring both.</string>
<string name="settings_onepod_mode_description">Auto play/pause, auto-connect, and on supported models noise cancellation, react to a single pod instead of requiring both.</string>
<string name="settings_popup_caseopen_label">Show case popup</string>
<string name="settings_popup_caseopen_description">Show a popup when the device case is opened (experimental).</string>
<string name="settings_popup_connected_label">Show connection popup</string>