Implement Spatial audio toggle domain layer

BUG: 343317785
Test: atest SpatialAudioInteractorTest
Flag: com.android.settings.flags.enable_bluetooth_device_details_polish
Change-Id: Ic73e56a1ca41f9fa58d5219666478a7edc55059d
This commit is contained in:
Haijie Hong
2024-08-13 20:20:01 +08:00
parent 41f7c222b6
commit c1b24f0a9e
17 changed files with 825 additions and 257 deletions

View File

@@ -0,0 +1,26 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="960"
android:viewportWidth="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,520Q414,520 367,473Q320,426 320,360Q320,294 367,247Q414,200 480,200Q546,200 593,247Q640,294 640,360Q640,426 593,473Q546,520 480,520ZM160,840L160,728Q160,695 177,666Q194,637 224,622Q275,596 339,578Q403,560 480,560Q557,560 621,578Q685,596 736,622Q766,637 783,666Q800,695 800,728L800,840L160,840ZM240,760L720,760L720,728Q720,717 714.5,708Q709,699 700,694Q664,676 607.5,658Q551,640 480,640Q409,640 352.5,658Q296,676 260,694Q251,699 245.5,708Q240,717 240,728L240,760ZM480,440Q513,440 536.5,416.5Q560,393 560,360Q560,327 536.5,303.5Q513,280 480,280Q447,280 423.5,303.5Q400,327 400,360Q400,393 423.5,416.5Q447,440 480,440ZM39,200L39,120Q56,120 70,113.5Q84,107 95,96Q106,85 112,71Q118,57 118,40L199,40Q199,73 186.5,102Q174,131 152,153Q130,175 101,187.5Q72,200 39,200ZM39,361L39,281Q90,281 133.5,262Q177,243 209,210Q241,177 260,133.5Q279,90 279,40L360,40Q360,106 335,164.5Q310,223 266,267Q222,311 164,336Q106,361 39,361ZM920,361Q854,361 795.5,336Q737,311 693,267Q649,223 624,164.5Q599,106 599,40L679,40Q679,90 698,133.5Q717,177 750,210Q783,243 826.5,262Q870,281 920,281L920,361ZM920,200Q887,200 858,187.5Q829,175 807,153Q785,131 772.5,102Q760,73 760,40L840,40Q840,57 846.5,71Q853,85 864,96Q875,107 889,113.5Q903,120 920,120L920,200ZM480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360ZM480,760L480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760Q480,760 480,760L480,760L480,760Z" />
</vector>

View File

@@ -0,0 +1,26 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="960"
android:viewportWidth="960">
<path
android:fillColor="@android:color/white"
android:pathData="M920,401Q848,401 782,373.5Q716,346 665,295Q614,244 586.5,178Q559,112 559,40L639,40Q639,97 660,148Q681,199 721,239Q761,279 812,300.5Q863,322 920,322L920,401ZM920,242Q879,242 842.5,227Q806,212 777,183Q748,154 733,117.5Q718,81 718,40L797,40Q797,65 806.5,87.5Q816,110 833,127Q850,144 872.5,153Q895,162 920,162L920,242ZM400,520Q334,520 287,473Q240,426 240,360Q240,294 287,247Q334,200 400,200Q466,200 513,247Q560,294 560,360Q560,426 513,473Q466,520 400,520ZM80,840L80,728Q80,695 97,666Q114,637 144,622Q195,596 259,578Q323,560 400,560Q477,560 541,578Q605,596 656,622Q686,637 703,666Q720,695 720,728L720,840L80,840ZM160,760L640,760L640,728Q640,717 634.5,708Q629,699 620,694Q584,676 527.5,658Q471,640 400,640Q329,640 272.5,658Q216,676 180,694Q171,699 165.5,708Q160,717 160,728L160,760ZM400,440Q433,440 456.5,416.5Q480,393 480,360Q480,327 456.5,303.5Q433,280 400,280Q367,280 343.5,303.5Q320,327 320,360Q320,393 343.5,416.5Q367,440 400,440ZM400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360ZM400,760L400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760L400,760L400,760Z" />
</vector>

View File

@@ -0,0 +1,26 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="960"
android:viewportWidth="960">
<path
android:fillColor="@android:color/white"
android:pathData="M750,550L806,494Q766,454 743.5,402.5Q721,351 721,294Q721,237 743.5,186Q766,135 806,95L750,37Q699,88 670,155Q641,222 641,294Q641,366 670,432.5Q699,499 750,550ZM862,436L918,380Q901,363 891,341Q881,319 881,294Q881,269 891,247Q901,225 918,208L862,151Q833,180 817,216Q801,252 801,293Q801,334 817,371Q833,408 862,436ZM400,520Q334,520 287,473Q240,426 240,360Q240,294 287,247Q334,200 400,200Q466,200 513,247Q560,294 560,360Q560,426 513,473Q466,520 400,520ZM80,840L80,728Q80,695 97,666Q114,637 144,622Q195,596 259,578Q323,560 400,560Q477,560 541,578Q605,596 656,622Q686,637 703,666Q720,695 720,728L720,840L80,840ZM160,760L640,760L640,728Q640,717 634.5,708Q629,699 620,694Q584,676 527.5,658Q471,640 400,640Q329,640 272.5,658Q216,676 180,694Q171,699 165.5,708Q160,717 160,728L160,760ZM400,440Q433,440 456.5,416.5Q480,393 480,360Q480,327 456.5,303.5Q433,280 400,280Q367,280 343.5,303.5Q320,327 320,360Q320,393 343.5,416.5Q367,440 400,440ZM400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360Q400,360 400,360ZM400,760L400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760Q400,760 400,760L400,760L400,760Z" />
</vector>

