feat(aap): Persist learned ANC settings across reconnects

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent 07b3b95270
commit 384d81be18
19 changed files with 575 additions and 23 deletions
@@ -17,6 +17,7 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
@@ -79,10 +80,21 @@ class DeviceSettingsViewModel @Inject constructor(
data object OpenBluetoothSettings : Event
data class SendFailed(val command: AapCommand, val message: String?) : Event
data object SystemRenameUnavailable : Event
data object OffModeRejectedByDevice : Event
}
val events = SingleEventFlow<Event>()
init {
launch {
aapManager.offRejectedEvents.collect { address ->
if (address == currentAddress()) {
events.tryEmit(Event.OffModeRejectedByDevice)
}
}
}
}
val state = targetProfileId.flatMapLatest { profileId ->
if (profileId == null) return@flatMapLatest flowOf(State(device = null))
combine(
@@ -253,16 +265,25 @@ class DeviceSettingsViewModel @Inject constructor(
fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask))
fun setListeningModeOffVisibility(enabled: Boolean, currentCycleMask: Int) = launch {
fun setAllowOffOption(enabled: Boolean) = launch {
if (!upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
// Keep in sync with cycleBit(OFF) in DeviceSettingsScreen.
val offBit = 0x01
val newMask = if (enabled) currentCycleMask or offBit else currentCycleMask and offBit.inv()
if (sendInternal(AapCommand.SetListeningModeCycle(newMask))) {
sendInternal(AapCommand.SetAllowOffOption(enabled))
if (enabled) {
sendInternal(AapCommand.SetAllowOffOption(enabled = true))
} else {
val profileId = targetProfileId.value
val currentMask = profileId
?.let { deviceMonitor.getDeviceForProfile(it) }
?.resolvedAncCycleMask
?: DEFAULT_CYCLE_MASK_WITH_OFF
// Always send the cycle-mask update first — local state can diverge from the
// device's actual mask since 0x1A is never echoed. Stripping OFF unconditionally
// keeps the stem cycle consistent with the disabled capability.
val newMask = currentMask and OFF_BIT.inv()
sendInternal(AapCommand.SetListeningModeCycle(newMask))
sendInternal(AapCommand.SetAllowOffOption(enabled = false))
}
}
@@ -416,5 +437,10 @@ class DeviceSettingsViewModel @Inject constructor(
companion object {
private val TAG = logTag("DeviceSettings", "VM")
private const val OFF_BIT = 0x01
// Apple's factory-default listening-mode cycle mask: ON | TRANSPARENCY | ADAPTIVE.
// Used as a conservative fallback when disabling Allow Off and we don't have a
// live/persisted mask to mutate.
private const val DEFAULT_CYCLE_MASK_WITH_OFF = 0x0F
}
}
@@ -20,12 +20,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -70,12 +73,24 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
NavigationEventHandler(vm)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
// Collect workerAutolaunch passively to keep it active
LaunchedEffect(Unit) {
vm.workerAutolaunch.collect {}
}
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
OverviewViewModel.Event.OffModeRejectedByDevice -> {
snackbarHostState.showSnackbar(offRejectedMessage)
}
}
}
}
// Permission handling
var awaitingPermission by rememberSaveable { mutableStateOf(false) }
@@ -129,6 +144,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
OverviewScreen(
state = currentState,
snackbarHostState = snackbarHostState,
onRequestPermission = { vm.requestPermission(it) },
onBluetoothSettings = {
try {
@@ -153,6 +169,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
@Composable
fun OverviewScreen(
state: OverviewViewModel.State,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onRequestPermission: (Permission) -> Unit,
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
@@ -234,6 +251,7 @@ fun OverviewScreen(
},
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = Modifier
@@ -58,6 +58,20 @@ class OverviewViewModel @Inject constructor(
val requestPermissionEvent = SingleEventFlow<Permission>()
sealed interface Event {
data object OffModeRejectedByDevice : Event
}
val events = SingleEventFlow<Event>()
init {
launch {
aapManager.offRejectedEvents.collect {
events.tryEmit(Event.OffModeRejectedByDevice)
}
}
}
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
@@ -72,6 +72,8 @@ class DeviceMonitor @Inject constructor(
profileAddress = profile?.address,
profileModel = profile?.model,
profileKeyState = profile.toBleKeyState(),
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
isSystemConnected = profile?.address in connectedAddresses,
)
@@ -145,6 +147,8 @@ class DeviceMonitor @Inject constructor(
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
isSystemConnected = profile.address in connectedAddresses,
)
@@ -224,6 +228,8 @@ class DeviceMonitor @Inject constructor(
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
)
}
@@ -47,6 +47,16 @@ data class PodDevice(
* every time the BLE scanner misses the next advertisement batch.
*/
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
/**
* Last-known AllowOffOption value persisted on the profile. Used as a fallback when the
* live AAP state has no AllowOffOption setting (fresh session, device not pushing 0x34).
*/
internal val profileLearnedAllowOffEnabled: Boolean? = null,
/**
* Last-known ListeningModeCycle mask persisted on the profile. Used as a fallback when
* the live AAP state has no ListeningModeCycle setting (device never echoes 0x1A back).
*/
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(),
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
@@ -308,9 +318,11 @@ data class PodDevice(
val listeningModeCycle: AapSetting.ListeningModeCycle?
get() = aap?.setting()
?: profileLastRequestedListeningModeCycleMask?.let { AapSetting.ListeningModeCycle(modeMask = it) }
val allowOffOption: AapSetting.AllowOffOption?
get() = aap?.setting()
?: profileLearnedAllowOffEnabled?.let { AapSetting.AllowOffOption(enabled = it) }
val stemConfig: AapSetting.StemConfig?
get() = aap?.setting()
@@ -45,6 +45,8 @@ val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = resolvedAncCycleMask,
allowOffEnabled = allowOffOption?.enabled == true,
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
allowOffEnabled = allowOffOption?.enabled != false,
)
}
@@ -0,0 +1,70 @@
package eu.darken.capod.monitor.core.aap
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.AapSetting
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
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
/**
* Persists the learned AllowOffOption and ListeningModeCycle values to [AppleDeviceProfile]
* whenever they change in AAP state. AAP state is dropped on disconnect and neither setting
* is proactively echoed by the device, so without persistence every reconnect would force the
* UI back to defaults (OFF hidden, cycle mask 0x0E).
*/
@Singleton
class AapLearnedSettingsPersister @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
) {
private data class LearnedSnapshot(
val allowOffEnabled: Boolean?,
val cycleMask: Int?,
)
fun monitor(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.snapshot() } }
.distinctUntilChanged()
.onEach { addressToSnapshot ->
addressToSnapshot.forEach { (address, snapshot) ->
if (snapshot.allowOffEnabled == null && snapshot.cycleMask == null) return@forEach
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address } ?: return@forEach
val allowOffChanged = snapshot.allowOffEnabled != null &&
profile.learnedAllowOffEnabled != snapshot.allowOffEnabled
val cycleChanged = snapshot.cycleMask != null &&
profile.lastRequestedListeningModeCycleMask != snapshot.cycleMask
if (!allowOffChanged && !cycleChanged) return@forEach
profilesRepo.updateAppleProfile(profile.id) {
it.copy(
learnedAllowOffEnabled = if (allowOffChanged) snapshot.allowOffEnabled else it.learnedAllowOffEnabled,
lastRequestedListeningModeCycleMask = if (cycleChanged) snapshot.cycleMask else it.lastRequestedListeningModeCycleMask,
)
}
if (allowOffChanged) log(TAG) { "Persisted learnedAllowOffEnabled=${snapshot.allowOffEnabled} for $address" }
if (cycleChanged) log(TAG) { "Persisted lastRequestedListeningModeCycleMask=0x%02X for $address".format(snapshot.cycleMask) }
}
}
.map { }
.setupCommonEventHandlers(TAG) { "learnedSettingsPersister" }
private fun AapPodState.snapshot(): LearnedSnapshot = LearnedSnapshot(
allowOffEnabled = setting<AapSetting.AllowOffOption>()?.enabled,
cycleMask = setting<AapSetting.ListeningModeCycle>()?.modeMask,
)
companion object {
private val TAG = logTag("Monitor", "AapLearnedSettingsPersister")
}
}
@@ -24,6 +24,7 @@ class AapLifecycleManager @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val aapAutoConnect: AapAutoConnect,
private val aapKeyPersister: AapKeyPersister,
private val aapLearnedSettingsPersister: AapLearnedSettingsPersister,
private val stemConfigSender: StemConfigSender,
private val stemPressReaction: StemPressReaction,
) {
@@ -32,6 +33,7 @@ class AapLifecycleManager @Inject constructor(
merge(
aapAutoConnect.monitor(),
aapKeyPersister.monitor(),
aapLearnedSettingsPersister.monitor(),
stemConfigSender.monitor(),
stemPressReaction.monitor(),
)
@@ -64,6 +64,10 @@ class AapConnectionManager @Inject constructor(
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
suspend fun connect(
@@ -108,6 +112,13 @@ class AapConnectionManager @Inject constructor(
}
}
// Forward OFF-rejection events from this connection (child coroutine)
launch {
connection.offRejected.collect {
_offRejectedEvents.tryEmit(address)
}
}
connection.state.collect { podState ->
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "Connection to $address disconnected" }
@@ -45,6 +45,7 @@ internal class AapConnection(
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
@@ -47,6 +47,10 @@ internal class AapSessionEngine(
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
private val hidTracker = HidTracker { msg -> log(TAG) { msg } }
private val inboundInterpreter = AapInboundInterpreter(profile)
private val ancController = AapAncController()
@@ -359,6 +363,7 @@ internal class AapSessionEngine(
private fun handleRejectedCommand(command: AapCommand?) {
if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) {
applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc))
_offRejected.tryEmit(Unit)
}
}
@@ -65,9 +65,12 @@ internal class AapSettingsCoordinator(
fun flush(pendingCommands: List<AapCommand>): FlushResult {
val sorted = pendingCommands.sortedBy {
when (it) {
is AapCommand.SetAllowOffOption -> 0
is AapCommand.SetAncMode -> 1
else -> 2
// Cycle mask must go before AllowOffOption(false) so we don't leave the device
// with OFF still in the stem cycle but no longer permitted as a mode.
is AapCommand.SetListeningModeCycle -> 0
is AapCommand.SetAllowOffOption -> 1
is AapCommand.SetAncMode -> 2
else -> 3
}
}
return FlushResult(
@@ -29,6 +29,18 @@ data class AppleDeviceProfile(
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
* mode is allowed on the device is effectively sticky until the owner toggles it.
*/
@SerialName("learnedAllowOffEnabled") val learnedAllowOffEnabled: Boolean? = null,
/**
* Last-known device-side ListeningModeCycle mask (AAP setting 0x1A). The device never
* echoes this back as a push setting, so without persistence every reconnect resets the
* UI to the default 0x0E (no OFF bit) even if the real cycle on-device includes OFF.
*/
@SerialName("learnedListeningModeCycleMask") val lastRequestedListeningModeCycleMask: Int? = null,
) : DeviceProfile, HasReactionConfig {
override val reactionConfig: ReactionConfig
+3 -2
View File
@@ -520,8 +520,8 @@
<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</string>
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_allow_off_label">Allow Off mode</string>
<string name="device_settings_allow_off_description">Allow Off as a selectable noise control mode. When disabled, stems skip Off while cycling.</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_rename_label">Rename</string>
@@ -532,6 +532,7 @@
<string name="device_settings_rename_system_unavailable">Android didn\'t let us rename the device here. To update the name in Bluetooth settings, rename it there or re-pair the device.</string>
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Settings</string>
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
<string name="device_settings_anc_off_rejected_message">Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control.</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>