diff --git a/AndroidManifest.xml b/AndroidManifest.xml index ef382409e6b..12075a2a1df 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -3202,6 +3202,10 @@ + + + + mediaDevices, - boolean selected) { + private void addRow(ListBuilder listBuilder, List mediaDevices, boolean selected) { for (MediaDevice device : mediaDevices) { final int maxVolume = device.getMaxVolume(); final IconCompat titleIcon = Utils.createIconWithDrawable(device.getIcon()); diff --git a/src/com/android/settings/media/MediaOutputSlice.java b/src/com/android/settings/media/MediaOutputSlice.java index eff838decc2..026b8a883c3 100644 --- a/src/com/android/settings/media/MediaOutputSlice.java +++ b/src/com/android/settings/media/MediaOutputSlice.java @@ -44,6 +44,7 @@ import com.android.settings.slices.CustomSliceable; import com.android.settings.slices.SliceBackgroundWorker; import com.android.settings.slices.SliceBroadcastReceiver; import com.android.settingslib.media.MediaDevice; +import com.android.settingslib.media.MediaOutputSliceConstants; import java.util.Collection; @@ -54,6 +55,8 @@ public class MediaOutputSlice implements CustomSliceable { private static final String TAG = "MediaOutputSlice"; private static final String MEDIA_DEVICE_ID = "media_device_id"; + private static final String MEDIA_GROUP_DEVICE = "media_group_device"; + private static final String MEDIA_GROUP_REQUEST = "media_group_request"; private static final int NON_SLIDER_VALUE = -1; public static final String MEDIA_PACKAGE_NAME = "media_package_name"; @@ -86,52 +89,94 @@ public class MediaOutputSlice implements CustomSliceable { final Collection devices = getMediaDevices(); final MediaDeviceUpdateWorker worker = getWorker(); - final MediaDevice connectedDevice = worker.getCurrentConnectedMediaDevice(); - final boolean isTouched = worker.getIsTouched(); - // Fix the last top device when user press device to transfer. - final MediaDevice topDevice = isTouched ? worker.getTopDevice() : connectedDevice; - if (topDevice != null) { - addRow(topDevice, connectedDevice, listBuilder); - worker.setTopDevice(topDevice); - } + if (worker.getSelectedMediaDevice().size() > 1) { + // Insert group item to the first when it is available + listBuilder.addInputRange(getGroupRow()); + // Add all other devices + for (MediaDevice device : devices) { + addRow(device, null /* connectedDevice */, listBuilder); + } + } else { + final MediaDevice connectedDevice = worker.getCurrentConnectedMediaDevice(); + final boolean isTouched = worker.getIsTouched(); + // Fix the last top device when user press device to transfer. + final MediaDevice topDevice = isTouched ? worker.getTopDevice() : connectedDevice; - for (MediaDevice device : devices) { - if (topDevice == null - || !TextUtils.equals(topDevice.getId(), device.getId())) { - addRow(device, connectedDevice, listBuilder); + if (topDevice != null) { + addRow(topDevice, connectedDevice, listBuilder); + worker.setTopDevice(topDevice); + } + + for (MediaDevice device : devices) { + if (topDevice == null || !TextUtils.equals(topDevice.getId(), device.getId())) { + addRow(device, connectedDevice, listBuilder); + } } } - return listBuilder.build(); } - private void addRow(MediaDevice device, MediaDevice connectedDevice, ListBuilder listBuilder) { - if (connectedDevice != null && TextUtils.equals(device.getId(), connectedDevice.getId())) { - listBuilder.addInputRange(getActiveDeviceHeaderRow(device)); - } else { - listBuilder.addRow(getMediaDeviceRow(device)); - } - } - - private ListBuilder.InputRangeBuilder getActiveDeviceHeaderRow(MediaDevice device) { - final String title = device.getName(); - final IconCompat icon = getDeviceIconCompat(device); - + private ListBuilder.InputRangeBuilder getGroupRow() { + final IconCompat icon = IconCompat.createWithResource(mContext, + R.drawable.ic_speaker_group_black_24dp); + final CharSequence sessionName = getWorker().getSessionName(); + final CharSequence title = TextUtils.isEmpty(sessionName) + ? mContext.getString(R.string.media_output_group) : sessionName; final PendingIntent broadcastAction = - getBroadcastIntent(mContext, device.getId(), device.hashCode()); + getBroadcastIntent(mContext, MEDIA_GROUP_DEVICE, MEDIA_GROUP_DEVICE.hashCode()); final SliceAction primarySliceAction = SliceAction.createDeeplink(broadcastAction, icon, ListBuilder.ICON_IMAGE, title); final ListBuilder.InputRangeBuilder builder = new ListBuilder.InputRangeBuilder() .setTitleItem(icon, ListBuilder.ICON_IMAGE) .setTitle(title) .setPrimaryAction(primarySliceAction) - .setInputAction(getSliderInputAction(device.hashCode(), device.getId())) - .setMax(device.getMaxVolume()) - .setValue(device.getCurrentVolume()); + .setInputAction(getSliderInputAction(MEDIA_GROUP_DEVICE.hashCode(), + MEDIA_GROUP_DEVICE)) + .setMax(getWorker().getSessionVolumeMax()) + .setValue(getWorker().getSessionVolume()) + .addEndItem(getEndItemSliceAction()); return builder; } + private void addRow(MediaDevice device, MediaDevice connectedDevice, ListBuilder listBuilder) { + if (connectedDevice != null && TextUtils.equals(device.getId(), connectedDevice.getId())) { + final String title = device.getName(); + final IconCompat icon = getDeviceIconCompat(device); + + final PendingIntent broadcastAction = + getBroadcastIntent(mContext, device.getId(), device.hashCode()); + final SliceAction primarySliceAction = SliceAction.createDeeplink(broadcastAction, icon, + ListBuilder.ICON_IMAGE, title); + + if (device.getMaxVolume() > 0) { + final ListBuilder.InputRangeBuilder builder = new ListBuilder.InputRangeBuilder() + .setTitleItem(icon, ListBuilder.ICON_IMAGE) + .setTitle(title) + .setPrimaryAction(primarySliceAction) + .setInputAction(getSliderInputAction(device.hashCode(), device.getId())) + .setMax(device.getMaxVolume()) + .setValue(device.getCurrentVolume()); + // Check end item visibility + if (device.getDeviceType() == MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE + && !getWorker().getSelectableMediaDevice().isEmpty()) { + builder.addEndItem(getEndItemSliceAction()); + } + listBuilder.addInputRange(builder); + } else { + final ListBuilder.RowBuilder builder = getMediaDeviceRow(device); + // Check end item visibility + if (device.getDeviceType() == MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE + && !getWorker().getSelectableMediaDevice().isEmpty()) { + builder.addEndItem(getEndItemSliceAction()); + } + listBuilder.addRow(builder); + } + } else { + listBuilder.addRow(getMediaDeviceRow(device)); + } + } + private PendingIntent getSliderInputAction(int requestCode, String id) { final Intent intent = new Intent(getUri().toString()) .setData(getUri()) @@ -141,6 +186,20 @@ public class MediaOutputSlice implements CustomSliceable { return PendingIntent.getBroadcast(mContext, requestCode, intent, 0); } + private SliceAction getEndItemSliceAction() { + final Intent intent = new Intent() + .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT_GROUP) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME, + getWorker().getPackageName()); + + return SliceAction.createDeeplink( + PendingIntent.getActivity(mContext, 0 /* requestCode */, intent, 0 /* flags */), + IconCompat.createWithResource(mContext, R.drawable.ic_add_blue_24dp), + ListBuilder.ICON_IMAGE, + mContext.getText(R.string.add)); + } + private IconCompat getDeviceIconCompat(MediaDevice device) { Drawable drawable = device.getIcon(); if (drawable == null) { @@ -169,14 +228,12 @@ public class MediaOutputSlice implements CustomSliceable { final PendingIntent broadcastAction = getBroadcastIntent(mContext, device.getId(), device.hashCode()); final IconCompat deviceIcon = getDeviceIconCompat(device); - final ListBuilder.RowBuilder rowBuilder = new ListBuilder.RowBuilder() - .setTitleItem(deviceIcon, ListBuilder.ICON_IMAGE) - .setPrimaryAction(SliceAction.create(broadcastAction, deviceIcon, - ListBuilder.ICON_IMAGE, deviceName)); - // Append status to tile only for the disconnected Bluetooth device. + .setTitleItem(deviceIcon, ListBuilder.ICON_IMAGE); + if (device.getDeviceType() == MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE && !device.isConnected()) { + // Append status to title only for the disconnected Bluetooth device. final SpannableString spannableTitle = new SpannableString( mContext.getString(R.string.media_output_disconnected_status, deviceName)); spannableTitle.setSpan(new ForegroundColorSpan(Color.GRAY), deviceName.length(), @@ -214,19 +271,27 @@ public class MediaOutputSlice implements CustomSliceable { if (TextUtils.isEmpty(id)) { return; } - final MediaDevice device = worker.getMediaDeviceById(id); - if (device == null) { - return; - } + final int newPosition = intent.getIntExtra(EXTRA_RANGE_VALUE, NON_SLIDER_VALUE); - if (newPosition == NON_SLIDER_VALUE) { - // Intent for device connection - Log.d(TAG, "onNotifyChange() device name : " + device.getName()); - worker.setIsTouched(true); - worker.connectDevice(device); + if (TextUtils.equals(id, MEDIA_GROUP_DEVICE)) { + // Session volume adjustment + worker.adjustSessionVolume(newPosition); } else { - // Intent for volume adjustment - worker.adjustVolume(device, newPosition); + final MediaDevice device = worker.getMediaDeviceById(id); + if (device == null) { + Log.d(TAG, "onNotifyChange: Unable to get device " + id); + return; + } + + if (newPosition == NON_SLIDER_VALUE) { + // Intent for device connection + Log.d(TAG, "onNotifyChange: Switch to " + device.getName()); + worker.setIsTouched(true); + worker.connectDevice(device); + } else { + // Single device volume adjustment + worker.adjustVolume(device, newPosition); + } } } diff --git a/src/com/android/settings/panel/PanelFeatureProviderImpl.java b/src/com/android/settings/panel/PanelFeatureProviderImpl.java index 04d3095cc38..93c602512f5 100644 --- a/src/com/android/settings/panel/PanelFeatureProviderImpl.java +++ b/src/com/android/settings/panel/PanelFeatureProviderImpl.java @@ -17,6 +17,7 @@ package com.android.settings.panel; import static com.android.settingslib.media.MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT; +import static com.android.settingslib.media.MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT_GROUP; import android.content.Context; import android.os.Bundle; @@ -46,6 +47,8 @@ public class PanelFeatureProviderImpl implements PanelFeatureProvider { return WifiPanel.create(context); case Settings.Panel.ACTION_VOLUME: return VolumePanel.create(context); + case ACTION_MEDIA_OUTPUT_GROUP: + return MediaOutputGroupPanel.create(context, mediaPackageName); } throw new IllegalStateException("No matching panel for: " + panelType); diff --git a/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java b/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java index c01c9b09549..03d85b2c8b8 100644 --- a/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java +++ b/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java @@ -36,6 +36,7 @@ import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; import android.media.AudioManager; +import android.text.TextUtils; import androidx.slice.Slice; import androidx.slice.SliceMetadata; @@ -67,7 +68,9 @@ import java.util.List; public class MediaOutputSliceTest { private static final String TEST_DEVICE_1_ID = "test_device_1_id"; + private static final String TEST_DEVICE_2_ID = "test_device_2_id"; private static final String TEST_DEVICE_1_NAME = "test_device_1_name"; + private static final String TEST_DEVICE_2_NAME = "test_device_2_name"; private static final int TEST_DEVICE_1_ICON = com.android.internal.R.drawable.ic_bt_headphones_a2dp; @@ -98,7 +101,8 @@ public class MediaOutputSliceTest { mShadowBluetoothAdapter.setEnabled(true); mMediaOutputSlice = new MediaOutputSlice(mContext); - mMediaDeviceUpdateWorker = new MediaDeviceUpdateWorker(mContext, MEDIA_OUTPUT_SLICE_URI); + mMediaDeviceUpdateWorker = new MediaDeviceUpdateWorker(mContext, + MEDIA_OUTPUT_SLICE_URI); mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); mMediaDeviceUpdateWorker.mLocalMediaManager = mLocalMediaManager; mMediaOutputSlice.init(mMediaDeviceUpdateWorker); @@ -147,6 +151,19 @@ public class MediaOutputSliceTest { when(device.getName()).thenReturn(TEST_DEVICE_1_NAME); when(device.getIcon()).thenReturn(mTestDrawable); when(device.getMaxVolume()).thenReturn(100); + when(device.isConnected()).thenReturn(true); + when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + final MediaDevice device2 = mock(MediaDevice.class); + when(device2.getName()).thenReturn(TEST_DEVICE_2_NAME); + when(device2.getIcon()).thenReturn(mTestDrawable); + when(device2.getMaxVolume()).thenReturn(100); + when(device2.isConnected()).thenReturn(false); + when(device2.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device2.getId()).thenReturn(TEST_DEVICE_2_ID); + mDevices.add(device); + mDevices.add(device2); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(device); final Slice mediaSlice = mMediaOutputSlice.getSlice(); @@ -165,8 +182,16 @@ public class MediaOutputSliceTest { when(device.getMaxVolume()).thenReturn(100); when(device.isConnected()).thenReturn(false); when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE); - + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + final MediaDevice device2 = mock(MediaDevice.class); + when(device2.getName()).thenReturn(TEST_DEVICE_2_NAME); + when(device2.getIcon()).thenReturn(mTestDrawable); + when(device2.getMaxVolume()).thenReturn(100); + when(device2.isConnected()).thenReturn(false); + when(device2.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE); + when(device2.getId()).thenReturn(TEST_DEVICE_2_ID); mDevices.add(device); + mDevices.add(device2); mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); final Slice mediaSlice = mMediaOutputSlice.getSlice(); @@ -177,6 +202,139 @@ public class MediaOutputSliceTest { R.string.media_output_disconnected_status, TEST_DEVICE_1_NAME)); } + @Test + public void getSlice_inGroupState_checkSliceSize() { + final List mSelectedDevices = new ArrayList<>(); + final List mSelectableDevices = new ArrayList<>(); + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getName()).thenReturn(TEST_DEVICE_1_NAME); + when(device.getIcon()).thenReturn(mTestDrawable); + when(device.getMaxVolume()).thenReturn(100); + when(device.isConnected()).thenReturn(true); + when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + final MediaDevice device2 = mock(MediaDevice.class); + when(device2.getName()).thenReturn(TEST_DEVICE_2_NAME); + when(device2.getIcon()).thenReturn(mTestDrawable); + when(device2.getMaxVolume()).thenReturn(100); + when(device2.isConnected()).thenReturn(true); + when(device2.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device2.getId()).thenReturn(TEST_DEVICE_2_ID); + mSelectedDevices.add(device); + mSelectedDevices.add(device2); + when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(device); + mDevices.add(device); + mDevices.add(device2); + when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mSelectedDevices); + when(mLocalMediaManager.getSelectableMediaDevice()).thenReturn(mSelectableDevices); + when(mMediaDeviceUpdateWorker.getSessionVolumeMax()).thenReturn(100); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Slice mediaSlice = mMediaOutputSlice.getSlice(); + + assertThat(SliceQuery.findAll(mediaSlice, FORMAT_SLICE, HINT_LIST_ITEM, null).size()) + .isEqualTo(mDevices.size() + 1); + } + + @Test + public void getSlice_notInGroupState_checkSliceSize() { + final List mSelectedDevices = new ArrayList<>(); + final List mSelectableDevices = new ArrayList<>(); + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getName()).thenReturn(TEST_DEVICE_1_NAME); + when(device.getIcon()).thenReturn(mTestDrawable); + when(device.getMaxVolume()).thenReturn(100); + when(device.isConnected()).thenReturn(true); + when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + final MediaDevice device2 = mock(MediaDevice.class); + when(device2.getName()).thenReturn(TEST_DEVICE_2_NAME); + when(device2.getIcon()).thenReturn(mTestDrawable); + when(device2.getMaxVolume()).thenReturn(100); + when(device2.isConnected()).thenReturn(true); + when(device2.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device2.getId()).thenReturn(TEST_DEVICE_2_ID); + mSelectedDevices.add(device); + mSelectableDevices.add(device2); + when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(device); + mDevices.add(device); + mDevices.add(device2); + when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mSelectedDevices); + when(mLocalMediaManager.getSelectableMediaDevice()).thenReturn(mSelectableDevices); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Slice mediaSlice = mMediaOutputSlice.getSlice(); + + assertThat(SliceQuery.findAll(mediaSlice, FORMAT_SLICE, HINT_LIST_ITEM, null).size()) + .isEqualTo(mDevices.size()); + } + + @Test + public void getSlice_singleCastDevice_notContainGroupIconText() { + final List mSelectedDevices = new ArrayList<>(); + final List mSelectableDevices = new ArrayList<>(); + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getName()).thenReturn(TEST_DEVICE_1_NAME); + when(device.getIcon()).thenReturn(mTestDrawable); + when(device.getMaxVolume()).thenReturn(100); + when(device.isConnected()).thenReturn(true); + when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mDevices); + when(mLocalMediaManager.getSelectableMediaDevice()).thenReturn(null); + mSelectedDevices.add(device); + when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(device); + mDevices.add(device); + when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mSelectedDevices); + when(mLocalMediaManager.getSelectableMediaDevice()).thenReturn(mSelectableDevices); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Slice mediaSlice = mMediaOutputSlice.getSlice(); + + final String sliceInfo = SliceQuery.findAll(mediaSlice, FORMAT_SLICE, HINT_LIST_ITEM, + null).toString(); + + assertThat(TextUtils.indexOf(sliceInfo, mContext.getText(R.string.add))).isEqualTo(-1); + } + + @Test + public void getSlice_multipleCastDevices_containGroupIconText() { + final List mSelectedDevices = new ArrayList<>(); + final List mSelectableDevices = new ArrayList<>(); + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getName()).thenReturn(TEST_DEVICE_1_NAME); + when(device.getIcon()).thenReturn(mTestDrawable); + when(device.getMaxVolume()).thenReturn(100); + when(device.isConnected()).thenReturn(true); + when(device.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + final MediaDevice device2 = mock(MediaDevice.class); + when(device2.getName()).thenReturn(TEST_DEVICE_2_NAME); + when(device2.getIcon()).thenReturn(mTestDrawable); + when(device2.getMaxVolume()).thenReturn(100); + when(device2.isConnected()).thenReturn(true); + when(device2.getDeviceType()).thenReturn(MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE); + when(device2.getId()).thenReturn(TEST_DEVICE_2_ID); + mSelectedDevices.add(device); + mSelectableDevices.add(device2); + when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(device); + mDevices.add(device); + mDevices.add(device2); + when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mSelectedDevices); + when(mLocalMediaManager.getSelectableMediaDevice()).thenReturn(mSelectableDevices); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Slice mediaSlice = mMediaOutputSlice.getSlice(); + String sliceInfo = SliceQuery.findAll(mediaSlice, FORMAT_SLICE, HINT_LIST_ITEM, + null).toString(); + + assertThat(TextUtils.indexOf(sliceInfo, mContext.getText(R.string.add))).isNotEqualTo(-1); + } + @Test public void onNotifyChange_foundMediaDevice_connect() { mDevices.clear();