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