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