1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.bluetooth;
18 
19 import static android.media.Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE;
20 
21 import android.app.settings.SettingsEnums;
22 import android.bluetooth.BluetoothProfile;
23 import android.content.Context;
24 import android.media.AudioDeviceAttributes;
25 import android.media.AudioDeviceInfo;
26 import android.media.AudioManager;
27 import android.media.Spatializer;
28 import android.text.TextUtils;
29 import android.util.Log;
30 
31 import androidx.annotation.Nullable;
32 import androidx.annotation.VisibleForTesting;
33 import androidx.preference.Preference;
34 import androidx.preference.PreferenceCategory;
35 import androidx.preference.PreferenceFragmentCompat;
36 import androidx.preference.PreferenceScreen;
37 import androidx.preference.SwitchPreferenceCompat;
38 import androidx.preference.TwoStatePreference;
39 
40 import com.android.settings.R;
41 import com.android.settings.overlay.FeatureFactory;
42 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
43 import com.android.settingslib.bluetooth.LocalBluetoothProfile;
44 import com.android.settingslib.core.lifecycle.Lifecycle;
45 import com.android.settingslib.flags.Flags;
46 import com.android.settingslib.utils.ThreadUtils;
47 
48 import com.google.common.collect.ImmutableSet;
49 
50 import java.util.Set;
51 import java.util.concurrent.atomic.AtomicBoolean;
52 
53 /**
54  * The controller of the Spatial audio setting in the bluetooth detail settings.
55  */
56 public class BluetoothDetailsSpatialAudioController extends BluetoothDetailsController
57         implements Preference.OnPreferenceClickListener {
58 
59     private static final String TAG = "BluetoothSpatialAudioController";
60     private static final String KEY_SPATIAL_AUDIO_GROUP = "spatial_audio_group";
61     private static final String KEY_SPATIAL_AUDIO = "spatial_audio";
62     private static final String KEY_HEAD_TRACKING = "head_tracking";
63 
64     private final AudioManager mAudioManager;
65     private final Spatializer mSpatializer;
66 
67     @VisibleForTesting
68     PreferenceCategory mProfilesContainer;
69     @VisibleForTesting @Nullable AudioDeviceAttributes mAudioDevice = null;
70 
71     AtomicBoolean mHasHeadTracker = new AtomicBoolean(false);
72     AtomicBoolean mInitialRefresh = new AtomicBoolean(true);
73 
74     public static final Set<Integer> SA_PROFILES =
75             ImmutableSet.of(
76                     BluetoothProfile.A2DP, BluetoothProfile.LE_AUDIO, BluetoothProfile.HEARING_AID);
77 
BluetoothDetailsSpatialAudioController( Context context, PreferenceFragmentCompat fragment, CachedBluetoothDevice device, Lifecycle lifecycle)78     public BluetoothDetailsSpatialAudioController(
79             Context context,
80             PreferenceFragmentCompat fragment,
81             CachedBluetoothDevice device,
82             Lifecycle lifecycle) {
83         super(context, fragment, device, lifecycle);
84         mAudioManager = context.getSystemService(AudioManager.class);
85         mSpatializer = FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider()
86                 .getSpatializer(context);
87     }
88 
89     @Override
isAvailable()90     public boolean isAvailable() {
91         return mSpatializer.getImmersiveAudioLevel() != SPATIALIZER_IMMERSIVE_LEVEL_NONE;
92     }
93 
94     @Override
onPreferenceClick(Preference preference)95     public boolean onPreferenceClick(Preference preference) {
96         TwoStatePreference switchPreference = (TwoStatePreference) preference;
97         String key = switchPreference.getKey();
98         if (TextUtils.equals(key, KEY_SPATIAL_AUDIO)) {
99             mMetricsFeatureProvider.action(
100                     mContext,
101                     SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_SPATIAL_AUDIO_TOGGLE_CLICKED,
102                     switchPreference.isChecked());
103             updateSpatializerEnabled(switchPreference.isChecked());
104             ThreadUtils.postOnBackgroundThread(
105                     () -> {
106                         mHasHeadTracker.set(
107                                 mAudioDevice != null && mSpatializer.hasHeadTracker(mAudioDevice));
108                         mContext.getMainExecutor()
109                                 .execute(() -> refreshSpatialAudioEnabled(switchPreference));
110                     });
111             return true;
112         } else if (TextUtils.equals(key, KEY_HEAD_TRACKING)) {
113             mMetricsFeatureProvider.action(
114                     mContext,
115                     SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_HEAD_TRACKING_TOGGLE_CLICKED,
116                     switchPreference.isChecked());
117             updateSpatializerHeadTracking(switchPreference.isChecked());
118             return true;
119         } else {
120             Log.w(TAG, "invalid key name.");
121             return false;
122         }
123     }
124 
updateSpatializerEnabled(boolean enabled)125     private void updateSpatializerEnabled(boolean enabled)  {
126         if (mAudioDevice == null) {
127             Log.w(TAG, "cannot update spatializer enabled for null audio device.");
128             return;
129         }
130         if (enabled) {
131             mSpatializer.addCompatibleAudioDevice(mAudioDevice);
132         } else {
133             mSpatializer.removeCompatibleAudioDevice(mAudioDevice);
134         }
135     }
136 
updateSpatializerHeadTracking(boolean enabled)137     private void updateSpatializerHeadTracking(boolean enabled)  {
138         if (mAudioDevice == null) {
139             Log.w(TAG, "cannot update spatializer head tracking for null audio device.");
140             return;
141         }
142         mSpatializer.setHeadTrackerEnabled(enabled, mAudioDevice);
143     }
144 
145     @Override
getPreferenceKey()146     public String getPreferenceKey() {
147         return KEY_SPATIAL_AUDIO_GROUP;
148     }
149 
150     @Override
init(PreferenceScreen screen)151     protected void init(PreferenceScreen screen) {
152         mProfilesContainer = screen.findPreference(getPreferenceKey());
153         refresh();
154     }
155 
156     @Override
refresh()157     protected void refresh() {
158         if (Flags.enableDeterminingSpatialAudioAttributesByProfile()) {
159             getAvailableDeviceByProfileState();
160         } else {
161             if (mAudioDevice == null) {
162                 getAvailableDevice();
163             }
164         }
165         ThreadUtils.postOnBackgroundThread(
166                 () -> {
167                     mHasHeadTracker.set(
168                             mAudioDevice != null && mSpatializer.hasHeadTracker(mAudioDevice));
169                     mContext.getMainExecutor().execute(this::refreshUi);
170                 });
171     }
172 
refreshUi()173     private void refreshUi() {
174         TwoStatePreference spatialAudioPref = mProfilesContainer.findPreference(KEY_SPATIAL_AUDIO);
175         if (spatialAudioPref == null && mAudioDevice != null) {
176             spatialAudioPref = createSpatialAudioPreference(mProfilesContainer.getContext());
177             mProfilesContainer.addPreference(spatialAudioPref);
178         } else if (mAudioDevice == null || !mSpatializer.isAvailableForDevice(mAudioDevice)) {
179             if (spatialAudioPref != null) {
180                 mProfilesContainer.removePreference(spatialAudioPref);
181             }
182             final TwoStatePreference headTrackingPref =
183                     mProfilesContainer.findPreference(KEY_HEAD_TRACKING);
184             if (headTrackingPref != null) {
185                 mProfilesContainer.removePreference(headTrackingPref);
186             }
187             mAudioDevice = null;
188             return;
189         }
190 
191         refreshSpatialAudioEnabled(spatialAudioPref);
192     }
193 
refreshSpatialAudioEnabled( TwoStatePreference spatialAudioPref)194     private void refreshSpatialAudioEnabled(
195             TwoStatePreference spatialAudioPref) {
196         boolean isSpatialAudioOn = mSpatializer.getCompatibleAudioDevices().contains(mAudioDevice);
197         Log.d(TAG, "refresh() isSpatialAudioOn : " + isSpatialAudioOn);
198         spatialAudioPref.setChecked(isSpatialAudioOn);
199 
200         TwoStatePreference headTrackingPref = mProfilesContainer.findPreference(KEY_HEAD_TRACKING);
201         if (headTrackingPref == null) {
202             headTrackingPref = createHeadTrackingPreference(mProfilesContainer.getContext());
203             mProfilesContainer.addPreference(headTrackingPref);
204         }
205         refreshHeadTracking(spatialAudioPref, headTrackingPref);
206     }
207 
refreshHeadTracking(TwoStatePreference spatialAudioPref, TwoStatePreference headTrackingPref)208     private void refreshHeadTracking(TwoStatePreference spatialAudioPref,
209             TwoStatePreference headTrackingPref) {
210         boolean isHeadTrackingAvailable = spatialAudioPref.isChecked() && mHasHeadTracker.get();
211         Log.d(TAG, "refresh() has head tracker : " + mHasHeadTracker.get());
212         headTrackingPref.setVisible(isHeadTrackingAvailable);
213         if (isHeadTrackingAvailable) {
214             headTrackingPref.setChecked(mSpatializer.isHeadTrackerEnabled(mAudioDevice));
215         }
216 
217         if (mInitialRefresh.compareAndSet(true, false)) {
218             // Only triggered when shown for the first time
219             mMetricsFeatureProvider.action(
220                     mContext,
221                     SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_SPATIAL_AUDIO_TRIGGERED,
222                     spatialAudioPref.isChecked());
223             if (mHasHeadTracker.get()) {
224                 mMetricsFeatureProvider.action(
225                         mContext,
226                         SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_HEAD_TRACKING_TRIGGERED,
227                         headTrackingPref.isChecked());
228             }
229         }
230     }
231 
232     @VisibleForTesting
createSpatialAudioPreference(Context context)233     TwoStatePreference createSpatialAudioPreference(Context context) {
234         TwoStatePreference pref = new SwitchPreferenceCompat(context);
235         pref.setKey(KEY_SPATIAL_AUDIO);
236         pref.setTitle(context.getString(R.string.bluetooth_details_spatial_audio_title));
237         pref.setSummary(context.getString(R.string.bluetooth_details_spatial_audio_summary));
238         pref.setOnPreferenceClickListener(this);
239         return pref;
240     }
241 
242     @VisibleForTesting
createHeadTrackingPreference(Context context)243     TwoStatePreference createHeadTrackingPreference(Context context) {
244         TwoStatePreference pref = new SwitchPreferenceCompat(context);
245         pref.setKey(KEY_HEAD_TRACKING);
246         pref.setTitle(context.getString(R.string.bluetooth_details_head_tracking_title));
247         pref.setSummary(context.getString(R.string.bluetooth_details_head_tracking_summary));
248         pref.setOnPreferenceClickListener(this);
249         return pref;
250     }
251 
getAvailableDevice()252     private void getAvailableDevice() {
253         AudioDeviceAttributes a2dpDevice = new AudioDeviceAttributes(
254                 AudioDeviceAttributes.ROLE_OUTPUT,
255                 AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
256                 mCachedDevice.getAddress());
257         AudioDeviceAttributes bleHeadsetDevice = new AudioDeviceAttributes(
258                 AudioDeviceAttributes.ROLE_OUTPUT,
259                 AudioDeviceInfo.TYPE_BLE_HEADSET,
260                 mCachedDevice.getAddress());
261         AudioDeviceAttributes bleSpeakerDevice = new AudioDeviceAttributes(
262                 AudioDeviceAttributes.ROLE_OUTPUT,
263                 AudioDeviceInfo.TYPE_BLE_SPEAKER,
264                 mCachedDevice.getAddress());
265         AudioDeviceAttributes bleBroadcastDevice = new AudioDeviceAttributes(
266                 AudioDeviceAttributes.ROLE_OUTPUT,
267                 AudioDeviceInfo.TYPE_BLE_BROADCAST,
268                 mCachedDevice.getAddress());
269         AudioDeviceAttributes hearingAidDevice = new AudioDeviceAttributes(
270                 AudioDeviceAttributes.ROLE_OUTPUT,
271                 AudioDeviceInfo.TYPE_HEARING_AID,
272                 mCachedDevice.getAddress());
273 
274         if (mSpatializer.isAvailableForDevice(bleHeadsetDevice)) {
275             mAudioDevice = bleHeadsetDevice;
276         } else if (mSpatializer.isAvailableForDevice(bleSpeakerDevice)) {
277             mAudioDevice = bleSpeakerDevice;
278         } else if (mSpatializer.isAvailableForDevice(bleBroadcastDevice)) {
279             mAudioDevice = bleBroadcastDevice;
280         } else if (mSpatializer.isAvailableForDevice(a2dpDevice)) {
281             mAudioDevice = a2dpDevice;
282         } else if (mSpatializer.isAvailableForDevice(hearingAidDevice)) {
283             mAudioDevice = hearingAidDevice;
284         } else {
285             mAudioDevice = null;
286         }
287 
288         Log.d(TAG, "getAvailableDevice() device : "
289                 + mCachedDevice.getDevice().getAnonymizedAddress()
290                 + ", is available : " + (mAudioDevice != null)
291                 + ", type : " + (mAudioDevice == null ? "no type" : mAudioDevice.getType()));
292     }
293 
getAvailableDeviceByProfileState()294     private void getAvailableDeviceByProfileState() {
295         Log.i(
296                 TAG,
297                 "getAvailableDevice() mCachedDevice: "
298                         + mCachedDevice
299                         + " profiles: "
300                         + mCachedDevice.getProfiles());
301 
302         AudioDeviceAttributes saDevice = null;
303         for (LocalBluetoothProfile profile : mCachedDevice.getProfiles()) {
304             // pick first enabled profile that is compatible with spatial audio
305             if (SA_PROFILES.contains(profile.getProfileId())
306                     && profile.isEnabled(mCachedDevice.getDevice())) {
307                 switch (profile.getProfileId()) {
308                     case BluetoothProfile.A2DP:
309                         saDevice =
310                                 new AudioDeviceAttributes(
311                                         AudioDeviceAttributes.ROLE_OUTPUT,
312                                         AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
313                                         mCachedDevice.getAddress());
314                         break;
315                     case BluetoothProfile.LE_AUDIO:
316                         if (mAudioManager.getBluetoothAudioDeviceCategory(
317                                 mCachedDevice.getAddress())
318                                 == AudioManager.AUDIO_DEVICE_CATEGORY_SPEAKER) {
319                             saDevice =
320                                     new AudioDeviceAttributes(
321                                             AudioDeviceAttributes.ROLE_OUTPUT,
322                                             AudioDeviceInfo.TYPE_BLE_SPEAKER,
323                                             mCachedDevice.getAddress());
324                         } else {
325                             saDevice =
326                                     new AudioDeviceAttributes(
327                                             AudioDeviceAttributes.ROLE_OUTPUT,
328                                             AudioDeviceInfo.TYPE_BLE_HEADSET,
329                                             mCachedDevice.getAddress());
330                         }
331 
332                         break;
333                     case BluetoothProfile.HEARING_AID:
334                         saDevice =
335                                 new AudioDeviceAttributes(
336                                         AudioDeviceAttributes.ROLE_OUTPUT,
337                                         AudioDeviceInfo.TYPE_HEARING_AID,
338                                         mCachedDevice.getAddress());
339                         break;
340                     default:
341                         Log.i(
342                                 TAG,
343                                 "unrecognized profile for spatial audio: "
344                                         + profile.getProfileId());
345                         break;
346                 }
347                 break;
348             }
349         }
350         mAudioDevice = null;
351         if (saDevice != null && mSpatializer.isAvailableForDevice(saDevice)) {
352             mAudioDevice = saDevice;
353         }
354 
355         Log.d(
356                 TAG,
357                 "getAvailableDevice() device : "
358                         + mCachedDevice.getDevice().getAnonymizedAddress()
359                         + ", is available : "
360                         + (mAudioDevice != null)
361                         + ", type : "
362                         + (mAudioDevice == null ? "no type" : mAudioDevice.getType()));
363     }
364 
365     @VisibleForTesting
setAvailableDevice(AudioDeviceAttributes audioDevice)366     void setAvailableDevice(AudioDeviceAttributes audioDevice) {
367         mAudioDevice = audioDevice;
368     }
369 }
370