From c1b24f0a9e5c69521e1bb4edfb3222480fd99150 Mon Sep 17 00:00:00 2001 From: Haijie Hong Date: Tue, 13 Aug 2024 20:20:01 +0800 Subject: [PATCH] Implement Spatial audio toggle domain layer BUG: 343317785 Test: atest SpatialAudioInteractorTest Flag: com.android.settings.flags.enable_bluetooth_device_details_polish Change-Id: Ic73e56a1ca41f9fa58d5219666478a7edc55059d --- res/drawable/ic_head_tracking.xml | 26 ++ res/drawable/ic_spatial_audio.xml | 26 ++ res/drawable/ic_spatial_audio_off.xml | 26 ++ res/values/strings.xml | 12 + ...luetoothDetailsSpatialAudioController.java | 57 +--- .../bluetooth/BluetoothFeatureProvider.java | 9 + .../BluetoothFeatureProviderImpl.java | 105 -------- .../bluetooth/BluetoothFeatureProviderImpl.kt | 106 ++++++++ .../interactor/SpatialAudioInteractor.kt | 155 +++++++++++ .../settings/bluetooth/ui/composable/Icon.kt | 48 ++++ .../composable/MultiTogglePreferenceGroup.kt | 87 +++--- .../ui/view/DeviceDetailsFragmentFormatter.kt | 27 +- .../BluetoothDeviceDetailsViewModel.kt | 20 +- .../bluetooth/utils/DeviceSettingUtils.kt | 29 -- .../interactor/SpatialAudioInteractorTest.kt | 254 ++++++++++++++++++ .../DeviceDetailsFragmentFormatterTest.kt | 19 +- .../BluetoothDeviceDetailsViewModelTest.kt | 76 +++++- 17 files changed, 825 insertions(+), 257 deletions(-) create mode 100644 res/drawable/ic_head_tracking.xml create mode 100644 res/drawable/ic_spatial_audio.xml create mode 100644 res/drawable/ic_spatial_audio_off.xml delete mode 100644 src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java create mode 100644 src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.kt create mode 100644 src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractor.kt create mode 100644 src/com/android/settings/bluetooth/ui/composable/Icon.kt delete mode 100644 src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt create mode 100644 tests/robotests/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractorTest.kt diff --git a/res/drawable/ic_head_tracking.xml b/res/drawable/ic_head_tracking.xml new file mode 100644 index 00000000000..d4a44fd9858 --- /dev/null +++ b/res/drawable/ic_head_tracking.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/res/drawable/ic_spatial_audio.xml b/res/drawable/ic_spatial_audio.xml new file mode 100644 index 00000000000..0ee609ab79f --- /dev/null +++ b/res/drawable/ic_spatial_audio.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/res/drawable/ic_spatial_audio_off.xml b/res/drawable/ic_spatial_audio_off.xml new file mode 100644 index 00000000000..c7d3272b380 --- /dev/null +++ b/res/drawable/ic_spatial_audio_off.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 6c018c23ec9..4b30dc19abe 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -7946,6 +7946,18 @@ Connected devices settings + + Spatial Audio + + + Off + + + Off + + + Off + {count, plural, diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java b/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java index 4ff71360a49..398edb6b991 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java @@ -39,8 +39,8 @@ import androidx.preference.TwoStatePreference; import com.android.settings.R; import com.android.settings.overlay.FeatureFactory; +import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; -import com.android.settingslib.bluetooth.LocalBluetoothProfile; import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.flags.Flags; import com.android.settingslib.utils.ThreadUtils; @@ -299,57 +299,14 @@ public class BluetoothDetailsSpatialAudioController extends BluetoothDetailsCont + " profiles: " + mCachedDevice.getProfiles()); - AudioDeviceAttributes saDevice = null; - for (LocalBluetoothProfile profile : mCachedDevice.getProfiles()) { - // pick first enabled profile that is compatible with spatial audio - if (SA_PROFILES.contains(profile.getProfileId()) - && profile.isEnabled(mCachedDevice.getDevice())) { - switch (profile.getProfileId()) { - case BluetoothProfile.A2DP: - saDevice = - new AudioDeviceAttributes( - AudioDeviceAttributes.ROLE_OUTPUT, - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, - mCachedDevice.getAddress()); - break; - case BluetoothProfile.LE_AUDIO: - if (mAudioManager.getBluetoothAudioDeviceCategory( - mCachedDevice.getAddress()) - == AudioManager.AUDIO_DEVICE_CATEGORY_SPEAKER) { - saDevice = - new AudioDeviceAttributes( - AudioDeviceAttributes.ROLE_OUTPUT, - AudioDeviceInfo.TYPE_BLE_SPEAKER, - mCachedDevice.getAddress()); - } else { - saDevice = - new AudioDeviceAttributes( - AudioDeviceAttributes.ROLE_OUTPUT, - AudioDeviceInfo.TYPE_BLE_HEADSET, - mCachedDevice.getAddress()); - } - - break; - case BluetoothProfile.HEARING_AID: - saDevice = - new AudioDeviceAttributes( - AudioDeviceAttributes.ROLE_OUTPUT, - AudioDeviceInfo.TYPE_HEARING_AID, - mCachedDevice.getAddress()); - break; - default: - Log.i( - TAG, - "unrecognized profile for spatial audio: " - + profile.getProfileId()); - break; - } - break; - } - } - mAudioDevice = null; + AudioDeviceAttributes saDevice = + BluetoothUtils.getAudioDeviceAttributesForSpatialAudio( + mCachedDevice, + mAudioManager.getBluetoothAudioDeviceCategory(mCachedDevice.getAddress())); if (saDevice != null && mSpatializer.isAvailableForDevice(saDevice)) { mAudioDevice = saDevice; + } else { + mAudioDevice = null; } Log.d( diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java b/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java index 594134483f0..be0f6f36b6c 100644 --- a/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java +++ b/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java @@ -20,6 +20,7 @@ import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.content.ComponentName; import android.content.Context; +import android.media.AudioManager; import android.media.Spatializer; import android.net.Uri; @@ -28,6 +29,7 @@ import androidx.lifecycle.LifecycleCoroutineScope; import androidx.preference.Preference; import com.android.settings.SettingsPreferenceFragment; +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor; import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository; @@ -98,6 +100,13 @@ public interface BluetoothFeatureProvider { @NonNull BluetoothAdapter bluetoothAdapter, @NonNull LifecycleCoroutineScope scope); + /** Gets spatial audio interactor. */ + @NonNull + SpatialAudioInteractor getSpatialAudioInteractor( + @NonNull Context context, + @NonNull AudioManager audioManager, + @NonNull LifecycleCoroutineScope scope); + /** Gets device details fragment layout formatter. */ @NonNull DeviceDetailsFragmentFormatter getDeviceDetailsFragmentFormatter( diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java deleted file mode 100644 index ae6e740998a..00000000000 --- a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.settings.bluetooth; - -import static com.android.settings.bluetooth.utils.DeviceSettingUtilsKt.createDeviceSettingRepository; - -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.content.ComponentName; -import android.content.Context; -import android.media.AudioManager; -import android.media.Spatializer; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LifecycleCoroutineScope; -import androidx.preference.Preference; - -import com.android.settings.SettingsPreferenceFragment; -import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter; -import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatterImpl; -import com.android.settingslib.bluetooth.BluetoothUtils; -import com.android.settingslib.bluetooth.CachedBluetoothDevice; -import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; - -import java.util.List; -import java.util.Set; - -/** - * Impl of {@link BluetoothFeatureProvider} - */ -public class BluetoothFeatureProviderImpl implements BluetoothFeatureProvider { - - @Override - public Uri getBluetoothDeviceSettingsUri(BluetoothDevice bluetoothDevice) { - final byte[] uriByte = bluetoothDevice.getMetadata( - BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI); - return uriByte == null ? null : Uri.parse(new String(uriByte)); - } - - @Override - public String getBluetoothDeviceControlUri(BluetoothDevice bluetoothDevice) { - return BluetoothUtils.getControlUriMetaData(bluetoothDevice); - } - - @Override - public List getRelatedTools() { - return null; - } - - @Override - public Spatializer getSpatializer(Context context) { - AudioManager audioManager = context.getSystemService(AudioManager.class); - return audioManager.getSpatializer(); - } - - @Override - public List getBluetoothExtraOptions(Context context, - CachedBluetoothDevice device) { - return ImmutableList.of(); - } - - @Override - public Set getInvisibleProfilePreferenceKeys( - Context context, BluetoothDevice bluetoothDevice) { - return ImmutableSet.of(); - } - - @Override - @NonNull - public DeviceSettingRepository getDeviceSettingRepository( - @NonNull Context context, - @NonNull BluetoothAdapter bluetoothAdapter, - @NonNull LifecycleCoroutineScope scope) { - return createDeviceSettingRepository(context, bluetoothAdapter, scope); - } - - @Override - @NonNull - public DeviceDetailsFragmentFormatter getDeviceDetailsFragmentFormatter( - @NonNull Context context, - @NonNull SettingsPreferenceFragment fragment, - @NonNull BluetoothAdapter bluetoothAdapter, - @NonNull CachedBluetoothDevice cachedDevice) { - return new DeviceDetailsFragmentFormatterImpl( - context, fragment, bluetoothAdapter, cachedDevice); - } -} diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.kt b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.kt new file mode 100644 index 00000000000..3a549c6b2de --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.bluetooth + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.ComponentName +import android.content.Context +import android.media.AudioManager +import android.media.Spatializer +import android.net.Uri +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.preference.Preference +import com.android.settings.SettingsPreferenceFragment +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractorImpl +import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter +import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatterImpl +import com.android.settingslib.bluetooth.BluetoothUtils +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository +import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepositoryImpl +import com.android.settingslib.media.data.repository.SpatializerRepositoryImpl +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableSet +import kotlinx.coroutines.Dispatchers + +/** Impl of [BluetoothFeatureProvider] */ +open class BluetoothFeatureProviderImpl : BluetoothFeatureProvider { + override fun getBluetoothDeviceSettingsUri(bluetoothDevice: BluetoothDevice): Uri? { + val uriByte = bluetoothDevice.getMetadata(BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI) + return uriByte?.let { Uri.parse(String(it)) } + } + + override fun getBluetoothDeviceControlUri(bluetoothDevice: BluetoothDevice): String? { + return BluetoothUtils.getControlUriMetaData(bluetoothDevice) + } + + override fun getRelatedTools(): List? { + return null + } + + override fun getSpatializer(context: Context): Spatializer? { + val audioManager = context.getSystemService(AudioManager::class.java) + return audioManager.spatializer + } + + override fun getBluetoothExtraOptions( + context: Context, + device: CachedBluetoothDevice + ): List? { + return ImmutableList.of() + } + + override fun getInvisibleProfilePreferenceKeys( + context: Context, + bluetoothDevice: BluetoothDevice + ): Set { + return ImmutableSet.of() + } + + override fun getDeviceSettingRepository( + context: Context, + bluetoothAdapter: BluetoothAdapter, + scope: LifecycleCoroutineScope + ): DeviceSettingRepository = + DeviceSettingRepositoryImpl(context, bluetoothAdapter, scope, Dispatchers.IO) + + override fun getSpatialAudioInteractor( + context: Context, + audioManager: AudioManager, + scope: LifecycleCoroutineScope + ): SpatialAudioInteractor { + return SpatialAudioInteractorImpl( + context, audioManager, + SpatializerInteractor( + SpatializerRepositoryImpl( + audioManager.spatializer, + Dispatchers.IO + ) + ), scope, Dispatchers.IO) + } + + override fun getDeviceDetailsFragmentFormatter( + context: Context, + fragment: SettingsPreferenceFragment, + bluetoothAdapter: BluetoothAdapter, + cachedDevice: CachedBluetoothDevice + ): DeviceDetailsFragmentFormatter { + return DeviceDetailsFragmentFormatterImpl(context, fragment, bluetoothAdapter, cachedDevice) + } +} diff --git a/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractor.kt b/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractor.kt new file mode 100644 index 00000000000..6b72b53aa3f --- /dev/null +++ b/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractor.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth.domain.interactor + +import android.content.Context +import android.media.AudioManager +import android.util.Log +import com.android.settings.R +import com.android.settingslib.bluetooth.BluetoothUtils +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel +import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Provides device setting for spatial audio. */ +interface SpatialAudioInteractor { + /** Gets device setting for spatial audio */ + fun getDeviceSetting( + cachedDevice: CachedBluetoothDevice, + ): Flow +} + +class SpatialAudioInteractorImpl( + private val context: Context, + private val audioManager: AudioManager, + private val spatializerInteractor: SpatializerInteractor, + private val coroutineScope: CoroutineScope, + private val backgroundCoroutineContext: CoroutineContext, +) : SpatialAudioInteractor { + private val spatialAudioOffToggle = + ToggleModel( + context.getString(R.string.spatial_audio_multi_toggle_off), + DeviceSettingIcon.ResourceIcon(R.drawable.ic_spatial_audio_off)) + private val spatialAudioOnToggle = + ToggleModel( + context.getString(R.string.spatial_audio_multi_toggle_on), + DeviceSettingIcon.ResourceIcon(R.drawable.ic_spatial_audio)) + private val headTrackingOnToggle = + ToggleModel( + context.getString(R.string.spatial_audio_multi_toggle_head_tracking_on), + DeviceSettingIcon.ResourceIcon(R.drawable.ic_head_tracking)) + private val changes = MutableSharedFlow() + + override fun getDeviceSetting( + cachedDevice: CachedBluetoothDevice, + ): Flow = + changes + .onStart { emit(Unit) } + .map { getSpatialAudioDeviceSettingModel(cachedDevice) } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), initialValue = null) + + private suspend fun getSpatialAudioDeviceSettingModel( + cachedDevice: CachedBluetoothDevice, + ): DeviceSettingModel? { + // TODO(b/343317785): use audio repository instead of calling AudioManager directly. + Log.i(TAG, "CachedDevice: $cachedDevice profiles: ${cachedDevice.profiles}") + val attributes = + BluetoothUtils.getAudioDeviceAttributesForSpatialAudio( + cachedDevice, audioManager.getBluetoothAudioDeviceCategory(cachedDevice.address)) + ?: run { + Log.i(TAG, "No audio profiles in cachedDevice: ${cachedDevice.address}.") + return null + } + + Log.i(TAG, "Audio device attributes for ${cachedDevice.address}: $attributes.") + val spatialAudioAvailable = spatializerInteractor.isSpatialAudioAvailable(attributes) + if (!spatialAudioAvailable) { + Log.i(TAG, "Spatial audio is not available for ${cachedDevice.address}") + return null + } + val headTrackingAvailable = + spatialAudioAvailable && spatializerInteractor.isHeadTrackingAvailable(attributes) + val toggles = + if (headTrackingAvailable) { + listOf(spatialAudioOffToggle, spatialAudioOnToggle, headTrackingOnToggle) + } else { + listOf(spatialAudioOffToggle, spatialAudioOnToggle) + } + val spatialAudioEnabled = spatializerInteractor.isSpatialAudioEnabled(attributes) + val headTrackingEnabled = + spatialAudioEnabled && spatializerInteractor.isHeadTrackingEnabled(attributes) + + val activeIndex = + when { + headTrackingEnabled -> INDEX_HEAD_TRACKING_ENABLED + spatialAudioEnabled -> INDEX_SPATIAL_AUDIO_ON + else -> INDEX_SPATIAL_AUDIO_OFF + } + Log.i( + TAG, + "Head tracking available: $headTrackingAvailable, " + + "spatial audio enabled: $spatialAudioEnabled, " + + "head tracking enabled: $headTrackingEnabled") + return DeviceSettingModel.MultiTogglePreference( + cachedDevice = cachedDevice, + id = DeviceSettingId.DEVICE_SETTING_ID_SPATIAL_AUDIO_MULTI_TOGGLE, + title = context.getString(R.string.spatial_audio_multi_toggle_title), + toggles = toggles, + isActive = spatialAudioEnabled, + state = DeviceSettingStateModel.MultiTogglePreferenceState(activeIndex), + isAllowedChangingState = true, + updateState = { newState -> + coroutineScope.launch(backgroundCoroutineContext) { + Log.i(TAG, "Update spatial audio state: $newState") + when (newState.selectedIndex) { + INDEX_SPATIAL_AUDIO_OFF -> { + spatializerInteractor.setSpatialAudioEnabled(attributes, false) + } + INDEX_SPATIAL_AUDIO_ON -> { + spatializerInteractor.setSpatialAudioEnabled(attributes, true) + spatializerInteractor.setHeadTrackingEnabled(attributes, false) + } + INDEX_HEAD_TRACKING_ENABLED -> { + spatializerInteractor.setSpatialAudioEnabled(attributes, true) + spatializerInteractor.setHeadTrackingEnabled(attributes, true) + } + } + changes.emit(Unit) + } + }) + } + + companion object { + private const val TAG = "SpatialAudioInteractorImpl" + private const val INDEX_SPATIAL_AUDIO_OFF = 0 + private const val INDEX_SPATIAL_AUDIO_ON = 1 + private const val INDEX_HEAD_TRACKING_ENABLED = 2 + } +} diff --git a/src/com/android/settings/bluetooth/ui/composable/Icon.kt b/src/com/android/settings/bluetooth/ui/composable/Icon.kt new file mode 100644 index 00000000000..676bd14fcca --- /dev/null +++ b/src/com/android/settings/bluetooth/ui/composable/Icon.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth.ui.composable + +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon + +@Composable +fun Icon( + icon: DeviceSettingIcon, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + when (icon) { + is DeviceSettingIcon.BitmapIcon -> + androidx.compose.material3.Icon( + icon.bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier, + tint = LocalContentColor.current) + is DeviceSettingIcon.ResourceIcon -> + androidx.compose.material3.Icon( + painterResource(icon.resId), + contentDescription = null, + modifier = modifier, + tint = tint) + else -> {} + } +} diff --git a/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt b/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt index b42e7d0cf72..8fe3c255d34 100644 --- a/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt +++ b/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -67,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import com.android.settings.R +import com.android.settings.bluetooth.ui.composable.Icon as DeviceSettingComposeIcon import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel import com.android.settingslib.spa.framework.theme.SettingsDimension @@ -97,35 +97,29 @@ fun MultiTogglePreferenceGroup( Surface( modifier = Modifier.height(64.dp), shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surface - ) { - Button( - modifier = - Modifier.fillMaxSize().padding(8.dp).semantics { - role = Role.Switch - toggleableState = - if (preferenceModel.isActive) { - ToggleableState.On - } else { - ToggleableState.Off - } - contentDescription = preferenceModel.title - }, - onClick = { settingIdForPopUp = preferenceModel.id }, - shape = RoundedCornerShape(20.dp), - colors = getButtonColors(preferenceModel.isActive), - contentPadding = PaddingValues(0.dp) - ) { - Icon( - preferenceModel.toggles[preferenceModel.state.selectedIndex] - .icon - .asImageBitmap(), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = LocalContentColor.current - ) + color = MaterialTheme.colorScheme.surface) { + Button( + modifier = + Modifier.fillMaxSize().padding(8.dp).semantics { + role = Role.Switch + toggleableState = + if (preferenceModel.isActive) { + ToggleableState.On + } else { + ToggleableState.Off + } + contentDescription = preferenceModel.title + }, + onClick = { settingIdForPopUp = preferenceModel.id }, + shape = RoundedCornerShape(20.dp), + colors = getButtonColors(preferenceModel.isActive), + contentPadding = PaddingValues(0.dp)) { + DeviceSettingComposeIcon( + preferenceModel.toggles[preferenceModel.state.selectedIndex] + .icon, + modifier = Modifier.size(24.dp)) + } } - } } Row { Text(text = preferenceModel.title, fontSize = 12.sp) } } @@ -173,8 +167,7 @@ private fun dialog( Icon( painterResource(id = R.drawable.ic_close), null, - tint = MaterialTheme.colorScheme.inverseSurface - ) + tint = MaterialTheme.colorScheme.inverseSurface) } Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 20.dp)) { dialogContent(multiTogglePreference) @@ -182,8 +175,7 @@ private fun dialog( } }, ) - } - ) + }) } @Composable @@ -208,9 +200,7 @@ private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiToggleP Modifier.fillMaxWidth() .height(64.dp) .background( - MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(28.dp) - ), + MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(28.dp)), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { @@ -224,9 +214,7 @@ private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiToggleP .width(selectedRect!!.width.toDp()) .background( MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(20.dp) - ) - ) + shape = RoundedCornerShape(20.dp))) } } Row { @@ -238,9 +226,7 @@ private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiToggleP .padding(horizontal = 8.dp) .height(48.dp) .background( - Color.Transparent, - shape = RoundedCornerShape(28.dp) - ) + Color.Transparent, shape = RoundedCornerShape(28.dp)) .onGloballyPositioned { layoutCoordinates -> if (selected) { selectedRect = layoutCoordinates.boundsInParent() @@ -252,22 +238,16 @@ private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiToggleP Button( onClick = { multiTogglePreference.updateState( - DeviceSettingStateModel.MultiTogglePreferenceState(idx) - ) + DeviceSettingStateModel.MultiTogglePreferenceState(idx)) }, modifier = Modifier.fillMaxSize(), colors = ButtonDefaults.buttonColors( containerColor = Color.Transparent, - contentColor = LocalContentColor.current - ), + contentColor = LocalContentColor.current), ) { - Icon( - bitmap = toggle.icon.asImageBitmap(), - null, - modifier = Modifier.size(24.dp), - tint = LocalContentColor.current - ) + DeviceSettingComposeIcon( + toggle.icon, modifier = Modifier.size(24.dp)) } } } @@ -285,8 +265,7 @@ private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiToggleP text = toggle.label, fontSize = 12.sp, textAlign = TextAlign.Center, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp) - ) + modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) } } } diff --git a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt index 3b77aae5b03..b75579dfa0d 100644 --- a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt +++ b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt @@ -18,20 +18,19 @@ package com.android.settings.bluetooth.ui.view import android.bluetooth.BluetoothAdapter import android.content.Context +import android.media.AudioManager import android.util.Log import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.android.settings.SettingsPreferenceFragment +import com.android.settings.bluetooth.ui.composable.Icon import com.android.settings.bluetooth.ui.composable.MultiTogglePreferenceGroup import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout import com.android.settings.bluetooth.ui.viewmodel.BluetoothDeviceDetailsViewModel @@ -42,7 +41,6 @@ import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSetti import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel import com.android.settingslib.spa.framework.theme.SettingsDimension -import com.android.settingslib.spa.widget.preference.Preference as SpaPreference import com.android.settingslib.spa.widget.preference.PreferenceModel import com.android.settingslib.spa.widget.preference.SwitchPreference import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel @@ -52,6 +50,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import com.android.settingslib.spa.widget.preference.Preference as SpaPreference + /** Handles device details fragment layout according to config. */ interface DeviceDetailsFragmentFormatter { @@ -72,19 +72,24 @@ class DeviceDetailsFragmentFormatterImpl( private val repository = featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository( context, bluetoothAdapter, fragment.lifecycleScope) + private val spatialAudioInteractor = + featureFactory.bluetoothFeatureProvider.getSpatialAudioInteractor( + context, context.getSystemService(AudioManager::class.java), fragment.lifecycleScope) private val viewModel: BluetoothDeviceDetailsViewModel = ViewModelProvider( fragment, BluetoothDeviceDetailsViewModel.Factory( repository, + spatialAudioInteractor, cachedDevice, )) .get(BluetoothDeviceDetailsViewModel::class.java) override fun getVisiblePreferenceKeysForMainPage(): List? = runBlocking { - viewModel.getItems()?.filterIsInstance()?.map { - it.preferenceKey - } + viewModel + .getItems() + ?.filterIsInstance() + ?.mapNotNull { it.preferenceKey } } /** Updates bluetooth device details fragment layout. */ @@ -208,12 +213,8 @@ class DeviceDetailsFragmentFormatterImpl( @Composable private fun deviceSettingIcon(model: DeviceSettingModel.ActionSwitchPreference) { - model.icon?.let { bitmap -> - Icon( - bitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier.size(SettingsDimension.itemIconSize), - tint = LocalContentColor.current) + model.icon?.let { icon -> + Icon(icon, modifier = Modifier.size(SettingsDimension.itemIconSize)) } } diff --git a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt index 1c4861462d5..befff830da3 100644 --- a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt +++ b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt @@ -19,6 +19,7 @@ package com.android.settings.bluetooth.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutRow import com.android.settingslib.bluetooth.CachedBluetoothDevice @@ -29,6 +30,7 @@ import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSetti import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn class BluetoothDeviceDetailsViewModel( private val deviceSettingRepository: DeviceSettingRepository, + private val spatialAudioInteractor: SpatialAudioInteractor, private val cachedDevice: CachedBluetoothDevice, ) : ViewModel() { private val items = @@ -46,8 +49,16 @@ class BluetoothDeviceDetailsViewModel( suspend fun getItems(): List? = items.await()?.mainItems - fun getDeviceSetting(cachedDevice: CachedBluetoothDevice, @DeviceSettingId settingId: Int) = - deviceSettingRepository.getDeviceSetting(cachedDevice, settingId) + fun getDeviceSetting( + cachedDevice: CachedBluetoothDevice, + @DeviceSettingId settingId: Int + ): Flow { + return when (settingId) { + DeviceSettingId.DEVICE_SETTING_ID_SPATIAL_AUDIO_MULTI_TOGGLE -> + spatialAudioInteractor.getDeviceSetting(cachedDevice) + else -> deviceSettingRepository.getDeviceSetting(cachedDevice, settingId) + } + } suspend fun getLayout(): DeviceSettingLayout? { val configItems = getItems() ?: return null @@ -93,11 +104,14 @@ class BluetoothDeviceDetailsViewModel( class Factory( private val deviceSettingRepository: DeviceSettingRepository, + private val spatialAudioInteractor: SpatialAudioInteractor, private val cachedDevice: CachedBluetoothDevice, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return BluetoothDeviceDetailsViewModel(deviceSettingRepository, cachedDevice) as T + return BluetoothDeviceDetailsViewModel( + deviceSettingRepository, spatialAudioInteractor, cachedDevice) + as T } } diff --git a/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt b/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt deleted file mode 100644 index 1bb8f201272..00000000000 --- a/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.settings.bluetooth.utils - -import android.bluetooth.BluetoothAdapter -import android.content.Context -import androidx.lifecycle.LifecycleCoroutineScope -import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepositoryImpl -import kotlinx.coroutines.Dispatchers - -fun createDeviceSettingRepository( - context: Context, - bluetoothAdapter: BluetoothAdapter, - coroutineScope: LifecycleCoroutineScope -) = DeviceSettingRepositoryImpl(context, bluetoothAdapter, coroutineScope, Dispatchers.IO) diff --git a/tests/robotests/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractorTest.kt b/tests/robotests/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractorTest.kt new file mode 100644 index 00000000000..a83b7c2780e --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/domain/interactor/SpatialAudioInteractorTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth.domain.interactor + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.media.AudioDeviceAttributes +import android.media.AudioDeviceInfo +import android.media.AudioManager +import androidx.test.core.app.ApplicationProvider +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.LeAudioProfile +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel +import com.android.settingslib.media.data.repository.SpatializerRepository +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SpatialAudioInteractorTest { + @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Mock private lateinit var audioManager: AudioManager + @Mock private lateinit var cachedDevice: CachedBluetoothDevice + @Mock private lateinit var bluetoothDevice: BluetoothDevice + @Mock private lateinit var spatializerRepository: SpatializerRepository + @Mock private lateinit var leAudioProfile: LeAudioProfile + + private lateinit var underTest: SpatialAudioInteractor + private val testScope = TestScope() + + @Before + fun setUp() { + val context = spy(ApplicationProvider.getApplicationContext()) + `when`(cachedDevice.device).thenReturn(bluetoothDevice) + `when`(cachedDevice.address).thenReturn(BLUETOOTH_ADDRESS) + `when`(leAudioProfile.profileId).thenReturn(BluetoothProfile.LE_AUDIO) + underTest = + SpatialAudioInteractorImpl( + context, + audioManager, + SpatializerInteractor(spatializerRepository), + testScope.backgroundScope, + testScope.testScheduler) + } + + @Test + fun getDeviceSetting_noAudioProfile_returnNull() { + testScope.runTest { + val setting = getLatestValue(underTest.getDeviceSetting(cachedDevice)) + + assertThat(setting).isNull() + verifyNoInteractions(spatializerRepository) + } + } + + @Test + fun getDeviceSetting_audioProfileNotEnabled_returnNull() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(false) + + val setting = getLatestValue(underTest.getDeviceSetting(cachedDevice)) + + assertThat(setting).isNull() + verifyNoInteractions(spatializerRepository) + } + } + + @Test + fun getDeviceSetting_spatialAudioNotSupported_returnNull() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(true) + `when`( + spatializerRepository.isSpatialAudioAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(false) + + val setting = getLatestValue(underTest.getDeviceSetting(cachedDevice)) + + assertThat(setting).isNull() + } + } + + @Test + fun getDeviceSetting_spatialAudioSupported_returnTwoToggles() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(true) + `when`( + spatializerRepository.isSpatialAudioAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`( + spatializerRepository.isHeadTrackingAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(false) + `when`(spatializerRepository.getSpatialAudioCompatibleDevices()) + .thenReturn(listOf(BLE_AUDIO_DEVICE_ATTRIBUTES)) + `when`(spatializerRepository.isHeadTrackingEnabled(BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(false) + + val setting = + getLatestValue(underTest.getDeviceSetting(cachedDevice)) + as DeviceSettingModel.MultiTogglePreference + + assertThat(setting).isNotNull() + assertThat(setting.toggles.size).isEqualTo(2) + assertThat(setting.state.selectedIndex).isEqualTo(1) + } + } + + @Test + fun getDeviceSetting_headTrackingSupported_returnThreeToggles() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(true) + `when`( + spatializerRepository.isSpatialAudioAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`( + spatializerRepository.isHeadTrackingAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`(spatializerRepository.getSpatialAudioCompatibleDevices()) + .thenReturn(listOf(BLE_AUDIO_DEVICE_ATTRIBUTES)) + `when`(spatializerRepository.isHeadTrackingEnabled(BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + + val setting = + getLatestValue(underTest.getDeviceSetting(cachedDevice)) + as DeviceSettingModel.MultiTogglePreference + + assertThat(setting).isNotNull() + assertThat(setting.toggles.size).isEqualTo(3) + assertThat(setting.state.selectedIndex).isEqualTo(2) + } + } + + @Test + fun getDeviceSetting_updateState_enableSpatialAudio() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(true) + `when`( + spatializerRepository.isSpatialAudioAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`( + spatializerRepository.isHeadTrackingAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`(spatializerRepository.getSpatialAudioCompatibleDevices()).thenReturn(listOf()) + `when`(spatializerRepository.isHeadTrackingEnabled(BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(false) + + val setting = + getLatestValue(underTest.getDeviceSetting(cachedDevice)) + as DeviceSettingModel.MultiTogglePreference + setting.updateState(DeviceSettingStateModel.MultiTogglePreferenceState(2)) + runCurrent() + + assertThat(setting).isNotNull() + verify(spatializerRepository, times(1)) + .addSpatialAudioCompatibleDevice(BLE_AUDIO_DEVICE_ATTRIBUTES) + } + } + + @Test + fun getDeviceSetting_updateState_enableHeadTracking() { + testScope.runTest { + `when`(cachedDevice.profiles).thenReturn(listOf(leAudioProfile)) + `when`(leAudioProfile.isEnabled(bluetoothDevice)).thenReturn(true) + `when`( + spatializerRepository.isSpatialAudioAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`( + spatializerRepository.isHeadTrackingAvailableForDevice( + BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(true) + `when`(spatializerRepository.getSpatialAudioCompatibleDevices()).thenReturn(listOf()) + `when`(spatializerRepository.isHeadTrackingEnabled(BLE_AUDIO_DEVICE_ATTRIBUTES)) + .thenReturn(false) + + val setting = + getLatestValue(underTest.getDeviceSetting(cachedDevice)) + as DeviceSettingModel.MultiTogglePreference + setting.updateState(DeviceSettingStateModel.MultiTogglePreferenceState(2)) + runCurrent() + + assertThat(setting).isNotNull() + verify(spatializerRepository, times(1)) + .addSpatialAudioCompatibleDevice(BLE_AUDIO_DEVICE_ATTRIBUTES) + verify(spatializerRepository, times(1)) + .setHeadTrackingEnabled(BLE_AUDIO_DEVICE_ATTRIBUTES, true) + } + } + + private fun getLatestValue(deviceSettingFlow: Flow): DeviceSettingModel? { + var latestValue: DeviceSettingModel? = null + deviceSettingFlow.onEach { latestValue = it }.launchIn(testScope.backgroundScope) + testScope.runCurrent() + return latestValue + } + + private companion object { + const val BLUETOOTH_ADDRESS = "12:34:56:78:12:34" + val BLE_AUDIO_DEVICE_ATTRIBUTES = + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_HEADSET, + BLUETOOTH_ADDRESS, + ) + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt index 468a2f0b702..609d7679f16 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt @@ -19,11 +19,13 @@ package com.android.settings.bluetooth.ui.view import android.bluetooth.BluetoothAdapter import android.content.Context import android.graphics.Bitmap +import android.media.AudioManager import androidx.fragment.app.FragmentActivity import androidx.preference.Preference import androidx.preference.PreferenceManager import androidx.preference.PreferenceScreen import androidx.test.core.app.ApplicationProvider +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor import com.android.settings.dashboard.DashboardFragment import com.android.settings.testutils.FakeFeatureFactory import com.android.settingslib.bluetooth.CachedBluetoothDevice @@ -31,6 +33,7 @@ import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigModel +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel @@ -45,6 +48,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Mock +import org.mockito.Mockito.any import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule @@ -59,6 +63,7 @@ class DeviceDetailsFragmentFormatterTest { @Mock private lateinit var cachedDevice: CachedBluetoothDevice @Mock private lateinit var bluetoothAdapter: BluetoothAdapter @Mock private lateinit var repository: DeviceSettingRepository + @Mock private lateinit var spatialAudioInteractor: SpatialAudioInteractor private lateinit var fragment: TestFragment private lateinit var underTest: DeviceDetailsFragmentFormatter @@ -73,6 +78,10 @@ class DeviceDetailsFragmentFormatterTest { featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository( eq(context), eq(bluetoothAdapter), any())) .thenReturn(repository) + `when`( + featureFactory.bluetoothFeatureProvider.getSpatialAudioInteractor( + eq(context), any(AudioManager::class.java), any())) + .thenReturn(spatialAudioInteractor) val fragmentActivity = Robolectric.setupActivity(FragmentActivity::class.java) assertThat(fragmentActivity.applicationContext).isNotNull() fragment = TestFragment(context) @@ -186,7 +195,15 @@ class DeviceDetailsFragmentFormatterTest { toggles = listOf( ToggleModel( - "", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))), + "", DeviceSettingIcon.BitmapIcon( + Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) + ) + ) + ), isActive = true, state = DeviceSettingStateModel.MultiTogglePreferenceState(0), isAllowedChangingState = true, diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt index cc462bbfd62..a1fadb8b354 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt @@ -20,6 +20,7 @@ import android.bluetooth.BluetoothAdapter import android.content.Context import android.graphics.Bitmap import androidx.test.core.app.ApplicationProvider +import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout import com.android.settings.testutils.FakeFeatureFactory import com.android.settingslib.bluetooth.CachedBluetoothDevice @@ -27,6 +28,7 @@ import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigModel +import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel @@ -45,6 +47,8 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule @@ -61,6 +65,8 @@ class BluetoothDeviceDetailsViewModelTest { @Mock private lateinit var repository: DeviceSettingRepository + @Mock private lateinit var spatialAudioInteractor: SpatialAudioInteractor + private lateinit var underTest: BluetoothDeviceDetailsViewModel private lateinit var featureFactory: FakeFeatureFactory private val testScope = TestScope() @@ -74,7 +80,8 @@ class BluetoothDeviceDetailsViewModelTest { eq(context), eq(bluetoothAdapter), any())) .thenReturn(repository) - underTest = BluetoothDeviceDetailsViewModel(repository, cachedDevice) + underTest = + BluetoothDeviceDetailsViewModel(repository, spatialAudioInteractor, cachedDevice) } @Test @@ -91,6 +98,66 @@ class BluetoothDeviceDetailsViewModelTest { } } + @Test + fun getDeviceSetting_returnRepositoryResponse() { + testScope.runTest { + val remoteSettingId1 = 10001 + val pref = buildMultiTogglePreference(remoteSettingId1) + `when`(repository.getDeviceSettingsConfig(cachedDevice)) + .thenReturn( + DeviceSettingConfigModel( + listOf( + BUILTIN_SETTING_ITEM_1, + buildRemoteSettingItem(remoteSettingId1), + ), + listOf(), + "footer")) + `when`(repository.getDeviceSetting(cachedDevice, remoteSettingId1)) + .thenReturn(flowOf(pref)) + + var deviceSetting: DeviceSettingModel? = null + underTest + .getDeviceSetting(cachedDevice, remoteSettingId1) + .onEach { deviceSetting = it } + .launchIn(testScope.backgroundScope) + runCurrent() + + assertThat(deviceSetting).isSameInstanceAs(pref) + verify(repository, times(1)).getDeviceSetting(cachedDevice, remoteSettingId1) + } + } + + @Test + fun getDeviceSetting_spatialAudio_returnSpatialAudioInteractorResponse() { + testScope.runTest { + val pref = + buildMultiTogglePreference( + DeviceSettingId.DEVICE_SETTING_ID_SPATIAL_AUDIO_MULTI_TOGGLE) + `when`(repository.getDeviceSettingsConfig(cachedDevice)) + .thenReturn( + DeviceSettingConfigModel( + listOf( + BUILTIN_SETTING_ITEM_1, + buildRemoteSettingItem( + DeviceSettingId.DEVICE_SETTING_ID_SPATIAL_AUDIO_MULTI_TOGGLE), + ), + listOf(), + "footer")) + `when`(spatialAudioInteractor.getDeviceSetting(cachedDevice)).thenReturn(flowOf(pref)) + + var deviceSetting: DeviceSettingModel? = null + underTest + .getDeviceSetting( + cachedDevice, DeviceSettingId.DEVICE_SETTING_ID_SPATIAL_AUDIO_MULTI_TOGGLE) + .onEach { deviceSetting = it } + .launchIn(testScope.backgroundScope) + runCurrent() + + assertThat(deviceSetting).isSameInstanceAs(pref) + verify(spatialAudioInteractor, times(1)).getDeviceSetting(cachedDevice) + } + } + @Test fun getLayout_builtinDeviceSettings() { testScope.runTest { @@ -163,7 +230,12 @@ class BluetoothDeviceDetailsViewModelTest { cachedDevice, settingId, "title", - toggles = listOf(ToggleModel("", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))), + toggles = + listOf( + ToggleModel( + "toggle1", + DeviceSettingIcon.BitmapIcon( + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)))), isActive = true, state = DeviceSettingStateModel.MultiTogglePreferenceState(0), isAllowedChangingState = true,