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,