summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--media/java/android/media/flags/media_better_together.aconfig10
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/dialog/MediaSwitchingController.java50
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/dialog/OutputMediaItemListProxy.java255
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaSwitchingControllerTest.java29
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/dialog/OutputMediaItemListProxyTest.java383
5 files changed, 701 insertions, 26 deletions
diff --git a/media/java/android/media/flags/media_better_together.aconfig b/media/java/android/media/flags/media_better_together.aconfig
index e39a0aa8717e..48e2f4e15238 100644
--- a/media/java/android/media/flags/media_better_together.aconfig
+++ b/media/java/android/media/flags/media_better_together.aconfig
@@ -242,3 +242,13 @@ flag {
description: "Fallbacks to the default handling for volume adjustment when media session has fixed volume handling and its app is in the foreground and setting a media controller."
bug: "293743975"
}
+
+flag {
+ name: "fix_output_media_item_list_index_out_of_bounds_exception"
+ namespace: "media_better_together"
+ description: "Fixes a bug of causing IndexOutOfBoundsException when building media item list."
+ bug: "398246089"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaSwitchingController.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaSwitchingController.java
index f79693138e24..d0c6a3e6a3ef 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaSwitchingController.java
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaSwitchingController.java
@@ -66,6 +66,7 @@ import androidx.annotation.VisibleForTesting;
import androidx.core.graphics.drawable.IconCompat;
import com.android.internal.annotations.GuardedBy;
+import com.android.media.flags.Flags;
import com.android.settingslib.RestrictedLockUtilsInternal;
import com.android.settingslib.Utils;
import com.android.settingslib.bluetooth.BluetoothUtils;
@@ -78,7 +79,6 @@ import com.android.settingslib.media.InputMediaDevice;
import com.android.settingslib.media.InputRouteManager;
import com.android.settingslib.media.LocalMediaManager;
import com.android.settingslib.media.MediaDevice;
-import com.android.settingslib.media.flags.Flags;
import com.android.settingslib.utils.ThreadUtils;
import com.android.systemui.animation.ActivityTransitionAnimator;
import com.android.systemui.animation.DialogTransitionAnimator;
@@ -226,7 +226,7 @@ public class MediaSwitchingController
InfoMediaManager.createInstance(mContext, packageName, userHandle, lbm, token);
mLocalMediaManager = new LocalMediaManager(mContext, lbm, imm, packageName);
mMetricLogger = new MediaOutputMetricLogger(mContext, mPackageName);
- mOutputMediaItemListProxy = new OutputMediaItemListProxy();
+ mOutputMediaItemListProxy = new OutputMediaItemListProxy(context);
mDialogTransitionAnimator = dialogTransitionAnimator;
mNearbyMediaDevicesManager = nearbyMediaDevicesManager;
mMediaOutputColorSchemeLegacy = MediaOutputColorSchemeLegacy.fromSystemColors(mContext);
@@ -308,7 +308,8 @@ public class MediaSwitchingController
}
private MediaController getMediaController() {
- if (mToken != null && Flags.usePlaybackInfoForRoutingControls()) {
+ if (mToken != null
+ && com.android.settingslib.media.flags.Flags.usePlaybackInfoForRoutingControls()) {
return new MediaController(mContext, mToken);
} else {
for (NotificationEntry entry : mNotifCollection.getAllNotifs()) {
@@ -577,19 +578,35 @@ public class MediaSwitchingController
private void buildMediaItems(List<MediaDevice> devices) {
synchronized (mMediaDevicesLock) {
- List<MediaItem> updatedMediaItems =
- buildMediaItems(mOutputMediaItemListProxy.getOutputMediaItemList(), devices);
- mOutputMediaItemListProxy.clearAndAddAll(updatedMediaItems);
+ if (!mLocalMediaManager.isPreferenceRouteListingExist()) {
+ attachRangeInfo(devices);
+ Collections.sort(devices, Comparator.naturalOrder());
+ }
+ if (Flags.fixOutputMediaItemListIndexOutOfBoundsException()) {
+ // For the first time building list, to make sure the top device is the connected
+ // device.
+ boolean needToHandleMutingExpectedDevice =
+ hasMutingExpectedDevice() && !isCurrentConnectedDeviceRemote();
+ final MediaDevice connectedMediaDevice =
+ needToHandleMutingExpectedDevice ? null : getCurrentConnectedMediaDevice();
+ mOutputMediaItemListProxy.updateMediaDevices(
+ devices,
+ getSelectedMediaDevice(),
+ connectedMediaDevice,
+ needToHandleMutingExpectedDevice,
+ getConnectNewDeviceItem());
+ } else {
+ List<MediaItem> updatedMediaItems =
+ buildMediaItems(
+ mOutputMediaItemListProxy.getOutputMediaItemList(), devices);
+ mOutputMediaItemListProxy.clearAndAddAll(updatedMediaItems);
+ }
}
}
protected List<MediaItem> buildMediaItems(
List<MediaItem> oldMediaItems, List<MediaDevice> devices) {
synchronized (mMediaDevicesLock) {
- if (!mLocalMediaManager.isPreferenceRouteListingExist()) {
- attachRangeInfo(devices);
- Collections.sort(devices, Comparator.naturalOrder());
- }
// For the first time building list, to make sure the top device is the connected
// device.
boolean needToHandleMutingExpectedDevice =
@@ -648,8 +665,7 @@ public class MediaSwitchingController
.map(MediaItem::createDeviceMediaItem)
.collect(Collectors.toList());
- boolean shouldAddFirstSeenSelectedDevice =
- com.android.media.flags.Flags.enableOutputSwitcherDeviceGrouping();
+ boolean shouldAddFirstSeenSelectedDevice = Flags.enableOutputSwitcherDeviceGrouping();
if (shouldAddFirstSeenSelectedDevice) {
finalMediaItems.clear();
@@ -675,7 +691,7 @@ public class MediaSwitchingController
}
private boolean enableInputRouting() {
- return com.android.media.flags.Flags.enableAudioInputDeviceRoutingAndVolumeControl();
+ return Flags.enableAudioInputDeviceRoutingAndVolumeControl();
}
private void buildInputMediaItems(List<MediaDevice> devices) {
@@ -703,8 +719,7 @@ public class MediaSwitchingController
if (connectedMediaDevice != null) {
selectedDevicesIds.add(connectedMediaDevice.getId());
}
- boolean groupSelectedDevices =
- com.android.media.flags.Flags.enableOutputSwitcherDeviceGrouping();
+ boolean groupSelectedDevices = Flags.enableOutputSwitcherDeviceGrouping();
int nextSelectedItemIndex = 0;
boolean suggestedDeviceAdded = false;
boolean displayGroupAdded = false;
@@ -879,6 +894,11 @@ public class MediaSwitchingController
return mLocalMediaManager.getCurrentConnectedDevice();
}
+ @VisibleForTesting
+ void clearMediaItemList() {
+ mOutputMediaItemListProxy.clear();
+ }
+
boolean addDeviceToPlayMedia(MediaDevice device) {
mMetricLogger.logInteractionExpansion(device);
return mLocalMediaManager.addDeviceToPlayMedia(device);
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/OutputMediaItemListProxy.java b/packages/SystemUI/src/com/android/systemui/media/dialog/OutputMediaItemListProxy.java
index 1c9c0b102cb7..45ca2c6ee8e5 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dialog/OutputMediaItemListProxy.java
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/OutputMediaItemListProxy.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2025 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.
@@ -16,22 +16,175 @@
package com.android.systemui.media.dialog;
+import android.content.Context;
+
+import androidx.annotation.Nullable;
+
+import com.android.media.flags.Flags;
+import com.android.settingslib.media.MediaDevice;
+import com.android.systemui.res.R;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Collectors;
/** A proxy of holding the list of Output Switcher's output media items. */
public class OutputMediaItemListProxy {
+ private final Context mContext;
private final List<MediaItem> mOutputMediaItemList;
- public OutputMediaItemListProxy() {
+ // Use separated lists to hold different media items and create the list of output media items
+ // by using those separated lists and group dividers.
+ private final List<MediaItem> mSelectedMediaItems;
+ private final List<MediaItem> mSuggestedMediaItems;
+ private final List<MediaItem> mSpeakersAndDisplaysMediaItems;
+ @Nullable private MediaItem mConnectNewDeviceMediaItem;
+
+ public OutputMediaItemListProxy(Context context) {
+ mContext = context;
mOutputMediaItemList = new CopyOnWriteArrayList<>();
+ mSelectedMediaItems = new CopyOnWriteArrayList<>();
+ mSuggestedMediaItems = new CopyOnWriteArrayList<>();
+ mSpeakersAndDisplaysMediaItems = new CopyOnWriteArrayList<>();
}
/** Returns the list of output media items. */
public List<MediaItem> getOutputMediaItemList() {
+ if (Flags.fixOutputMediaItemListIndexOutOfBoundsException()) {
+ if (isEmpty() && !mOutputMediaItemList.isEmpty()) {
+ // Ensures mOutputMediaItemList is empty when all individual media item lists are
+ // empty, preventing unexpected state issues.
+ mOutputMediaItemList.clear();
+ } else if (!isEmpty() && mOutputMediaItemList.isEmpty()) {
+ // When any individual media item list is modified, the cached mOutputMediaItemList
+ // is emptied. On the next request for the output media item list, a fresh list is
+ // created and stored in the cache.
+ mOutputMediaItemList.addAll(createOutputMediaItemList());
+ }
+ }
return mOutputMediaItemList;
}
+ private List<MediaItem> createOutputMediaItemList() {
+ List<MediaItem> finalMediaItems = new CopyOnWriteArrayList<>();
+ finalMediaItems.addAll(mSelectedMediaItems);
+ if (!mSuggestedMediaItems.isEmpty()) {
+ finalMediaItems.add(
+ MediaItem.createGroupDividerMediaItem(
+ mContext.getString(
+ R.string.media_output_group_title_suggested_device)));
+ finalMediaItems.addAll(mSuggestedMediaItems);
+ }
+ if (!mSpeakersAndDisplaysMediaItems.isEmpty()) {
+ finalMediaItems.add(
+ MediaItem.createGroupDividerMediaItem(
+ mContext.getString(
+ R.string.media_output_group_title_speakers_and_displays)));
+ finalMediaItems.addAll(mSpeakersAndDisplaysMediaItems);
+ }
+ if (mConnectNewDeviceMediaItem != null) {
+ finalMediaItems.add(mConnectNewDeviceMediaItem);
+ }
+ return finalMediaItems;
+ }
+
+ /** Updates the list of output media items with a given list of media devices. */
+ public void updateMediaDevices(
+ List<MediaDevice> devices,
+ List<MediaDevice> selectedDevices,
+ @Nullable MediaDevice connectedMediaDevice,
+ boolean needToHandleMutingExpectedDevice,
+ @Nullable MediaItem connectNewDeviceMediaItem) {
+ Set<String> selectedOrConnectedMediaDeviceIds =
+ selectedDevices.stream().map(MediaDevice::getId).collect(Collectors.toSet());
+ if (connectedMediaDevice != null) {
+ selectedOrConnectedMediaDeviceIds.add(connectedMediaDevice.getId());
+ }
+
+ List<MediaItem> selectedMediaItems = new ArrayList<>();
+ List<MediaItem> suggestedMediaItems = new ArrayList<>();
+ List<MediaItem> speakersAndDisplaysMediaItems = new ArrayList<>();
+ Map<String, MediaItem> deviceIdToMediaItemMap = new HashMap<>();
+ buildMediaItems(
+ devices,
+ selectedOrConnectedMediaDeviceIds,
+ needToHandleMutingExpectedDevice,
+ selectedMediaItems,
+ suggestedMediaItems,
+ speakersAndDisplaysMediaItems,
+ deviceIdToMediaItemMap);
+
+ List<MediaItem> updatedSelectedMediaItems = new CopyOnWriteArrayList<>();
+ List<MediaItem> updatedSuggestedMediaItems = new CopyOnWriteArrayList<>();
+ List<MediaItem> updatedSpeakersAndDisplaysMediaItems = new CopyOnWriteArrayList<>();
+ if (isEmpty()) {
+ updatedSelectedMediaItems.addAll(selectedMediaItems);
+ updatedSuggestedMediaItems.addAll(suggestedMediaItems);
+ updatedSpeakersAndDisplaysMediaItems.addAll(speakersAndDisplaysMediaItems);
+ } else {
+ Set<String> updatedDeviceIds = new HashSet<>();
+ // Preserve the existing media item order while updating with the latest device
+ // information. Some items may retain their original group (suggested, speakers and
+ // displays) to maintain this order.
+ updateMediaItems(
+ mSelectedMediaItems,
+ updatedSelectedMediaItems,
+ deviceIdToMediaItemMap,
+ updatedDeviceIds);
+ updateMediaItems(
+ mSuggestedMediaItems,
+ updatedSuggestedMediaItems,
+ deviceIdToMediaItemMap,
+ updatedDeviceIds);
+ updateMediaItems(
+ mSpeakersAndDisplaysMediaItems,
+ updatedSpeakersAndDisplaysMediaItems,
+ deviceIdToMediaItemMap,
+ updatedDeviceIds);
+
+ // Append new media items that are not already in the existing lists to the output list.
+ List<MediaItem> remainingMediaItems = new ArrayList<>();
+ remainingMediaItems.addAll(
+ getRemainingMediaItems(selectedMediaItems, updatedDeviceIds));
+ remainingMediaItems.addAll(
+ getRemainingMediaItems(suggestedMediaItems, updatedDeviceIds));
+ remainingMediaItems.addAll(
+ getRemainingMediaItems(speakersAndDisplaysMediaItems, updatedDeviceIds));
+ updatedSpeakersAndDisplaysMediaItems.addAll(remainingMediaItems);
+ }
+
+ if (Flags.enableOutputSwitcherDeviceGrouping() && !updatedSelectedMediaItems.isEmpty()) {
+ MediaItem selectedMediaItem = updatedSelectedMediaItems.get(0);
+ Optional<MediaDevice> mediaDeviceOptional = selectedMediaItem.getMediaDevice();
+ if (mediaDeviceOptional.isPresent()) {
+ MediaItem updatedMediaItem =
+ MediaItem.createDeviceMediaItem(
+ mediaDeviceOptional.get(), /* isFirstDeviceInGroup= */ true);
+ updatedSelectedMediaItems.remove(0);
+ updatedSelectedMediaItems.add(0, updatedMediaItem);
+ }
+ }
+
+ mSelectedMediaItems.clear();
+ mSelectedMediaItems.addAll(updatedSelectedMediaItems);
+ mSuggestedMediaItems.clear();
+ mSuggestedMediaItems.addAll(updatedSuggestedMediaItems);
+ mSpeakersAndDisplaysMediaItems.clear();
+ mSpeakersAndDisplaysMediaItems.addAll(updatedSpeakersAndDisplaysMediaItems);
+ mConnectNewDeviceMediaItem = connectNewDeviceMediaItem;
+
+ // The cached mOutputMediaItemList is cleared upon any update to individual media item
+ // lists. This ensures getOutputMediaItemList() computes and caches a fresh list on the next
+ // invocation.
+ mOutputMediaItemList.clear();
+ }
+
/** Updates the list of output media items with the given list. */
public void clearAndAddAll(List<MediaItem> updatedMediaItems) {
mOutputMediaItemList.clear();
@@ -40,16 +193,112 @@ public class OutputMediaItemListProxy {
/** Removes the media items with muting expected devices. */
public void removeMutingExpectedDevices() {
+ if (Flags.fixOutputMediaItemListIndexOutOfBoundsException()) {
+ mSelectedMediaItems.removeIf((MediaItem::isMutingExpectedDevice));
+ mSuggestedMediaItems.removeIf((MediaItem::isMutingExpectedDevice));
+ mSpeakersAndDisplaysMediaItems.removeIf((MediaItem::isMutingExpectedDevice));
+ if (mConnectNewDeviceMediaItem != null
+ && mConnectNewDeviceMediaItem.isMutingExpectedDevice()) {
+ mConnectNewDeviceMediaItem = null;
+ }
+ }
mOutputMediaItemList.removeIf((MediaItem::isMutingExpectedDevice));
}
/** Clears the output media item list. */
public void clear() {
+ if (Flags.fixOutputMediaItemListIndexOutOfBoundsException()) {
+ mSelectedMediaItems.clear();
+ mSuggestedMediaItems.clear();
+ mSpeakersAndDisplaysMediaItems.clear();
+ mConnectNewDeviceMediaItem = null;
+ }
mOutputMediaItemList.clear();
}
/** Returns whether the output media item list is empty. */
public boolean isEmpty() {
- return mOutputMediaItemList.isEmpty();
+ if (Flags.fixOutputMediaItemListIndexOutOfBoundsException()) {
+ return mSelectedMediaItems.isEmpty()
+ && mSuggestedMediaItems.isEmpty()
+ && mSpeakersAndDisplaysMediaItems.isEmpty()
+ && (mConnectNewDeviceMediaItem == null);
+ } else {
+ return mOutputMediaItemList.isEmpty();
+ }
+ }
+
+ private void buildMediaItems(
+ List<MediaDevice> devices,
+ Set<String> selectedOrConnectedMediaDeviceIds,
+ boolean needToHandleMutingExpectedDevice,
+ List<MediaItem> selectedMediaItems,
+ List<MediaItem> suggestedMediaItems,
+ List<MediaItem> speakersAndDisplaysMediaItems,
+ Map<String, MediaItem> deviceIdToMediaItemMap) {
+ for (MediaDevice device : devices) {
+ String deviceId = device.getId();
+ MediaItem mediaItem = MediaItem.createDeviceMediaItem(device);
+ if (needToHandleMutingExpectedDevice && device.isMutingExpectedDevice()) {
+ selectedMediaItems.add(0, mediaItem);
+ } else if (!needToHandleMutingExpectedDevice
+ && selectedOrConnectedMediaDeviceIds.contains(device.getId())) {
+ if (Flags.enableOutputSwitcherDeviceGrouping()) {
+ selectedMediaItems.add(mediaItem);
+ } else {
+ selectedMediaItems.add(0, mediaItem);
+ }
+ } else if (device.isSuggestedDevice()) {
+ suggestedMediaItems.add(mediaItem);
+ } else {
+ speakersAndDisplaysMediaItems.add(mediaItem);
+ }
+ deviceIdToMediaItemMap.put(deviceId, mediaItem);
+ }
+ }
+
+ /** Returns a list of media items that remains the same order as the existing media items. */
+ private void updateMediaItems(
+ List<MediaItem> existingMediaItems,
+ List<MediaItem> updatedMediaItems,
+ Map<String, MediaItem> deviceIdToMediaItemMap,
+ Set<String> updatedDeviceIds) {
+ List<String> existingDeviceIds = getDeviceIds(existingMediaItems);
+ for (String deviceId : existingDeviceIds) {
+ MediaItem mediaItem = deviceIdToMediaItemMap.get(deviceId);
+ if (mediaItem != null) {
+ updatedMediaItems.add(mediaItem);
+ updatedDeviceIds.add(deviceId);
+ }
+ }
+ }
+
+ /**
+ * Returns media items from the input list that are not associated with the given device IDs.
+ */
+ private List<MediaItem> getRemainingMediaItems(
+ List<MediaItem> mediaItems, Set<String> deviceIds) {
+ List<MediaItem> remainingMediaItems = new ArrayList<>();
+ for (MediaItem item : mediaItems) {
+ Optional<MediaDevice> mediaDeviceOptional = item.getMediaDevice();
+ if (mediaDeviceOptional.isPresent()) {
+ String deviceId = mediaDeviceOptional.get().getId();
+ if (!deviceIds.contains(deviceId)) {
+ remainingMediaItems.add(item);
+ }
+ }
+ }
+ return remainingMediaItems;
+ }
+
+ /** Returns a list of media device IDs for the given list of media items. */
+ private List<String> getDeviceIds(List<MediaItem> mediaItems) {
+ List<String> deviceIds = new ArrayList<>();
+ for (MediaItem item : mediaItems) {
+ if (item != null && item.getMediaDevice().isPresent()) {
+ deviceIds.add(item.getMediaDevice().get().getId());
+ }
+ }
+ return deviceIds;
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaSwitchingControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaSwitchingControllerTest.java
index 5c26dac5eb30..798aa428e73e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaSwitchingControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaSwitchingControllerTest.java
@@ -60,13 +60,13 @@ import android.os.RemoteException;
import android.os.UserHandle;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.FlagsParameterization;
import android.service.notification.StatusBarNotification;
import android.testing.TestableLooper;
import android.text.TextUtils;
import android.view.View;
import androidx.core.graphics.drawable.IconCompat;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;
import com.android.media.flags.Flags;
@@ -101,6 +101,9 @@ import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4;
+import platform.test.runner.parameterized.Parameters;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -108,7 +111,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@SmallTest
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedAndroidJunit4.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class MediaSwitchingControllerTest extends SysuiTestCase {
private static final String TEST_DEVICE_1_ID = "test_device_1_id";
@@ -201,6 +204,17 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
private MediaDescription mMediaDescription;
private List<RoutingSessionInfo> mRoutingSessionInfos = new ArrayList<>();
+ @Parameters(name = "{0}")
+ public static List<FlagsParameterization> getParams() {
+ return FlagsParameterization.allCombinationsOf(
+ Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION,
+ Flags.FLAG_ENABLE_OUTPUT_SWITCHER_DEVICE_GROUPING);
+ }
+
+ public MediaSwitchingControllerTest(FlagsParameterization flags) {
+ mSetFlagsRule.setFlagsParameterization(flags);
+ }
+
@Before
public void setUp() {
mPackageName = mContext.getPackageName();
@@ -260,7 +274,6 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
mMediaDevices.add(mMediaDevice1);
mMediaDevices.add(mMediaDevice2);
-
when(mNearbyDevice1.getMediaRoute2Id()).thenReturn(TEST_DEVICE_1_ID);
when(mNearbyDevice1.getRangeZone()).thenReturn(NearbyDevice.RANGE_FAR);
when(mNearbyDevice2.getMediaRoute2Id()).thenReturn(TEST_DEVICE_2_ID);
@@ -689,7 +702,7 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
mMediaSwitchingController.start(mCb);
reset(mCb);
- mMediaSwitchingController.getMediaItemList().clear();
+ mMediaSwitchingController.clearMediaItemList();
mMediaSwitchingController.onDeviceListUpdate(mMediaDevices);
final List<MediaDevice> devices = new ArrayList<>();
int dividerSize = 0;
@@ -1528,7 +1541,7 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
.getSelectedMediaDevice();
mMediaSwitchingController.start(mCb);
reset(mCb);
- mMediaSwitchingController.getMediaItemList().clear();
+ mMediaSwitchingController.clearMediaItemList();
mMediaSwitchingController.onDeviceListUpdate(mMediaDevices);
@@ -1546,7 +1559,7 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
.getSelectedMediaDevice();
mMediaSwitchingController.start(mCb);
reset(mCb);
- mMediaSwitchingController.getMediaItemList().clear();
+ mMediaSwitchingController.clearMediaItemList();
mMediaSwitchingController.onDeviceListUpdate(mMediaDevices);
@@ -1564,7 +1577,7 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
.getSelectedMediaDevice();
mMediaSwitchingController.start(mCb);
reset(mCb);
- mMediaSwitchingController.getMediaItemList().clear();
+ mMediaSwitchingController.clearMediaItemList();
mMediaSwitchingController.onDeviceListUpdate(mMediaDevices);
@@ -1582,7 +1595,7 @@ public class MediaSwitchingControllerTest extends SysuiTestCase {
.getSelectedMediaDevice();
mMediaSwitchingController.start(mCb);
reset(mCb);
- mMediaSwitchingController.getMediaItemList().clear();
+ mMediaSwitchingController.clearMediaItemList();
mMediaSwitchingController.onDeviceListUpdate(mMediaDevices);
mMediaDevices.clear();
mMediaDevices.add(mMediaDevice2);
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/OutputMediaItemListProxyTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/OutputMediaItemListProxyTest.java
new file mode 100644
index 000000000000..f6edd49f142f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/OutputMediaItemListProxyTest.java
@@ -0,0 +1,383 @@
+/*
+ * Copyright (C) 2025 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.systemui.media.dialog;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.platform.test.annotations.DisableFlags;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.FlagsParameterization;
+import android.testing.TestableLooper;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.media.flags.Flags;
+import com.android.settingslib.media.MediaDevice;
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4;
+import platform.test.runner.parameterized.Parameters;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@SmallTest
+@RunWith(ParameterizedAndroidJunit4.class)
+@TestableLooper.RunWithLooper
+public class OutputMediaItemListProxyTest extends SysuiTestCase {
+ private static final String DEVICE_ID_1 = "device_id_1";
+ private static final String DEVICE_ID_2 = "device_id_2";
+ private static final String DEVICE_ID_3 = "device_id_3";
+ private static final String DEVICE_ID_4 = "device_id_4";
+ @Mock private MediaDevice mMediaDevice1;
+ @Mock private MediaDevice mMediaDevice2;
+ @Mock private MediaDevice mMediaDevice3;
+ @Mock private MediaDevice mMediaDevice4;
+
+ private MediaItem mMediaItem1;
+ private MediaItem mMediaItem2;
+ private MediaItem mConnectNewDeviceMediaItem;
+ private OutputMediaItemListProxy mOutputMediaItemListProxy;
+
+ @Parameters(name = "{0}")
+ public static List<FlagsParameterization> getParams() {
+ return FlagsParameterization.allCombinationsOf(
+ Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION,
+ Flags.FLAG_ENABLE_OUTPUT_SWITCHER_DEVICE_GROUPING);
+ }
+
+ public OutputMediaItemListProxyTest(FlagsParameterization flags) {
+ mSetFlagsRule.setFlagsParameterization(flags);
+ }
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ when(mMediaDevice1.getId()).thenReturn(DEVICE_ID_1);
+ when(mMediaDevice2.getId()).thenReturn(DEVICE_ID_2);
+ when(mMediaDevice2.isSuggestedDevice()).thenReturn(true);
+ when(mMediaDevice3.getId()).thenReturn(DEVICE_ID_3);
+ when(mMediaDevice4.getId()).thenReturn(DEVICE_ID_4);
+ mMediaItem1 = MediaItem.createDeviceMediaItem(mMediaDevice1);
+ mMediaItem2 = MediaItem.createDeviceMediaItem(mMediaDevice2);
+ mConnectNewDeviceMediaItem = MediaItem.createPairNewDeviceMediaItem();
+
+ mOutputMediaItemListProxy = new OutputMediaItemListProxy(mContext);
+ }
+
+ @EnableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void updateMediaDevices_shouldUpdateMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ // Create the initial output media item list with mMediaDevice2 and mMediaDevice3.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice2, mMediaDevice3),
+ /* selectedDevices */ List.of(mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ // Check the output media items to be
+ // * a media item with the selected mMediaDevice3
+ // * a group divider for suggested devices
+ // * a media item with the mMediaDevice2
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice3, null, mMediaDevice2);
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList().get(0).isFirstDeviceInGroup())
+ .isEqualTo(Flags.enableOutputSwitcherDeviceGrouping());
+
+ // Update the output media item list with more media devices.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice4, mMediaDevice1, mMediaDevice3, mMediaDevice2),
+ /* selectedDevices */ List.of(mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ // Check the output media items to be
+ // * a media item with the selected route mMediaDevice3
+ // * a group divider for suggested devices
+ // * a media item with the route mMediaDevice2
+ // * a group divider for speakers and displays
+ // * a media item with the route mMediaDevice4
+ // * a media item with the route mMediaDevice1
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(
+ mMediaDevice3, null, mMediaDevice2, null, mMediaDevice4, mMediaDevice1);
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList().get(0).isFirstDeviceInGroup())
+ .isEqualTo(Flags.enableOutputSwitcherDeviceGrouping());
+
+ // Update the output media item list where mMediaDevice4 is offline and new selected device.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice1, mMediaDevice3, mMediaDevice2),
+ /* selectedDevices */ List.of(mMediaDevice1, mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ // Check the output media items to be
+ // * a media item with the selected route mMediaDevice3
+ // * a group divider for suggested devices
+ // * a media item with the route mMediaDevice2
+ // * a group divider for speakers and displays
+ // * a media item with the route mMediaDevice1
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice3, null, mMediaDevice2, null, mMediaDevice1);
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList().get(0).isFirstDeviceInGroup())
+ .isEqualTo(Flags.enableOutputSwitcherDeviceGrouping());
+ }
+
+ @EnableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void updateMediaDevices_multipleSelectedDevices_shouldHaveCorrectDeviceOrdering() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ // Create the initial output media item list with mMediaDevice2 and mMediaDevice3.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice2, mMediaDevice4, mMediaDevice3, mMediaDevice1),
+ /* selectedDevices */ List.of(mMediaDevice1, mMediaDevice2, mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ if (Flags.enableOutputSwitcherDeviceGrouping()) {
+ // When the device grouping is enabled, the order of selected devices are preserved:
+ // * a media item with the selected mMediaDevice2
+ // * a media item with the selected mMediaDevice3
+ // * a media item with the selected mMediaDevice1
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(
+ mMediaDevice2, mMediaDevice3, mMediaDevice1, null, mMediaDevice4);
+ assertThat(
+ mOutputMediaItemListProxy
+ .getOutputMediaItemList()
+ .get(0)
+ .isFirstDeviceInGroup())
+ .isTrue();
+ } else {
+ // When the device grouping is disabled, the order of selected devices are reverted:
+ // * a media item with the selected mMediaDevice1
+ // * a media item with the selected mMediaDevice3
+ // * a media item with the selected mMediaDevice2
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(
+ mMediaDevice1, mMediaDevice3, mMediaDevice2, null, mMediaDevice4);
+ }
+
+ // Update the output media item list with a selected device being deselected.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice4, mMediaDevice1, mMediaDevice3, mMediaDevice2),
+ /* selectedDevices */ List.of(mMediaDevice2, mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ if (Flags.enableOutputSwitcherDeviceGrouping()) {
+ // When the device grouping is enabled, the order of selected devices are preserved:
+ // * a media item with the selected mMediaDevice2
+ // * a media item with the selected mMediaDevice3
+ // * a media item with the selected mMediaDevice1
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(
+ mMediaDevice2, mMediaDevice3, mMediaDevice1, null, mMediaDevice4);
+ assertThat(
+ mOutputMediaItemListProxy
+ .getOutputMediaItemList()
+ .get(0)
+ .isFirstDeviceInGroup())
+ .isTrue();
+ } else {
+ // When the device grouping is disabled, the order of selected devices are reverted:
+ // * a media item with the selected mMediaDevice1
+ // * a media item with the selected mMediaDevice3
+ // * a media item with the selected mMediaDevice2
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(
+ mMediaDevice1, mMediaDevice3, mMediaDevice2, null, mMediaDevice4);
+ }
+
+ // Update the output media item list with a selected device is missing.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice1, mMediaDevice3, mMediaDevice4),
+ /* selectedDevices */ List.of(mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ if (Flags.enableOutputSwitcherDeviceGrouping()) {
+ // When the device grouping is enabled, the order of selected devices are preserved:
+ // * a media item with the selected mMediaDevice3
+ // * a media item with the selected mMediaDevice1
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice3, mMediaDevice1, null, mMediaDevice4);
+ assertThat(
+ mOutputMediaItemListProxy
+ .getOutputMediaItemList()
+ .get(0)
+ .isFirstDeviceInGroup())
+ .isTrue();
+ } else {
+ // When the device grouping is disabled, the order of selected devices are reverted:
+ // * a media item with the selected mMediaDevice1
+ // * a media item with the selected mMediaDevice3
+ // * a group divider for speakers and displays
+ // * a media item with the mMediaDevice4
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice1, mMediaDevice3, null, mMediaDevice4);
+ }
+ }
+
+ @EnableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void updateMediaDevices_withConnectNewDeviceMediaItem_shouldUpdateMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ // Create the initial output media item list with a connect new device media item.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice2, mMediaDevice3),
+ /* selectedDevices */ List.of(mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ mConnectNewDeviceMediaItem);
+
+ // Check the output media items to be
+ // * a media item with the selected mMediaDevice3
+ // * a group divider for suggested devices
+ // * a media item with the mMediaDevice2
+ // * a connect new device media item
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList())
+ .contains(mConnectNewDeviceMediaItem);
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice3, null, mMediaDevice2, null);
+
+ // Update the output media item list without a connect new device media item.
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice2, mMediaDevice3),
+ /* selectedDevices */ List.of(mMediaDevice3),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+
+ // Check the output media items to be
+ // * a media item with the selected mMediaDevice3
+ // * a group divider for suggested devices
+ // * a media item with the mMediaDevice2
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList())
+ .doesNotContain(mConnectNewDeviceMediaItem);
+ assertThat(getMediaDevices(mOutputMediaItemListProxy.getOutputMediaItemList()))
+ .containsExactly(mMediaDevice3, null, mMediaDevice2);
+ }
+
+ @DisableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void clearAndAddAll_shouldUpdateMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ mOutputMediaItemListProxy.clearAndAddAll(List.of(mMediaItem1));
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList()).containsExactly(mMediaItem1);
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+
+ mOutputMediaItemListProxy.clearAndAddAll(List.of(mMediaItem2));
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList()).containsExactly(mMediaItem2);
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+ }
+
+ @EnableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void clear_flagOn_shouldClearMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice1),
+ /* selectedDevices */ List.of(),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+
+ mOutputMediaItemListProxy.clear();
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+ }
+
+ @DisableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void clear_flagOff_shouldClearMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ mOutputMediaItemListProxy.clearAndAddAll(List.of(mMediaItem1));
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+
+ mOutputMediaItemListProxy.clear();
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+ }
+
+ @EnableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void removeMutingExpectedDevices_flagOn_shouldClearMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ mOutputMediaItemListProxy.updateMediaDevices(
+ /* devices= */ List.of(mMediaDevice1),
+ /* selectedDevices */ List.of(),
+ /* connectedMediaDevice= */ null,
+ /* needToHandleMutingExpectedDevice= */ false,
+ /* connectNewDeviceMediaItem= */ null);
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+
+ mOutputMediaItemListProxy.removeMutingExpectedDevices();
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+ }
+
+ @DisableFlags(Flags.FLAG_FIX_OUTPUT_MEDIA_ITEM_LIST_INDEX_OUT_OF_BOUNDS_EXCEPTION)
+ @Test
+ public void removeMutingExpectedDevices_flagOff_shouldClearMediaItemList() {
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isTrue();
+
+ mOutputMediaItemListProxy.clearAndAddAll(List.of(mMediaItem1));
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+
+ mOutputMediaItemListProxy.removeMutingExpectedDevices();
+ assertThat(mOutputMediaItemListProxy.getOutputMediaItemList()).containsExactly(mMediaItem1);
+ assertThat(mOutputMediaItemListProxy.isEmpty()).isFalse();
+ }
+
+ private List<MediaDevice> getMediaDevices(List<MediaItem> mediaItems) {
+ return mediaItems.stream()
+ .map(item -> item.getMediaDevice().orElse(null))
+ .collect(Collectors.toList());
+ }
+}