mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
feat(aap): Persist learned ANC settings across reconnects
This commit is contained in:
+32
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user