Add Connected Device slice to Contextual Settings Homepage

- Support Bluetooth device information.
- Not yet integrate slice background worker.

Bug: 114807655
Test: robotests, visual
Change-Id: I23f902137b0468349ee627bed6a394d42ea4a00d
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f18ec41..f2661bd 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -10264,4 +10264,12 @@
     <string name="see_more">See more</string>
     <!-- See less items in contextual homepage [CHAR LIMIT=30]-->
     <string name="see_less">See less</string>
+
+    <!-- Summary for connected devices count in connected device slice. [CHAR LIMIT=NONE] -->
+    <plurals name="show_connected_devices">
+        <item quantity="one"><xliff:g id="number_device_count">%1$d</xliff:g> device connected</item>
+        <item quantity="other"><xliff:g id="number_device_count">%1$d</xliff:g> devices connected</item>
+    </plurals>
+    <!-- Title for no connected devices in connected device slice. [CHAR LIMIT=NONE] -->
+    <string name="no_connected_devices">No connected devices</string>
 </resources>
diff --git a/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java b/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
index 572c36d..36c0a11 100644
--- a/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
+++ b/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
@@ -25,6 +25,7 @@
 import com.android.settings.homepage.contextualcards.deviceinfo.DeviceInfoSlice;
 import com.android.settings.homepage.contextualcards.deviceinfo.EmergencyInfoSlice;
 import com.android.settings.homepage.contextualcards.deviceinfo.StorageSlice;
+import com.android.settings.homepage.contextualcards.slices.ConnectedDeviceSlice;
 import com.android.settings.intelligence.ContextualCardProto.ContextualCard;
 import com.android.settings.intelligence.ContextualCardProto.ContextualCardList;
 import com.android.settings.wifi.WifiSlice;
@@ -69,6 +70,11 @@
                         .setSliceUri(BatterySlice.BATTERY_CARD_URI.toSafeString())
                         .setCardName(BatterySlice.PATH_BATTERY_INFO)
                         .build();
+        final ContextualCard connectedDeviceCard =
+                ContextualCard.newBuilder()
+                        .setSliceUri(ConnectedDeviceSlice.CONNECTED_DEVICE_URI.toString())
+                        .setCardName(ConnectedDeviceSlice.PATH_CONNECTED_DEVICE)
+                        .build();
         final ContextualCardList cards = ContextualCardList.newBuilder()
                 .addCard(wifiCard)
                 .addCard(dataUsageCard)
@@ -76,6 +82,7 @@
                 .addCard(storageInfoCard)
                 .addCard(emergencyInfoCard)
                 .addCard(batteryInfoCard)
+                .addCard(connectedDeviceCard)
                 .build();
 
         return cards;
