Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f61bc19f authored by Yanting Yang's avatar Yanting Yang
Browse files

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
parent 416ff0ab
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -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>
+7 −0
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import com.android.settings.homepage.contextualcards.deviceinfo.DataUsageSlice;
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 @@ public class SettingsContextualCardProvider extends ContextualCardProvider {
                        .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 @@ public class SettingsContextualCardProvider extends ContextualCardProvider {
                .addCard(storageInfoCard)
                .addCard(emergencyInfoCard)
                .addCard(batteryInfoCard)
                .addCard(connectedDeviceCard)
                .build();

        return cards;
+286 −0
Original line number Diff line number Diff line
/*
 * 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
+2 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import com.android.settings.homepage.contextualcards.deviceinfo.BatterySlice;
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 @@ public class CustomSliceManager {
        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);
    }
}
+99 −0
Original line number Diff line number Diff line
/*
 * 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