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 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 (mBluetoothDevices == null) { 200 mBluetoothDevices = new HashSet<>(); 201 } 202 mBluetoothDevices.clear(); 203 if (mCachedDevice.getDevice() != null) { 204 mBluetoothDevices.add(mCachedDevice.getDevice()); 205 } 206 for (CachedBluetoothDevice cbd : mCachedDevice.getMemberDevice()) { 207 mBluetoothDevices.add(cbd.getDevice()); 208 } 209 if (mBluetoothDevices.isEmpty()) { 210 Log.d(TAG, "No BT device to register."); 211 return; 212 } 213 mBluetoothDevices.forEach(bd -> 214 mBluetoothAdapter.addOnMetadataChangedListener(bd, 215 getContext().getMainExecutor(), mMetadataListener)); 216 } 217 unregisterMetadataChangedListener()218 private void unregisterMetadataChangedListener() { 219 if (mBluetoothDevices == null || mBluetoothDevices.isEmpty()) { 220 Log.d(TAG, "No BT device to unregister."); 221 return; 222 } 223 mBluetoothDevices.forEach( 224 bd -> mBluetoothAdapter.removeOnMetadataChangedListener(bd, mMetadataListener)); 225 mBluetoothDevices.clear(); 226 } 227 getBluetoothDevice()228 public CachedBluetoothDevice getBluetoothDevice() { 229 return mCachedDevice; 230 } 231 hideSecondTarget(boolean hideSecondTarget)232 public void hideSecondTarget(boolean hideSecondTarget) { 233 mHideSecondTarget = hideSecondTarget; 234 } 235 236 @SuppressWarnings("FutureReturnValueIgnored") onPreferenceAttributesChanged()237 void onPreferenceAttributesChanged() { 238 try { 239 ThreadUtils.postOnBackgroundThread(() -> { 240 @Nullable String name = mCachedDevice.getName(); 241 // Null check is done at the framework 242 @Nullable String connectionSummary = getConnectionSummary(); 243 @NonNull Pair<Drawable, String> pair = mCachedDevice.getDrawableWithDescription(); 244 boolean isBusy = mCachedDevice.isBusy(); 245 // Device is only visible in the UI if it has a valid name besides MAC address or 246 // when user allows showing devices without user-friendly name in developer settings 247 boolean isVisible = 248 mShowDevicesWithoutNames || mCachedDevice.hasHumanReadableName(); 249 250 ThreadUtils.postOnMainThread(() -> { 251 /* 252 * The preference framework takes care of making sure the value has 253 * changed before proceeding. It will also call notifyChanged() if 254 * any preference info has changed from the previous value. 255 */ 256 setTitle(name); 257 setSummary(connectionSummary); 258 setIcon(pair.first); 259 contentDescription = pair.second; 260 // Used to gray out the item 261 setEnabled(!isBusy); 262 setVisible(isVisible); 263 264 // This could affect ordering, so notify that 265 if (mNeedNotifyHierarchyChanged) { 266 notifyHierarchyChanged(); 267 } 268 }); 269 }); 270 } catch (RejectedExecutionException e) { 271 Log.w(TAG, "Handler thread unavailable, skipping getConnectionSummary!"); 272 } 273 } 274 275 @Override onBindViewHolder(PreferenceViewHolder view)276 public void onBindViewHolder(PreferenceViewHolder view) { 277 // Disable this view if the bluetooth enable/disable preference view is off 278 if (null != findPreferenceInHierarchy("bt_checkbox")) { 279 setDependency("bt_checkbox"); 280 } 281 282 if (mCachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) { 283 ImageView deviceDetails = (ImageView) view.findViewById(R.id.settings_button); 284 285 if (deviceDetails != null) { 286 deviceDetails.setOnClickListener(this); 287 } 288 } 289 final ImageView imageView = (ImageView) view.findViewById(android.R.id.icon); 290 if (imageView != null) { 291 imageView.setContentDescription(contentDescription); 292 // Set property to prevent Talkback from reading out. 293 imageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); 294 imageView.setElevation( 295 getContext().getResources().getDimension(R.dimen.bt_icon_elevation)); 296 } 297 super.onBindViewHolder(view); 298 } 299 300 @Override equals(Object o)301 public boolean equals(Object o) { 302 if ((o == null) || !(o instanceof BluetoothDevicePreference)) { 303 return false; 304 } 305 return mCachedDevice.equals( 306 ((BluetoothDevicePreference) o).mCachedDevice); 307 } 308 309 @Override hashCode()310 public int hashCode() { 311 return mCachedDevice.hashCode(); 312 } 313 314 @Override compareTo(Preference another)315 public int compareTo(Preference another) { 316 if (!(another instanceof BluetoothDevicePreference)) { 317 // Rely on default sort 318 return super.compareTo(another); 319 } 320 321 switch (mType) { 322 case SortType.TYPE_DEFAULT: 323 return mCachedDevice 324 .compareTo(((BluetoothDevicePreference) another).mCachedDevice); 325 case SortType.TYPE_FIFO: 326 return mId > ((BluetoothDevicePreference) another).mId ? 1 : -1; 327 default: 328 return super.compareTo(another); 329 } 330 } 331 onClicked()332 void onClicked() { 333 Context context = getContext(); 334 int bondState = mCachedDevice.getBondState(); 335 336 final MetricsFeatureProvider metricsFeatureProvider = 337 FeatureFactory.getFactory(context).getMetricsFeatureProvider(); 338 339 if (mCachedDevice.isConnected()) { 340 metricsFeatureProvider.action(context, 341 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_DISCONNECT); 342 askDisconnect(); 343 } else if (bondState == BluetoothDevice.BOND_BONDED) { 344 metricsFeatureProvider.action(context, 345 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_CONNECT); 346 mCachedDevice.connect(); 347 } else if (bondState == BluetoothDevice.BOND_NONE) { 348 metricsFeatureProvider.action(context, 349 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR); 350 if (!mCachedDevice.hasHumanReadableName()) { 351 metricsFeatureProvider.action(context, 352 SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_DEVICES_WITHOUT_NAMES); 353 } 354 pair(); 355 } 356 } 357 358 // Show disconnect confirmation dialog for a device. askDisconnect()359 private void askDisconnect() { 360 Context context = getContext(); 361 String name = mCachedDevice.getName(); 362 if (TextUtils.isEmpty(name)) { 363 name = context.getString(R.string.bluetooth_device); 364 } 365 String message = context.getString(R.string.bluetooth_disconnect_all_profiles, name); 366 String title = context.getString(R.string.bluetooth_disconnect_title); 367 368 DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener() { 369 public void onClick(DialogInterface dialog, int which) { 370 mCachedDevice.disconnect(); 371 } 372 }; 373 374 mDisconnectDialog = Utils.showDisconnectDialog(context, 375 mDisconnectDialog, disconnectListener, title, Html.fromHtml(message)); 376 } 377 pair()378 private void pair() { 379 if (!mCachedDevice.startPairing()) { 380 Utils.showError(getContext(), mCachedDevice.getName(), 381 R.string.bluetooth_pairing_error_message); 382 } 383 } 384 getConnectionSummary()385 private String getConnectionSummary() { 386 String summary = null; 387 if (mCachedDevice.getBondState() != BluetoothDevice.BOND_NONE) { 388 summary = mCachedDevice.getConnectionSummary(); 389 } 390 return summary; 391 } 392 } 393