summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Robert Snoeberger <snoeberger@google.com> 2020-05-14 16:47:02 -0400
committer Robert Snoeberger <snoeberger@google.com> 2020-05-19 20:01:03 -0400
commit70d0d6bd99955f154a5f7c60dace82b4cbccbec6 (patch)
tree34b379831d4a0a9b844a40f2b9688890632cb29e
parenta9c6632f543fa0b171bc436a2a530375ee165c9d (diff)
towards MR2: update to new backend
Splitting MediaDeviceManager out of MediaControlPanel. This will make it easier to integrate MediaRouter2Manager#getRoutingSessionForMediaController. Bug: 155266917 Test: atest tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt Test: atest tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java Test: manual - switch route between phone, headphones and cast and check output switcher chip in the player. Change-Id: Ibe6a7e41bd8fb0f0976d1c1c7f2f94cfc4fc6f5e
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt40
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java193
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaData.kt9
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt81
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt33
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt117
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt29
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt19
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java7
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java7
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt15
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java161
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt195
13 files changed, 730 insertions, 176 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt b/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt
new file mode 100644
index 000000000000..94a0835f22f8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2020 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
+
+import android.content.Context
+
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.settingslib.media.InfoMediaManager
+import com.android.settingslib.media.LocalMediaManager
+
+import javax.inject.Inject
+
+/**
+ * Factory to create [LocalMediaManager] objects.
+ */
+class LocalMediaManagerFactory @Inject constructor(
+ private val context: Context,
+ private val localBluetoothManager: LocalBluetoothManager?
+) {
+ /** Creates a [LocalMediaManager] for the given package. */
+ fun create(packageName: String): LocalMediaManager {
+ return InfoMediaManager(context, packageName, null, localBluetoothManager).run {
+ LocalMediaManager(context, localBluetoothManager, this, packageName)
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index aee7a4649d08..f90798bd30b8 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -41,7 +41,6 @@ import android.util.Log;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ImageView;
-import android.widget.LinearLayout;
import android.widget.SeekBar;
import android.widget.TextView;
@@ -56,14 +55,11 @@ import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import com.android.settingslib.Utils;
-import com.android.settingslib.media.LocalMediaManager;
-import com.android.settingslib.media.MediaDevice;
import com.android.settingslib.media.MediaOutputSliceConstants;
import com.android.settingslib.widget.AdaptiveIcon;
import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.qs.QSMediaBrowser;
-import com.android.systemui.util.Assert;
import com.android.systemui.util.concurrency.DelayableExecutor;
import org.jetbrains.annotations.NotNull;
@@ -77,7 +73,6 @@ import java.util.concurrent.Executor;
*/
public class MediaControlPanel {
private static final String TAG = "MediaControlPanel";
- @Nullable private final LocalMediaManager mLocalMediaManager;
// Button IDs for QS controls
static final int[] ACTION_IDS = {
@@ -100,7 +95,6 @@ public class MediaControlPanel {
private MediaSession.Token mToken;
private MediaController mController;
private int mBackgroundColor;
- private MediaDevice mDevice;
protected ComponentName mServiceComponent;
private boolean mIsRegistered = false;
private List<KeyFrames> mKeyFrames;
@@ -113,7 +107,6 @@ public class MediaControlPanel {
public static final String MEDIA_PREFERENCE_KEY = "browser_components";
private SharedPreferences mSharedPrefs;
private boolean mCheckedForResumption = false;
- private boolean mIsRemotePlayback;
private QSMediaBrowser mQSMediaBrowser;
private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
@@ -122,7 +115,6 @@ public class MediaControlPanel {
Log.d(TAG, "session destroyed");
mController.unregisterCallback(mSessionCallback);
clearControls();
- makeInactive();
}
@Override
public void onPlaybackStateChanged(PlaybackState state) {
@@ -130,31 +122,6 @@ public class MediaControlPanel {
if (s == PlaybackState.STATE_NONE) {
Log.d(TAG, "playback state change will trigger resumption, state=" + state);
clearControls();
- makeInactive();
- }
- }
- };
-
- private final LocalMediaManager.DeviceCallback mDeviceCallback =
- new LocalMediaManager.DeviceCallback() {
- @Override
- public void onDeviceListUpdate(List<MediaDevice> devices) {
- if (mLocalMediaManager == null) {
- return;
- }
- MediaDevice currentDevice = mLocalMediaManager.getCurrentConnectedDevice();
- // Check because this can be called several times while changing devices
- if (mDevice == null || !mDevice.equals(currentDevice)) {
- mDevice = currentDevice;
- updateDevice(mDevice);
- }
- }
-
- @Override
- public void onSelectedDeviceStateChanged(MediaDevice device, int state) {
- if (mDevice == null || !mDevice.equals(device)) {
- mDevice = device;
- updateDevice(mDevice);
}
}
};
@@ -162,16 +129,13 @@ public class MediaControlPanel {
/**
* Initialize a new control panel
* @param context
- * @param routeManager Manager used to listen for device change events.
* @param foregroundExecutor foreground executor
* @param backgroundExecutor background executor, used for processing artwork
* @param activityStarter activity starter
*/
- public MediaControlPanel(Context context, @Nullable LocalMediaManager routeManager,
- Executor foregroundExecutor, DelayableExecutor backgroundExecutor,
- ActivityStarter activityStarter) {
+ public MediaControlPanel(Context context, Executor foregroundExecutor,
+ DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) {
mContext = context;
- mLocalMediaManager = routeManager;
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter;
@@ -183,7 +147,6 @@ public class MediaControlPanel {
if (mSeekBarObserver != null) {
mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver);
}
- makeInactive();
}
private void loadDimens() {
@@ -318,30 +281,67 @@ public class MediaControlPanel {
artistText.setText(data.getArtist());
// Transfer chip
- if (mLocalMediaManager != null) {
- mViewHolder.getSeamless().setVisibility(View.VISIBLE);
- setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */);
- setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */);
- updateDevice(mLocalMediaManager.getCurrentConnectedDevice());
- mViewHolder.getSeamless().setOnClickListener(v -> {
- final Intent intent = new Intent()
- .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
- .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
- mController.getPackageName())
- .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
- mActivityStarter.startActivity(intent, false, true /* dismissShade */,
- Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
- });
- } else {
- Log.d(TAG, "LocalMediaManager is null. Not binding output chip for pkg=" + pkgName);
- }
+ mViewHolder.getSeamless().setVisibility(View.VISIBLE);
+ setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */);
+ setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */);
+ mViewHolder.getSeamless().setOnClickListener(v -> {
+ final Intent intent = new Intent()
+ .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
+ .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
+ mController.getPackageName())
+ .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
+ mActivityStarter.startActivity(intent, false, true /* dismissShade */,
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ });
+ final boolean isRemotePlayback;
PlaybackInfo playbackInfo = mController.getPlaybackInfo();
if (playbackInfo != null) {
- mIsRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE;
+ isRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE;
} else {
Log.d(TAG, "PlaybackInfo was null. Defaulting to local playback.");
- mIsRemotePlayback = false;
+ isRemotePlayback = false;
+ }
+
+ ImageView iconView = mViewHolder.getSeamlessIcon();
+ TextView deviceName = mViewHolder.getSeamlessText();
+
+ // Update the outline color
+ RippleDrawable bkgDrawable = (RippleDrawable) mViewHolder.getSeamless().getBackground();
+ GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0);
+ rect.setStroke(2, deviceName.getCurrentTextColor());
+ rect.setColor(Color.TRANSPARENT);
+
+ if (isRemotePlayback) {
+ mViewHolder.getSeamless().setEnabled(false);
+ // TODO(b/156875717): setEnabled should cause the alpha to change.
+ mViewHolder.getSeamless().setAlpha(0.38f);
+ iconView.setImageResource(R.drawable.ic_hardware_speaker);
+ iconView.setVisibility(View.VISIBLE);
+ deviceName.setText(R.string.media_seamless_remote_device);
+ } else if (data.getDevice() != null && data.getDevice().getIcon() != null
+ && data.getDevice().getName() != null) {
+ mViewHolder.getSeamless().setEnabled(true);
+ mViewHolder.getSeamless().setAlpha(1f);
+ Drawable icon = data.getDevice().getIcon();
+ iconView.setVisibility(View.VISIBLE);
+
+ if (icon instanceof AdaptiveIcon) {
+ AdaptiveIcon aIcon = (AdaptiveIcon) icon;
+ aIcon.setBackgroundColor(mBackgroundColor);
+ iconView.setImageDrawable(aIcon);
+ } else {
+ iconView.setImageDrawable(icon);
+ }
+ deviceName.setText(data.getDevice().getName());
+ } else {
+ // Reset to default
+ Log.w(TAG, "device is null. Not binding output chip.");
+ mViewHolder.getSeamless().setEnabled(true);
+ mViewHolder.getSeamless().setAlpha(1f);
+ iconView.setVisibility(View.GONE);
+ deviceName.setText(com.android.internal.R.string.ext_media_seamless_action);
}
+
List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
// Media controls
int i = 0;
@@ -382,8 +382,6 @@ public class MediaControlPanel {
// Set up long press menu
// TODO: b/156036025 bring back media guts
- makeActive();
-
// Update both constraint sets to regenerate the animation.
mViewHolder.getPlayer().updateState(R.id.collapsed, collapsedSet);
mViewHolder.getPlayer().updateState(R.id.expanded, expandedSet);
@@ -515,60 +513,6 @@ public class MediaControlPanel {
}
/**
- * Update the current device information
- * @param device device information to display
- */
- private void updateDevice(MediaDevice device) {
- mForegroundExecutor.execute(() -> {
- updateChipInternal(device);
- });
- }
-
- private void updateChipInternal(MediaDevice device) {
- if (mViewHolder == null) {
- return;
- }
- ImageView iconView = mViewHolder.getSeamlessIcon();
- TextView deviceName = mViewHolder.getSeamlessText();
-
- // Update the outline color
- LinearLayout viewLayout = (LinearLayout) mViewHolder.getSeamless();
- RippleDrawable bkgDrawable = (RippleDrawable) viewLayout.getBackground();
- GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0);
- rect.setStroke(2, deviceName.getCurrentTextColor());
- rect.setColor(Color.TRANSPARENT);
-
- if (mIsRemotePlayback) {
- mViewHolder.getSeamless().setEnabled(false);
- mViewHolder.getSeamless().setAlpha(0.38f);
- iconView.setImageResource(R.drawable.ic_hardware_speaker);
- iconView.setVisibility(View.VISIBLE);
- deviceName.setText(R.string.media_seamless_remote_device);
- } else if (device != null) {
- mViewHolder.getSeamless().setEnabled(true);
- mViewHolder.getSeamless().setAlpha(1f);
- Drawable icon = device.getIcon();
- iconView.setVisibility(View.VISIBLE);
-
- if (icon instanceof AdaptiveIcon) {
- AdaptiveIcon aIcon = (AdaptiveIcon) icon;
- aIcon.setBackgroundColor(mBackgroundColor);
- iconView.setImageDrawable(aIcon);
- } else {
- iconView.setImageDrawable(icon);
- }
- deviceName.setText(device.getName());
- } else {
- // Reset to default
- Log.d(TAG, "device is null. Not binding output chip.");
- mViewHolder.getSeamless().setEnabled(true);
- mViewHolder.getSeamless().setAlpha(1f);
- iconView.setVisibility(View.GONE);
- deviceName.setText(com.android.internal.R.string.ext_media_seamless_action);
- }
- }
-
- /**
* Puts controls into a resumption state if possible, or calls removePlayer if no component was
* found that could resume playback
*/
@@ -642,27 +586,6 @@ public class MediaControlPanel {
set.setAlpha(actionId, visible ? 1.0f : 0.0f);
}
- private void makeActive() {
- Assert.isMainThread();
- if (!mIsRegistered) {
- if (mLocalMediaManager != null) {
- mLocalMediaManager.registerCallback(mDeviceCallback);
- mLocalMediaManager.startScan();
- }
- mIsRegistered = true;
- }
- }
-
- private void makeInactive() {
- Assert.isMainThread();
- if (mIsRegistered) {
- if (mLocalMediaManager != null) {
- mLocalMediaManager.stopScan();
- mLocalMediaManager.unregisterCallback(mDeviceCallback);
- }
- mIsRegistered = false;
- }
- }
/**
* Verify that we can connect to the given component with a MediaBrowser, and if so, add that
* component to the list of resumption components
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
index 85965d03a096..41d411019921 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
@@ -34,7 +34,8 @@ data class MediaData(
val actionsToShowInCompact: List<Int>,
val packageName: String?,
val token: MediaSession.Token?,
- val clickIntent: PendingIntent?
+ val clickIntent: PendingIntent?,
+ val device: MediaDeviceData?
)
/** State of a media action. */
@@ -43,3 +44,9 @@ data class MediaAction(
val intent: PendingIntent?,
val contentDescription: CharSequence?
)
+
+/** State of the media device. */
+data class MediaDeviceData(
+ val icon: Drawable?,
+ val name: String?
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt
new file mode 100644
index 000000000000..cce9838bb8e2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2020 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
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Combines updates from [MediaDataManager] with [MediaDeviceManager].
+ */
+@Singleton
+class MediaDataCombineLatest @Inject constructor(
+ private val dataSource: MediaDataManager,
+ private val deviceSource: MediaDeviceManager
+) {
+ private val listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
+ private val entries: MutableMap<String, Pair<MediaData?, MediaDeviceData?>> = mutableMapOf()
+
+ init {
+ dataSource.addListener(object : MediaDataManager.Listener {
+ override fun onMediaDataLoaded(key: String, data: MediaData) {
+ entries[key] = data to entries[key]?.second
+ update(key)
+ }
+ override fun onMediaDataRemoved(key: String) {
+ remove(key)
+ }
+ })
+ deviceSource.addListener(object : MediaDeviceManager.Listener {
+ override fun onMediaDeviceChanged(key: String, data: MediaDeviceData?) {
+ entries[key] = entries[key]?.first to data
+ update(key)
+ }
+ override fun onKeyRemoved(key: String) {
+ remove(key)
+ }
+ })
+ }
+
+ /**
+ * Add a listener for [MediaData] changes that has been combined with latest [MediaDeviceData].
+ */
+ fun addListener(listener: MediaDataManager.Listener) = listeners.add(listener)
+
+ /**
+ * Remove a listener registered with addListener.
+ */
+ fun removeListener(listener: MediaDataManager.Listener) = listeners.remove(listener)
+
+ private fun update(key: String) {
+ val (entry, device) = entries[key] ?: null to null
+ if (entry != null && device != null) {
+ val data = entry.copy(device = device)
+ listeners.forEach {
+ it.onMediaDataLoaded(key, data)
+ }
+ }
+ }
+
+ private fun remove(key: String) {
+ entries.remove(key)?.let {
+ listeners.forEach {
+ it.onMediaDataRemoved(key)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
index f950d41f02b1..8cbe3ecf5387 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -55,7 +55,19 @@ private const val LUMINOSITY_THRESHOLD = 0.05f
private const val SATURATION_MULTIPLIER = 0.8f
private val LOADING = MediaData(false, 0, null, null, null, null, null,
- emptyList(), emptyList(), null, null, null)
+ emptyList(), emptyList(), null, null, null, null)
+
+fun isMediaNotification(sbn: StatusBarNotification): Boolean {
+ if (!sbn.notification.hasMediaSession()) {
+ return false
+ }
+ val notificationStyle = sbn.notification.notificationStyle
+ if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) ||
+ Notification.MediaStyle::class.java.equals(notificationStyle)) {
+ return true
+ }
+ return false
+}
/**
* A class that facilitates management and loading of Media Data, ready for binding.
@@ -72,7 +84,7 @@ class MediaDataManager @Inject constructor(
private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
- if (isMediaNotification(sbn)) {
+ if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) {
if (!mediaEntries.containsKey(key)) {
mediaEntries.put(key, LOADING)
}
@@ -204,7 +216,7 @@ class MediaDataManager @Inject constructor(
foregroundExecutor.execute {
onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song,
artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
- notif.contentIntent))
+ notif.contentIntent, null))
}
}
@@ -270,21 +282,6 @@ class MediaDataManager @Inject constructor(
}
}
- private fun isMediaNotification(sbn: StatusBarNotification): Boolean {
- if (!Utils.useQsMediaPlayer(context)) {
- return false
- }
- if (!sbn.notification.hasMediaSession()) {
- return false
- }
- val notificationStyle = sbn.notification.notificationStyle
- if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) ||
- Notification.MediaStyle::class.java.equals(notificationStyle)) {
- return true
- }
- return false
- }
-
/**
* Are there any media notifications active?
*/
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt
new file mode 100644
index 000000000000..2d16e2930365
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2020 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
+
+import android.content.Context
+import android.service.notification.StatusBarNotification
+import com.android.settingslib.media.LocalMediaManager
+import com.android.settingslib.media.MediaDevice
+import com.android.systemui.dagger.qualifiers.Main
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Provides information about the route (ie. device) where playback is occurring.
+ */
+@Singleton
+class MediaDeviceManager @Inject constructor(
+ private val context: Context,
+ private val localMediaManagerFactory: LocalMediaManagerFactory,
+ private val featureFlag: MediaFeatureFlag,
+ @Main private val fgExecutor: Executor
+) {
+ private val listeners: MutableSet<Listener> = mutableSetOf()
+ private val entries: MutableMap<String, Token> = mutableMapOf()
+
+ /**
+ * Add a listener for changes to the media route (ie. device).
+ */
+ fun addListener(listener: Listener) = listeners.add(listener)
+
+ /**
+ * Remove a listener that has been registered with addListener.
+ */
+ fun removeListener(listener: Listener) = listeners.remove(listener)
+
+ fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+ if (featureFlag.enabled && isMediaNotification(sbn)) {
+ var tok = entries[key]
+ if (tok == null) {
+ tok = Token(key, localMediaManagerFactory.create(sbn.packageName))
+ entries[key] = tok
+ tok.start()
+ }
+ } else {
+ onNotificationRemoved(key)
+ }
+ }
+
+ fun onNotificationRemoved(key: String) {
+ val token = entries.remove(key)
+ token?.stop()
+ token?.let {
+ listeners.forEach {
+ it.onKeyRemoved(key)
+ }
+ }
+ }
+
+ private fun processDevice(key: String, device: MediaDevice?) {
+ val data = MediaDeviceData(device?.icon, device?.name)
+ listeners.forEach {
+ it.onMediaDeviceChanged(key, data)
+ }
+ }
+
+ interface Listener {
+ /** Called when the route has changed for a given notification. */
+ fun onMediaDeviceChanged(key: String, data: MediaDeviceData?)
+ /** Called when the notification was removed. */
+ fun onKeyRemoved(key: String)
+ }
+
+ private inner class Token(
+ val key: String,
+ val localMediaManager: LocalMediaManager
+ ) : LocalMediaManager.DeviceCallback {
+ private var current: MediaDevice? = null
+ set(value) {
+ if (value != field) {
+ field = value
+ processDevice(key, value)
+ }
+ }
+ fun start() {
+ localMediaManager.registerCallback(this)
+ localMediaManager.startScan()
+ current = localMediaManager.getCurrentConnectedDevice()
+ }
+ fun stop() {
+ localMediaManager.stopScan()
+ localMediaManager.unregisterCallback(this)
+ }
+ override fun onDeviceListUpdate(devices: List<MediaDevice>?) = fgExecutor.execute {
+ current = localMediaManager.getCurrentConnectedDevice()
+ }
+ override fun onSelectedDeviceStateChanged(device: MediaDevice, state: Int) {
+ fgExecutor.execute {
+ current = device
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt b/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt
new file mode 100644
index 000000000000..75eb33da64d8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2020 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
+
+import android.content.Context
+import com.android.systemui.util.Utils
+import javax.inject.Inject
+
+/**
+ * Provides access to the current value of the feature flag.
+ */
+class MediaFeatureFlag @Inject constructor(private val context: Context) {
+ val enabled
+ get() = Utils.useQsMediaPlayer(context)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
index 8db9dcc1ecec..17e8404fe705 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
@@ -6,9 +6,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
-import com.android.settingslib.bluetooth.LocalBluetoothManager
-import com.android.settingslib.media.InfoMediaManager
-import com.android.settingslib.media.LocalMediaManager
import com.android.systemui.R
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main
@@ -30,10 +27,9 @@ class MediaViewManager @Inject constructor(
private val context: Context,
@Main private val foregroundExecutor: Executor,
@Background private val backgroundExecutor: DelayableExecutor,
- private val localBluetoothManager: LocalBluetoothManager?,
private val visualStabilityManager: VisualStabilityManager,
private val activityStarter: ActivityStarter,
- mediaManager: MediaDataManager
+ mediaManager: MediaDataCombineLatest
) {
private var playerWidth: Int = 0
private var playerWidthPlusPadding: Int = 0
@@ -42,7 +38,7 @@ class MediaViewManager @Inject constructor(
val mediaCarousel: HorizontalScrollView
private val mediaContent: ViewGroup
private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
- private val visualStabilityCallback : VisualStabilityManager.Callback
+ private val visualStabilityCallback: VisualStabilityManager.Callback
private var activeMediaIndex: Int = 0
private var needsReordering: Boolean = false
private var scrollIntoCurrentMedia: Int = 0
@@ -151,15 +147,8 @@ class MediaViewManager @Inject constructor(
private fun updateView(key: String, data: MediaData) {
var existingPlayer = mediaPlayers[key]
if (existingPlayer == null) {
- // Set up listener for device changes
- // TODO: integrate with MediaTransferManager?
- val imm = InfoMediaManager(context, data.packageName,
- null /* notification */, localBluetoothManager)
- val routeManager = LocalMediaManager(context, localBluetoothManager,
- imm, data.packageName)
-
- existingPlayer = MediaControlPanel(context, routeManager, foregroundExecutor,
- backgroundExecutor, activityStarter)
+ existingPlayer = MediaControlPanel(context, foregroundExecutor, backgroundExecutor,
+ activityStarter)
existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context),
mediaContent))
mediaPlayers[key] = existingPlayer
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
index 0fa1f553c1f3..8ed69d8fb982 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
@@ -53,6 +53,7 @@ import com.android.systemui.Interpolators;
import com.android.systemui.colorextraction.SysuiColorExtractor;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.media.MediaDataManager;
+import com.android.systemui.media.MediaDeviceManager;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.dagger.StatusBarModule;
import com.android.systemui.statusbar.notification.NotificationEntryListener;
@@ -218,7 +219,8 @@ public class NotificationMediaManager implements Dumpable {
KeyguardBypassController keyguardBypassController,
@Main DelayableExecutor mainExecutor,
DeviceConfigProxy deviceConfig,
- MediaDataManager mediaDataManager) {
+ MediaDataManager mediaDataManager,
+ MediaDeviceManager mediaDeviceManager) {
mContext = context;
mMediaArtworkProcessor = mediaArtworkProcessor;
mKeyguardBypassController = keyguardBypassController;
@@ -239,11 +241,13 @@ public class NotificationMediaManager implements Dumpable {
@Override
public void onPendingEntryAdded(NotificationEntry entry) {
mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn());
+ mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn());
}
@Override
public void onPreEntryUpdated(NotificationEntry entry) {
mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn());
+ mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn());
}
@Override
@@ -264,6 +268,7 @@ public class NotificationMediaManager implements Dumpable {
int reason) {
onNotificationRemoved(entry.getKey());
mediaDataManager.onNotificationRemoved(entry.getKey());
+ mediaDeviceManager.onNotificationRemoved(entry.getKey());
}
});
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java
index 8c9ce500e3ea..ac2a9c118672 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java
@@ -24,6 +24,7 @@ import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.bubbles.BubbleController;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.media.MediaDataManager;
+import com.android.systemui.media.MediaDeviceManager;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.ActionClickLogger;
import com.android.systemui.statusbar.CommandQueue;
@@ -101,7 +102,8 @@ public interface StatusBarDependenciesModule {
KeyguardBypassController keyguardBypassController,
@Main DelayableExecutor mainExecutor,
DeviceConfigProxy deviceConfigProxy,
- MediaDataManager mediaDataManager) {
+ MediaDataManager mediaDataManager,
+ MediaDeviceManager mediaDeviceManager) {
return new NotificationMediaManager(
context,
statusBarLazy,
@@ -111,7 +113,8 @@ public interface StatusBarDependenciesModule {
keyguardBypassController,
mainExecutor,
deviceConfigProxy,
- mediaDataManager);
+ mediaDataManager,
+ mediaDeviceManager);
}
/** */
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
index 60d15a8fb480..e8fb41a18ce9 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
@@ -18,6 +18,8 @@ package com.android.systemui.media
import android.content.res.ColorStateList
import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.RippleDrawable
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
@@ -104,7 +106,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
activityStarter = mock(ActivityStarter::class.java)
- player = MediaControlPanel(context, null, fgExecutor, bgExecutor, activityStarter)
+ player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter)
// Mock out a view holder for the player to attach to.
holder = mock(PlayerViewHolder::class.java)
@@ -129,6 +131,9 @@ public class MediaControlPanelTest : SysuiTestCase() {
artistText = TextView(context)
whenever(holder.artistText).thenReturn(artistText)
seamless = FrameLayout(context)
+ val seamlessBackground = mock(RippleDrawable::class.java)
+ seamless.setBackground(seamlessBackground)
+ whenever(seamlessBackground.getDrawable(0)).thenReturn(mock(GradientDrawable::class.java))
whenever(holder.seamless).thenReturn(seamless)
seamlessIcon = ImageView(context)
whenever(holder.seamlessIcon).thenReturn(seamlessIcon)
@@ -176,7 +181,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Test
fun bindWhenUnattached() {
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
- emptyList(), PACKAGE, null, null)
+ emptyList(), PACKAGE, null, null, MediaDeviceData(null, DEVICE_NAME))
player.bind(state)
assertThat(player.isPlaying()).isFalse()
}
@@ -185,7 +190,8 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindText() {
player.attach(holder)
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
- emptyList(), PACKAGE, session.getSessionToken(), null)
+ emptyList(), PACKAGE, session.getSessionToken(), null,
+ MediaDeviceData(null, DEVICE_NAME))
player.bind(state)
assertThat(appName.getText()).isEqualTo(APP)
assertThat(titleText.getText()).isEqualTo(TITLE)
@@ -196,7 +202,8 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindBackgroundColor() {
player.attach(holder)
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
- emptyList(), PACKAGE, session.getSessionToken(), null)
+ emptyList(), PACKAGE, session.getSessionToken(), null,
+ MediaDeviceData(null, DEVICE_NAME))
player.bind(state)
assertThat(background.getBackgroundTintList()).isEqualTo(ColorStateList.valueOf(BG_COLOR))
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
new file mode 100644
index 000000000000..64a180f8aaab
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2020 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.graphics.Color;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class MediaDataCombineLatestTest extends SysuiTestCase {
+
+ private static final String KEY = "TEST_KEY";
+ private static final String APP = "APP";
+ private static final String PACKAGE = "PKG";
+ private static final int BG_COLOR = Color.RED;
+ private static final String ARTIST = "ARTIST";
+ private static final String TITLE = "TITLE";
+ private static final String DEVICE_NAME = "DEVICE_NAME";
+
+ private MediaDataCombineLatest mManager;
+
+ @Mock private MediaDataManager mDataSource;
+ @Mock private MediaDeviceManager mDeviceSource;
+ @Mock private MediaDataManager.Listener mListener;
+
+ private MediaDataManager.Listener mDataListener;
+ private MediaDeviceManager.Listener mDeviceListener;
+
+ private MediaData mMediaData;
+ private MediaDeviceData mDeviceData;
+
+ @Before
+ public void setUp() {
+ mDataSource = mock(MediaDataManager.class);
+ mDeviceSource = mock(MediaDeviceManager.class);
+ mListener = mock(MediaDataManager.Listener.class);
+
+ mManager = new MediaDataCombineLatest(mDataSource, mDeviceSource);
+
+ mDataListener = captureDataListener();
+ mDeviceListener = captureDeviceListener();
+
+ mManager.addListener(mListener);
+
+ mMediaData = new MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null,
+ new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null);
+ mDeviceData = new MediaDeviceData(null, DEVICE_NAME);
+ }
+
+ @Test
+ public void eventNotEmittedWithoutDevice() {
+ // WHEN data source emits an event without device data
+ mDataListener.onMediaDataLoaded(KEY, mMediaData);
+ // THEN an event isn't emitted
+ verify(mListener, never()).onMediaDataLoaded(eq(KEY), any());
+ }
+
+ @Test
+ public void eventNotEmittedWithoutMedia() {
+ // WHEN device source emits an event without media data
+ mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
+ // THEN an event isn't emitted
+ verify(mListener, never()).onMediaDataLoaded(eq(KEY), any());
+ }
+
+ @Test
+ public void emitEventAfterDeviceFirst() {
+ // GIVEN that a device event has already been received
+ mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
+ // WHEN media event is received
+ mDataListener.onMediaDataLoaded(KEY, mMediaData);
+ // THEN the listener receives a combined event
+ ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class);
+ verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture());
+ assertThat(captor.getValue().getDevice()).isNotNull();
+ }
+
+ @Test
+ public void emitEventAfterMediaFirst() {
+ // GIVEN that media event has already been received
+ mDataListener.onMediaDataLoaded(KEY, mMediaData);
+ // WHEN device event is received
+ mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
+ // THEN the listener receives a combined event
+ ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class);
+ verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture());
+ assertThat(captor.getValue().getDevice()).isNotNull();
+ }
+
+ @Test
+ public void mediaDataRemoved() {
+ // WHEN media data is removed without first receiving device or data
+ mDataListener.onMediaDataRemoved(KEY);
+ // THEN a removed event isn't emitted
+ verify(mListener, never()).onMediaDataRemoved(eq(KEY));
+ }
+
+ @Test
+ public void mediaDataRemovedAfterMediaEvent() {
+ mDataListener.onMediaDataLoaded(KEY, mMediaData);
+ mDataListener.onMediaDataRemoved(KEY);
+ verify(mListener).onMediaDataRemoved(eq(KEY));
+ }
+
+ @Test
+ public void mediaDataRemovedAfterDeviceEvent() {
+ mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
+ mDataListener.onMediaDataRemoved(KEY);
+ verify(mListener).onMediaDataRemoved(eq(KEY));
+ }
+
+ private MediaDataManager.Listener captureDataListener() {
+ ArgumentCaptor<MediaDataManager.Listener> captor = ArgumentCaptor.forClass(
+ MediaDataManager.Listener.class);
+ verify(mDataSource).addListener(captor.capture());
+ return captor.getValue();
+ }
+
+ private MediaDeviceManager.Listener captureDeviceListener() {
+ ArgumentCaptor<MediaDeviceManager.Listener> captor = ArgumentCaptor.forClass(
+ MediaDeviceManager.Listener.class);
+ verify(mDeviceSource).addListener(captor.capture());
+ return captor.getValue();
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
new file mode 100644
index 000000000000..ac6b5f6bca66
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2020 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
+
+import android.app.Notification
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Process
+import android.service.notification.StatusBarNotification
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+
+import com.android.settingslib.media.LocalMediaManager
+import com.android.settingslib.media.MediaDevice
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+
+import com.google.common.truth.Truth.assertThat
+
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.any
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when` as whenever
+
+private const val KEY = "TEST_KEY"
+private const val PACKAGE = "PKG"
+private const val SESSION_KEY = "SESSION_KEY"
+private const val SESSION_ARTIST = "SESSION_ARTIST"
+private const val SESSION_TITLE = "SESSION_TITLE"
+private const val DEVICE_NAME = "DEVICE_NAME"
+
+private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper
+public class MediaDeviceManagerTest : SysuiTestCase() {
+
+ private lateinit var manager: MediaDeviceManager
+
+ @Mock private lateinit var lmmFactory: LocalMediaManagerFactory
+ @Mock private lateinit var lmm: LocalMediaManager
+ @Mock private lateinit var featureFlag: MediaFeatureFlag
+ private lateinit var fakeExecutor: FakeExecutor
+
+ @Mock private lateinit var device: MediaDevice
+ private lateinit var session: MediaSession
+ private lateinit var metadataBuilder: MediaMetadata.Builder
+ private lateinit var playbackBuilder: PlaybackState.Builder
+ private lateinit var notifBuilder: Notification.Builder
+ private lateinit var sbn: StatusBarNotification
+
+ @Before
+ fun setup() {
+ lmmFactory = mock(LocalMediaManagerFactory::class.java)
+ lmm = mock(LocalMediaManager::class.java)
+ device = mock(MediaDevice::class.java)
+ whenever(device.name).thenReturn(DEVICE_NAME)
+ whenever(lmmFactory.create(PACKAGE)).thenReturn(lmm)
+ whenever(lmm.getCurrentConnectedDevice()).thenReturn(device)
+ featureFlag = mock(MediaFeatureFlag::class.java)
+ whenever(featureFlag.enabled).thenReturn(true)
+
+ fakeExecutor = FakeExecutor(FakeSystemClock())
+
+ manager = MediaDeviceManager(context, lmmFactory, featureFlag, fakeExecutor)
+
+ // Create a media sesssion and notification for testing.
+ metadataBuilder = MediaMetadata.Builder().apply {
+ putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST)
+ putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
+ }
+ playbackBuilder = PlaybackState.Builder().apply {
+ setState(PlaybackState.STATE_PAUSED, 6000L, 1f)
+ setActions(PlaybackState.ACTION_PLAY)
+ }
+ session = MediaSession(context, SESSION_KEY).apply {
+ setMetadata(metadataBuilder.build())
+ setPlaybackState(playbackBuilder.build())
+ }
+ session.setActive(true)
+ notifBuilder = Notification.Builder(context, "NONE").apply {
+ setContentTitle(SESSION_TITLE)
+ setContentText(SESSION_ARTIST)
+ setSmallIcon(android.R.drawable.ic_media_pause)
+ setStyle(Notification.MediaStyle().setMediaSession(session.getSessionToken()))
+ }
+ sbn = StatusBarNotification(PACKAGE, PACKAGE, 0, "TAG", Process.myUid(), 0, 0,
+ notifBuilder.build(), Process.myUserHandle(), 0)
+ }
+
+ @After
+ fun tearDown() {
+ session.release()
+ }
+
+ @Test
+ fun removeUnknown() {
+ manager.onNotificationRemoved("unknown")
+ }
+
+ @Test
+ fun addNotification() {
+ manager.onNotificationAdded(KEY, sbn)
+ verify(lmmFactory).create(PACKAGE)
+ }
+
+ @Test
+ fun featureDisabled() {
+ whenever(featureFlag.enabled).thenReturn(false)
+ manager.onNotificationAdded(KEY, sbn)
+ verify(lmmFactory, never()).create(PACKAGE)
+ }
+
+ @Test
+ fun addAndRemoveNotification() {
+ manager.onNotificationAdded(KEY, sbn)
+ manager.onNotificationRemoved(KEY)
+ verify(lmm).unregisterCallback(any())
+ }
+
+ @Test
+ fun deviceListUpdate() {
+ val listener = mock(MediaDeviceManager.Listener::class.java)
+ manager.addListener(listener)
+ manager.onNotificationAdded(KEY, sbn)
+ val deviceCallback = captureCallback()
+ // WHEN the device list changes
+ deviceCallback.onDeviceListUpdate(mutableListOf(device))
+ assertThat(fakeExecutor.runAllReady()).isEqualTo(1)
+ // THEN the update is dispatched to the listener
+ val captor = ArgumentCaptor.forClass(MediaDeviceData::class.java)
+ verify(listener).onMediaDeviceChanged(eq(KEY), captor.capture())
+ val data = captor.getValue()
+ assertThat(data.name).isEqualTo(DEVICE_NAME)
+ }
+
+ @Test
+ fun selectedDeviceStateChanged() {
+ val listener = mock(MediaDeviceManager.Listener::class.java)
+ manager.addListener(listener)
+ manager.onNotificationAdded(KEY, sbn)
+ val deviceCallback = captureCallback()
+ // WHEN the selected device changes state
+ deviceCallback.onSelectedDeviceStateChanged(device, 1)
+ assertThat(fakeExecutor.runAllReady()).isEqualTo(1)
+ // THEN the update is dispatched to the listener
+ val captor = ArgumentCaptor.forClass(MediaDeviceData::class.java)
+ verify(listener).onMediaDeviceChanged(eq(KEY), captor.capture())
+ val data = captor.getValue()
+ assertThat(data.name).isEqualTo(DEVICE_NAME)
+ }
+
+ @Test
+ fun listenerReceivesKeyRemoved() {
+ manager.onNotificationAdded(KEY, sbn)
+ val listener = mock(MediaDeviceManager.Listener::class.java)
+ manager.addListener(listener)
+ // WHEN the notification is removed
+ manager.onNotificationRemoved(KEY)
+ // THEN the listener receives key removed event
+ verify(listener).onKeyRemoved(eq(KEY))
+ }
+
+ fun captureCallback(): LocalMediaManager.DeviceCallback {
+ val captor = ArgumentCaptor.forClass(LocalMediaManager.DeviceCallback::class.java)
+ verify(lmm).registerCallback(captor.capture())
+ return captor.getValue()
+ }
+}