mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -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>
|
||||
|
||||
+84
-5
@@ -16,12 +16,10 @@ import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.Called
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@@ -70,6 +68,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
|
||||
private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow<BluetoothAddress>
|
||||
|
||||
private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk {
|
||||
every { this@mockk.address } returns address
|
||||
@@ -84,7 +83,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
every { it.isPro } returns false
|
||||
})
|
||||
|
||||
val syntheticDevice = mockk<PodDevice>().also {
|
||||
val syntheticDevice = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testAddress
|
||||
every { it.address } returns testAddress
|
||||
}
|
||||
@@ -92,7 +91,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
every { it.devices } returns devicesFlow
|
||||
coEvery { it.getDeviceForProfile(testAddress) } returns syntheticDevice
|
||||
}
|
||||
aapManager = mockk(relaxed = true)
|
||||
offRejectedFlow = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16)
|
||||
aapManager = mockk(relaxed = true) {
|
||||
every { offRejectedEvents } returns offRejectedFlow
|
||||
}
|
||||
upgradeRepo = mockk<UpgradeRepo>().also {
|
||||
every { it.upgradeInfo } returns upgradeInfoFlow
|
||||
}
|
||||
@@ -282,7 +284,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
|
||||
vm.setDeviceName("NewName")
|
||||
|
||||
verify { aapManager wasNot Called }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetDeviceName("NewName")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -391,4 +393,81 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
sendFailed.command shouldBe AapCommand.SetNcWithOneAirPod(true)
|
||||
sendFailed.message shouldBe "socket closed"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offRejectedEvents for current address emits OffModeRejectedByDevice`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
offRejectedFlow.emit(testAddress)
|
||||
|
||||
val event = vm.events.first()
|
||||
event shouldBe DeviceSettingsViewModel.Event.OffModeRejectedByDevice
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offRejectedEvents for other address is ignored`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
offRejectedFlow.emit("11:22:33:44:55:66")
|
||||
// No event should have been emitted — send another recognized event afterward
|
||||
// so we can assert that the first emission from vm.events is the later one.
|
||||
coEvery {
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true))
|
||||
} throws IllegalStateException("socket closed")
|
||||
vm.setNcWithOneAirPod(true)
|
||||
|
||||
val event = vm.events.first()
|
||||
event.shouldBeInstanceOf<DeviceSettingsViewModel.Event.SendFailed>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption(true) as Pro sends only SetAllowOffOption`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(true)
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(true)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(false)
|
||||
|
||||
// Fallback cycle mask (0x0F) with OFF bit stripped = 0x0E.
|
||||
coVerify(ordering = io.mockk.Ordering.ORDERED) {
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetListeningModeCycle(0x0E))
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption as non-Pro sends no commands`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(true)
|
||||
vm.setAllowOffOption(false)
|
||||
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(true)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(false)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -92,4 +94,96 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
reportedCycleMask = 0x0A,
|
||||
) shouldBe 0x0A
|
||||
}
|
||||
|
||||
// -- PodDevice.visibleAncModes extension: the null-coalesce lives here --
|
||||
|
||||
private fun deviceWith(
|
||||
allowOffSetting: AapSetting.AllowOffOption? = null,
|
||||
learnedAllowOffEnabled: Boolean? = null,
|
||||
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
|
||||
): PodDevice {
|
||||
val ancSetting = AapSetting.AncMode(current = currentMode, supported = allModes)
|
||||
val settings: Map<kotlin.reflect.KClass<out AapSetting>, AapSetting> = buildMap {
|
||||
put(AapSetting.AncMode::class, ancSetting)
|
||||
if (allowOffSetting != null) put(AapSetting.AllowOffOption::class, allowOffSetting)
|
||||
}
|
||||
return PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(settings = settings),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLearnedAllowOffEnabled = learnedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown AllowOffOption is treated as allowed — OFF visible by default`() {
|
||||
val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = null)
|
||||
device.visibleAncModes shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed AllowOffOption=false hides OFF`() {
|
||||
val device = deviceWith(
|
||||
allowOffSetting = AapSetting.AllowOffOption(enabled = false),
|
||||
learnedAllowOffEnabled = null,
|
||||
)
|
||||
device.visibleAncModes shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile-learned AllowOffEnabled=false acts as fallback and hides OFF`() {
|
||||
val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = false)
|
||||
device.visibleAncModes shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live AAP AllowOffOption overrides profile fallback`() {
|
||||
val device = deviceWith(
|
||||
allowOffSetting = AapSetting.AllowOffOption(enabled = true),
|
||||
learnedAllowOffEnabled = false,
|
||||
)
|
||||
device.visibleAncModes shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile-learned ListeningModeCycle mask is used when AAP state has none`() {
|
||||
val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes)
|
||||
val device = PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(settings = mapOf(AapSetting.AncMode::class to ancSetting)),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLearnedAllowOffEnabled = true,
|
||||
profileLastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
device.listeningModeCycle?.modeMask shouldBe 0x0F
|
||||
device.resolvedAncCycleMask shouldBe 0x0F
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live AAP ListeningModeCycle overrides profile fallback`() {
|
||||
val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes)
|
||||
val device = PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to ancSetting,
|
||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = 0x0A),
|
||||
),
|
||||
),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
device.listeningModeCycle?.modeMask shouldBe 0x0A
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
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 io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class AapLearnedSettingsPersisterTest : BaseTest() {
|
||||
|
||||
private val testAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private val testProfile = AppleDeviceProfile(
|
||||
label = "Test AirPods",
|
||||
model = PodModel.AIRPODS_PRO,
|
||||
address = testAddress,
|
||||
)
|
||||
|
||||
private fun stateWithSettings(
|
||||
allowOffEnabled: Boolean? = null,
|
||||
cycleMask: Int? = null,
|
||||
): AapPodState {
|
||||
val settings = buildMap<kotlin.reflect.KClass<out AapSetting>, AapSetting> {
|
||||
allowOffEnabled?.let { put(AapSetting.AllowOffOption::class, AapSetting.AllowOffOption(it)) }
|
||||
cycleMask?.let { put(AapSetting.ListeningModeCycle::class, AapSetting.ListeningModeCycle(it)) }
|
||||
}
|
||||
return AapPodState(settings = settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists AllowOffOption value to matching profile`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(testProfile))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = false))
|
||||
advanceUntilIdle()
|
||||
|
||||
val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>()
|
||||
coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) }
|
||||
val updated = transform.captured(testProfile)
|
||||
assert(updated.learnedAllowOffEnabled == false) { "Expected learnedAllowOffEnabled=false" }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists ListeningModeCycle mask to matching profile`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(testProfile))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(cycleMask = 0x0F))
|
||||
advanceUntilIdle()
|
||||
|
||||
val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>()
|
||||
coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) }
|
||||
val updated = transform.captured(testProfile)
|
||||
assert(updated.lastRequestedListeningModeCycleMask == 0x0F) { "Expected lastRequestedListeningModeCycleMask=0x0F" }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not write when settings match already-persisted values`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profileWithStored = testProfile.copy(
|
||||
learnedAllowOffEnabled = true,
|
||||
lastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(profileWithStored))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = true, cycleMask = 0x0F))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,86 @@ class AapSessionEngineTest : BaseTest() {
|
||||
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected OFF command emits offRejected event`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<Unit>()
|
||||
val collectJob = launch { engine.offRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
rejected.size shouldBe 1
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected non-OFF command does not emit offRejected`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<Unit>()
|
||||
val collectJob = launch { engine.offRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON)) { }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
rejected shouldBe emptyList()
|
||||
collectJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-6
@@ -147,7 +147,7 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush sorts AllowOffOption before AncMode before others`() {
|
||||
fun `flush sorts ListeningModeCycle before AllowOffOption before AncMode before others`() {
|
||||
val coord = createCoordinator()
|
||||
val state = stateWithSetting(
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
|
||||
@@ -157,12 +157,14 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
val second =
|
||||
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
|
||||
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
|
||||
val result = coord.flush(third.pendingCommands)
|
||||
val fourth = coord.enqueue(third.pendingCommands, AapCommand.SetListeningModeCycle(0x0F), state)
|
||||
val result = coord.flush(fourth.pendingCommands)
|
||||
|
||||
result.commands shouldHaveSize 3
|
||||
result.commands[0].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
|
||||
result.commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
|
||||
result.commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
result.commands shouldHaveSize 4
|
||||
result.commands[0].shouldBeInstanceOf<AapCommand.SetListeningModeCycle>()
|
||||
result.commands[1].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
|
||||
result.commands[2].shouldBeInstanceOf<AapCommand.SetAncMode>()
|
||||
result.commands[3].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user