• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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