1 /* 2 * Copyright (C) 2021 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.qc; 18 19 import static android.os.UserManager.DISALLOW_BLUETOOTH; 20 import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; 21 22 import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE; 23 import static com.android.car.qc.QCItem.QC_TYPE_ACTION_TOGGLE; 24 import static com.android.car.settings.qc.QCUtils.getActionDisabledDialogIntent; 25 import static com.android.car.settings.qc.QCUtils.getAvailabilityStatusForZoneFromXml; 26 27 import android.app.PendingIntent; 28 import android.bluetooth.BluetoothAdapter; 29 import android.bluetooth.BluetoothClass; 30 import android.bluetooth.BluetoothDevice; 31 import android.bluetooth.BluetoothProfile; 32 import android.content.Context; 33 import android.content.Intent; 34 import android.content.pm.PackageManager; 35 import android.graphics.drawable.Icon; 36 import android.net.Uri; 37 import android.os.Bundle; 38 import android.os.SystemProperties; 39 40 import androidx.annotation.DrawableRes; 41 import androidx.annotation.VisibleForTesting; 42 43 import com.android.car.qc.QCActionItem; 44 import com.android.car.qc.QCItem; 45 import com.android.car.qc.QCList; 46 import com.android.car.qc.QCRow; 47 import com.android.car.settings.R; 48 import com.android.car.settings.bluetooth.BluetoothUtils; 49 import com.android.car.settings.common.Logger; 50 import com.android.car.settings.enterprise.EnterpriseUtils; 51 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 52 import com.android.settingslib.bluetooth.HidProfile; 53 import com.android.settingslib.bluetooth.LocalBluetoothManager; 54 import com.android.settingslib.bluetooth.LocalBluetoothProfile; 55 56 import java.util.ArrayList; 57 import java.util.Collection; 58 import java.util.Comparator; 59 import java.util.List; 60 import java.util.Set; 61 62 /** 63 * QCItem for showing paired bluetooth devices. 64 */ 65 public class PairedBluetoothDevices extends SettingsQCItem { 66 @VisibleForTesting 67 static final String EXTRA_DEVICE_KEY = "BT_EXTRA_DEVICE_KEY"; 68 @VisibleForTesting 69 static final String EXTRA_BUTTON_TYPE = "BT_EXTRA_BUTTON_TYPE"; 70 @VisibleForTesting 71 static final String BLUETOOTH_BUTTON = "BLUETOOTH_BUTTON"; 72 @VisibleForTesting 73 static final String PHONE_BUTTON = "PHONE_BUTTON"; 74 @VisibleForTesting 75 static final String MEDIA_BUTTON = "MEDIA_BUTTON"; 76 private static final Logger LOG = new Logger(PairedBluetoothDevices.class); 77 78 private final LocalBluetoothManager mBluetoothManager; 79 private final int mDeviceLimit; 80 private final boolean mShowDevicesWithoutNames; 81 PairedBluetoothDevices(Context context)82 public PairedBluetoothDevices(Context context) { 83 super(context); 84 setAvailabilityStatusForZone(getAvailabilityStatusForZoneFromXml(context, 85 R.xml.bluetooth_settings_fragment, R.string.pk_bluetooth_paired_devices)); 86 mBluetoothManager = LocalBluetoothManager.getInstance(context, /* onInitCallback= */ null); 87 mDeviceLimit = context.getResources().getInteger( 88 R.integer.config_qc_bluetooth_device_limit); 89 mShowDevicesWithoutNames = SystemProperties.getBoolean( 90 BluetoothUtils.BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY, false); 91 } 92 93 @Override getQCItem()94 QCItem getQCItem() { 95 if (!getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH) 96 || EnterpriseUtils.hasUserRestrictionByDpm(getContext(), DISALLOW_BLUETOOTH) 97 || mDeviceLimit == 0 || isHiddenForZone()) { 98 return null; 99 } 100 101 QCList.Builder listBuilder = new QCList.Builder(); 102 103 if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) { 104 listBuilder.addRow(new QCRow.Builder() 105 .setIcon(Icon.createWithResource(getContext(), 106 R.drawable.ic_settings_bluetooth_disabled)) 107 .setTitle(getContext().getString(R.string.qc_bluetooth_off_devices_info)) 108 .build()); 109 return listBuilder.build(); 110 } 111 112 Collection<CachedBluetoothDevice> cachedDevices = 113 mBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy(); 114 115 //TODO(b/198339129): Use BluetoothDeviceFilter.BONDED_DEVICE_FILTER 116 Set<BluetoothDevice> bondedDevices = mBluetoothManager.getBluetoothAdapter() 117 .getBondedDevices(); 118 119 List<CachedBluetoothDevice> filteredDevices = new ArrayList<>(); 120 for (CachedBluetoothDevice cachedDevice : cachedDevices) { 121 if (bondedDevices != null && bondedDevices.contains(cachedDevice.getDevice())) { 122 filteredDevices.add(cachedDevice); 123 } 124 } 125 filteredDevices.sort(Comparator.naturalOrder()); 126 127 if (filteredDevices.isEmpty()) { 128 listBuilder.addRow(new QCRow.Builder() 129 .setIcon(Icon.createWithResource(getContext(), 130 R.drawable.ic_add)) 131 .setTitle(getContext().getString(R.string.qc_bluetooth_on_no_devices_info)) 132 .build()); 133 return listBuilder.build(); 134 } 135 136 int i = 0; 137 int deviceLimit = mDeviceLimit >= 0 ? Math.min(mDeviceLimit, filteredDevices.size()) 138 : filteredDevices.size(); 139 for (int j = 0; j < deviceLimit; j++) { 140 CachedBluetoothDevice cachedDevice = filteredDevices.get(j); 141 if (mShowDevicesWithoutNames || cachedDevice.hasHumanReadableName()) { 142 listBuilder.addRow(new QCRow.Builder() 143 .setTitle(cachedDevice.getName()) 144 .setSubtitle(cachedDevice.getCarConnectionSummary(/* shortSummary= */ true)) 145 .setIcon(Icon.createWithResource(getContext(), getIconRes(cachedDevice))) 146 .addEndItem(createBluetoothButton(cachedDevice, i++)) 147 .addEndItem(createPhoneButton(cachedDevice, i++)) 148 .addEndItem(createMediaButton(cachedDevice, i++)) 149 .build() 150 ); 151 } 152 } 153 154 return listBuilder.build(); 155 } 156 157 @Override getUri()158 Uri getUri() { 159 return SettingsQCRegistry.PAIRED_BLUETOOTH_DEVICES_URI; 160 } 161 162 @Override onNotifyChange(Intent intent)163 void onNotifyChange(Intent intent) { 164 String deviceKey = intent.getStringExtra(EXTRA_DEVICE_KEY); 165 if (deviceKey == null) { 166 return; 167 } 168 CachedBluetoothDevice device = null; 169 Collection<CachedBluetoothDevice> cachedDevices = 170 mBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy(); 171 for (CachedBluetoothDevice cachedDevice : cachedDevices) { 172 if (cachedDevice.getAddress().equals(deviceKey)) { 173 device = cachedDevice; 174 break; 175 } 176 } 177 if (device == null) { 178 return; 179 } 180 181 String buttonType = intent.getStringExtra(EXTRA_BUTTON_TYPE); 182 boolean newState = intent.getBooleanExtra(QC_ACTION_TOGGLE_STATE, true); 183 if (BLUETOOTH_BUTTON.equals(buttonType)) { 184 if (newState) { 185 LocalBluetoothProfile phoneProfile = getProfile(device, 186 BluetoothProfile.HEADSET_CLIENT); 187 LocalBluetoothProfile mediaProfile = getProfile(device, BluetoothProfile.A2DP_SINK); 188 // If trying to connect and both phone and media are disabled, connecting will 189 // always fail. In this case force both profiles on. 190 if (phoneProfile != null && mediaProfile != null 191 && !phoneProfile.isEnabled(device.getDevice()) 192 && !mediaProfile.isEnabled(device.getDevice())) { 193 phoneProfile.setEnabled(device.getDevice(), true); 194 mediaProfile.setEnabled(device.getDevice(), true); 195 } 196 device.connect(); 197 } else if (device.isConnected()) { 198 device.disconnect(); 199 } 200 } else if (PHONE_BUTTON.equals(buttonType)) { 201 LocalBluetoothProfile profile = getProfile(device, BluetoothProfile.HEADSET_CLIENT); 202 if (profile != null) { 203 profile.setEnabled(device.getDevice(), newState); 204 } 205 } else if (MEDIA_BUTTON.equals(buttonType)) { 206 LocalBluetoothProfile profile = getProfile(device, BluetoothProfile.A2DP_SINK); 207 if (profile != null) { 208 profile.setEnabled(device.getDevice(), newState); 209 } 210 } else { 211 LOG.d("Unknown button type: " + buttonType); 212 } 213 } 214 215 @Override getBackgroundWorkerClass()216 Class getBackgroundWorkerClass() { 217 return PairedBluetoothDevicesWorker.class; 218 } 219 220 @DrawableRes getIconRes(CachedBluetoothDevice device)221 private int getIconRes(CachedBluetoothDevice device) { 222 BluetoothClass btClass = device.getBtClass(); 223 if (btClass != null) { 224 switch (btClass.getMajorDeviceClass()) { 225 case BluetoothClass.Device.Major.COMPUTER: 226 return com.android.internal.R.drawable.ic_bt_laptop; 227 case BluetoothClass.Device.Major.PHONE: 228 return com.android.internal.R.drawable.ic_phone; 229 case BluetoothClass.Device.Major.PERIPHERAL: 230 return HidProfile.getHidClassDrawable(btClass); 231 case BluetoothClass.Device.Major.IMAGING: 232 return com.android.internal.R.drawable.ic_settings_print; 233 default: 234 // unrecognized device class; continue 235 } 236 } 237 238 List<LocalBluetoothProfile> profiles = device.getProfiles(); 239 for (LocalBluetoothProfile profile : profiles) { 240 int resId = profile.getDrawableResource(btClass); 241 if (resId != 0) { 242 return resId; 243 } 244 } 245 if (btClass != null) { 246 if (btClass.doesClassMatch(BluetoothClass.PROFILE_HEADSET)) { 247 return com.android.internal.R.drawable.ic_bt_headset_hfp; 248 } 249 if (btClass.doesClassMatch(BluetoothClass.PROFILE_A2DP)) { 250 return com.android.internal.R.drawable.ic_bt_headphones_a2dp; 251 } 252 } 253 return com.android.internal.R.drawable.ic_settings_bluetooth; 254 } 255 createBluetoothButton(CachedBluetoothDevice device, int requestCode)256 private QCActionItem createBluetoothButton(CachedBluetoothDevice device, int requestCode) { 257 return createBluetoothDeviceToggle(device, requestCode, BLUETOOTH_BUTTON, 258 Icon.createWithResource(getContext(), R.drawable.ic_qc_bluetooth), 259 getContext().getString( 260 R.string.bluetooth_bonded_bluetooth_toggle_content_description), 261 true, !device.isBusy(), false, device.isConnected()); 262 } 263 createPhoneButton(CachedBluetoothDevice device, int requestCode)264 private QCActionItem createPhoneButton(CachedBluetoothDevice device, int requestCode) { 265 BluetoothProfileToggleState phoneState = getBluetoothProfileToggleState(device, 266 BluetoothProfile.HEADSET_CLIENT); 267 int iconRes = phoneState.mIsAvailable ? R.drawable.ic_qc_bluetooth_phone 268 : R.drawable.ic_qc_bluetooth_phone_unavailable; 269 return createBluetoothDeviceToggle(device, requestCode, PHONE_BUTTON, 270 Icon.createWithResource(getContext(), iconRes), 271 getContext().getString(R.string.bluetooth_bonded_phone_toggle_content_description), 272 phoneState.mIsAvailable, phoneState.mIsEnabled, 273 phoneState.mIsClickableWhileDisabled, phoneState.mIsChecked); 274 } 275 createMediaButton(CachedBluetoothDevice device, int requestCode)276 private QCActionItem createMediaButton(CachedBluetoothDevice device, int requestCode) { 277 BluetoothProfileToggleState mediaState = getBluetoothProfileToggleState(device, 278 BluetoothProfile.A2DP_SINK); 279 int iconRes = mediaState.mIsAvailable ? R.drawable.ic_qc_bluetooth_media 280 : R.drawable.ic_qc_bluetooth_media_unavailable; 281 return createBluetoothDeviceToggle(device, requestCode, MEDIA_BUTTON, 282 Icon.createWithResource(getContext(), iconRes), 283 getContext().getString(R.string.bluetooth_bonded_media_toggle_content_description), 284 mediaState.mIsAvailable, mediaState.mIsEnabled, 285 mediaState.mIsClickableWhileDisabled, mediaState.mIsChecked); 286 } 287 createBluetoothDeviceToggle(CachedBluetoothDevice device, int requestCode, String buttonType, Icon icon, String contentDescription, boolean available, boolean enabled, boolean clickableWhileDisabled, boolean checked)288 private QCActionItem createBluetoothDeviceToggle(CachedBluetoothDevice device, int requestCode, 289 String buttonType, Icon icon, String contentDescription, boolean available, 290 boolean enabled, 291 boolean clickableWhileDisabled, boolean checked) { 292 Bundle extras = new Bundle(); 293 extras.putString(EXTRA_BUTTON_TYPE, buttonType); 294 extras.putString(EXTRA_DEVICE_KEY, device.getAddress()); 295 PendingIntent action = getBroadcastIntent(extras, requestCode); 296 297 boolean isReadOnlyForZone = isReadOnlyForZone(); 298 PendingIntent disabledPendingIntent = isReadOnlyForZone 299 ? QCUtils.getDisabledToastBroadcastIntent(getContext()) 300 : getActionDisabledDialogIntent(getContext(), DISALLOW_CONFIG_BLUETOOTH); 301 302 return new QCActionItem.Builder(QC_TYPE_ACTION_TOGGLE) 303 .setAvailable(available) 304 .setChecked(checked) 305 .setEnabled(enabled && isWritableForZone()) 306 .setClickableWhileDisabled(clickableWhileDisabled | isReadOnlyForZone) 307 .setAction(action) 308 .setDisabledClickAction(disabledPendingIntent) 309 .setIcon(icon) 310 .setContentDescription(contentDescription) 311 .build(); 312 } 313 getProfile(CachedBluetoothDevice device, int profileId)314 private LocalBluetoothProfile getProfile(CachedBluetoothDevice device, int profileId) { 315 for (LocalBluetoothProfile profile : device.getProfiles()) { 316 if (profile.getProfileId() == profileId) { 317 return profile; 318 } 319 } 320 return null; 321 } 322 getBluetoothProfileToggleState(CachedBluetoothDevice device, int profileId)323 private BluetoothProfileToggleState getBluetoothProfileToggleState(CachedBluetoothDevice device, 324 int profileId) { 325 LocalBluetoothProfile profile = getProfile(device, profileId); 326 if (!device.isConnected() || profile == null) { 327 return new BluetoothProfileToggleState(false, false, false, false); 328 } 329 boolean hasUmRestrictions = EnterpriseUtils.hasUserRestrictionByUm(getContext(), 330 DISALLOW_CONFIG_BLUETOOTH); 331 boolean hasDpmRestrictions = EnterpriseUtils.hasUserRestrictionByDpm(getContext(), 332 DISALLOW_CONFIG_BLUETOOTH); 333 return new BluetoothProfileToggleState(true, !hasDpmRestrictions && !hasUmRestrictions 334 && !device.isBusy(), hasDpmRestrictions, profile.isEnabled(device.getDevice())); 335 } 336 337 private static class BluetoothProfileToggleState { 338 final boolean mIsAvailable; 339 final boolean mIsEnabled; 340 final boolean mIsClickableWhileDisabled; 341 final boolean mIsChecked; 342 BluetoothProfileToggleState(boolean isAvailable, boolean isEnabled, boolean isClickableWhileDisabled, boolean isChecked)343 BluetoothProfileToggleState(boolean isAvailable, boolean isEnabled, 344 boolean isClickableWhileDisabled, boolean isChecked) { 345 mIsAvailable = isAvailable; 346 mIsEnabled = isEnabled; 347 mIsClickableWhileDisabled = isClickableWhileDisabled; 348 mIsChecked = isChecked; 349 } 350 } 351 } 352