1 /*
2  * Copyright (C) 2021 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.car.settings.qc;
18 
19 import static android.os.UserManager.DISALLOW_BLUETOOTH;
20 
21 import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE;
22 import static com.android.car.qc.QCItem.QC_TYPE_ACTION_TOGGLE;
23 
24 import android.app.PendingIntent;
25 import android.bluetooth.BluetoothAdapter;
26 import android.bluetooth.BluetoothClass;
27 import android.bluetooth.BluetoothDevice;
28 import android.bluetooth.BluetoothProfile;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.pm.PackageManager;
32 import android.graphics.drawable.Icon;
33 import android.net.Uri;
34 import android.os.Bundle;
35 import android.os.UserManager;
36 
37 import androidx.annotation.DrawableRes;
38 import androidx.annotation.VisibleForTesting;
39 
40 import com.android.car.qc.QCActionItem;
41 import com.android.car.qc.QCItem;
42 import com.android.car.qc.QCList;
43 import com.android.car.qc.QCRow;
44 import com.android.car.settings.R;
45 import com.android.car.settings.common.Logger;
46 import com.android.settingslib.bluetooth.BluetoothUtils;
47 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
48 import com.android.settingslib.bluetooth.HidProfile;
49 import com.android.settingslib.bluetooth.LocalBluetoothManager;
50 import com.android.settingslib.bluetooth.LocalBluetoothProfile;
51 
52 import java.util.ArrayList;
53 import java.util.Collection;
54 import java.util.Comparator;
55 import java.util.List;
56 import java.util.Set;
57 
58 /**
59  * QCItem for showing paired bluetooth devices.
60  */
61 public class PairedBluetoothDevices extends SettingsQCItem {
62     @VisibleForTesting
63     static final String EXTRA_DEVICE_KEY = "BT_EXTRA_DEVICE_KEY";
64     @VisibleForTesting
65     static final String EXTRA_BUTTON_TYPE = "BT_EXTRA_BUTTON_TYPE";
66     @VisibleForTesting
67     static final String BLUETOOTH_BUTTON = "BLUETOOTH_BUTTON";
68     @VisibleForTesting
69     static final String PHONE_BUTTON = "PHONE_BUTTON";
70     @VisibleForTesting
71     static final String MEDIA_BUTTON = "MEDIA_BUTTON";
72     private static final Logger LOG = new Logger(PairedBluetoothDevices.class);
73 
74     private final LocalBluetoothManager mBluetoothManager;
75     private final UserManager mUserManager;
76     private final int mDeviceLimit;
77 
PairedBluetoothDevices(Context context)78     public PairedBluetoothDevices(Context context) {
79         super(context);
80         mBluetoothManager = LocalBluetoothManager.getInstance(context, /* onInitCallback= */ null);
81         mUserManager = UserManager.get(context);
82         mDeviceLimit = context.getResources().getInteger(
83                 R.integer.config_qc_bluetooth_device_limit);
84     }
85 
86     @Override
getQCItem()87     QCItem getQCItem() {
88         if (!getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
89                 || mUserManager.hasUserRestriction(DISALLOW_BLUETOOTH)
90                 || mDeviceLimit == 0) {
91             return null;
92         }
93 
94         QCList.Builder listBuilder = new QCList.Builder();
95 
96         if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) {
97             listBuilder.addRow(new QCRow.Builder()
98                     .setIcon(Icon.createWithResource(getContext(),
99                             R.drawable.ic_settings_bluetooth_disabled))
100                     .setTitle(getContext().getString(R.string.qc_bluetooth_off_devices_info))
101                     .build());
102             return listBuilder.build();
103         }
104 
105         Collection<CachedBluetoothDevice> cachedDevices =
106                 mBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy();
107 
108         //TODO(b/198339129): Use BluetoothDeviceFilter.BONDED_DEVICE_FILTER
109         Set<BluetoothDevice> bondedDevices = mBluetoothManager.getBluetoothAdapter()
110                 .getBondedDevices();
111 
112         List<CachedBluetoothDevice> filteredDevices = new ArrayList<>();
113         for (CachedBluetoothDevice cachedDevice : cachedDevices) {
114             if (bondedDevices != null && bondedDevices.contains(cachedDevice.getDevice())) {
115                 filteredDevices.add(cachedDevice);
116             }
117         }
118         filteredDevices.sort(Comparator.naturalOrder());
119 
120         if (filteredDevices.isEmpty()) {
121             listBuilder.addRow(new QCRow.Builder()
122                     .setIcon(Icon.createWithResource(getContext(),
123                             R.drawable.ic_settings_bluetooth))
124                     .setTitle(getContext().getString(R.string.qc_bluetooth_on_no_devices_info))
125                     .build());
126             return listBuilder.build();
127         }
128 
129         int i = 0;
130         int deviceLimit = mDeviceLimit >= 0 ? Math.min(mDeviceLimit, filteredDevices.size())
131                 : filteredDevices.size();
132         for (int j = 0; j < deviceLimit; j++) {
133             CachedBluetoothDevice cachedDevice = filteredDevices.get(j);
134             listBuilder.addRow(new QCRow.Builder()
135                     .setTitle(cachedDevice.getName())
136                     .setSubtitle(getSubtitle(cachedDevice))
137                     .setIcon(Icon.createWithResource(getContext(), getIconRes(cachedDevice)))
138                     .addEndItem(createBluetoothButton(cachedDevice, i++))
139                     .addEndItem(createPhoneButton(cachedDevice, i++))
140                     .addEndItem(createMediaButton(cachedDevice, i++))
141                     .build()
142             );
143         }
144 
145         return listBuilder.build();
146     }
147 
148     @Override
getUri()149     Uri getUri() {
150         return SettingsQCRegistry.PAIRED_BLUETOOTH_DEVICES_URI;
151     }
152 
153 
154     @Override
onNotifyChange(Intent intent)155     void onNotifyChange(Intent intent) {
156         String deviceKey = intent.getStringExtra(EXTRA_DEVICE_KEY);
157         if (deviceKey == null) {
158             return;
159         }
160         CachedBluetoothDevice device = null;
161         Collection<CachedBluetoothDevice> cachedDevices =
162                 mBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy();
163         for (CachedBluetoothDevice cachedDevice : cachedDevices) {
164             if (cachedDevice.getAddress().equals(deviceKey)) {
165                 device = cachedDevice;
166                 break;
167             }
168         }
169         if (device == null) {
170             return;
171         }
172 
173         String buttonType = intent.getStringExtra(EXTRA_BUTTON_TYPE);
174         boolean newState = intent.getBooleanExtra(QC_ACTION_TOGGLE_STATE, true);
175         if (BLUETOOTH_BUTTON.equals(buttonType)) {
176             if (newState) {
177                 LocalBluetoothProfile phoneProfile = getProfile(device,
178                         BluetoothProfile.HEADSET_CLIENT);
179                 LocalBluetoothProfile mediaProfile = getProfile(device, BluetoothProfile.A2DP_SINK);
180                 // If trying to connect and both phone and media are disabled, connecting will
181                 // always fail. In this case force both profiles on.
182                 if (phoneProfile != null && mediaProfile != null
183                         && !phoneProfile.isEnabled(device.getDevice())
184                         && !mediaProfile.isEnabled(device.getDevice())) {
185                     phoneProfile.setEnabled(device.getDevice(), true);
186                     mediaProfile.setEnabled(device.getDevice(), true);
187                 }
188                 device.connect();
189             } else if (device.isConnected()) {
190                 device.disconnect();
191             }
192         } else if (PHONE_BUTTON.equals(buttonType)) {
193             LocalBluetoothProfile profile = getProfile(device, BluetoothProfile.HEADSET_CLIENT);
194             if (profile != null) {
195                 profile.setEnabled(device.getDevice(), newState);
196             }
197         } else if (MEDIA_BUTTON.equals(buttonType)) {
198             LocalBluetoothProfile profile = getProfile(device, BluetoothProfile.A2DP_SINK);
199             if (profile != null) {
200                 profile.setEnabled(device.getDevice(), newState);
201             }
202         } else {
203             LOG.d("Unknown button type: " + buttonType);
204         }
205     }
206 
207     @Override
getBackgroundWorkerClass()208     Class getBackgroundWorkerClass() {
209         return PairedBluetoothDevicesWorker.class;
210     }
211 
getSubtitle(CachedBluetoothDevice device)212     private String getSubtitle(CachedBluetoothDevice device) {
213         if (device.isConnected()) {
214             return getContext().getString(BluetoothUtils
215                             .getConnectionStateSummary(BluetoothProfile.STATE_CONNECTED),
216                     /* appended text= */ "");
217         }
218         return device.getCarConnectionSummary();
219     }
220 
221     @DrawableRes
getIconRes(CachedBluetoothDevice device)222     private int getIconRes(CachedBluetoothDevice device) {
223         BluetoothClass btClass = device.getBtClass();
224         if (btClass != null) {
225             switch (btClass.getMajorDeviceClass()) {
226                 case BluetoothClass.Device.Major.COMPUTER:
227                     return com.android.internal.R.drawable.ic_bt_laptop;
228                 case BluetoothClass.Device.Major.PHONE:
229                     return com.android.internal.R.drawable.ic_phone;
230                 case BluetoothClass.Device.Major.PERIPHERAL:
231                     return HidProfile.getHidClassDrawable(btClass);
232                 case BluetoothClass.Device.Major.IMAGING:
233                     return com.android.internal.R.drawable.ic_settings_print;
234                 default:
235                     // unrecognized device class; continue
236             }
237         }
238 
239         List<LocalBluetoothProfile> profiles = device.getProfiles();
240         for (LocalBluetoothProfile profile : profiles) {
241             int resId = profile.getDrawableResource(btClass);
242             if (resId != 0) {
243                 return resId;
244             }
245         }
246         if (btClass != null) {
247             if (btClass.doesClassMatch(BluetoothClass.PROFILE_HEADSET)) {
248                 return com.android.internal.R.drawable.ic_bt_headset_hfp;
249             }
250             if (btClass.doesClassMatch(BluetoothClass.PROFILE_A2DP)) {
251                 return com.android.internal.R.drawable.ic_bt_headphones_a2dp;
252             }
253         }
254         return com.android.internal.R.drawable.ic_settings_bluetooth;
255     }
256 
createBluetoothButton(CachedBluetoothDevice device, int requestCode)257     private QCActionItem createBluetoothButton(CachedBluetoothDevice device, int requestCode) {
258         return createBluetoothDeviceToggle(device, requestCode, BLUETOOTH_BUTTON,
259                 Icon.createWithResource(getContext(), R.drawable.ic_qc_bluetooth), true,
260                 !device.isBusy(), device.isConnected());
261     }
262 
createPhoneButton(CachedBluetoothDevice device, int requestCode)263     private QCActionItem createPhoneButton(CachedBluetoothDevice device, int requestCode) {
264         BluetoothProfileToggleState phoneState = getBluetoothProfileToggleState(device,
265                 BluetoothProfile.HEADSET_CLIENT);
266         int iconRes = phoneState.mIsAvailable ? R.drawable.ic_qc_bluetooth_phone
267                 : R.drawable.ic_qc_bluetooth_phone_unavailable;
268         return createBluetoothDeviceToggle(device, requestCode, PHONE_BUTTON,
269                 Icon.createWithResource(getContext(), iconRes),
270                 phoneState.mIsAvailable, phoneState.mIsEnabled, phoneState.mIsChecked);
271     }
272 
createMediaButton(CachedBluetoothDevice device, int requestCode)273     private QCActionItem createMediaButton(CachedBluetoothDevice device, int requestCode) {
274         BluetoothProfileToggleState mediaState = getBluetoothProfileToggleState(device,
275                 BluetoothProfile.A2DP_SINK);
276         int iconRes = mediaState.mIsAvailable ? R.drawable.ic_qc_bluetooth_media
277                 : R.drawable.ic_qc_bluetooth_media_unavailable;
278         return createBluetoothDeviceToggle(device, requestCode, MEDIA_BUTTON,
279                 Icon.createWithResource(getContext(), iconRes),
280                 mediaState.mIsAvailable, mediaState.mIsEnabled, mediaState.mIsChecked);
281     }
282 
createBluetoothDeviceToggle(CachedBluetoothDevice device, int requestCode, String buttonType, Icon icon, boolean available, boolean enabled, boolean checked)283     private QCActionItem createBluetoothDeviceToggle(CachedBluetoothDevice device, int requestCode,
284             String buttonType, Icon icon, boolean available, boolean enabled, boolean checked) {
285         Bundle extras = new Bundle();
286         extras.putString(EXTRA_BUTTON_TYPE, buttonType);
287         extras.putString(EXTRA_DEVICE_KEY, device.getAddress());
288         PendingIntent action = getBroadcastIntent(extras, requestCode);
289 
290         return new QCActionItem.Builder(QC_TYPE_ACTION_TOGGLE)
291                 .setAvailable(available)
292                 .setChecked(checked)
293                 .setEnabled(enabled)
294                 .setAction(action)
295                 .setIcon(icon)
296                 .build();
297     }
298 
getProfile(CachedBluetoothDevice device, int profileId)299     private LocalBluetoothProfile getProfile(CachedBluetoothDevice device, int profileId) {
300         for (LocalBluetoothProfile profile : device.getProfiles()) {
301             if (profile.getProfileId() == profileId) {
302                 return profile;
303             }
304         }
305         return null;
306     }
307 
getBluetoothProfileToggleState(CachedBluetoothDevice device, int profileId)308     private BluetoothProfileToggleState getBluetoothProfileToggleState(CachedBluetoothDevice device,
309             int profileId) {
310         LocalBluetoothProfile profile = getProfile(device, profileId);
311         if (!device.isConnected() || profile == null) {
312             return new BluetoothProfileToggleState(false, false, false);
313         }
314         return new BluetoothProfileToggleState(true, !device.isBusy(),
315                 profile.isEnabled(device.getDevice()));
316     }
317 
318     private static class BluetoothProfileToggleState {
319         final boolean mIsAvailable;
320         final boolean mIsEnabled;
321         final boolean mIsChecked;
322 
BluetoothProfileToggleState(boolean isAvailable, boolean isEnabled, boolean isChecked)323         BluetoothProfileToggleState(boolean isAvailable, boolean isEnabled, boolean isChecked) {
324             mIsAvailable = isAvailable;
325             mIsEnabled = isEnabled;
326             mIsChecked = isChecked;
327         }
328     }
329 }
330