• 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.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