From d48be9a51a8d5bba38a189024f2919073defcb23 Mon Sep 17 00:00:00 2001 From: hughchen Date: Wed, 19 Dec 2018 19:48:51 +0800 Subject: [PATCH] Implement MediaOutputSlice Implement MediaOutputSlice that used to show the MediaDevice list and switch the device to transfer the media. Bug: 121083246 Test: make -j RunSettingsRoboTests Change-Id: I0d57cc75ca1fc8eae2d943819f84b1ec8b608255 --- res/values/strings.xml | 4 + .../media/MediaDeviceUpdateWorker.java | 107 ++++++++++ .../settings/media/MediaOutputSlice.java | 195 ++++++++++++++++++ .../media/MediaDeviceUpdateWorkerTest.java | 132 ++++++++++++ .../settings/media/MediaOutputSliceTest.java | 157 ++++++++++++++ 5 files changed, 595 insertions(+) create mode 100644 src/com/android/settings/media/MediaDeviceUpdateWorker.java create mode 100644 src/com/android/settings/media/MediaOutputSlice.java create mode 100644 tests/robotests/src/com/android/settings/media/MediaDeviceUpdateWorkerTest.java create mode 100644 tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java diff --git a/res/values/strings.xml b/res/values/strings.xml index 35c89234eee..6a9675bb33d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -10566,4 +10566,8 @@ %1$d notification channels. Tap to manage all. + + Switch output + + Currently playing on %1$s diff --git a/src/com/android/settings/media/MediaDeviceUpdateWorker.java b/src/com/android/settings/media/MediaDeviceUpdateWorker.java new file mode 100644 index 00000000000..7416018f6cd --- /dev/null +++ b/src/com/android/settings/media/MediaDeviceUpdateWorker.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2019 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.media; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.VisibleForTesting; + +import com.android.settings.slices.SliceBackgroundWorker; +import com.android.settingslib.media.LocalMediaManager; +import com.android.settingslib.media.MediaDevice; + +import java.util.ArrayList; +import java.util.List; + +/** + * SliceBackgroundWorker for get MediaDevice list and handle MediaDevice state change event. + */ +public class MediaDeviceUpdateWorker extends SliceBackgroundWorker + implements LocalMediaManager.DeviceCallback { + + private final Context mContext; + private final List mMediaDevices = new ArrayList<>(); + + private String mPackageName; + + @VisibleForTesting + LocalMediaManager mLocalMediaManager; + + public MediaDeviceUpdateWorker(Context context, Uri uri) { + super(context, uri); + mContext = context; + } + + public void setPackageName(String packageName) { + mPackageName = packageName; + } + + @Override + protected void onSlicePinned() { + mMediaDevices.clear(); + if (mLocalMediaManager == null) { + mLocalMediaManager = new LocalMediaManager(mContext, mPackageName, null); + } + + mLocalMediaManager.registerCallback(this); + mLocalMediaManager.startScan(); + } + + @Override + protected void onSliceUnpinned() { + mLocalMediaManager.unregisterCallback(this); + mLocalMediaManager.stopScan(); + } + + @Override + public void close() { + + } + + @Override + public void onDeviceListUpdate(List devices) { + buildMediaDevices(devices); + notifySliceChange(); + } + + private void buildMediaDevices(List devices) { + mMediaDevices.clear(); + mMediaDevices.addAll(devices); + } + + @Override + public void onSelectedDeviceStateChanged(MediaDevice device, int state) { + notifySliceChange(); + } + + public List getMediaDevices() { + return new ArrayList<>(mMediaDevices); + } + + public void connectDevice(MediaDevice device) { + mLocalMediaManager.connectDevice(device); + } + + public MediaDevice getMediaDeviceById(String id) { + return mLocalMediaManager.getMediaDeviceById(mMediaDevices, id); + } + + public MediaDevice getCurrentConnectedMediaDevice() { + return mLocalMediaManager.getCurrentConnectedDevice(); + } +} diff --git a/src/com/android/settings/media/MediaOutputSlice.java b/src/com/android/settings/media/MediaOutputSlice.java new file mode 100644 index 00000000000..5c5eb8823c1 --- /dev/null +++ b/src/com/android/settings/media/MediaOutputSlice.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2019 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.media; + +import static com.android.settings.slices.CustomSliceRegistry.MEDIA_OUTPUT_SLICE_URI; + +import android.annotation.ColorInt; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.UserHandle; +import android.util.IconDrawableFactory; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; +import androidx.core.graphics.drawable.IconCompat; +import androidx.slice.Slice; +import androidx.slice.builders.ListBuilder; +import androidx.slice.builders.SliceAction; + +import com.android.settings.R; +import com.android.settings.Utils; +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 java.util.List; + +/** + * Show the Media device that can be transfer the media. + */ +public class MediaOutputSlice implements CustomSliceable { + + private static final String TAG = "MediaOutputSlice"; + private static final String MEDIA_DEVICE_ID = "media_device_id"; + + public static final String MEDIA_PACKAGE_NAME = "media_package_name"; + + private final Context mContext; + + private MediaDeviceUpdateWorker mWorker; + private String mPackageName; + private IconDrawableFactory mIconDrawableFactory; + + public MediaOutputSlice(Context context) { + mContext = context; + mPackageName = getUri().getQueryParameter(MEDIA_PACKAGE_NAME); + mIconDrawableFactory = IconDrawableFactory.newInstance(mContext); + } + + @VisibleForTesting + void init(String packageName, MediaDeviceUpdateWorker worker, IconDrawableFactory factory) { + mPackageName = packageName; + mWorker = worker; + mIconDrawableFactory = factory; + } + + @Override + public Slice getSlice() { + final PackageManager pm = mContext.getPackageManager(); + + final List devices = getMediaDevices(); + final CharSequence title = Utils.getApplicationLabel(mContext, mPackageName); + final CharSequence summary = + mContext.getString(R.string.media_output_panel_summary_of_playing_device, + getConnectedDeviceName()); + + final Drawable drawable = + Utils.getBadgedIcon(mIconDrawableFactory, pm, mPackageName, UserHandle.myUserId()); + final IconCompat icon = IconCompat.createWithBitmap(getBitmapFromDrawable(drawable)); + + @ColorInt final int color = Utils.getColorAccentDefaultColor(mContext); + final SliceAction primarySliceAction = SliceAction.createDeeplink(getPrimaryAction(), icon, + ListBuilder.ICON_IMAGE, title); + + final ListBuilder listBuilder = new ListBuilder(mContext, MEDIA_OUTPUT_SLICE_URI, + ListBuilder.INFINITY) + .setAccentColor(color) + .addRow(new ListBuilder.RowBuilder() + .setTitleItem(icon, ListBuilder.ICON_IMAGE) + .setTitle(title) + .setSubtitle(summary) + .setPrimaryAction(primarySliceAction)); + + for (MediaDevice device : devices) { + listBuilder.addRow(getMediaDeviceRow(device)); + } + + return listBuilder.build(); + } + + private MediaDeviceUpdateWorker getWorker() { + if (mWorker == null) { + mWorker = (MediaDeviceUpdateWorker) SliceBackgroundWorker.getInstance(getUri()); + mWorker.setPackageName(mPackageName); + } + return mWorker; + } + + private List getMediaDevices() { + List devices = getWorker().getMediaDevices(); + return devices; + } + + private String getConnectedDeviceName() { + final MediaDevice device = getWorker().getCurrentConnectedMediaDevice(); + return device != null ? device.getName() : ""; + } + + private PendingIntent getPrimaryAction() { + final PackageManager pm = mContext.getPackageManager(); + final Intent launchIntent = pm.getLaunchIntentForPackage(mPackageName); + final Intent intent = launchIntent; + return PendingIntent.getActivity(mContext, 0 /* requestCode */, intent, 0 /* flags */); + } + + private Bitmap getBitmapFromDrawable(Drawable drawable) { + final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), + drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + private ListBuilder.RowBuilder getMediaDeviceRow(MediaDevice device) { + final String title = device.getName(); + final PendingIntent broadcastAction = + getBroadcastIntent(mContext, device.getId(), device.hashCode()); + final IconCompat deviceIcon = IconCompat.createWithResource(mContext, device.getIcon()); + final ListBuilder.RowBuilder rowBuilder = new ListBuilder.RowBuilder() + .setTitleItem(deviceIcon, ListBuilder.ICON_IMAGE) + .setPrimaryAction(SliceAction.create(broadcastAction, deviceIcon, + ListBuilder.ICON_IMAGE, title)) + .setTitle(title); + + return rowBuilder; + } + + private PendingIntent getBroadcastIntent(Context context, String id, int requestCode) { + final Intent intent = new Intent(getUri().toString()); + intent.setClass(context, SliceBroadcastReceiver.class); + intent.putExtra(MEDIA_DEVICE_ID, id); + return PendingIntent.getBroadcast(context, requestCode /* requestCode */, intent, + PendingIntent.FLAG_CANCEL_CURRENT); + } + + @Override + public Uri getUri() { + return MEDIA_OUTPUT_SLICE_URI; + } + + @Override + public void onNotifyChange(Intent intent) { + final MediaDeviceUpdateWorker worker = getWorker(); + final String id = intent != null ? intent.getStringExtra(MEDIA_DEVICE_ID) : ""; + final MediaDevice device = worker.getMediaDeviceById(id); + if (device != null) { + Log.d(TAG, "onNotifyChange() device name : " + device.getName()); + worker.connectDevice(device); + } + } + + @Override + public Intent getIntent() { + return null; + } + + @Override + public Class getBackgroundWorkerClass() { + return MediaDeviceUpdateWorker.class; + } +} diff --git a/tests/robotests/src/com/android/settings/media/MediaDeviceUpdateWorkerTest.java b/tests/robotests/src/com/android/settings/media/MediaDeviceUpdateWorkerTest.java new file mode 100644 index 00000000000..28bf4e9053b --- /dev/null +++ b/tests/robotests/src/com/android/settings/media/MediaDeviceUpdateWorkerTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2019 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.media; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import com.android.settingslib.media.MediaDevice; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class MediaDeviceUpdateWorkerTest { + + private static final Uri URI = Uri.parse("content://com.android.settings.slices/test"); + 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_3_ID = "test_device_3_id"; + + private final List mMediaDevices = new ArrayList<>(); + + private MediaDeviceUpdateWorker mMediaDeviceUpdateWorker; + private ContentResolver mResolver; + private Context mContext; + private MediaDevice mMediaDevice1; + private MediaDevice mMediaDevice2; + + @Before + public void setUp() { + mContext = spy(RuntimeEnvironment.application); + mMediaDeviceUpdateWorker = new MediaDeviceUpdateWorker(mContext, URI); + mResolver = mock(ContentResolver.class); + + mMediaDevice1 = mock(MediaDevice.class); + when(mMediaDevice1.getId()).thenReturn(TEST_DEVICE_1_ID); + mMediaDevice2 = mock(MediaDevice.class); + when(mMediaDevice2.getId()).thenReturn(TEST_DEVICE_2_ID); + mMediaDevices.add(mMediaDevice1); + mMediaDevices.add(mMediaDevice2); + + doReturn(mResolver).when(mContext).getContentResolver(); + } + + @Test + public void onDeviceListUpdate_shouldNotifyChange() { + mMediaDeviceUpdateWorker.onDeviceListUpdate(mMediaDevices); + + verify(mResolver).notifyChange(URI, null); + } + + @Test + public void onSelectedDeviceStateChanged_shouldNotifyChange() { + mMediaDeviceUpdateWorker.onSelectedDeviceStateChanged(null, 0); + + verify(mResolver).notifyChange(URI, null); + } + + @Test + public void onDeviceListUpdate_sameDeviceList_shouldBeEqual() { + mMediaDeviceUpdateWorker.onDeviceListUpdate(mMediaDevices); + + final List newDevices = new ArrayList<>(); + newDevices.add(mMediaDevice1); + newDevices.add(mMediaDevice2); + + mMediaDeviceUpdateWorker.onDeviceListUpdate(newDevices); + final List devices = mMediaDeviceUpdateWorker.getMediaDevices(); + + assertThat(devices.get(0).getId()).isEqualTo(newDevices.get(0).getId()); + assertThat(devices.get(1).getId()).isEqualTo(newDevices.get(1).getId()); + } + + @Test + public void onDeviceListUpdate_add1DeviceToDeviceList_shouldBeEqual() { + mMediaDeviceUpdateWorker.onDeviceListUpdate(mMediaDevices); + + final List newDevices = new ArrayList<>(); + final MediaDevice device3 = mock(MediaDevice.class); + when(mMediaDevice2.getId()).thenReturn(TEST_DEVICE_3_ID); + newDevices.add(mMediaDevice1); + newDevices.add(mMediaDevice2); + newDevices.add(device3); + + mMediaDeviceUpdateWorker.onDeviceListUpdate(newDevices); + final List devices = mMediaDeviceUpdateWorker.getMediaDevices(); + + assertThat(devices.size()).isEqualTo(newDevices.size()); + } + + @Test + public void onDeviceListUpdate_less1DeviceToDeviceList_shouldBeEqual() { + mMediaDeviceUpdateWorker.onDeviceListUpdate(mMediaDevices); + + final List newDevices = new ArrayList<>(); + newDevices.add(mMediaDevice1); + + mMediaDeviceUpdateWorker.onDeviceListUpdate(newDevices); + final List devices = mMediaDeviceUpdateWorker.getMediaDevices(); + + assertThat(devices.size()).isEqualTo(newDevices.size()); + } +} diff --git a/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java b/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java new file mode 100644 index 00000000000..daaba90bfa6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/media/MediaOutputSliceTest.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2019 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.media; + +import static com.android.settings.slices.CustomSliceRegistry.MEDIA_OUTPUT_SLICE_URI; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.os.UserHandle; +import android.util.IconDrawableFactory; + +import androidx.slice.Slice; +import androidx.slice.SliceMetadata; +import androidx.slice.SliceProvider; +import androidx.slice.core.SliceAction; +import androidx.slice.widget.SliceLiveData; + +import com.android.settingslib.media.LocalMediaManager; +import com.android.settingslib.media.MediaDevice; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class MediaOutputSliceTest { + + private static final String TEST_PACKAGE_NAME = "com.fake.android.music"; + private static final String TEST_LABEL = "Test app"; + private static final String TEST_DEVICE_1_ID = "test_device_1_id"; + + @Mock + private PackageManager mPackageManager; + @Mock + private ApplicationInfo mApplicationInfo; + @Mock + private ApplicationInfo mApplicationInfo2; + @Mock + private LocalMediaManager mLocalMediaManager; + @Mock + private IconDrawableFactory mIconDrawableFactory; + @Mock + private Drawable mTestDrawable; + + private final List mDevices = new ArrayList<>(); + + private Context mContext; + private MediaOutputSlice mMediaOutputSlice; + private MediaDeviceUpdateWorker mMediaDeviceUpdateWorker; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + mContext = spy(RuntimeEnvironment.application); + + when(mContext.getPackageManager()).thenReturn(mPackageManager); + when(mPackageManager.getApplicationInfo(eq(TEST_PACKAGE_NAME), anyInt())) + .thenReturn(mApplicationInfo); + when(mPackageManager.getApplicationInfoAsUser(eq(TEST_PACKAGE_NAME), anyInt(), anyInt())) + .thenReturn(mApplicationInfo2); + when(mApplicationInfo.loadLabel(mPackageManager)).thenReturn(TEST_LABEL); + when(mIconDrawableFactory.getBadgedIcon(mApplicationInfo2, UserHandle.myUserId())) + .thenReturn(mTestDrawable); + when(mTestDrawable.getIntrinsicWidth()).thenReturn(100); + when(mTestDrawable.getIntrinsicHeight()).thenReturn(100); + + // Set-up specs for SliceMetadata. + SliceProvider.setSpecs(SliceLiveData.SUPPORTED_SPECS); + + mMediaOutputSlice = new MediaOutputSlice(mContext); + mMediaDeviceUpdateWorker = new MediaDeviceUpdateWorker(mContext, MEDIA_OUTPUT_SLICE_URI); + mMediaDeviceUpdateWorker.setPackageName(TEST_PACKAGE_NAME); + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + mMediaDeviceUpdateWorker.mLocalMediaManager = mLocalMediaManager; + mMediaOutputSlice.init(TEST_PACKAGE_NAME, mMediaDeviceUpdateWorker, mIconDrawableFactory); + } + + @Test + public void getSlice_shouldHaveAppTitle() { + final Slice mediaSlice = mMediaOutputSlice.getSlice(); + final SliceMetadata metadata = SliceMetadata.from(mContext, mediaSlice); + + final SliceAction primaryAction = metadata.getPrimaryAction(); + assertThat(primaryAction.getTitle().toString()).isEqualTo(TEST_LABEL); + } + + @Test + public void onNotifyChange_foundMediaDevice_connect() { + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + when(mLocalMediaManager.getMediaDeviceById(mDevices, TEST_DEVICE_1_ID)).thenReturn(device); + mDevices.add(device); + + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Intent intent = new Intent(); + intent.putExtra("media_device_id", TEST_DEVICE_1_ID); + + mMediaOutputSlice.onNotifyChange(intent); + + verify(mLocalMediaManager).connectDevice(device); + } + + @Test + public void onNotifyChange_notFoundMediaDevice_doNothing() { + mDevices.clear(); + final MediaDevice device = mock(MediaDevice.class); + when(device.getId()).thenReturn(TEST_DEVICE_1_ID); + when(mLocalMediaManager.getMediaDeviceById(mDevices, TEST_DEVICE_1_ID)).thenReturn(device); + mDevices.add(device); + + mMediaDeviceUpdateWorker.onDeviceListUpdate(mDevices); + + final Intent intent = new Intent(); + intent.putExtra("media_device_id", "fake_123"); + + mMediaOutputSlice.onNotifyChange(intent); + + verify(mLocalMediaManager, never()).connectDevice(device); + } +}