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