1 /*
2  * Copyright (C) 2018 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.homepage.contextualcards.slices;
18 
19 import android.app.PendingIntent;
20 import android.app.settings.SettingsEnums;
21 import android.bluetooth.BluetoothAdapter;
22 import android.bluetooth.BluetoothDevice;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.graphics.drawable.Drawable;
26 import android.net.Uri;
27 import android.os.Bundle;
28 import android.util.Log;
29 import android.util.Pair;
30 
31 import androidx.core.graphics.drawable.IconCompat;
32 import androidx.slice.Slice;
33 import androidx.slice.builders.ListBuilder;
34 import androidx.slice.builders.SliceAction;
35 
36 import com.android.internal.annotations.VisibleForTesting;
37 import com.android.settings.R;
38 import com.android.settings.SubSettings;
39 import com.android.settings.Utils;
40 import com.android.settings.bluetooth.AvailableMediaBluetoothDeviceUpdater;
41 import com.android.settings.bluetooth.BluetoothDeviceDetailsFragment;
42 import com.android.settings.bluetooth.BluetoothPairingDetail;
43 import com.android.settings.bluetooth.SavedBluetoothDeviceUpdater;
44 import com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment;
45 import com.android.settings.core.SubSettingLauncher;
46 import com.android.settings.slices.CustomSliceRegistry;
47 import com.android.settings.slices.CustomSliceable;
48 import com.android.settings.slices.SliceBroadcastReceiver;
49 import com.android.settings.slices.SliceBuilderUtils;
50 import com.android.settingslib.bluetooth.BluetoothUtils;
51 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
52 import com.android.settingslib.bluetooth.LocalBluetoothManager;
53 
54 import java.util.ArrayList;
55 import java.util.Collection;
56 import java.util.Comparator;
57 import java.util.List;
58 import java.util.stream.Collectors;
59 
60 public class BluetoothDevicesSlice implements CustomSliceable {
61 
62     @VisibleForTesting
63     static final String BLUETOOTH_DEVICE_HASH_CODE = "bluetooth_device_hash_code";
64 
65     @VisibleForTesting
66     static final int DEFAULT_EXPANDED_ROW_COUNT = 2;
67 
68     @VisibleForTesting
69     static final String EXTRA_ENABLE_BLUETOOTH = "enable_bluetooth";
70 
71     /**
72      * Refer {@link com.android.settings.bluetooth.BluetoothDevicePreference#compareTo} to sort the
73      * Bluetooth devices by {@link CachedBluetoothDevice}.
74      */
75     private static final Comparator<CachedBluetoothDevice> COMPARATOR = Comparator.naturalOrder();
76 
77     private static final String TAG = "BluetoothDevicesSlice";
78 
79     // For seamless UI transition after tapping this slice to enable Bluetooth, this flag is to
80     // update the layout promptly since it takes time for Bluetooth to reflect the enabling state.
81     private static boolean sBluetoothEnabling;
82 
83     private final Context mContext;
84     private AvailableMediaBluetoothDeviceUpdater mAvailableMediaBtDeviceUpdater;
85     private SavedBluetoothDeviceUpdater mSavedBtDeviceUpdater;
86 
BluetoothDevicesSlice(Context context)87     public BluetoothDevicesSlice(Context context) {
88         mContext = context;
89         BluetoothUpdateWorker.initLocalBtManager(context);
90     }
91 
92     @Override
getUri()93     public Uri getUri() {
94         return CustomSliceRegistry.BLUETOOTH_DEVICES_SLICE_URI;
95     }
96 
97     @Override
getSlice()98     public Slice getSlice() {
99         final BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
100         if (btAdapter == null) {
101             Log.i(TAG, "Bluetooth is not supported on this hardware platform");
102             return null;
103         }
104 
105         final ListBuilder listBuilder = new ListBuilder(mContext, getUri(), ListBuilder.INFINITY)
106                 .setAccentColor(COLOR_NOT_TINTED);
107 
108         // Only show this header when Bluetooth is off and not turning on.
109         if (!isBluetoothEnabled(btAdapter) && !sBluetoothEnabling) {
110             return listBuilder.addRow(getBluetoothOffHeader()).build();
111         }
112 
113         // Always reset this flag when showing the layout of Bluetooth on
114         sBluetoothEnabling = false;
115 
116         // Add the header of Bluetooth on
117         listBuilder.addRow(getBluetoothOnHeader());
118 
119         // Add row builders of Bluetooth devices.
120         getBluetoothRowBuilders().forEach(row -> listBuilder.addRow(row));
121 
122         return listBuilder.build();
123     }
124 
125     @Override
getIntent()126     public Intent getIntent() {
127         final String screenTitle = mContext.getText(R.string.connected_devices_dashboard_title)
128                 .toString();
129 
130         return SliceBuilderUtils.buildSearchResultPageIntent(mContext,
131                 ConnectedDeviceDashboardFragment.class.getName(), "" /* key */,
132                 screenTitle,
133                 SettingsEnums.SLICE,
134                 this)
135                 .setClassName(mContext.getPackageName(), SubSettings.class.getName())
136                 .setData(getUri());
137     }
138 
139     @Override
getSliceHighlightMenuRes()140     public int getSliceHighlightMenuRes() {
141         return R.string.menu_key_connected_devices;
142     }
143 
144     @Override
onNotifyChange(Intent intent)145     public void onNotifyChange(Intent intent) {
146         final boolean enableBluetooth = intent.getBooleanExtra(EXTRA_ENABLE_BLUETOOTH, false);
147         if (enableBluetooth) {
148             final BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
149             if (!isBluetoothEnabled(btAdapter)) {
150                 sBluetoothEnabling = true;
151                 btAdapter.enable();
152                 mContext.getContentResolver().notifyChange(getUri(), null);
153             }
154             return;
155         }
156 
157         final int bluetoothDeviceHashCode = intent.getIntExtra(BLUETOOTH_DEVICE_HASH_CODE, -1);
158         for (CachedBluetoothDevice device : getPairedBluetoothDevices()) {
159             if (device.hashCode() == bluetoothDeviceHashCode) {
160                 if (device.isConnected()) {
161                     device.setActive();
162                 } else if (!device.isBusy()) {
163                     device.connect();
164                 }
165                 return;
166             }
167         }
168     }
169 
170     @Override
getBackgroundWorkerClass()171     public Class getBackgroundWorkerClass() {
172         return BluetoothUpdateWorker.class;
173     }
174 
175     @VisibleForTesting
getPairedBluetoothDevices()176     List<CachedBluetoothDevice> getPairedBluetoothDevices() {
177         final List<CachedBluetoothDevice> bluetoothDeviceList = new ArrayList<>();
178 
179         // If Bluetooth is disabled, skip getting the Bluetooth devices.
180         if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) {
181             Log.i(TAG, "Cannot get Bluetooth devices, Bluetooth is disabled.");
182             return bluetoothDeviceList;
183         }
184 
185         final LocalBluetoothManager localBtManager = BluetoothUpdateWorker.getLocalBtManager();
186         if (localBtManager == null) {
187             Log.i(TAG, "Cannot get Bluetooth devices, Bluetooth is not ready.");
188             return bluetoothDeviceList;
189         }
190 
191         final Collection<CachedBluetoothDevice> cachedDevices =
192                 localBtManager.getCachedDeviceManager().getCachedDevicesCopy();
193 
194         // Get all paired devices and sort them.
195         return cachedDevices.stream()
196                 .filter(device -> device.getDevice().getBondState() == BluetoothDevice.BOND_BONDED)
197                 .sorted(COMPARATOR).collect(Collectors.toList());
198     }
199 
200     @VisibleForTesting
getBluetoothDetailIntent(CachedBluetoothDevice device)201     PendingIntent getBluetoothDetailIntent(CachedBluetoothDevice device) {
202         final Bundle args = new Bundle();
203         args.putString(BluetoothDeviceDetailsFragment.KEY_DEVICE_ADDRESS,
204                 device.getDevice().getAddress());
205         final SubSettingLauncher subSettingLauncher = new SubSettingLauncher(mContext);
206         subSettingLauncher.setDestination(BluetoothDeviceDetailsFragment.class.getName())
207                 .setArguments(args)
208                 .setTitleRes(R.string.device_details_title)
209                 .setSourceMetricsCategory(SettingsEnums.BLUETOOTH_DEVICE_DETAILS);
210 
211         // The requestCode should be unique, use the hashcode of device as request code.
212         return PendingIntent
213                 .getActivity(mContext, device.hashCode() /* requestCode */,
214                         subSettingLauncher.toIntent(),
215                         PendingIntent.FLAG_IMMUTABLE);
216     }
217 
218     @VisibleForTesting
getBluetoothDeviceIcon(CachedBluetoothDevice device)219     IconCompat getBluetoothDeviceIcon(CachedBluetoothDevice device) {
220         final Pair<Drawable, String> pair =
221                 BluetoothUtils.getBtRainbowDrawableWithDescription(mContext, device);
222         final Drawable drawable = pair.first;
223 
224         // Use default Bluetooth icon if we can't get one.
225         if (drawable == null) {
226             return IconCompat.createWithResource(mContext,
227                     com.android.internal.R.drawable.ic_settings_bluetooth);
228         }
229 
230         return Utils.createIconWithDrawable(drawable);
231     }
232 
getBluetoothOffHeader()233     private ListBuilder.RowBuilder getBluetoothOffHeader() {
234         final Drawable drawable = mContext.getDrawable(R.drawable.ic_bluetooth_disabled);
235         final int tint = Utils.getDisabled(mContext, Utils.getColorAttrDefaultColor(mContext,
236                 android.R.attr.colorControlNormal));
237         drawable.setTint(tint);
238         final IconCompat icon = Utils.createIconWithDrawable(drawable);
239         final CharSequence title = mContext.getText(R.string.bluetooth_devices_card_off_title);
240         final CharSequence summary = mContext.getText(R.string.bluetooth_devices_card_off_summary);
241         final Intent intent = new Intent(getUri().toString())
242                 .setClass(mContext, SliceBroadcastReceiver.class)
243                 .putExtra(EXTRA_ENABLE_BLUETOOTH, true);
244         final SliceAction action = SliceAction.create(PendingIntent.getBroadcast(mContext,
245                 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE), icon,
246                 ListBuilder.ICON_IMAGE, title);
247 
248         return new ListBuilder.RowBuilder()
249                 .setTitleItem(icon, ListBuilder.ICON_IMAGE)
250                 .setTitle(title)
251                 .setSubtitle(summary)
252                 .setPrimaryAction(action);
253     }
254 
getBluetoothOnHeader()255     private ListBuilder.RowBuilder getBluetoothOnHeader() {
256         final Drawable drawable = mContext.getDrawable(
257                 com.android.internal.R.drawable.ic_settings_bluetooth);
258         drawable.setTint(Utils.getColorAccentDefaultColor(mContext));
259         final IconCompat icon = Utils.createIconWithDrawable(drawable);
260         final CharSequence title = mContext.getText(R.string.bluetooth_devices);
261         final PendingIntent primaryActionIntent = PendingIntent.getActivity(mContext,
262                 0 /* requestCode */, getIntent(), PendingIntent.FLAG_IMMUTABLE);
263         final SliceAction primarySliceAction = SliceAction.createDeeplink(primaryActionIntent, icon,
264                 ListBuilder.ICON_IMAGE, title);
265 
266         return new ListBuilder.RowBuilder()
267                 .setTitleItem(icon, ListBuilder.ICON_IMAGE)
268                 .setTitle(title)
269                 .setPrimaryAction(primarySliceAction)
270                 .addEndItem(getPairNewDeviceAction());
271     }
272 
getPairNewDeviceAction()273     private SliceAction getPairNewDeviceAction() {
274         final Drawable drawable = mContext.getDrawable(R.drawable.ic_add_24dp);
275         drawable.setTint(Utils.getColorAccentDefaultColor(mContext));
276         final IconCompat icon = Utils.createIconWithDrawable(drawable);
277         final String title = mContext.getString(R.string.bluetooth_pairing_pref_title);
278         final Intent intent = new SubSettingLauncher(mContext)
279                 .setDestination(BluetoothPairingDetail.class.getName())
280                 .setTitleRes(R.string.bluetooth_pairing_page_title)
281                 .setSourceMetricsCategory(SettingsEnums.BLUETOOTH_PAIRING)
282                 .toIntent();
283         final PendingIntent pi = PendingIntent.getActivity(mContext, intent.hashCode(), intent,
284                 PendingIntent.FLAG_IMMUTABLE);
285         return SliceAction.createDeeplink(pi, icon, ListBuilder.ICON_IMAGE, title);
286     }
287 
getBluetoothRowBuilders()288     private List<ListBuilder.RowBuilder> getBluetoothRowBuilders() {
289         final List<ListBuilder.RowBuilder> bluetoothRows = new ArrayList<>();
290         final List<CachedBluetoothDevice> pairedDevices = getPairedBluetoothDevices();
291         if (pairedDevices.isEmpty()) {
292             return bluetoothRows;
293         }
294 
295         // Initialize updaters without being blocked after paired devices is available because
296         // LocalBluetoothManager is ready.
297         lazyInitUpdaters();
298 
299         // Create row builders based on paired devices.
300         for (CachedBluetoothDevice device : pairedDevices) {
301             if (bluetoothRows.size() >= DEFAULT_EXPANDED_ROW_COUNT) {
302                 break;
303             }
304 
305             String summary = device.getConnectionSummary();
306             if (summary == null) {
307                 summary = mContext.getString(
308                         R.string.connected_device_previously_connected_screen_title);
309             }
310             final ListBuilder.RowBuilder rowBuilder = new ListBuilder.RowBuilder()
311                     .setTitleItem(getBluetoothDeviceIcon(device), ListBuilder.ICON_IMAGE)
312                     .setTitle(device.getName())
313                     .setSubtitle(summary);
314 
315             if (mAvailableMediaBtDeviceUpdater.isFilterMatched(device)
316                     || mSavedBtDeviceUpdater.isFilterMatched(device)) {
317                 // For all available media devices and previously connected devices, the primary
318                 // action is to activate or connect, and the end gear icon links to detail page.
319                 rowBuilder.setPrimaryAction(buildPrimaryBluetoothAction(device));
320                 rowBuilder.addEndItem(buildBluetoothDetailDeepLinkAction(device));
321             } else {
322                 // For other devices, the primary action is to link to detail page.
323                 rowBuilder.setPrimaryAction(buildBluetoothDetailDeepLinkAction(device));
324             }
325 
326             bluetoothRows.add(rowBuilder);
327         }
328 
329         return bluetoothRows;
330     }
331 
lazyInitUpdaters()332     private void lazyInitUpdaters() {
333         if (mAvailableMediaBtDeviceUpdater == null) {
334             mAvailableMediaBtDeviceUpdater = new AvailableMediaBluetoothDeviceUpdater(mContext,
335                     /* devicePreferenceCallback= */ null, /* metricsCategory= */ 0);
336         }
337 
338         if (mSavedBtDeviceUpdater == null) {
339             mSavedBtDeviceUpdater = new SavedBluetoothDeviceUpdater(mContext,
340                     /* devicePreferenceCallback= */ null, /* showConnectedDevice= */
341                     false, /* metricsCategory= */ 0);
342         }
343     }
344 
345     @VisibleForTesting
buildPrimaryBluetoothAction(CachedBluetoothDevice bluetoothDevice)346     SliceAction buildPrimaryBluetoothAction(CachedBluetoothDevice bluetoothDevice) {
347         final Intent intent = new Intent(getUri().toString())
348                 .setClass(mContext, SliceBroadcastReceiver.class)
349                 .putExtra(BLUETOOTH_DEVICE_HASH_CODE, bluetoothDevice.hashCode());
350 
351         return SliceAction.create(
352                 PendingIntent.getBroadcast(mContext, bluetoothDevice.hashCode(), intent,
353                         PendingIntent.FLAG_IMMUTABLE),
354                 getBluetoothDeviceIcon(bluetoothDevice),
355                 ListBuilder.ICON_IMAGE,
356                 bluetoothDevice.getName());
357     }
358 
359     @VisibleForTesting
buildBluetoothDetailDeepLinkAction(CachedBluetoothDevice bluetoothDevice)360     SliceAction buildBluetoothDetailDeepLinkAction(CachedBluetoothDevice bluetoothDevice) {
361         return SliceAction.createDeeplink(
362                 getBluetoothDetailIntent(bluetoothDevice),
363                 IconCompat.createWithResource(mContext, R.drawable.ic_settings_accent),
364                 ListBuilder.ICON_IMAGE,
365                 bluetoothDevice.getName());
366     }
367 
isBluetoothEnabled(BluetoothAdapter btAdapter)368     private boolean isBluetoothEnabled(BluetoothAdapter btAdapter) {
369         switch (btAdapter.getState()) {
370             case BluetoothAdapter.STATE_ON:
371             case BluetoothAdapter.STATE_TURNING_ON:
372                 return true;
373             default:
374                 return false;
375         }
376     }
377 }
378