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