/* * Copyright (C) 2017 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.bluetooth.BluetoothDevice.METADATA_MODEL_NAME; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothProfile; import android.content.Context; import android.os.SystemProperties; import android.sysprop.BluetoothProperties; 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.SwitchPreferenceCompat; import androidx.preference.TwoStatePreference; import com.android.settings.R; import com.android.settings.flags.Flags; import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.A2dpProfile; import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.HeadsetProfile; import com.android.settingslib.bluetooth.LeAudioProfile; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.bluetooth.LocalBluetoothProfile; import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; import com.android.settingslib.bluetooth.MapProfile; import com.android.settingslib.bluetooth.PanProfile; import com.android.settingslib.bluetooth.PbapServerProfile; import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.utils.ThreadUtils; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; /** * This class adds switches for toggling the individual profiles that a Bluetooth device * supports, such as "Phone audio", "Media audio", "Contact sharing", etc. */ public class BluetoothDetailsProfilesController extends BluetoothDetailsController implements Preference.OnPreferenceClickListener, LocalBluetoothProfileManager.ServiceListener { public static final String HIGH_QUALITY_AUDIO_PREF_TAG = "A2dpProfileHighQualityAudio"; private static final String TAG = "BtDetailsProfilesCtrl"; private static final String KEY_PROFILES_GROUP = "bluetooth_profiles"; private static final String KEY_BOTTOM_PREFERENCE = "bottom_preference"; private static final int ORDINAL = 99; private static final String ENABLE_DUAL_MODE_AUDIO = "persist.bluetooth.enable_dual_mode_audio"; private static final String LE_AUDIO_CONNECTION_BY_DEFAULT_PROPERTY = "ro.bluetooth.leaudio.le_audio_connection_by_default"; private static final boolean LE_AUDIO_TOGGLE_VISIBLE_DEFAULT_VALUE = true; private static final String LE_AUDIO_TOGGLE_VISIBLE_PROPERTY = "persist.bluetooth.leaudio.toggle_visible"; private static final String BYPASS_LE_AUDIO_ALLOWLIST_PROPERTY = "persist.bluetooth.leaudio.bypass_allow_list"; private static final String LE_AUDIO_TOGGLE_VISIBLE_FOR_ASHA_PROPERTY = "bluetooth.leaudio.toggle_visible_for_asha"; private Set mInvisibleProfiles = Collections.emptySet(); private final AtomicReference> mAdditionalInvisibleProfiles = new AtomicReference<>(); private LocalBluetoothManager mManager; private LocalBluetoothProfileManager mProfileManager; private CachedBluetoothDevice mCachedDevice; private Set mCachedDeviceGroup; private Map> mProfileDeviceMap = new HashMap>(); private boolean mIsLeAudioToggleEnabled = false; private boolean mIsLeAudioOnlyDevice = false; private boolean mHasExtraSpace; @VisibleForTesting PreferenceCategory mProfilesContainer; public BluetoothDetailsProfilesController( Context context, PreferenceFragmentCompat fragment, LocalBluetoothManager manager, CachedBluetoothDevice device, Lifecycle lifecycle) { super(context, fragment, device, lifecycle); mManager = manager; mProfileManager = mManager.getProfileManager(); mCachedDevice = device; mCachedDeviceGroup = Utils.findAllCachedBluetoothDevicesByGroupId(mManager, mCachedDevice); } /** Sets the profiles to be hidden. */ public void setInvisibleProfiles(List invisibleProfiles) { if (invisibleProfiles != null) { mInvisibleProfiles = Set.copyOf(invisibleProfiles); } } /** Sets whether it should show an extra padding on top of the preference. */ public void setHasExtraSpace(boolean hasExtraSpace) { if (hasExtraSpace) { mProfilesContainer.setLayoutResource(R.layout.preference_bluetooth_profile_category); } else { mProfilesContainer.setLayoutResource(R.layout.preference_category_bluetooth_no_padding); } } @Override protected void init(PreferenceScreen screen) { mProfilesContainer = (PreferenceCategory) screen.findPreference(getPreferenceKey()); mProfilesContainer.setLayoutResource(R.layout.preference_bluetooth_profile_category); // Call refresh here even though it will get called later in onResume, to avoid the // list of switches appearing to "pop" into the page. refresh(); } /** * Creates a switch preference for the particular profile. * * @param context The context to use when creating the TwoStatePreference * @param profile The profile for which the preference controls. * @return A preference that allows the user to choose whether this profile * will be connected to. */ private TwoStatePreference createProfilePreference(Context context, LocalBluetoothProfile profile) { TwoStatePreference pref = new SwitchPreferenceCompat(context); pref.setKey(profile.toString()); pref.setTitle(profile.getNameResource(mCachedDevice.getDevice())); pref.setOnPreferenceClickListener(this); pref.setOrder(profile.getOrdinal()); boolean isLeEnabledByDefault = SystemProperties.getBoolean(LE_AUDIO_CONNECTION_BY_DEFAULT_PROPERTY, true); if (profile instanceof LeAudioProfile && (!isLeEnabledByDefault || !isModelNameInAllowList( BluetoothUtils.getStringMetaData(mCachedDevice.getDevice(), METADATA_MODEL_NAME)))) { pref.setSummary(R.string.device_details_leaudio_toggle_summary); } return pref; } /** * Checks if the device model name is in the LE audio allow list based on its model name. * * @param modelName The model name of the device to be checked. * @return true if the device is in the allow list, false otherwise. */ @VisibleForTesting boolean isModelNameInAllowList(String modelName) { if (modelName == null || modelName.isEmpty()) { return false; } return BluetoothProperties.le_audio_allow_list().contains(modelName); } /** * Refreshes the state for an existing TwoStatePreference for a profile. */ private void refreshProfilePreference(TwoStatePreference profilePref, LocalBluetoothProfile profile) { BluetoothDevice device = mCachedDevice.getDevice(); boolean isLeAudioEnabled = isLeAudioEnabled(); if (profile instanceof A2dpProfile || profile instanceof HeadsetProfile || profile instanceof LeAudioProfile) { List deviceList = mProfileDeviceMap.get( profile.toString()); boolean isBusy = deviceList != null && deviceList.stream().anyMatch(item -> item.isBusy()); profilePref.setEnabled(!isBusy); } else { profilePref.setEnabled(!mCachedDevice.isBusy()); } if (profile instanceof LeAudioProfile) { boolean showLeAudioToggle = mIsLeAudioToggleEnabled; if (Flags.hideLeAudioToggleForLeAudioOnlyDevice() && mIsLeAudioOnlyDevice) { showLeAudioToggle = false; Log.d( TAG, "Hide LeAudio toggle for LeAudio-only Device: " + mCachedDevice.getDevice().getAnonymizedAddress()); } profilePref.setVisible(showLeAudioToggle); } if (profile instanceof MapProfile) { profilePref.setChecked(device.getMessageAccessPermission() == BluetoothDevice.ACCESS_ALLOWED); } else if (profile instanceof PbapServerProfile) { profilePref.setChecked(device.getPhonebookAccessPermission() == BluetoothDevice.ACCESS_ALLOWED); profilePref.setSummary(profile.getSummaryResourceForDevice(mCachedDevice.getDevice())); } else if (profile instanceof PanProfile) { profilePref.setChecked(profile.getConnectionStatus(device) == BluetoothProfile.STATE_CONNECTED); } else { profilePref.setChecked(profile.isEnabled(device)); } if (profile instanceof A2dpProfile) { A2dpProfile a2dp = (A2dpProfile) profile; TwoStatePreference highQualityPref = mProfilesContainer.findPreference(HIGH_QUALITY_AUDIO_PREF_TAG); if (highQualityPref != null) { if (a2dp.isEnabled(device) && a2dp.supportsHighQualityAudio(device)) { highQualityPref.setVisible(true); highQualityPref.setTitle(a2dp.getHighQualityAudioOptionLabel(device)); highQualityPref.setChecked(a2dp.isHighQualityAudioEnabled(device)); highQualityPref.setEnabled(!mCachedDevice.isBusy()); } else { highQualityPref.setVisible(false); } } } } private boolean isLeAudioEnabled(){ LocalBluetoothProfile leAudio = mProfileManager.getLeAudioProfile(); if (leAudio != null) { List leAudioDeviceList = mProfileDeviceMap.get( leAudio.toString()); if (leAudioDeviceList != null && leAudioDeviceList.stream() .anyMatch(item -> leAudio.isEnabled(item.getDevice()))) { return true; } } return false; } /** * Helper method to enable a profile for a device. */ private void enableProfile(LocalBluetoothProfile profile) { final BluetoothDevice bluetoothDevice = mCachedDevice.getDevice(); if (profile instanceof PbapServerProfile) { bluetoothDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_ALLOWED); // We don't need to do the additional steps below for this profile. return; } if (profile instanceof MapProfile) { bluetoothDevice.setMessageAccessPermission(BluetoothDevice.ACCESS_ALLOWED); } if (profile instanceof LeAudioProfile) { enableLeAudioProfile(profile); return; } profile.setEnabled(bluetoothDevice, true); } /** * Helper method to disable a profile for a device */ private void disableProfile(LocalBluetoothProfile profile) { if (profile instanceof LeAudioProfile) { disableLeAudioProfile(profile); return; } final BluetoothDevice bluetoothDevice = mCachedDevice.getDevice(); profile.setEnabled(bluetoothDevice, false); if (profile instanceof MapProfile) { bluetoothDevice.setMessageAccessPermission(BluetoothDevice.ACCESS_REJECTED); } else if (profile instanceof PbapServerProfile) { bluetoothDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_REJECTED); } } /** * When the pref for a bluetooth profile is clicked on, we want to toggle the enabled/disabled * state for that profile. */ @Override public boolean onPreferenceClick(Preference preference) { LocalBluetoothProfile profile = mProfileManager.getProfileByName(preference.getKey()); if (profile == null) { // It might be the PbapServerProfile, which is not stored by name. PbapServerProfile psp = mManager.getProfileManager().getPbapProfile(); if (TextUtils.equals(preference.getKey(), psp.toString())) { profile = psp; } else { return false; } } TwoStatePreference profilePref = (TwoStatePreference) preference; if (profilePref.isChecked()) { enableProfile(profile); } else { disableProfile(profile); } refreshProfilePreference(profilePref, profile); return true; } /** * Helper to get the list of connectable and special profiles. */ private List getProfiles() { List result = new ArrayList<>(); mProfileDeviceMap.clear(); if (mCachedDeviceGroup == null || mCachedDeviceGroup.isEmpty()) { return result; } for (CachedBluetoothDevice cachedItem : mCachedDeviceGroup) { List tmpResult = cachedItem.getUiAccessibleProfiles(); for (LocalBluetoothProfile profile : tmpResult) { if (mProfileDeviceMap.containsKey(profile.toString())) { mProfileDeviceMap.get(profile.toString()).add(cachedItem); } else { List tmpCachedDeviceList = new ArrayList<>(); tmpCachedDeviceList.add(cachedItem); mProfileDeviceMap.put(profile.toString(), tmpCachedDeviceList); result.add(profile); } } } final BluetoothDevice device = mCachedDevice.getDevice(); final int pbapPermission = device.getPhonebookAccessPermission(); // Only provide PBAP cabability if the client device has requested PBAP. if (pbapPermission != BluetoothDevice.ACCESS_UNKNOWN) { final PbapServerProfile psp = mManager.getProfileManager().getPbapProfile(); if (psp != null) { result.add(psp); } } final MapProfile mapProfile = mManager.getProfileManager().getMapProfile(); final int mapPermission = device.getMessageAccessPermission(); if (mapPermission != BluetoothDevice.ACCESS_UNKNOWN && mapProfile != null) { result.add(mapProfile); } // Removes phone calls & media audio toggles for dual mode devices boolean leAudioSupported = result.contains( mManager.getProfileManager().getLeAudioProfile()); boolean classicAudioSupported = result.contains( mManager.getProfileManager().getA2dpProfile()) || result.contains( mManager.getProfileManager().getHeadsetProfile()); if (leAudioSupported && classicAudioSupported) { result.remove(mManager.getProfileManager().getA2dpProfile()); result.remove(mManager.getProfileManager().getHeadsetProfile()); } boolean hearingAidSupported = result.contains( mManager.getProfileManager().getHearingAidProfile()); // Remove hearing aids toggle anyway since showing the toggle will confuse users if (hearingAidSupported) { result.remove(mManager.getProfileManager().getHearingAidProfile()); if (leAudioSupported && !SystemProperties.getBoolean(BYPASS_LE_AUDIO_ALLOWLIST_PROPERTY, false) && !SystemProperties.getBoolean( LE_AUDIO_TOGGLE_VISIBLE_FOR_ASHA_PROPERTY, true)) { result.remove(mManager.getProfileManager().getLeAudioProfile()); } } if (leAudioSupported && !classicAudioSupported && !hearingAidSupported) { mIsLeAudioOnlyDevice = true; } Log.d(TAG, "getProfiles:Map:" + mProfileDeviceMap); return result; } private boolean isCurrentDeviceInOrByPassAllowList() { if (!SystemProperties.getBoolean(LE_AUDIO_CONNECTION_BY_DEFAULT_PROPERTY, true)) { return false; } return SystemProperties.getBoolean(BYPASS_LE_AUDIO_ALLOWLIST_PROPERTY, false) || isModelNameInAllowList( BluetoothUtils.getStringMetaData( mCachedDevice.getDevice(), METADATA_MODEL_NAME)); } /** * Disable the Le Audio profile for each of the Le Audio devices. * * @param profile the LeAudio profile */ private void disableLeAudioProfile(LocalBluetoothProfile profile) { if (profile == null || mProfileDeviceMap.get(profile.toString()) == null) { Log.e(TAG, "There is no the LE profile or no device in mProfileDeviceMap. Do nothing."); return; } mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_PROFILE_LE_AUDIO_OFF, isCurrentDeviceInOrByPassAllowList()); Utils.setLeAudioEnabled(mManager, List.copyOf(mCachedDeviceGroup), false); } /** * Enable the Le Audio profile for each of the Le Audio devices. * * @param profile the LeAudio profile */ private void enableLeAudioProfile(LocalBluetoothProfile profile) { if (profile == null || mProfileDeviceMap.get(profile.toString()) == null) { Log.e(TAG, "There is no the LE profile or no device in mProfileDeviceMap. Do nothing."); return; } mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_BLUETOOTH_PROFILE_LE_AUDIO_ON, isCurrentDeviceInOrByPassAllowList()); Utils.setLeAudioEnabled(mManager, List.copyOf(mCachedDeviceGroup), true); } /** * This is a helper method to be called after adding a Preference for a profile. If that * profile happened to be A2dp and the device supports high quality audio, it will add a * separate preference for controlling whether to actually use high quality audio. * * @param profile the profile just added */ private void maybeAddHighQualityAudioPref(LocalBluetoothProfile profile) { if (!(profile instanceof A2dpProfile)) { return; } BluetoothDevice device = mCachedDevice.getDevice(); A2dpProfile a2dp = (A2dpProfile) profile; if (a2dp.isProfileReady() && a2dp.supportsHighQualityAudio(device)) { TwoStatePreference highQualityAudioPref = new SwitchPreferenceCompat( mProfilesContainer.getContext()); highQualityAudioPref.setKey(HIGH_QUALITY_AUDIO_PREF_TAG); highQualityAudioPref.setVisible(false); highQualityAudioPref.setOnPreferenceClickListener(clickedPref -> { boolean enable = ((TwoStatePreference) clickedPref).isChecked(); a2dp.setHighQualityAudioEnabled(mCachedDevice.getDevice(), enable); return true; }); mProfilesContainer.addPreference(highQualityAudioPref); } } @Override public void onPause() { for (CachedBluetoothDevice item : mCachedDeviceGroup) { item.unregisterCallback(this); } mProfileManager.removeServiceListener(this); } @Override public void onResume() { updateLeAudioConfig(); for (CachedBluetoothDevice item : mCachedDeviceGroup) { item.registerCallback(this); } mProfileManager.addServiceListener(this); refresh(); } private void updateLeAudioConfig() { boolean isLeAudioToggleVisible = SystemProperties.getBoolean( LE_AUDIO_TOGGLE_VISIBLE_PROPERTY, LE_AUDIO_TOGGLE_VISIBLE_DEFAULT_VALUE); boolean isLeEnabledByDefault = SystemProperties.getBoolean(LE_AUDIO_CONNECTION_BY_DEFAULT_PROPERTY, true); mIsLeAudioToggleEnabled = isLeAudioToggleVisible || isLeEnabledByDefault; Log.d(TAG, "LE_AUDIO_TOGGLE_VISIBLE_PROPERTY:" + isLeAudioToggleVisible + ", LE_AUDIO_CONNECTION_BY_DEFAULT_PROPERTY:" + isLeEnabledByDefault); } @Override public void onDeviceAttributesChanged() { for (CachedBluetoothDevice item : mCachedDeviceGroup) { item.unregisterCallback(this); } mCachedDeviceGroup = Utils.findAllCachedBluetoothDevicesByGroupId(mManager, mCachedDevice); for (CachedBluetoothDevice item : mCachedDeviceGroup) { item.registerCallback(this); } super.onDeviceAttributesChanged(); } @Override public void onServiceConnected() { refresh(); } @Override public void onServiceDisconnected() { refresh(); } /** * Refreshes the state of the switches for all profiles, possibly adding or removing switches as * needed. */ @Override protected void refresh() { ThreadUtils.postOnBackgroundThread( () -> { mAdditionalInvisibleProfiles.set( FeatureFactory.getFeatureFactory() .getBluetoothFeatureProvider() .getInvisibleProfilePreferenceKeys( mContext, mCachedDevice.getDevice())); ThreadUtils.postOnMainThread(this::refreshUi); }); } private void refreshUi() { for (LocalBluetoothProfile profile : getProfiles()) { if (profile == null || !profile.isProfileReady()) { continue; } TwoStatePreference pref = mProfilesContainer.findPreference(profile.toString()); if (pref == null) { pref = createProfilePreference(mProfilesContainer.getContext(), profile); mProfilesContainer.addPreference(pref); maybeAddHighQualityAudioPref(profile); } refreshProfilePreference(pref, profile); } for (LocalBluetoothProfile removedProfile : mCachedDevice.getRemovedProfiles()) { final TwoStatePreference pref = mProfilesContainer.findPreference(removedProfile.toString()); if (pref != null) { mProfilesContainer.removePreference(pref); } } Preference preference = mProfilesContainer.findPreference(KEY_BOTTOM_PREFERENCE); if (preference == null) { preference = new Preference(mContext); if (mHasExtraSpace) { preference.setLayoutResource(R.layout.preference_bluetooth_profile_category); } else { preference.setLayoutResource(R.layout.preference_category_bluetooth_no_padding); } preference.setEnabled(false); preference.setKey(KEY_BOTTOM_PREFERENCE); preference.setOrder(ORDINAL); preference.setSelectable(false); mProfilesContainer.addPreference(preference); } Set additionalInvisibleProfiles = mAdditionalInvisibleProfiles.get(); HashSet combinedInvisibleProfiles = new HashSet<>(mInvisibleProfiles); if (additionalInvisibleProfiles != null) { combinedInvisibleProfiles.addAll(additionalInvisibleProfiles); } Log.i(TAG, "Invisible profiles: " + combinedInvisibleProfiles); for (int i = 0; i < mProfilesContainer.getPreferenceCount(); ++i) { Preference pref = mProfilesContainer.getPreference(i); pref.setVisible(pref.isVisible() && !combinedInvisibleProfiles.contains(pref.getKey())); } } @Override public String getPreferenceKey() { return KEY_PROFILES_GROUP; } }