View File

@@ -7946,6 +7946,18 @@
<!-- Sound: Footer hyperlink text to launch the Connected devices settings page. [CHAR LIMIT=NONE]-->
<string name="spatial_audio_footer_learn_more_text">Connected devices settings</string>
<!-- Bluetooth device details: spatial audio multi-toggle title. [CHAR LIMIT=20]-->
<string name="spatial_audio_multi_toggle_title">Spatial Audio</string>
<!-- Bluetooth device details: spatial audio is off. [CHAR LIMIT=20]-->
<string name="spatial_audio_multi_toggle_off">Off</string>
<!-- Bluetooth device details: spatial audio is on. [CHAR LIMIT=20]-->
<string name="spatial_audio_multi_toggle_on">Off</string>
<!-- Bluetooth device details: head tracking is on. [CHAR LIMIT=20]-->
<string name="spatial_audio_multi_toggle_head_tracking_on">Off</string>
<!-- Zen Modes: Summary for the Do not Disturb option that describes how many automatic rules (schedules) are enabled [CHAR LIMIT=NONE]-->
<string name="zen_mode_settings_schedules_summary">
{count, plural,

View File

@@ -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(

View File

@@ -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(

View File

@@ -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<ComponentName> getRelatedTools() {
return null;
}
@Override
public Spatializer getSpatializer(Context context) {
AudioManager audioManager = context.getSystemService(AudioManager.class);
return audioManager.getSpatializer();
}
@Override
public List<Preference> getBluetoothExtraOptions(Context context,
CachedBluetoothDevice device) {
return ImmutableList.of();
}
@Override
public Set<String> 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);
}
}

View File

@@ -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<ComponentName>? {
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<Preference>? {
return ImmutableList.of<Preference>()
}
override fun getInvisibleProfilePreferenceKeys(
context: Context,
bluetoothDevice: BluetoothDevice
): Set<String> {
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)
}
}

View File

@@ -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<DeviceSettingModel?>
}
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<Unit>()
override fun getDeviceSetting(
cachedDevice: CachedBluetoothDevice,
): Flow<DeviceSettingModel?> =
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
}
}

View File

@@ -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 -> {}
}
}

View File

@@ -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,8 +97,7 @@ fun MultiTogglePreferenceGroup(
Surface(
modifier = Modifier.height(64.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface
) {
color = MaterialTheme.colorScheme.surface) {
Button(
modifier =
Modifier.fillMaxSize().padding(8.dp).semantics {
@@ -114,16 +113,11 @@ fun MultiTogglePreferenceGroup(
onClick = { settingIdForPopUp = preferenceModel.id },
shape = RoundedCornerShape(20.dp),
colors = getButtonColors(preferenceModel.isActive),
contentPadding = PaddingValues(0.dp)
) {
Icon(
contentPadding = PaddingValues(0.dp)) {
DeviceSettingComposeIcon(
preferenceModel.toggles[preferenceModel.state.selectedIndex]
.icon
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = LocalContentColor.current
)
.icon,
modifier = Modifier.size(24.dp))
}
}
}
@@ -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))
}
}
}

View File

@@ -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<String>? = runBlocking {
viewModel.getItems()?.filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()?.map {
it.preferenceKey
}
viewModel
.getItems()
?.filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()
?.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))
}
}

View File

@@ -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<DeviceSettingConfigItemModel>? = items.await()?.mainItems
fun getDeviceSetting(cachedDevice: CachedBluetoothDevice, @DeviceSettingId settingId: Int) =
deviceSettingRepository.getDeviceSetting(cachedDevice, settingId)
fun getDeviceSetting(
cachedDevice: CachedBluetoothDevice,
@DeviceSettingId settingId: Int
): Flow<DeviceSettingModel?> {
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 <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return BluetoothDeviceDetailsViewModel(deviceSettingRepository, cachedDevice) as T
return BluetoothDeviceDetailsViewModel(
deviceSettingRepository, spatialAudioInteractor, cachedDevice)
as T
}
}

View File

@@ -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)

View File

@@ -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<Context>())
`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?>): 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,
)
}
}

View File

@@ -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,

View File

@@ -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,