1 /* 2 * Copyright (C) 2023 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.settings.bluetooth; 18 19 import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; 20 21 import android.app.settings.SettingsEnums; 22 import android.bluetooth.BluetoothAdapter; 23 import android.bluetooth.BluetoothDevice; 24 import android.content.Context; 25 import android.content.DialogInterface; 26 import android.content.res.Resources; 27 import android.graphics.drawable.Drawable; 28 import android.os.UserManager; 29 import android.text.Html; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.util.Pair; 33 import android.util.TypedValue; 34 import android.view.View; 35 import android.widget.ImageView; 36 37 import androidx.annotation.IntDef; 38 import androidx.annotation.NonNull; 39 import androidx.annotation.Nullable; 40 import androidx.annotation.VisibleForTesting; 41 import androidx.appcompat.app.AlertDialog; 42 import androidx.preference.Preference; 43 import androidx.preference.PreferenceViewHolder; 44 45 import com.android.settings.R; 46 import com.android.settings.overlay.FeatureFactory; 47 import com.android.settings.widget.GearPreference; 48 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 49 import com.android.settingslib.bluetooth.LocalBluetoothManager; 50 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; 51 import com.android.settingslib.flags.Flags; 52 import com.android.settingslib.utils.ThreadUtils; 53 54 import java.lang.annotation.Retention; 55 import java.lang.annotation.RetentionPolicy; 56 import java.util.HashSet; 57 import java.util.Set; 58 import java.util.concurrent.RejectedExecutionException; 59 import java.util.concurrent.atomic.AtomicInteger; 60 import java.util.stream.Collectors; 61 62 /** 63 * BluetoothDevicePreference is the preference type used to display each remote 64 * Bluetooth device in the Bluetooth Settings screen. 65 */ 66 public final class BluetoothDevicePreference extends GearPreference { 67 private static final String TAG = "BluetoothDevicePref"; 68 69 private static int sDimAlpha = Integer.MIN_VALUE; 70 71 @Retention(RetentionPolicy.SOURCE) 72 @IntDef({SortType.TYPE_DEFAULT, 73 SortType.TYPE_FIFO, 74 SortType.TYPE_NO_SORT}) 75 public @interface SortType { 76 int TYPE_DEFAULT = 1; 77 int TYPE_FIFO = 2; 78 int TYPE_NO_SORT = 3; 79 } 80 81 private final CachedBluetoothDevice mCachedDevice; 82 private Set<CachedBluetoothDevice> mCachedDeviceGroup; 83 84 private final UserManager mUserManager; 85 private final LocalBluetoothManager mLocalBtManager; 86 87 private Set<BluetoothDevice> mBluetoothDevices; 88 @VisibleForTesting 89 BluetoothAdapter mBluetoothAdapter; 90 private final boolean mShowDevicesWithoutNames; 91 @NonNull 92 private static final AtomicInteger sNextId = new AtomicInteger(); 93 private final int mId; 94 private final int mType; 95 96 private AlertDialog mDisconnectDialog; 97 @Nullable private AlertDialog mBlockPairingDialog; 98 private String contentDescription = null; 99 private boolean mHideSecondTarget = false; 100 private boolean mIsCallbackRemoved = true; 101 @VisibleForTesting 102 boolean mNeedNotifyHierarchyChanged = false; 103 /* Talk-back descriptions for various BT icons */ 104 Resources mResources; 105 final BluetoothDevicePreferenceCallback mCallback; 106 @VisibleForTesting 107 final BluetoothAdapter.OnMetadataChangedListener mMetadataListener = 108 new BluetoothAdapter.OnMetadataChangedListener() { 109 @Override 110 public void onMetadataChanged(BluetoothDevice device, int key, byte[] value) { 111 Log.d(TAG, String.format("Metadata updated in Device %s: %d = %s.", 112 device.getAnonymizedAddress(), 113 key, value == null ? null : new String(value))); 114 onPreferenceAttributesChanged(); 115 } 116 }; 117 118 private class BluetoothDevicePreferenceCallback implements CachedBluetoothDevice.Callback { 119 120 @Override onDeviceAttributesChanged()121 public void onDeviceAttributesChanged() { 122 onPreferenceAttributesChanged(); 123 Set<CachedBluetoothDevice> newCachedDeviceGroup = new HashSet<>( 124 Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice)); 125 if (!mCachedDeviceGroup.equals(newCachedDeviceGroup)) { 126 for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { 127 cachedBluetoothDevice.unregisterCallback(this); 128 } 129 unregisterMetadataChangedListener(); 130 131 mCachedDeviceGroup = newCachedDeviceGroup; 132 133 for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { 134 cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), this); 135 } 136 registerMetadataChangedListener(); 137 } 138 } 139 } 140 BluetoothDevicePreference(Context context, CachedBluetoothDevice cachedDevice, boolean showDeviceWithoutNames, @SortType int type)141 public BluetoothDevicePreference(Context context, CachedBluetoothDevice cachedDevice, 142 boolean showDeviceWithoutNames, @SortType int type) { 143 super(context, null); 144 mResources = getContext().getResources(); 145 mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE); 146 mLocalBtManager = Utils.getLocalBluetoothManager(context); 147 mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); 148 mShowDevicesWithoutNames = showDeviceWithoutNames; 149 150 if (sDimAlpha == Integer.MIN_VALUE) { 151 TypedValue outValue = new TypedValue(); 152 context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); 153 sDimAlpha = (int) (outValue.getFloat() * 255); 154 } 155 156 mCachedDevice = cachedDevice; 157 mCachedDeviceGroup = new HashSet<>( 158 Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice)); 159 mCallback = new BluetoothDevicePreferenceCallback(); 160 mId = sNextId.getAndIncrement(); 161 mType = type; 162 setVisible(false); 163 164 onPreferenceAttributesChanged(); 165 } 166 setNeedNotifyHierarchyChanged(boolean needNotifyHierarchyChanged)167 public void setNeedNotifyHierarchyChanged(boolean needNotifyHierarchyChanged) { 168 mNeedNotifyHierarchyChanged = needNotifyHierarchyChanged; 169 } 170 171 @Override shouldHideSecondTarget()172 protected boolean shouldHideSecondTarget() { 173 return mCachedDevice == null 174 || mCachedDevice.getBondState() != BluetoothDevice.BOND_BONDED 175 || mUserManager.hasUserRestriction(DISALLOW_CONFIG_BLUETOOTH) 176 || mHideSecondTarget; 177 } 178 179 @Override getSecondTargetResId()180 protected int getSecondTargetResId() { 181 return R.layout.preference_widget_gear; 182 } 183 getCachedDevice()184 public CachedBluetoothDevice getCachedDevice() { 185 return mCachedDevice; 186 } 187 188 @Override onPrepareForRemoval()189 protected void onPrepareForRemoval() { 190 super.onPrepareForRemoval(); 191 if (!mIsCallbackRemoved) { 192 for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { 193 cachedBluetoothDevice.unregisterCallback(mCallback); 194 } 195 unregisterMetadataChangedListener(); 196 mIsCallbackRemoved = true; 197 } 198 if (mDisconnectDialog != null) { 199 mDisconnectDialog.dismiss(); 200 mDisconnectDialog = null; 201 } 202 } 203 204 @Override onAttached()205 public void onAttached() { 206 super.onAttached(); 207 if (mIsCallbackRemoved) { 208 for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { 209 cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), mCallback); 210 } 211 registerMetadataChangedListener(); 212 mIsCallbackRemoved = false; 213 } 214 onPreferenceAttributesChanged(); 215 } 216 217 @Override onDetached()218 public void onDetached() { 219 super.onDetached(); 220 if (!mIsCallbackRemoved) { 221 for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { 222 cachedBluetoothDevice.unregisterCallback(mCallback); 223 } 224 unregisterMetadataChangedListener(); 225 mIsCallbackRemoved = true; 226 } 227 } 228 registerMetadataChangedListener()229 private void registerMetadataChangedListener() { 230 if (mBluetoothAdapter == null) { 231 Log.d(TAG, "No mBluetoothAdapter"); 232 return; 233 } 234 235 mBluetoothDevices = mCachedDeviceGroup.stream() 236 .map(CachedBluetoothDevice::getDevice) 237 .collect(Collectors.toCollection(HashSet::new)); 238 239 if (mBluetoothDevices.isEmpty()) { 240 Log.d(TAG, "No BT device to register."); 241 return; 242 } 243 Set<BluetoothDevice> errorDevices = new HashSet<>(); 244 mBluetoothDevices.forEach(bd -> { 245 try { 246 boolean isSuccess = mBluetoothAdapter.addOnMetadataChangedListener(bd, 247 getContext().getMainExecutor(), mMetadataListener); 248 if (!isSuccess) { 249 Log.e(TAG, bd.getAnonymizedAddress() + ": add into Listener failed"); 250 errorDevices.add(bd); 251 } 252 } catch (NullPointerException e) { 253 errorDevices.add(bd); 254 Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); 255 } catch (IllegalArgumentException e) { 256 errorDevices.add(bd); 257 Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); 258 } 259 }); 260 for (BluetoothDevice errorDevice : errorDevices) { 261 mBluetoothDevices.remove(errorDevice); 262 Log.d(TAG, "mBluetoothDevices remove " + errorDevice.getAnonymizedAddress()); 263 } 264 } 265 unregisterMetadataChangedListener()266 private void unregisterMetadataChangedListener() { 267 if (mBluetoothAdapter == null) { 268 Log.d(TAG, "No mBluetoothAdapter"); 269 return; 270 } 271 if (mBluetoothDevices == null || mBluetoothDevices.isEmpty()) { 272 Log.d(TAG, "No BT device to unregister."); 273 return; 274 } 275 mBluetoothDevices.forEach(bd -> { 276 try { 277 mBluetoothAdapter.removeOnMetadataChangedListener(bd, mMetadataListener); 278 } catch (NullPointerException e) { 279 Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); 280 } catch (IllegalArgumentException e) { 281 Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); 282 } 283 }); 284 mBluetoothDevices.clear(); 285 } 286 getBluetoothDevice()287 public CachedBluetoothDevice getBluetoothDevice() { 288 return mCachedDevice; 289 } 290 hideSecondTarget(boolean hideSecondTarget)291 public void hideSecondTarget(boolean hideSecondTarget) { 292 mHideSecondTarget = hideSecondTarget; 293 } 294 295 @SuppressWarnings("FutureReturnValueIgnored") onPreferenceAttributesChanged()296 void onPreferenceAttributesChanged() { 297 try { 298 ThreadUtils.postOnBackgroundThread(() -> { 299 if (mCachedDevice.getDevice() != null) { 300 Log.d(TAG, "onPreferenceAttributesChanged, start updating for device " 301 + mCachedDevice.getDevice().getAnonymizedAddress()); 302 } 303 @Nullable String name = mCachedDevice.getName(); 304 // Null check is done at the framework 305 @Nullable String connectionSummary = getConnectionSummary(); 306 @NonNull Pair<Drawable, String> pair = mCachedDevice.getDrawableWithDescription(); 307 boolean isBusy = mCachedDevice.isBusy(); 308 // Device is only visible in the UI if it has a valid name besides MAC address or 309 // when user allows showing devices without user-friendly name in developer settings 310 boolean isVisible = 311 mShowDevicesWithoutNames || mCachedDevice.hasHumanReadableName(); 312 313 ThreadUtils.postOnMainThread(() -> { 314 /* 315 * The preference framework takes care of making sure the value has 316 * changed before proceeding. It will also call notifyChanged() if 317 * any preference info has changed from the previous value. 318 */ 319 setTitle(name); 320 setSummary(connectionSummary); 321 setIcon(pair.first); 322 contentDescription = pair.second; 323 // Used to gray out the item 324 setEnabled(!isBusy); 325 setVisible(isVisible); 326 327 // This could affect ordering, so notify that 328 if (mNeedNotifyHierarchyChanged) { 329 notifyHierarchyChanged(); 330 } 331 }); 332 Log.d(TAG, "onPreferenceAttributesChanged, complete updating for device " + name); 333 }); 334 } catch (RejectedExecutionException e) { 335 Log.w(TAG, "Handler thread unavailable, skipping getConnectionSummary!"); 336 } 337 } 338 339 @Override onBindViewHolder(PreferenceViewHolder view)340 public void onBindViewHolder(PreferenceViewHolder view) { 341 // Disable this view if the bluetooth enable/disable preference view is off 342 if (null != findPreferenceInHierarchy("bt_checkbox")) { 343 setDependency("bt_checkbox"); 344 } 345 346 if (mCachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) { 347 ImageView deviceDetails = (ImageView) view.findViewById(R.id.settings_button); 348 deviceDetails.setContentDescription( 349 getContext().getResources().getString( 350 R.string.device_detail_icon_content_description, getTitle())); 351 352 if (deviceDetails != null) { 353 deviceDetails.setOnClickListener(this); 354 } 355 } 356 final ImageView imageView = (ImageView) view.findViewById(android.R.id.icon); 357 if (imageView != null) { 358 imageView.setContentDescription(contentDescription); 359 // Set property to prevent Talkback from reading out. 360 imageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); 361 imageView.setElevation( 362 getContext().getResources().getDimension(R.dimen.bt_icon_elevation)); 363 } 364 super.onBindViewHolder(view); 365 } 366 367 @Override equals(Object o)368 public boolean equals(Object o) { 369 if ((o == null) || !(o instanceof BluetoothDevicePreference)) { 370 return false; 371 } 372 return mCachedDevice.equals( 373 ((BluetoothDevicePreference) o).mCachedDevice); 374 } 375 376 @Override hashCode()377 public int hashCode() { 378 return mCachedDevice.hashCode(); 379 } 380 381 @Override compareTo(Preference another)382 public int compareTo(Preference another) { 383 if (!(another instanceof BluetoothDevicePreference)) { 384 // Rely on default sort 385 return super.compareTo(another); 386 } 387 388 switch (mType) { 389 case SortType.TYPE_DEFAULT: 390 return mCachedDevice 391 .compareTo(((BluetoothDevicePreference) another).mCachedDevice); 392 case SortType.TYPE_FIFO: 393 return mId > ((BluetoothDevicePreference) another).mId ? 1 : -1; 394 default: 395 return super.compareTo(another); 396 } 397 } 398 399 /** 400 * Performs different actions according to the device connected and bonded state after 401 * clicking on the preference. 402 */ onClicked()403 public void onClicked() { 404 Context context = getContext(); 405 int bondState = mCachedDevice.getBondState(); 406 407 final MetricsFeatureProvider metricsFeatureProvider = 408 FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); 409 410 if (mCachedDevice.isConnected()) { 411 metricsFeatureProvider.action(context, 412 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_DISCONNECT); 413 askDisconnect(); 414 } else if (bondState == BluetoothDevice.BOND_BONDED) { 415 metricsFeatureProvider.action(context, 416 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_CONNECT); 417 mCachedDevice.connect(); 418 } else if (bondState == BluetoothDevice.BOND_NONE) { 419 var unused = ThreadUtils.postOnBackgroundThread(() -> { 420 if (Flags.enableTemporaryBondDevicesUi() && Utils.shouldBlockPairingInAudioSharing( 421 mLocalBtManager)) { 422 // TODO: collect metric 423 context.getMainExecutor().execute(() -> 424 mBlockPairingDialog = 425 Utils.showBlockPairingDialog(context, mBlockPairingDialog, 426 mLocalBtManager)); 427 return; 428 } 429 metricsFeatureProvider.action(context, 430 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR); 431 if (!mCachedDevice.hasHumanReadableName()) { 432 metricsFeatureProvider.action(context, 433 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_DEVICES_WITHOUT_NAMES); 434 } 435 context.getMainExecutor().execute(() -> pair()); 436 }); 437 } 438 } 439 440 // Show disconnect confirmation dialog for a device. askDisconnect()441 private void askDisconnect() { 442 Context context = getContext(); 443 String name = mCachedDevice.getName(); 444 if (TextUtils.isEmpty(name)) { 445 name = context.getString(R.string.bluetooth_device); 446 } 447 String message = context.getString(R.string.bluetooth_disconnect_all_profiles, name); 448 String title = context.getString(R.string.bluetooth_disconnect_title); 449 450 DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener() { 451 public void onClick(DialogInterface dialog, int which) { 452 mCachedDevice.disconnect(); 453 } 454 }; 455 456 mDisconnectDialog = Utils.showDisconnectDialog(context, 457 mDisconnectDialog, disconnectListener, title, Html.fromHtml(message)); 458 } 459 pair()460 private void pair() { 461 if (!mCachedDevice.startPairing()) { 462 Utils.showError(getContext(), mCachedDevice.getName(), 463 com.android.settingslib.R.string.bluetooth_pairing_error_message); 464 } 465 } 466 getConnectionSummary()467 private String getConnectionSummary() { 468 String summary = null; 469 if (mCachedDevice.getBondState() != BluetoothDevice.BOND_NONE) { 470 summary = mCachedDevice.getConnectionSummary(); 471 } 472 return summary; 473 } 474 } 475