1 /* 2 * Copyright 2018 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.car.settings.bluetooth; 18 19 import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; 20 21 import android.bluetooth.BluetoothDevice; 22 import android.bluetooth.BluetoothProfile; 23 import android.car.drivingstate.CarUxRestrictions; 24 import android.content.Context; 25 import android.os.UserManager; 26 27 import androidx.annotation.VisibleForTesting; 28 import androidx.preference.PreferenceGroup; 29 30 import com.android.car.settings.R; 31 import com.android.car.settings.common.CarUxRestrictionsHelper; 32 import com.android.car.settings.common.FragmentController; 33 import com.android.car.settings.common.MultiActionPreference; 34 import com.android.car.settings.common.ToggleButtonActionItem; 35 import com.android.settingslib.bluetooth.BluetoothDeviceFilter; 36 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 37 import com.android.settingslib.bluetooth.LocalBluetoothManager; 38 import com.android.settingslib.bluetooth.LocalBluetoothProfile; 39 40 import java.util.Set; 41 42 /** 43 * Displays a list of bonded (paired) Bluetooth devices. Clicking on a device launch the device 44 * details page. Additional buttons to will connect/disconnect from the device, toggle phone calls, 45 * and toggle media audio. 46 * 47 * <p> 48 * Moreover, these buttons' availability and enable/disable status are controlled by UX restriction 49 * and user restriction. Specifically, 50 * <ul> 51 * <li>{@code BLUETOOTH_BUTTON}: always available and enabled. 52 * <li>{@code PHONE_BUTTON}: available when the device has {@code BluetoothProfile.HEADSET_CLIENT} 53 * and {@code CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP} is not set. Disabled but clickable when 54 * {@link UserManager.DISALLOW_CONFIG_BLUETOOTH} is set. 55 * <li>{@code MEDIA_BUTTON}: available when the device has {@code BluetoothProfile.A2DP_SINK} and 56 * {@code CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP} is not set; Disabled but clickable when 57 * {@link UserManager.DISALLOW_CONFIG_BLUETOOTH} is set. 58 * </ul> 59 * 60 * <p> 61 * Note: when button is disabled, it will still be shown as available. When the button is disabled 62 * because of {@link UserManager.DISALLOW_CONFIG_BLUETOOTH} is set by DevicePolicyManager, click 63 * on the button will show action disabled by admin dialog. 64 * 65 * <p> 66 * Device detail page will not be launched when UX retriction is set. It can still be launched 67 * when there is {@link UserManager.DISALLOW_CONFIG_BLUETOOTH} restriction. However, individual 68 * profile's toggle switch will be disabled - when clicked, shows action disabled by admin dialog. 69 */ 70 public class BluetoothBondedDevicesPreferenceController extends 71 BluetoothDevicesGroupPreferenceController implements 72 BluetoothDevicePreference.UpdateToggleButtonListener { 73 74 private static final MultiActionPreference.ActionItem BLUETOOTH_BUTTON = 75 MultiActionPreference.ActionItem.ACTION_ITEM1; 76 private static final MultiActionPreference.ActionItem PHONE_BUTTON = 77 MultiActionPreference.ActionItem.ACTION_ITEM2; 78 private static final MultiActionPreference.ActionItem MEDIA_BUTTON = 79 MultiActionPreference.ActionItem.ACTION_ITEM3; 80 81 private final BluetoothDeviceFilter.Filter mBondedDeviceTypeFilter = 82 new BondedDeviceTypeFilter(); 83 private boolean mShowDeviceDetails = true; 84 private boolean mHasUxRestriction; 85 BluetoothBondedDevicesPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions)86 public BluetoothBondedDevicesPreferenceController(Context context, String preferenceKey, 87 FragmentController fragmentController, CarUxRestrictions uxRestrictions) { 88 super(context, preferenceKey, fragmentController, uxRestrictions); 89 } 90 91 @VisibleForTesting BluetoothBondedDevicesPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions, LocalBluetoothManager localBluetoothManager, UserManager userManager)92 BluetoothBondedDevicesPreferenceController(Context context, String preferenceKey, 93 FragmentController fragmentController, CarUxRestrictions uxRestrictions, 94 LocalBluetoothManager localBluetoothManager, UserManager userManager) { 95 super(context, preferenceKey, fragmentController, uxRestrictions, localBluetoothManager, 96 userManager); 97 } 98 99 @Override getDeviceFilter()100 protected BluetoothDeviceFilter.Filter getDeviceFilter() { 101 return mBondedDeviceTypeFilter; 102 } 103 104 @Override createDevicePreference(CachedBluetoothDevice cachedDevice)105 protected BluetoothDevicePreference createDevicePreference(CachedBluetoothDevice cachedDevice) { 106 BluetoothDevicePreference pref = super.createDevicePreference(cachedDevice); 107 pref.getActionItem(BLUETOOTH_BUTTON).setVisible(true); 108 pref.getActionItem(PHONE_BUTTON).setVisible(true); 109 pref.getActionItem(MEDIA_BUTTON).setVisible(true); 110 pref.setToggleButtonUpdateListener(this); 111 mHasUxRestriction = hasNoSetupUxRestriction(); 112 setButtonsCheckedAndListeners(pref); 113 return pref; 114 } 115 116 @Override onDeviceClicked(CachedBluetoothDevice cachedDevice)117 protected void onDeviceClicked(CachedBluetoothDevice cachedDevice) { 118 if (mShowDeviceDetails) { 119 getFragmentController().launchFragment( 120 BluetoothDeviceDetailsFragment.newInstance(cachedDevice)); 121 } 122 } 123 124 @Override onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState)125 public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) { 126 refreshUi(); 127 } 128 129 @Override updateState(PreferenceGroup preferenceGroup)130 protected void updateState(PreferenceGroup preferenceGroup) { 131 super.updateState(preferenceGroup); 132 updateActionAvailability(preferenceGroup); 133 } 134 135 @Override updateToggleButtonState(BluetoothDevicePreference preference)136 public void updateToggleButtonState(BluetoothDevicePreference preference) { 137 updateActionAvailability(preference); 138 } 139 updateActionAvailability(PreferenceGroup group)140 private void updateActionAvailability(PreferenceGroup group) { 141 for (int i = 0; i < group.getPreferenceCount(); i++) { 142 BluetoothDevicePreference preference = 143 (BluetoothDevicePreference) group.getPreference(i); 144 updateActionAvailability(preference); 145 } 146 } 147 updateActionAvailability(BluetoothDevicePreference preference)148 private void updateActionAvailability(BluetoothDevicePreference preference) { 149 mHasUxRestriction = hasNoSetupUxRestriction(); 150 if (!mHasUxRestriction) { 151 setButtonsCheckedAndListeners(preference); 152 } else { 153 updatePhoneActionItemAvailability(preference, /* isAvailable= */ false); 154 updateMediaActionItemAvailability(preference, /* isAvailable= */ false); 155 } 156 mShowDeviceDetails = !mHasUxRestriction; 157 } 158 toggleBluetoothConnectivity(boolean connect, CachedBluetoothDevice cachedDevice)159 private void toggleBluetoothConnectivity(boolean connect, CachedBluetoothDevice cachedDevice) { 160 if (connect) { 161 cachedDevice.connect(); 162 } else if (cachedDevice.isConnected()) { 163 cachedDevice.disconnect(); 164 } 165 } 166 setButtonsCheckedAndListeners(BluetoothDevicePreference preference)167 private void setButtonsCheckedAndListeners(BluetoothDevicePreference preference) { 168 CachedBluetoothDevice cachedDevice = preference.getCachedDevice(); 169 170 // If device is currently attempting to connect/disconnect, disable further actions 171 if (cachedDevice.isBusy()) { 172 disableAllActionItems(preference); 173 // There is a case where on creation the cached device will try to automatically connect 174 // but does not report itself as busy yet. This ensures that the bluetooth button state 175 // is correct (should be checked in either connecting or disconnecting states). 176 preference.getActionItem(BLUETOOTH_BUTTON).setChecked(true); 177 return; 178 } 179 180 LocalBluetoothProfile phoneProfile = null; 181 LocalBluetoothProfile mediaProfile = null; 182 for (LocalBluetoothProfile profile : cachedDevice.getProfiles()) { 183 if (profile.getProfileId() == BluetoothProfile.HEADSET_CLIENT) { 184 phoneProfile = profile; 185 } else if (profile.getProfileId() == BluetoothProfile.A2DP_SINK) { 186 mediaProfile = profile; 187 } 188 } 189 LocalBluetoothProfile finalPhoneProfile = phoneProfile; 190 LocalBluetoothProfile finalMediaProfile = mediaProfile; 191 boolean isConnected = cachedDevice.isConnected(); 192 193 // Setup up bluetooth button 194 updateBluetoothActionItemAvailability(preference); 195 ToggleButtonActionItem bluetoothItem = preference.getActionItem(BLUETOOTH_BUTTON); 196 bluetoothItem.setChecked(isConnected); 197 bluetoothItem.setOnClickListener( 198 isChecked -> { 199 if (cachedDevice.isBusy()) { 200 return; 201 } 202 // If trying to connect and both phone and media are disabled, connecting will 203 // always fail. In this case force both profiles on. 204 if (isChecked && finalPhoneProfile != null && finalMediaProfile != null 205 && !finalPhoneProfile.isEnabled(cachedDevice.getDevice()) 206 && !finalMediaProfile.isEnabled(cachedDevice.getDevice())) { 207 finalPhoneProfile.setEnabled(cachedDevice.getDevice(), true); 208 finalMediaProfile.setEnabled(cachedDevice.getDevice(), true); 209 } 210 toggleBluetoothConnectivity(isChecked, cachedDevice); 211 }); 212 213 if (phoneProfile == null || !isConnected || mHasUxRestriction) { 214 // Disable phone button 215 updatePhoneActionItemAvailability(preference, /* isAvailable= */ false); 216 } else { 217 // Enable phone button 218 ToggleButtonActionItem phoneItem = preference.getActionItem(PHONE_BUTTON); 219 updatePhoneActionItemAvailability(preference, /* isAvailable= */ true); 220 boolean phoneEnabled = phoneProfile.isEnabled(cachedDevice.getDevice()); 221 222 if (hasDisallowConfigRestriction()) { 223 phoneItem.setOnClickWhileDisabledListener(p -> BluetoothUtils 224 .onClickWhileDisabled(getContext(), getFragmentController())); 225 } 226 phoneItem.setOnClickListener(isChecked -> 227 finalPhoneProfile.setEnabled(cachedDevice.getDevice(), isChecked)); 228 phoneItem.setChecked(phoneEnabled); 229 } 230 231 if (mediaProfile == null || !isConnected || mHasUxRestriction) { 232 // Disable media button 233 updateMediaActionItemAvailability(preference, /* isAvailable= */ false); 234 } else { 235 // Enable media button 236 ToggleButtonActionItem mediaItem = preference.getActionItem(MEDIA_BUTTON); 237 updateMediaActionItemAvailability(preference, /* isAvailable= */ true); 238 boolean mediaEnabled = mediaProfile.isEnabled(cachedDevice.getDevice()); 239 240 if (hasDisallowConfigRestriction()) { 241 mediaItem.setOnClickWhileDisabledListener(p -> BluetoothUtils 242 .onClickWhileDisabled(getContext(), getFragmentController())); 243 } 244 mediaItem.setOnClickListener(isChecked -> 245 finalMediaProfile.setEnabled(cachedDevice.getDevice(), isChecked)); 246 mediaItem.setChecked(mediaEnabled); 247 } 248 } 249 updateBluetoothActionItemAvailability(BluetoothDevicePreference preference)250 private void updateBluetoothActionItemAvailability(BluetoothDevicePreference preference) { 251 // Run on main thread because recyclerview may still be computing layout 252 getContext().getMainExecutor().execute(() -> { 253 ToggleButtonActionItem bluetoothItem = preference.getActionItem(BLUETOOTH_BUTTON); 254 bluetoothItem.setEnabled(true); 255 bluetoothItem.setDrawable(getContext(), R.drawable.ic_bluetooth_button); 256 }); 257 } 258 updatePhoneActionItemAvailability(BluetoothDevicePreference preference, boolean isAvailable)259 private void updatePhoneActionItemAvailability(BluetoothDevicePreference preference, 260 boolean isAvailable) { 261 // Run on main thread because recyclerview may still be computing layout 262 getContext().getMainExecutor().execute(() -> { 263 ToggleButtonActionItem phoneItem = preference.getActionItem(PHONE_BUTTON); 264 phoneItem.setEnabled(isAvailable && !hasDisallowConfigRestriction()); 265 phoneItem.setDrawable(getContext(), isAvailable 266 ? R.drawable.ic_bluetooth_phone : R.drawable.ic_bluetooth_phone_unavailable); 267 phoneItem.setRestricted(!isAvailable && mHasUxRestriction); 268 }); 269 } 270 updateMediaActionItemAvailability(BluetoothDevicePreference preference, boolean isAvailable)271 private void updateMediaActionItemAvailability(BluetoothDevicePreference preference, 272 boolean isAvailable) { 273 // Run on main thread because recyclerview may still be computing layout 274 getContext().getMainExecutor().execute(() -> { 275 ToggleButtonActionItem mediaItem = preference.getActionItem(MEDIA_BUTTON); 276 mediaItem.setEnabled(isAvailable && !hasDisallowConfigRestriction()); 277 mediaItem.setDrawable(getContext(), isAvailable 278 ? R.drawable.ic_bluetooth_media : R.drawable.ic_bluetooth_media_unavailable); 279 mediaItem.setRestricted(!isAvailable && mHasUxRestriction); 280 }); 281 } 282 disableAllActionItems(BluetoothDevicePreference preference)283 private void disableAllActionItems(BluetoothDevicePreference preference) { 284 // Run on main thread because recyclerview may still be computing layout 285 getContext().getMainExecutor().execute(() -> { 286 preference.getActionItem(BLUETOOTH_BUTTON).setEnabled(false); 287 preference.getActionItem(PHONE_BUTTON).setEnabled(false); 288 preference.getActionItem(MEDIA_BUTTON).setEnabled(false); 289 }); 290 } 291 hasDisallowConfigRestriction()292 private boolean hasDisallowConfigRestriction() { 293 return getUserManager().hasUserRestriction(DISALLOW_CONFIG_BLUETOOTH); 294 } 295 hasNoSetupUxRestriction()296 private boolean hasNoSetupUxRestriction() { 297 return CarUxRestrictionsHelper.isNoSetup(getUxRestrictions()); 298 } 299 300 /** Filter that matches only bonded devices with specific device types. */ 301 //TODO(b/198339129): Use BluetoothDeviceFilter.BONDED_DEVICE_FILTER 302 private class BondedDeviceTypeFilter implements BluetoothDeviceFilter.Filter { 303 @Override matches(BluetoothDevice device)304 public boolean matches(BluetoothDevice device) { 305 Set<BluetoothDevice> bondedDevices = mBluetoothManager.getBluetoothAdapter() 306 .getBondedDevices(); 307 return bondedDevices != null && bondedDevices.contains(device); 308 } 309 } 310 } 311