diff --git a/res/values/strings.xml b/res/values/strings.xml index 9083674dbdb..0299b70c439 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -14010,4 +14010,13 @@ *This is a temporary placeholder fallback activity. + + + Spatial audio + + Immersive audio seems like it\u0027s coming from all around you. Only works with some media. + + Make audio more realistic + + Shift positioning of audio so it sounds more natural. diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml index 9df195584d6..b21d5c931e5 100644 --- a/res/xml/bluetooth_device_details_fragment.xml +++ b/res/xml/bluetooth_device_details_fragment.xml @@ -52,6 +52,9 @@ + + diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java b/src/com/android/settings/bluetooth/BluetoothDetailsSpatialAudioController.java new file mode 100644 index 00000000000..89d923d616a --- /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 4980ba313fb..653248263bd 100644 --- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java +++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java @@ -187,6 +187,8 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment 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 00000000000..ef812473565 --- /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 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 compatibleAudioDevices = new ArrayList<>(); + when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices); + + mController.refresh(); + + assertThat(mSpatialAudioPref.isChecked()).isFalse(); + } + + @Test + public void refresh_spatialAudioOnAndHeadTrackingIsAvailable_showsHeadTrackingPreference() { + List 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 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 compatibleAudioDevices = new ArrayList<>(); + when(mSpatializer.getCompatibleAudioDevices()).thenReturn(compatibleAudioDevices); + + mController.refresh(); + + assertThat(mHeadTrackingPref.isVisible()).isFalse(); + } + + @Test + public void refresh_headTrackingIsTurnedOn_checksHeadTrackingPreference() { + List 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 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); + } +}