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