diff --git a/res/values/strings.xml b/res/values/strings.xml index f18ec41e4c3..f2661bda11b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -10264,4 +10264,12 @@ See more See less + + + + %1$d device connected + %1$d devices connected + + + No connected devices diff --git a/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java b/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java index 572c36dc15b..36c0a118023 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.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; 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 00000000000..83a6af5d972 --- /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 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 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 getBluetoothConnectedDevices() { + final List 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 cachedDevices = + bluetoothManager.getCachedDeviceManager().getCachedDevicesCopy(); + + // Get all connected Bluetooth devices and use Map to filter duplicated Bluetooth. + final Map 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 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 getBluetoothRowBuilder(SliceAction primarySliceAction) { + final List bluetoothRows = new ArrayList<>(); + + // According Bluetooth connected device to create row builders. + final List 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 4658d2a0b98..556c69817b3 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.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); } } 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 00000000000..23da1272b5c --- /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 mCachedDevices = new ArrayList(); + 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 sliceItems = slice.getItems(); + SliceTester.assertTitle(sliceItems, title); + } + + @Test + public void getSlice_hasNoConnectedDevices_shouldReturnCorrectHeader() { + final List connectedBluetoothList = new ArrayList<>(); + doReturn(connectedBluetoothList).when(mConnectedDeviceSlice).getBluetoothConnectedDevices(); + final Slice slice = mConnectedDeviceSlice.getSlice(); + + final List sliceItems = slice.getItems(); + SliceTester.assertTitle(sliceItems, mContext.getString(R.string.no_connected_devices)); + } +} \ No newline at end of file