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);
+    }
+}