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.BluetoothUtils; 43 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 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 if (com.android.settings.flags.Flags.enableBluetoothDeviceDetailsPolish()) { 154 mProfilesContainer.setLayoutResource(R.layout.preference_category_bluetooth_no_padding); 155 } 156 refresh(); 157 } 158 159 @Override refresh()160 protected void refresh() { 161 if (Flags.enableDeterminingSpatialAudioAttributesByProfile()) { 162 getAvailableDeviceByProfileState(); 163 } else { 164 if (mAudioDevice == null) { 165 getAvailableDevice(); 166 } 167 } 168 ThreadUtils.postOnBackgroundThread( 169 () -> { 170 mHasHeadTracker.set( 171 mAudioDevice != null && mSpatializer.hasHeadTracker(mAudioDevice)); 172 mContext.getMainExecutor().execute(this::refreshUi); 173 }); 174 } 175 refreshUi()176 private void refreshUi() { 177 TwoStatePreference spatialAudioPref = mProfilesContainer.findPreference(KEY_SPATIAL_AUDIO); 178 if (spatialAudioPref == null && mAudioDevice != null) { 179 spatialAudioPref = createSpatialAudioPreference(mProfilesContainer.getContext()); 180 mProfilesContainer.addPreference(spatialAudioPref); 181 } else if (mAudioDevice == null || !mSpatializer.isAvailableForDevice(mAudioDevice)) { 182 if (spatialAudioPref != null) { 183 mProfilesContainer.removePreference(spatialAudioPref); 184 } 185 final TwoStatePreference headTrackingPref = 186 mProfilesContainer.findPreference(KEY_HEAD_TRACKING); 187 if (headTrackingPref != null) { 188 mProfilesContainer.removePreference(headTrackingPref); 189 } 190 mAudioDevice = null; 191 return; 192 } 193 194 refreshSpatialAudioEnabled(spatialAudioPref); 195 } 196 refreshSpatialAudioEnabled( TwoStatePreference spatialAudioPref)197 private void refreshSpatialAudioEnabled( 198 TwoStatePreference spatialAudioPref) { 199 boolean isSpatialAudioOn = mSpatializer.getCompatibleAudioDevices().contains(mAudioDevice); 200 Log.d(TAG, "refresh() isSpatialAudioOn : " + isSpatialAudioOn); 201 spatialAudioPref.setChecked(isSpatialAudioOn); 202 203 TwoStatePreference headTrackingPref = mProfilesContainer.findPreference(KEY_HEAD_TRACKING); 204 if (headTrackingPref == null) { 205 headTrackingPref = createHeadTrackingPreference(mProfilesContainer.getContext()); 206 mProfilesContainer.addPreference(headTrackingPref); 207 } 208 refreshHeadTracking(spatialAudioPref, headTrackingPref); 209 } 210 refreshHeadTracking(TwoStatePreference spatialAudioPref, TwoStatePreference headTrackingPref)211 private void refreshHeadTracking(TwoStatePreference spatialAudioPref, 212 TwoStatePreference headTrackingPref) { 213 boolean isHeadTrackingAvailable = spatialAudioPref.isChecked() && mHasHeadTracker.get(); 214 Log.d(TAG, "refresh() has head tracker : " + mHasHeadTracker.get()); 215 headTrackingPref.setVisible(isHeadTrackingAvailable); 216 if (isHeadTrackingAvailable) { 217 headTrackingPref.setChecked(mSpatializer.isHeadTrackerEnabled(mAudioDevice)); 218 } 219 220 if (mInitialRefresh.compareAndSet(true, false)) { 221 // Only triggered when shown for the first time 222 mMetricsFeatureProvider.action( 223 mContext, 224 SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_SPATIAL_AUDIO_TRIGGERED, 225 spatialAudioPref.isChecked()); 226 if (mHasHeadTracker.get()) { 227 mMetricsFeatureProvider.action( 228 mContext, 229 SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_HEAD_TRACKING_TRIGGERED, 230 headTrackingPref.isChecked()); 231 } 232 } 233 } 234 235 @VisibleForTesting createSpatialAudioPreference(Context context)236 TwoStatePreference createSpatialAudioPreference(Context context) { 237 TwoStatePreference pref = new SwitchPreferenceCompat(context); 238 pref.setKey(KEY_SPATIAL_AUDIO); 239 pref.setTitle(context.getString(R.string.bluetooth_details_spatial_audio_title)); 240 pref.setSummary(context.getString(R.string.bluetooth_details_spatial_audio_summary)); 241 pref.setOnPreferenceClickListener(this); 242 return pref; 243 } 244 245 @VisibleForTesting createHeadTrackingPreference(Context context)246 TwoStatePreference createHeadTrackingPreference(Context context) { 247 TwoStatePreference pref = new SwitchPreferenceCompat(context); 248 pref.setKey(KEY_HEAD_TRACKING); 249 pref.setTitle(context.getString(R.string.bluetooth_details_head_tracking_title)); 250 pref.setSummary(context.getString(R.string.bluetooth_details_head_tracking_summary)); 251 pref.setOnPreferenceClickListener(this); 252 return pref; 253 } 254 getAvailableDevice()255 private void getAvailableDevice() { 256 AudioDeviceAttributes a2dpDevice = new AudioDeviceAttributes( 257 AudioDeviceAttributes.ROLE_OUTPUT, 258 AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, 259 mCachedDevice.getAddress()); 260 AudioDeviceAttributes bleHeadsetDevice = new AudioDeviceAttributes( 261 AudioDeviceAttributes.ROLE_OUTPUT, 262 AudioDeviceInfo.TYPE_BLE_HEADSET, 263 mCachedDevice.getAddress()); 264 AudioDeviceAttributes bleSpeakerDevice = new AudioDeviceAttributes( 265 AudioDeviceAttributes.ROLE_OUTPUT, 266 AudioDeviceInfo.TYPE_BLE_SPEAKER, 267 mCachedDevice.getAddress()); 268 AudioDeviceAttributes bleBroadcastDevice = new AudioDeviceAttributes( 269 AudioDeviceAttributes.ROLE_OUTPUT, 270 AudioDeviceInfo.TYPE_BLE_BROADCAST, 271 mCachedDevice.getAddress()); 272 AudioDeviceAttributes hearingAidDevice = new AudioDeviceAttributes( 273 AudioDeviceAttributes.ROLE_OUTPUT, 274 AudioDeviceInfo.TYPE_HEARING_AID, 275 mCachedDevice.getAddress()); 276 277 if (mSpatializer.isAvailableForDevice(bleHeadsetDevice)) { 278 mAudioDevice = bleHeadsetDevice; 279 } else if (mSpatializer.isAvailableForDevice(bleSpeakerDevice)) { 280 mAudioDevice = bleSpeakerDevice; 281 } else if (mSpatializer.isAvailableForDevice(bleBroadcastDevice)) { 282 mAudioDevice = bleBroadcastDevice; 283 } else if (mSpatializer.isAvailableForDevice(a2dpDevice)) { 284 mAudioDevice = a2dpDevice; 285 } else if (mSpatializer.isAvailableForDevice(hearingAidDevice)) { 286 mAudioDevice = hearingAidDevice; 287 } else { 288 mAudioDevice = null; 289 } 290 291 Log.d(TAG, "getAvailableDevice() device : " 292 + mCachedDevice.getDevice().getAnonymizedAddress() 293 + ", is available : " + (mAudioDevice != null) 294 + ", type : " + (mAudioDevice == null ? "no type" : mAudioDevice.getType())); 295 } 296 getAvailableDeviceByProfileState()297 private void getAvailableDeviceByProfileState() { 298 Log.i( 299 TAG, 300 "getAvailableDevice() mCachedDevice: " 301 + mCachedDevice 302 + " profiles: " 303 + mCachedDevice.getProfiles()); 304 305 AudioDeviceAttributes saDevice = 306 BluetoothUtils.getAudioDeviceAttributesForSpatialAudio( 307 mCachedDevice, 308 mAudioManager.getBluetoothAudioDeviceCategory(mCachedDevice.getAddress())); 309 if (saDevice != null && mSpatializer.isAvailableForDevice(saDevice)) { 310 mAudioDevice = saDevice; 311 } else { 312 mAudioDevice = null; 313 } 314 315 Log.d( 316 TAG, 317 "getAvailableDevice() device : " 318 + mCachedDevice.getDevice().getAnonymizedAddress() 319 + ", is available : " 320 + (mAudioDevice != null) 321 + ", type : " 322 + (mAudioDevice == null ? "no type" : mAudioDevice.getType())); 323 } 324 325 @VisibleForTesting setAvailableDevice(AudioDeviceAttributes audioDevice)326 void setAvailableDevice(AudioDeviceAttributes audioDevice) { 327 mAudioDevice = audioDevice; 328 } 329 } 330