/* * 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 android.media.Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothProfile; 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.Nullable; import androidx.annotation.VisibleForTesting; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import androidx.preference.TwoStatePreference; import com.android.settings.R; import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothProfile; import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.flags.Flags; import com.android.settingslib.utils.ThreadUtils; import com.google.common.collect.ImmutableSet; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; /** * 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 AudioManager mAudioManager; private final Spatializer mSpatializer; @VisibleForTesting PreferenceCategory mProfilesContainer; @VisibleForTesting @Nullable AudioDeviceAttributes mAudioDevice = null; AtomicBoolean mHasHeadTracker = new AtomicBoolean(false); AtomicBoolean mInitialRefresh = new AtomicBoolean(true); public static final Set SA_PROFILES = ImmutableSet.of( BluetoothProfile.A2DP, BluetoothProfile.LE_AUDIO, BluetoothProfile.HEARING_AID); public BluetoothDetailsSpatialAudioController( Context context, PreferenceFragmentCompat fragment, CachedBluetoothDevice device, Lifecycle lifecycle) { super(context, fragment, device, lifecycle); mAudioManager = context.getSystemService(AudioManager.class); mSpatializer = FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider() .getSpatializer(context); } @Override public boolean isAvailable() { return mSpatializer.getImmersiveAudioLevel() != SPATIALIZER_IMMERSIVE_LEVEL_NONE; } @Override public boolean onPreferenceClick(Preference preference) { TwoStatePreference switchPreference = (TwoStatePreference) preference; String key = switchPreference.getKey(); if (TextUtils.equals(key, KEY_SPATIAL_AUDIO)) { mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_SPATIAL_AUDIO_TOGGLE_CLICKED, switchPreference.isChecked()); updateSpatializerEnabled(switchPreference.isChecked()); ThreadUtils.postOnBackgroundThread( () -> { mHasHeadTracker.set( mAudioDevice != null && mSpatializer.hasHeadTracker(mAudioDevice)); mContext.getMainExecutor() .execute(() -> refreshSpatialAudioEnabled(switchPreference)); }); return true; } else if (TextUtils.equals(key, KEY_HEAD_TRACKING)) { mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_HEAD_TRACKING_TOGGLE_CLICKED, switchPreference.isChecked()); updateSpatializerHeadTracking(switchPreference.isChecked()); return true; } else { Log.w(TAG, "invalid key name."); return false; } } private void updateSpatializerEnabled(boolean enabled) { if (mAudioDevice == null) { Log.w(TAG, "cannot update spatializer enabled for null audio device."); return; } if (enabled) { mSpatializer.addCompatibleAudioDevice(mAudioDevice); } else { mSpatializer.removeCompatibleAudioDevice(mAudioDevice); } } private void updateSpatializerHeadTracking(boolean enabled) { if (mAudioDevice == null) { Log.w(TAG, "cannot update spatializer head tracking for null audio device."); return; } mSpatializer.setHeadTrackerEnabled(enabled, mAudioDevice); } @Override public String getPreferenceKey() { return KEY_SPATIAL_AUDIO_GROUP; } @Override protected void init(PreferenceScreen screen) { mProfilesContainer = screen.findPreference(getPreferenceKey()); refresh(); } @Override protected void refresh() { if (Flags.enableDeterminingSpatialAudioAttributesByProfile()) { getAvailableDeviceByProfileState(); } else { if (mAudioDevice == null) { getAvailableDevice(); } } ThreadUtils.postOnBackgroundThread( () -> { mHasHeadTracker.set( mAudioDevice != null && mSpatializer.hasHeadTracker(mAudioDevice)); mContext.getMainExecutor().execute(this::refreshUi); }); } private void refreshUi() { TwoStatePreference spatialAudioPref = mProfilesContainer.findPreference(KEY_SPATIAL_AUDIO); if (spatialAudioPref == null && mAudioDevice != null) { spatialAudioPref = createSpatialAudioPreference(mProfilesContainer.getContext()); mProfilesContainer.addPreference(spatialAudioPref); } else if (mAudioDevice == null || !mSpatializer.isAvailableForDevice(mAudioDevice)) { if (spatialAudioPref != null) { mProfilesContainer.removePreference(spatialAudioPref); } final TwoStatePreference headTrackingPref = mProfilesContainer.findPreference(KEY_HEAD_TRACKING); if (headTrackingPref != null) { mProfilesContainer.removePreference(headTrackingPref); } mAudioDevice = null; return; } refreshSpatialAudioEnabled(spatialAudioPref); } private void refreshSpatialAudioEnabled( TwoStatePreference spatialAudioPref) { boolean isSpatialAudioOn = mSpatializer.getCompatibleAudioDevices().contains(mAudioDevice); Log.d(TAG, "refresh() isSpatialAudioOn : " + isSpatialAudioOn); spatialAudioPref.setChecked(isSpatialAudioOn); TwoStatePreference headTrackingPref = mProfilesContainer.findPreference(KEY_HEAD_TRACKING); if (headTrackingPref == null) { headTrackingPref = createHeadTrackingPreference(mProfilesContainer.getContext()); mProfilesContainer.addPreference(headTrackingPref); } refreshHeadTracking(spatialAudioPref, headTrackingPref); } private void refreshHeadTracking(TwoStatePreference spatialAudioPref, TwoStatePreference headTrackingPref) { boolean isHeadTrackingAvailable = spatialAudioPref.isChecked() && mHasHeadTracker.get(); Log.d(TAG, "refresh() has head tracker : " + mHasHeadTracker.get()); headTrackingPref.setVisible(isHeadTrackingAvailable); if (isHeadTrackingAvailable) { headTrackingPref.setChecked(mSpatializer.isHeadTrackerEnabled(mAudioDevice)); } if (mInitialRefresh.compareAndSet(true, false)) { // Only triggered when shown for the first time mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_SPATIAL_AUDIO_TRIGGERED, spatialAudioPref.isChecked()); if (mHasHeadTracker.get()) { mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_HEAD_TRACKING_TRIGGERED, headTrackingPref.isChecked()); } } } @VisibleForTesting TwoStatePreference createSpatialAudioPreference(Context context) { TwoStatePreference pref = new SwitchPreferenceCompat(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 TwoStatePreference createHeadTrackingPreference(Context context) { TwoStatePreference pref = new SwitchPreferenceCompat(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; } private void getAvailableDevice() { AudioDeviceAttributes a2dpDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, mCachedDevice.getAddress()); AudioDeviceAttributes bleHeadsetDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLE_HEADSET, mCachedDevice.getAddress()); AudioDeviceAttributes bleSpeakerDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLE_SPEAKER, mCachedDevice.getAddress()); AudioDeviceAttributes bleBroadcastDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLE_BROADCAST, mCachedDevice.getAddress()); AudioDeviceAttributes hearingAidDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_HEARING_AID, mCachedDevice.getAddress()); if (mSpatializer.isAvailableForDevice(bleHeadsetDevice)) { mAudioDevice = bleHeadsetDevice; } else if (mSpatializer.isAvailableForDevice(bleSpeakerDevice)) { mAudioDevice = bleSpeakerDevice; } else if (mSpatializer.isAvailableForDevice(bleBroadcastDevice)) { mAudioDevice = bleBroadcastDevice; } else if (mSpatializer.isAvailableForDevice(a2dpDevice)) { mAudioDevice = a2dpDevice; } else if (mSpatializer.isAvailableForDevice(hearingAidDevice)) { mAudioDevice = hearingAidDevice; } else { mAudioDevice = null; } Log.d(TAG, "getAvailableDevice() device : " + mCachedDevice.getDevice().getAnonymizedAddress() + ", is available : " + (mAudioDevice != null) + ", type : " + (mAudioDevice == null ? "no type" : mAudioDevice.getType())); } private void getAvailableDeviceByProfileState() { Log.i( TAG, "getAvailableDevice() mCachedDevice: " + mCachedDevice + " profiles: " + mCachedDevice.getProfiles()); AudioDeviceAttributes saDevice = null; for (LocalBluetoothProfile profile : mCachedDevice.getProfiles()) { // pick first enabled profile that is compatible with spatial audio if (SA_PROFILES.contains(profile.getProfileId()) && profile.isEnabled(mCachedDevice.getDevice())) { switch (profile.getProfileId()) { case BluetoothProfile.A2DP: saDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, mCachedDevice.getAddress()); break; case BluetoothProfile.LE_AUDIO: if (mAudioManager.getBluetoothAudioDeviceCategory( mCachedDevice.getAddress()) == AudioManager.AUDIO_DEVICE_CATEGORY_SPEAKER) { saDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLE_SPEAKER, mCachedDevice.getAddress()); } else { saDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_BLE_HEADSET, mCachedDevice.getAddress()); } break; case BluetoothProfile.HEARING_AID: saDevice = new AudioDeviceAttributes( AudioDeviceAttributes.ROLE_OUTPUT, AudioDeviceInfo.TYPE_HEARING_AID, mCachedDevice.getAddress()); break; default: Log.i( TAG, "unrecognized profile for spatial audio: " + profile.getProfileId()); break; } break; } } mAudioDevice = null; if (saDevice != null && mSpatializer.isAvailableForDevice(saDevice)) { mAudioDevice = saDevice; } Log.d( TAG, "getAvailableDevice() device : " + mCachedDevice.getDevice().getAnonymizedAddress() + ", is available : " + (mAudioDevice != null) + ", type : " + (mAudioDevice == null ? "no type" : mAudioDevice.getType())); } @VisibleForTesting void setAvailableDevice(AudioDeviceAttributes audioDevice) { mAudioDevice = audioDevice; } }