Merge "RESTRICT AUTOMERGE Implement Spatial Audio and Head Tracking option in bluetooth settings" into tm-dev
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 383d977..63f4a34 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -14026,4 +14026,13 @@
<!-- Text to explain an activity is a temporary placeholder [CHAR LIMIT=none] -->
<string name="placeholder_activity" translatable="false">*This is a temporary placeholder fallback activity.</string>
+
+ <!-- The title of the spatial audio [CHAR LIMIT=none] -->
+ <string name="bluetooth_details_spatial_audio_title">Spatial audio</string>
+ <!-- The summary of the spatial audio [CHAR LIMIT=none] -->
+ <string name="bluetooth_details_spatial_audio_summary">Immersive audio seems like it\u0027s coming from all around you. Only works with some media.</string>
+ <!-- The title of the head tracking [CHAR LIMIT=none] -->
+ <string name="bluetooth_details_head_tracking_title">Make audio more realistic</string>
+ <!-- The summary of the head tracking [CHAR LIMIT=none] -->
+ <string name="bluetooth_details_head_tracking_summary">Shift positioning of audio so it sounds more natural.</string>
</resources>
diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml
index 9df1955..b21d5c9 100644
--- a/res/xml/bluetooth_device_details_fragment.xml
+++ b/res/xml/bluetooth_device_details_fragment.xml
@@ -53,6 +53,9 @@
android:key="device_companion_apps"/>
<PreferenceCategory
+ android:key="spatial_audio_group"/>
+
+ <PreferenceCategory
android:key="bluetooth_profiles"/>
<com.android.settingslib.widget.FooterPreference
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java b/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java
new file mode 100644
index 0000000..89d923d
--- /dev/null
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2022 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.content.Context;
+import android.media.AudioDeviceAttributes;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.media.Spatializer;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.SwitchPreference;
+
+import com.android.settings.R;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+
+/**
+ * The controller of the Spatial audio setting in the bluetooth detail settings.
+ */
+public class BluetoothDetailsSpatialAudioController extends BluetoothDetailsController
+ implements Preference.OnPreferenceClickListener {
+
+ private static final String TAG = "BluetoothSpatialAudioController";
+ private static final String KEY_SPATIAL_AUDIO_GROUP = "spatial_audio_group";
+ private static final String KEY_SPATIAL_AUDIO = "spatial_audio";
+ private static final String KEY_HEAD_TRACKING = "head_tracking";
+
+ private final Spatializer mSpatializer;
+
+ @VisibleForTesting
+ PreferenceCategory mProfilesContainer;
+ @VisibleForTesting
+ AudioDeviceAttributes mAudioDevice;
+
+ public BluetoothDetailsSpatialAudioController(
+ Context context,
+ PreferenceFragmentCompat fragment,
+ CachedBluetoothDevice device,
+ Lifecycle lifecycle) {
+ super(context, fragment, device, lifecycle);
+ AudioManager audioManager = context.getSystemService(AudioManager.class);
+ mSpatializer = audioManager.getSpatializer();
+ mAudioDevice = new AudioDeviceAttributes(
+ AudioDeviceAttributes.ROLE_OUTPUT,
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
+ mCachedDevice.getAddress());
+
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return mSpatializer.isAvailableForDevice(mAudioDevice) ? true : false;
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ SwitchPreference switchPreference = (SwitchPreference) preference;
+ String key = switchPreference.getKey();
+ if (TextUtils.equals(key, KEY_SPATIAL_AUDIO)) {
+ if (switchPreference.isChecked()) {
+ mSpatializer.addCompatibleAudioDevice(mAudioDevice);
+ } else {
+ mSpatializer.removeCompatibleAudioDevice(mAudioDevice);
+ }
+ refresh();
+ return true;
+ } else if (TextUtils.equals(key, KEY_HEAD_TRACKING)) {
+ mSpatializer.setHeadTrackerEnabled(switchPreference.isChecked(), mAudioDevice);
+ return true;
+ } else {
+ Log.w(TAG, "invalid key name.");
+ return false;
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY_SPATIAL_AUDIO_GROUP;
+ }
+
+ @Override
+ protected void init(PreferenceScreen screen) {
+ mProfilesContainer = screen.findPreference(getPreferenceKey());
+ mProfilesContainer.setLayoutResource(R.layout.preference_bluetooth_profile_category);
+ refresh();
+ }
+
+ @Override
+ protected void refresh() {
+ SwitchPreference spatialAudioPref = mProfilesContainer.findPreference(KEY_SPATIAL_AUDIO);
+ if (spatialAudioPref == null) {
+ spatialAudioPref = createSpatialAudioPreference(mProfilesContainer.getContext());
+ mProfilesContainer.addPreference(spatialAudioPref);
+ }
+
+ boolean isSpatialAudioOn = mSpatializer.getCompatibleAudioDevices().contains(mAudioDevice);
+ Log.d(TAG, "refresh() isSpatialAudioOn : " + isSpatialAudioOn);
+ spatialAudioPref.setChecked(isSpatialAudioOn);
+
+ SwitchPreference headTrackingPref = mProfilesContainer.findPreference(KEY_HEAD_TRACKING);
+ if (headTrackingPref == null) {
+ headTrackingPref = createHeadTrackingPreference(mProfilesContainer.getContext());
+ mProfilesContainer.addPreference(headTrackingPref);
+ }
+
+ boolean isHeadTrackingAvailable =
+ isSpatialAudioOn && mSpatializer.hasHeadTracker(mAudioDevice);
+ Log.d(TAG, "refresh() has head tracker : " + mSpatializer.hasHeadTracker(mAudioDevice));
+ headTrackingPref.setVisible(isHeadTrackingAvailable);
+ if (isHeadTrackingAvailable) {
+ headTrackingPref.setChecked(mSpatializer.isHeadTrackerEnabled(mAudioDevice));
+ }
+ }
+
+ @VisibleForTesting
+ SwitchPreference createSpatialAudioPreference(Context context) {
+ SwitchPreference pref = new SwitchPreference(context);
+ pref.setKey(KEY_SPATIAL_AUDIO);
+ pref.setTitle(context.getString(R.string.bluetooth_details_spatial_audio_title));
+ pref.setSummary(context.getString(R.string.bluetooth_details_spatial_audio_summary));
+ pref.setOnPreferenceClickListener(this);
+ return pref;
+ }
+
+ @VisibleForTesting
+ SwitchPreference createHeadTrackingPreference(Context context) {
+ SwitchPreference pref = new SwitchPreference(context);
+ pref.setKey(KEY_HEAD_TRACKING);
+ pref.setTitle(context.getString(R.string.bluetooth_details_head_tracking_title));
+ pref.setSummary(context.getString(R.string.bluetooth_details_head_tracking_summary));
+ pref.setOnPreferenceClickListener(this);
+ return pref;
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
index 4980ba3..6532482 100644
--- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
+++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
@@ -187,6 +187,8 @@
lifecycle));
controllers.add(new BluetoothDetailsCompanionAppsController(context, this,
mCachedDevice, lifecycle));
+ controllers.add(new BluetoothDetailsSpatialAudioController(context, this, mCachedDevice,
+ lifecycle));
controllers.add(new BluetoothDetailsProfilesController(context, this, mManager,
mCachedDevice, lifecycle));
controllers.add(new BluetoothDetailsMacAddressController(context, this, mCachedDevice,
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioControllerTest.java
new file mode 100644
index 0000000..ef81247
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioControllerTest.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2022 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.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.media.AudioDeviceAttributes;
+import android.media.AudioManager;
+import android.media.Spatializer;
+
+import androidx.preference.PreferenceCategory;
+import androidx.preference.SwitchPreference;
+
+import com.android.settingslib.core.lifecycle.Lifecycle;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+public class BluetoothDetailsSpatialAudioControllerTest extends BluetoothDetailsControllerTestBase {
+
+ private static final String MAC_ADDRESS = "04:52:C7:0B:D8:3C";
+ private static final String KEY_SPATIAL_AUDIO = "spatial_audio";
+ private static final String KEY_HEAD_TRACKING = "head_tracking";
+
+ @Mock
+ private AudioManager mAudioManager;
+ @Mock
+ private Spatializer mSpatializer;
+ @Mock
+ private Lifecycle mSpatialAudioLifecycle;
+ @Mock
+ private PreferenceCategory mProfilesContainer;
+
+ private BluetoothDetailsSpatialAudioController mController;
+ private SwitchPreference mSpatialAudioPref;
+ private SwitchPreference mHeadTrackingPref;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ mContext = spy(RuntimeEnvironment.application);
+ when(mContext.getSystemService(AudioManager.class)).thenReturn(mAudioManager);
+ when(mAudioManager.getSpatializer()).thenReturn(mSpatializer);
+ when(mCachedDevice.getAddress()).thenReturn(MAC_ADDRESS);
+
+ mController = new BluetoothDetailsSpatialAudioController(mContext, mFragment,
+ mCachedDevice, mSpatialAudioLifecycle);
+ mController.mProfilesContainer = mProfilesContainer;
+
+ mSpatialAudioPref = mController.createSpatialAudioPreference(mContext);
+ mHeadTrackingPref = mController.createHeadTrackingPreference(mContext);
+
+ when(mProfilesContainer.findPreference(KEY_SPATIAL_AUDIO)).thenReturn(mSpatialAudioPref);
+ when(mProfilesContainer.findPreference(KEY_HEAD_TRACKING)).thenReturn(mHeadTrackingPref);
+ }
+
+ @Test
+ public void isAvailable_spatialAudioIsAvailable_returnsTrue() {
+ when(mSpatializer.isAvailableForDevice(mController.mAudioDevice)).thenReturn(true);
+ assertThat(mController.isAvailable()).isTrue();
+ }
+
+ @Test
+ public void isAvailable_spatialAudioIsNotAvailable_returnsFalse() {
+ when(mSpatializer.isAvailableForDevice(mController.mAudioDevice)).thenReturn(false);
+ assertThat(mController.isAvailable()).isFalse();
+ }
+
+ @Test
+ public void refresh_spatialAudioIsTurnedOn_checksSpatialAudioPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ compatibleAudioDevices.add(mController.mAudioDevice);
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+
+ mController.refresh();
+
+ assertThat(mSpatialAudioPref.isChecked()).isTrue();
+ }
+
+ @Test
+ public void refresh_spatialAudioIsTurnedOff_unchecksSpatialAudioPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+
+ mController.refresh();
+
+ assertThat(mSpatialAudioPref.isChecked()).isFalse();
+ }
+
+ @Test
+ public void refresh_spatialAudioOnAndHeadTrackingIsAvailable_showsHeadTrackingPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ compatibleAudioDevices.add(mController.mAudioDevice);
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+ when(mSpatializer.hasHeadTracker(mController.mAudioDevice)).thenReturn(true);
+
+ mController.refresh();
+
+ assertThat(mHeadTrackingPref.isVisible()).isTrue();
+ }
+
+ @Test
+ public void
+ refresh_spatialAudioOnAndHeadTrackingIsNotAvailable_hidesHeadTrackingPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ compatibleAudioDevices.add(mController.mAudioDevice);
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+ when(mSpatializer.hasHeadTracker(mController.mAudioDevice)).thenReturn(false);
+
+ mController.refresh();
+
+ assertThat(mHeadTrackingPref.isVisible()).isFalse();
+ }
+
+ @Test
+ public void refresh_spatialAudioOff_hidesHeadTrackingPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+
+ mController.refresh();
+
+ assertThat(mHeadTrackingPref.isVisible()).isFalse();
+ }
+
+ @Test
+ public void refresh_headTrackingIsTurnedOn_checksHeadTrackingPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ compatibleAudioDevices.add(mController.mAudioDevice);
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+ when(mSpatializer.hasHeadTracker(mController.mAudioDevice)).thenReturn(true);
+ when(mSpatializer.isHeadTrackerEnabled(mController.mAudioDevice)).thenReturn(true);
+
+ mController.refresh();
+
+ assertThat(mHeadTrackingPref.isChecked()).isTrue();
+ }
+
+ @Test
+ public void refresh_headTrackingIsTurnedOff_unchecksHeadTrackingPreference() {
+ List<AudioDeviceAttributes> compatibleAudioDevices = new ArrayList<>();
+ compatibleAudioDevices.add(mController.mAudioDevice);
+ when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices);
+ when(mSpatializer.hasHeadTracker(mController.mAudioDevice)).thenReturn(true);
+ when(mSpatializer.isHeadTrackerEnabled(mController.mAudioDevice)).thenReturn(false);
+
+ mController.refresh();
+
+ assertThat(mHeadTrackingPref.isChecked()).isFalse();
+ }
+
+ @Test
+ public void turnedOnSpatialAudio_invokesAddCompatibleAudioDevice() {
+ mSpatialAudioPref.setChecked(true);
+ mController.onPreferenceClick(mSpatialAudioPref);
+ verify(mSpatializer).addCompatibleAudioDevice(mController.mAudioDevice);
+ }
+
+ @Test
+ public void turnedOffSpatialAudio_invokesRemoveCompatibleAudioDevice() {
+ mSpatialAudioPref.setChecked(false);
+ mController.onPreferenceClick(mSpatialAudioPref);
+ verify(mSpatializer).removeCompatibleAudioDevice(mController.mAudioDevice);
+ }
+
+ @Test
+ public void turnedOnHeadTracking_invokesSetHeadTrackerEnabled_setsTrue() {
+ mHeadTrackingPref.setChecked(true);
+ mController.onPreferenceClick(mHeadTrackingPref);
+ verify(mSpatializer).setHeadTrackerEnabled(true, mController.mAudioDevice);
+ }
+
+ @Test
+ public void turnedOffHeadTracking_invokesSetHeadTrackerEnabled_setsFalse() {
+ mHeadTrackingPref.setChecked(false);
+ mController.onPreferenceClick(mHeadTrackingPref);
+ verify(mSpatializer).setHeadTrackerEnabled(false, mController.mAudioDevice);
+ }
+}