diff --git a/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSlice.java b/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSlice.java
new file mode 100644
index 0000000..83a6af5
--- /dev/null
+++ b/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSlice.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.homepage.contextualcards.slices;
+
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.slice.Slice;
+import androidx.slice.builders.ListBuilder;
+import androidx.slice.builders.SliceAction;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.logging.nano.MetricsProto;
+import com.android.settings.R;
+import com.android.settings.SubSettings;
+import com.android.settings.Utils;
+import com.android.settings.bluetooth.BluetoothDeviceDetailsFragment;
+import com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.slices.CustomSliceable;
+import com.android.settings.slices.SettingsSliceProvider;
+import com.android.settings.slices.SliceBuilderUtils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.core.instrumentation.Instrumentable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * TODO(b/114807655): Contextual Home Page - Connected Device
+ *
+ * Show connected device info if one is currently connected. UI for connected device should
+ * match Connected Devices > Currently Connected Devices
+ *
+ * This Slice will show multiple currently connected devices, which includes:
+ * 1) Bluetooth.
+ * 2) Docks.
+ * ...
+ * TODO Other device types are under checking to support, will update later.
+ */
+public class ConnectedDeviceSlice implements CustomSliceable {
+
+    /**
+     * The path denotes the unique name of Connected device Slice.
+     */
+    public static final String PATH_CONNECTED_DEVICE = "connected_device";
+
+    /**
+     * Backing Uri for Connected device Slice.
+     */
+    public static final Uri CONNECTED_DEVICE_URI = new Uri.Builder()
+            .scheme(ContentResolver.SCHEME_CONTENT)
+            .authority(SettingsSliceProvider.SLICE_AUTHORITY)
+            .appendPath(PATH_CONNECTED_DEVICE)
+            .build();
+
+    /**
+     * To sort the Bluetooth devices by {@link CachedBluetoothDevice}.
+     * Refer compareTo method from {@link com.android.settings.bluetooth.BluetoothDevicePreference}.
+     */
+    private static final Comparator<CachedBluetoothDevice> COMPARATOR
+            = Comparator.naturalOrder();
+
+    private static final int DEFAULT_EXPANDED_ROW_COUNT = 4;
+
+    private static final String TAG = "ConnectedDeviceSlice";
+
+    private final Context mContext;
+
+    public ConnectedDeviceSlice(Context context) {
+        mContext = context;
+    }
+
+    private static Bitmap getBitmapFromVectorDrawable(Drawable VectorDrawable) {
+        final Bitmap bitmap = Bitmap.createBitmap(VectorDrawable.getIntrinsicWidth(),
+                VectorDrawable.getIntrinsicHeight(), Config.ARGB_8888);
+        final Canvas canvas = new Canvas(bitmap);
+
+        VectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+        VectorDrawable.draw(canvas);
+
+        return bitmap;
+    }
+
+    @Override
+    public Uri getUri() {
+        return CONNECTED_DEVICE_URI;
+    }
+
+    /**
+     * Return a Connected Device Slice bound to {@link #CONNECTED_DEVICE_URI}.
+     */
+    @Override
+    public Slice getSlice() {
+        final IconCompat icon = IconCompat.createWithResource(mContext,
+                R.drawable.ic_homepage_connected_device);
+        final CharSequence title = mContext.getText(R.string.connected_devices_dashboard_title);
+        final CharSequence titleNoConnectedDevices = mContext.getText(
+                R.string.no_connected_devices);
+        final PendingIntent primaryActionIntent = PendingIntent.getActivity(mContext, 0,
+                getIntent(), 0);
+        final SliceAction primarySliceAction = new SliceAction(primaryActionIntent, icon,
+                title);
+        final ListBuilder listBuilder =
+                new ListBuilder(mContext, CONNECTED_DEVICE_URI, ListBuilder.INFINITY)
+                        .setAccentColor(Utils.getColorAccentDefaultColor(mContext));
+
+        // Get row builders by connected devices, e.g. Bluetooth.
+        // TODO Add other type connected devices, e.g. Docks.
+        final List<ListBuilder.RowBuilder> rows = getBluetoothRowBuilder(primarySliceAction);
+
+        // Return a header with IsError flag, if no connected devices.
+        if (rows.isEmpty()) {
+            return listBuilder.setHeader(new ListBuilder.HeaderBuilder()
+                    .setTitle(titleNoConnectedDevices)
+                    .setPrimaryAction(primarySliceAction))
+                    .setIsError(true)
+                    .build();
+        }
+
+        // According the number of connected devices to set sub title of header.
+        listBuilder.setHeader(new ListBuilder.HeaderBuilder()
+                .setTitle(title)
+                .setSubtitle(getSubTitle(rows.size()))
+                .setPrimaryAction(primarySliceAction));
+
+        // Add rows.
+        for (ListBuilder.RowBuilder rowBuilder : rows) {
+            listBuilder.addRow(rowBuilder);
+        }
+
+        // Only show "see more" button when the number of data row is more than or equal to 4.
+        // TODO(b/118465996): SHOW MORE button won't work properly when having two data rows
+        if (rows.size() >= DEFAULT_EXPANDED_ROW_COUNT) {
+            listBuilder.setSeeMoreAction(primaryActionIntent);
+        }
+
+        return listBuilder.build();
+    }
+
+    @Override
+    public Intent getIntent() {
+        final String screenTitle = mContext.getText(R.string.connected_devices_dashboard_title)
+                .toString();
+        final Uri contentUri = new Uri.Builder().appendPath(PATH_CONNECTED_DEVICE).build();
+
+        return SliceBuilderUtils.buildSearchResultPageIntent(mContext,
+                ConnectedDeviceDashboardFragment.class.getName(), PATH_CONNECTED_DEVICE,
+                screenTitle,
+                MetricsProto.MetricsEvent.SLICE)
+                .setClassName(mContext.getPackageName(), SubSettings.class.getName())
+                .setData(contentUri);
+    }
+
+    @Override
+    public void onNotifyChange(Intent intent) {
+    }
+
+    @VisibleForTesting
+    List<CachedBluetoothDevice> getBluetoothConnectedDevices() {
+        final List<CachedBluetoothDevice> connectedBluetoothList = new ArrayList<>();
+
+        // If Bluetooth is disable, skip to get the bluetooth devices.
+        if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) {
+            Log.d(TAG, "Cannot get Bluetooth connected devices, Bluetooth is disabled.");
+            return connectedBluetoothList;
+        }
+
+        // Get the Bluetooth devices from LocalBluetoothManager.
+        final LocalBluetoothManager bluetoothManager =
+                com.android.settings.bluetooth.Utils.getLocalBtManager(mContext);
+        if (bluetoothManager == null) {
+            Log.d(TAG, "Cannot get Bluetooth connected devices, Bluetooth is not supported.");
+            return connectedBluetoothList;
+        }
+        final Collection<CachedBluetoothDevice> cachedDevices =
+                bluetoothManager.getCachedDeviceManager().getCachedDevicesCopy();
+
+        // Get all connected Bluetooth devices and use Map to filter duplicated Bluetooth.
+        final Map<BluetoothDevice, CachedBluetoothDevice> connectedBluetoothMap = new ArrayMap<>();
+        for (CachedBluetoothDevice device : cachedDevices) {
+            if (device.isConnected() && !connectedBluetoothMap.containsKey(device.getDevice())) {
+                connectedBluetoothMap.put(device.getDevice(), device);
+            }
+        }
+
+        // Sort connected Bluetooth devices.
+        connectedBluetoothList.addAll(connectedBluetoothMap.values());
+        Collections.sort(connectedBluetoothList, COMPARATOR);
+
+        return connectedBluetoothList;
+    }
+
+    @VisibleForTesting
+    PendingIntent getBluetoothDetailIntent(CachedBluetoothDevice device) {
+        final Bundle args = new Bundle();
+        args.putString(BluetoothDeviceDetailsFragment.KEY_DEVICE_ADDRESS,
+                device.getDevice().getAddress());
+        final SubSettingLauncher subSettingLauncher = new SubSettingLauncher(mContext);
+        subSettingLauncher.setDestination(BluetoothDeviceDetailsFragment.class.getName())
+                .setArguments(args)
+                .setTitleRes(R.string.device_details_title)
+                .setSourceMetricsCategory(Instrumentable.METRICS_CATEGORY_UNKNOWN);
+
+        // The requestCode should be unique, use the hashcode of device as request code.
+        return PendingIntent
+                .getActivity(mContext, device.hashCode()  /* requestCode */,
+                        subSettingLauncher.toIntent(),
+                        0  /* flags */);
+    }
+
+    @VisibleForTesting
+    IconCompat getConnectedDeviceIcon(CachedBluetoothDevice device) {
+        final Pair<Drawable, String> pair = BluetoothUtils
+                .getBtClassDrawableWithDescription(mContext, device);
+
+        if (pair.first != null) {
+            return IconCompat.createWithBitmap(getBitmapFromVectorDrawable(pair.first));
+        } else {
+            return IconCompat.createWithResource(mContext, R.drawable.ic_homepage_connected_device);
+        }
+    }
+
+    private List<ListBuilder.RowBuilder> getBluetoothRowBuilder(SliceAction primarySliceAction) {
+        final List<ListBuilder.RowBuilder> bluetoothRows = new ArrayList<>();
+
+        // According Bluetooth connected device to create row builders.
+        final List<CachedBluetoothDevice> bluetoothDevices = getBluetoothConnectedDevices();
+        for (CachedBluetoothDevice bluetoothDevice : bluetoothDevices) {
+            bluetoothRows.add(new ListBuilder.RowBuilder()
+                    .setTitleItem(getConnectedDeviceIcon(bluetoothDevice), ListBuilder.ICON_IMAGE)
+                    .setTitle(bluetoothDevice.getName())
+                    .setSubtitle(bluetoothDevice.getConnectionSummary())
+                    .setPrimaryAction(primarySliceAction)
+                    .addEndItem(buildBluetoothDetailDeepLinkAction(bluetoothDevice)));
+        }
+
+        return bluetoothRows;
+    }
+
+    private SliceAction buildBluetoothDetailDeepLinkAction(CachedBluetoothDevice bluetoothDevice) {
+        return new SliceAction(
+                getBluetoothDetailIntent(bluetoothDevice),
+                IconCompat.createWithResource(mContext, R.drawable.ic_settings),
+                bluetoothDevice.getName());
+    }
+
+    private CharSequence getSubTitle(int deviceCount) {
+        return mContext.getResources().getQuantityString(R.plurals.show_connected_devices,
+                deviceCount, deviceCount);
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/settings/slices/CustomSliceManager.java b/src/com/android/settings/slices/CustomSliceManager.java
index 4658d2a..556c698 100644
--- a/src/com/android/settings/slices/CustomSliceManager.java
+++ b/src/com/android/settings/slices/CustomSliceManager.java
@@ -24,6 +24,7 @@
 import com.android.settings.homepage.contextualcards.deviceinfo.DataUsageSlice;
 import com.android.settings.homepage.contextualcards.deviceinfo.DeviceInfoSlice;
 import com.android.settings.homepage.contextualcards.deviceinfo.StorageSlice;
+import com.android.settings.homepage.contextualcards.slices.ConnectedDeviceSlice;
 import com.android.settings.wifi.WifiSlice;
 
 import java.util.Map;
@@ -103,5 +104,6 @@
         mUriMap.put(DeviceInfoSlice.DEVICE_INFO_CARD_URI, DeviceInfoSlice.class);
         mUriMap.put(StorageSlice.STORAGE_CARD_URI, StorageSlice.class);
         mUriMap.put(BatterySlice.BATTERY_CARD_URI, BatterySlice.class);
+        mUriMap.put(ConnectedDeviceSlice.CONNECTED_DEVICE_URI, ConnectedDeviceSlice.class);
     }
 }
