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