diff --git a/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSliceTest.java b/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSliceTest.java
new file mode 100644
index 0000000..23da127
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/ConnectedDeviceSliceTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.homepage.contextualcards.slices;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.slice.Slice;
+import androidx.slice.SliceItem;
+import androidx.slice.SliceProvider;
+import androidx.slice.widget.SliceLiveData;
+
+import com.android.settings.R;
+import com.android.settings.testutils.SettingsRobolectricTestRunner;
+import com.android.settings.testutils.SliceTester;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(SettingsRobolectricTestRunner.class)
+public class ConnectedDeviceSliceTest {
+
+    @Mock
+    private CachedBluetoothDevice mCachedBluetoothDevice;
+
+    private List<CachedBluetoothDevice> mCachedDevices = new ArrayList<CachedBluetoothDevice>();
+    private Context mContext;
+    private ConnectedDeviceSlice mConnectedDeviceSlice;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mContext = RuntimeEnvironment.application;
+
+        // Set-up specs for SliceMetadata.
+        SliceProvider.setSpecs(SliceLiveData.SUPPORTED_SPECS);
+
+        mConnectedDeviceSlice = spy(new ConnectedDeviceSlice(mContext));
+    }
+
+    @Test
+    public void getSlice_hasConnectedDevices_shouldBeCorrectSliceContent() {
+        final String title = "BluetoothTitle";
+        final String summary = "BluetoothSummary";
+        final IconCompat icon = IconCompat.createWithResource(mContext,
+                R.drawable.ic_homepage_connected_device);
+        final PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0,
+                new Intent("test action"), 0);
+        doReturn(title).when(mCachedBluetoothDevice).getName();
+        doReturn(summary).when(mCachedBluetoothDevice).getConnectionSummary();
+        mCachedDevices.add(mCachedBluetoothDevice);
+        doReturn(mCachedDevices).when(mConnectedDeviceSlice).getBluetoothConnectedDevices();
+        doReturn(icon).when(mConnectedDeviceSlice).getConnectedDeviceIcon(any());
+        doReturn(pendingIntent).when(mConnectedDeviceSlice).getBluetoothDetailIntent(any());
+        final Slice slice = mConnectedDeviceSlice.getSlice();
+
+        final List<SliceItem> sliceItems = slice.getItems();
+        SliceTester.assertTitle(sliceItems, title);
+    }
+
+    @Test
+    public void getSlice_hasNoConnectedDevices_shouldReturnCorrectHeader() {
+        final List<CachedBluetoothDevice> connectedBluetoothList = new ArrayList<>();
+        doReturn(connectedBluetoothList).when(mConnectedDeviceSlice).getBluetoothConnectedDevices();
+        final Slice slice = mConnectedDeviceSlice.getSlice();
+
+        final List<SliceItem> sliceItems = slice.getItems();
+        SliceTester.assertTitle(sliceItems, mContext.getString(R.string.no_connected_devices));
+    }
+}
\ No newline at end of file