Merge "towards MR2: update to new backend" into rvc-dev

This commit is contained in:
TreeHugger Robot
2020-05-20 03:19:39 +00:00
committed by Android (Google) Code Review
13 changed files with 730 additions and 176 deletions

View File

@@ -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)
}
}
}

View File

@@ -41,7 +41,6 @@ import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.SeekBar; import android.widget.SeekBar;
import android.widget.TextView; import android.widget.TextView;
@@ -56,14 +55,11 @@ import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import com.android.settingslib.Utils; 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.media.MediaOutputSliceConstants;
import com.android.settingslib.widget.AdaptiveIcon; import com.android.settingslib.widget.AdaptiveIcon;
import com.android.systemui.R; import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.qs.QSMediaBrowser; import com.android.systemui.qs.QSMediaBrowser;
import com.android.systemui.util.Assert;
import com.android.systemui.util.concurrency.DelayableExecutor; import com.android.systemui.util.concurrency.DelayableExecutor;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -77,7 +73,6 @@ import java.util.concurrent.Executor;
*/ */
public class MediaControlPanel { public class MediaControlPanel {
private static final String TAG = "MediaControlPanel"; private static final String TAG = "MediaControlPanel";
@Nullable private final LocalMediaManager mLocalMediaManager;
// Button IDs for QS controls // Button IDs for QS controls
static final int[] ACTION_IDS = { static final int[] ACTION_IDS = {
@@ -100,7 +95,6 @@ public class MediaControlPanel {
private MediaSession.Token mToken; private MediaSession.Token mToken;
private MediaController mController; private MediaController mController;
private int mBackgroundColor; private int mBackgroundColor;
private MediaDevice mDevice;
protected ComponentName mServiceComponent; protected ComponentName mServiceComponent;
private boolean mIsRegistered = false; private boolean mIsRegistered = false;
private List<KeyFrames> mKeyFrames; private List<KeyFrames> mKeyFrames;
@@ -113,7 +107,6 @@ public class MediaControlPanel {
public static final String MEDIA_PREFERENCE_KEY = "browser_components"; public static final String MEDIA_PREFERENCE_KEY = "browser_components";
private SharedPreferences mSharedPrefs; private SharedPreferences mSharedPrefs;
private boolean mCheckedForResumption = false; private boolean mCheckedForResumption = false;
private boolean mIsRemotePlayback;
private QSMediaBrowser mQSMediaBrowser; private QSMediaBrowser mQSMediaBrowser;
private final MediaController.Callback mSessionCallback = new MediaController.Callback() { private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
@@ -122,7 +115,6 @@ public class MediaControlPanel {
Log.d(TAG, "session destroyed"); Log.d(TAG, "session destroyed");
mController.unregisterCallback(mSessionCallback); mController.unregisterCallback(mSessionCallback);
clearControls(); clearControls();
makeInactive();
} }
@Override @Override
public void onPlaybackStateChanged(PlaybackState state) { public void onPlaybackStateChanged(PlaybackState state) {
@@ -130,31 +122,6 @@ public class MediaControlPanel {
if (s == PlaybackState.STATE_NONE) { if (s == PlaybackState.STATE_NONE) {
Log.d(TAG, "playback state change will trigger resumption, state=" + state); Log.d(TAG, "playback state change will trigger resumption, state=" + state);
clearControls(); 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 * Initialize a new control panel
* @param context * @param context
* @param routeManager Manager used to listen for device change events.
* @param foregroundExecutor foreground executor * @param foregroundExecutor foreground executor
* @param backgroundExecutor background executor, used for processing artwork * @param backgroundExecutor background executor, used for processing artwork
* @param activityStarter activity starter * @param activityStarter activity starter
*/ */
public MediaControlPanel(Context context, @Nullable LocalMediaManager routeManager, public MediaControlPanel(Context context, Executor foregroundExecutor,
Executor foregroundExecutor, DelayableExecutor backgroundExecutor, DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) {
ActivityStarter activityStarter) {
mContext = context; mContext = context;
mLocalMediaManager = routeManager;
mForegroundExecutor = foregroundExecutor; mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor; mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter; mActivityStarter = activityStarter;
@@ -183,7 +147,6 @@ public class MediaControlPanel {
if (mSeekBarObserver != null) { if (mSeekBarObserver != null) {
mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver); mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver);
} }
makeInactive();
} }
private void loadDimens() { private void loadDimens() {
@@ -318,30 +281,67 @@ public class MediaControlPanel {
artistText.setText(data.getArtist()); artistText.setText(data.getArtist());
// Transfer chip // Transfer chip
if (mLocalMediaManager != null) { mViewHolder.getSeamless().setVisibility(View.VISIBLE);
mViewHolder.getSeamless().setVisibility(View.VISIBLE); setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */);
setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */); setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */);
setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */); mViewHolder.getSeamless().setOnClickListener(v -> {
updateDevice(mLocalMediaManager.getCurrentConnectedDevice()); final Intent intent = new Intent()
mViewHolder.getSeamless().setOnClickListener(v -> { .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
final Intent intent = new Intent() .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
.setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT) mController.getPackageName())
.putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME, .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
mController.getPackageName()) mActivityStarter.startActivity(intent, false, true /* dismissShade */,
.putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken); Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
mActivityStarter.startActivity(intent, false, true /* dismissShade */, });
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); final boolean isRemotePlayback;
});
} else {
Log.d(TAG, "LocalMediaManager is null. Not binding output chip for pkg=" + pkgName);
}
PlaybackInfo playbackInfo = mController.getPlaybackInfo(); PlaybackInfo playbackInfo = mController.getPlaybackInfo();
if (playbackInfo != null) { if (playbackInfo != null) {
mIsRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE; isRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE;
} else { } else {
Log.d(TAG, "PlaybackInfo was null. Defaulting to local playback."); 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(); List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
// Media controls // Media controls
int i = 0; int i = 0;
@@ -382,8 +382,6 @@ public class MediaControlPanel {
// Set up long press menu // Set up long press menu
// TODO: b/156036025 bring back media guts // TODO: b/156036025 bring back media guts
makeActive();
// Update both constraint sets to regenerate the animation. // Update both constraint sets to regenerate the animation.
mViewHolder.getPlayer().updateState(R.id.collapsed, collapsedSet); mViewHolder.getPlayer().updateState(R.id.collapsed, collapsedSet);
mViewHolder.getPlayer().updateState(R.id.expanded, expandedSet); mViewHolder.getPlayer().updateState(R.id.expanded, expandedSet);
@@ -514,60 +512,6 @@ public class MediaControlPanel {
return (state.getState() == PlaybackState.STATE_PLAYING); return (state.getState() == PlaybackState.STATE_PLAYING);
} }
/**
* 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 * Puts controls into a resumption state if possible, or calls removePlayer if no component was
* found that could resume playback * found that could resume playback
@@ -642,27 +586,6 @@ public class MediaControlPanel {
set.setAlpha(actionId, visible ? 1.0f : 0.0f); 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 * Verify that we can connect to the given component with a MediaBrowser, and if so, add that
* component to the list of resumption components * component to the list of resumption components

View File

@@ -34,7 +34,8 @@ data class MediaData(
val actionsToShowInCompact: List<Int>, val actionsToShowInCompact: List<Int>,
val packageName: String?, val packageName: String?,
val token: MediaSession.Token?, val token: MediaSession.Token?,
val clickIntent: PendingIntent? val clickIntent: PendingIntent?,
val device: MediaDeviceData?
) )
/** State of a media action. */ /** State of a media action. */
@@ -43,3 +44,9 @@ data class MediaAction(
val intent: PendingIntent?, val intent: PendingIntent?,
val contentDescription: CharSequence? val contentDescription: CharSequence?
) )
/** State of the media device. */
data class MediaDeviceData(
val icon: Drawable?,
val name: String?
)

View File

@@ -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)
}
}
}
}

View File

@@ -55,7 +55,19 @@ private const val LUMINOSITY_THRESHOLD = 0.05f
private const val SATURATION_MULTIPLIER = 0.8f private const val SATURATION_MULTIPLIER = 0.8f
private val LOADING = MediaData(false, 0, null, null, null, null, null, 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. * 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() private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
fun onNotificationAdded(key: String, sbn: StatusBarNotification) { fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
if (isMediaNotification(sbn)) { if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) {
if (!mediaEntries.containsKey(key)) { if (!mediaEntries.containsKey(key)) {
mediaEntries.put(key, LOADING) mediaEntries.put(key, LOADING)
} }
@@ -204,7 +216,7 @@ class MediaDataManager @Inject constructor(
foregroundExecutor.execute { foregroundExecutor.execute {
onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song, onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song,
artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token, 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? * Are there any media notifications active?
*/ */

View File

@@ -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
}
}
}
}

View File

@@ -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)
}

View File

@@ -6,9 +6,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.HorizontalScrollView import android.widget.HorizontalScrollView
import android.widget.LinearLayout 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.R
import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dagger.qualifiers.Main
@@ -30,10 +27,9 @@ class MediaViewManager @Inject constructor(
private val context: Context, private val context: Context,
@Main private val foregroundExecutor: Executor, @Main private val foregroundExecutor: Executor,
@Background private val backgroundExecutor: DelayableExecutor, @Background private val backgroundExecutor: DelayableExecutor,
private val localBluetoothManager: LocalBluetoothManager?,
private val visualStabilityManager: VisualStabilityManager, private val visualStabilityManager: VisualStabilityManager,
private val activityStarter: ActivityStarter, private val activityStarter: ActivityStarter,
mediaManager: MediaDataManager mediaManager: MediaDataCombineLatest
) { ) {
private var playerWidth: Int = 0 private var playerWidth: Int = 0
private var playerWidthPlusPadding: Int = 0 private var playerWidthPlusPadding: Int = 0
@@ -42,7 +38,7 @@ class MediaViewManager @Inject constructor(
val mediaCarousel: HorizontalScrollView val mediaCarousel: HorizontalScrollView
private val mediaContent: ViewGroup private val mediaContent: ViewGroup
private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf() private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
private val visualStabilityCallback : VisualStabilityManager.Callback private val visualStabilityCallback: VisualStabilityManager.Callback
private var activeMediaIndex: Int = 0 private var activeMediaIndex: Int = 0
private var needsReordering: Boolean = false private var needsReordering: Boolean = false
private var scrollIntoCurrentMedia: Int = 0 private var scrollIntoCurrentMedia: Int = 0
@@ -151,15 +147,8 @@ class MediaViewManager @Inject constructor(
private fun updateView(key: String, data: MediaData) { private fun updateView(key: String, data: MediaData) {
var existingPlayer = mediaPlayers[key] var existingPlayer = mediaPlayers[key]
if (existingPlayer == null) { if (existingPlayer == null) {
// Set up listener for device changes existingPlayer = MediaControlPanel(context, foregroundExecutor, backgroundExecutor,
// TODO: integrate with MediaTransferManager? activityStarter)
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.attach(PlayerViewHolder.create(LayoutInflater.from(context), existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context),
mediaContent)) mediaContent))
mediaPlayers[key] = existingPlayer mediaPlayers[key] = existingPlayer

View File

@@ -53,6 +53,7 @@ import com.android.systemui.Interpolators;
import com.android.systemui.colorextraction.SysuiColorExtractor; import com.android.systemui.colorextraction.SysuiColorExtractor;
import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.media.MediaDataManager; import com.android.systemui.media.MediaDataManager;
import com.android.systemui.media.MediaDeviceManager;
import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.dagger.StatusBarModule; import com.android.systemui.statusbar.dagger.StatusBarModule;
import com.android.systemui.statusbar.notification.NotificationEntryListener; import com.android.systemui.statusbar.notification.NotificationEntryListener;
@@ -218,7 +219,8 @@ public class NotificationMediaManager implements Dumpable {
KeyguardBypassController keyguardBypassController, KeyguardBypassController keyguardBypassController,
@Main DelayableExecutor mainExecutor, @Main DelayableExecutor mainExecutor,
DeviceConfigProxy deviceConfig, DeviceConfigProxy deviceConfig,
MediaDataManager mediaDataManager) { MediaDataManager mediaDataManager,
MediaDeviceManager mediaDeviceManager) {
mContext = context; mContext = context;
mMediaArtworkProcessor = mediaArtworkProcessor; mMediaArtworkProcessor = mediaArtworkProcessor;
mKeyguardBypassController = keyguardBypassController; mKeyguardBypassController = keyguardBypassController;
@@ -239,11 +241,13 @@ public class NotificationMediaManager implements Dumpable {
@Override @Override
public void onPendingEntryAdded(NotificationEntry entry) { public void onPendingEntryAdded(NotificationEntry entry) {
mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn()); mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn());
mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn());
} }
@Override @Override
public void onPreEntryUpdated(NotificationEntry entry) { public void onPreEntryUpdated(NotificationEntry entry) {
mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn()); mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn());
mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn());
} }
@Override @Override
@@ -264,6 +268,7 @@ public class NotificationMediaManager implements Dumpable {
int reason) { int reason) {
onNotificationRemoved(entry.getKey()); onNotificationRemoved(entry.getKey());
mediaDataManager.onNotificationRemoved(entry.getKey()); mediaDataManager.onNotificationRemoved(entry.getKey());
mediaDeviceManager.onNotificationRemoved(entry.getKey());
} }
}); });

View File

@@ -24,6 +24,7 @@ import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.bubbles.BubbleController; import com.android.systemui.bubbles.BubbleController;
import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.media.MediaDataManager; import com.android.systemui.media.MediaDataManager;
import com.android.systemui.media.MediaDeviceManager;
import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.ActionClickLogger; import com.android.systemui.statusbar.ActionClickLogger;
import com.android.systemui.statusbar.CommandQueue; import com.android.systemui.statusbar.CommandQueue;
@@ -101,7 +102,8 @@ public interface StatusBarDependenciesModule {
KeyguardBypassController keyguardBypassController, KeyguardBypassController keyguardBypassController,
@Main DelayableExecutor mainExecutor, @Main DelayableExecutor mainExecutor,
DeviceConfigProxy deviceConfigProxy, DeviceConfigProxy deviceConfigProxy,
MediaDataManager mediaDataManager) { MediaDataManager mediaDataManager,
MediaDeviceManager mediaDeviceManager) {
return new NotificationMediaManager( return new NotificationMediaManager(
context, context,
statusBarLazy, statusBarLazy,
@@ -111,7 +113,8 @@ public interface StatusBarDependenciesModule {
keyguardBypassController, keyguardBypassController,
mainExecutor, mainExecutor,
deviceConfigProxy, deviceConfigProxy,
mediaDataManager); mediaDataManager,
mediaDeviceManager);
} }
/** */ /** */

View File

@@ -18,6 +18,8 @@ package com.android.systemui.media
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.session.MediaSession import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
@@ -104,7 +106,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
activityStarter = mock(ActivityStarter::class.java) 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. // Mock out a view holder for the player to attach to.
holder = mock(PlayerViewHolder::class.java) holder = mock(PlayerViewHolder::class.java)
@@ -129,6 +131,9 @@ public class MediaControlPanelTest : SysuiTestCase() {
artistText = TextView(context) artistText = TextView(context)
whenever(holder.artistText).thenReturn(artistText) whenever(holder.artistText).thenReturn(artistText)
seamless = FrameLayout(context) 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) whenever(holder.seamless).thenReturn(seamless)
seamlessIcon = ImageView(context) seamlessIcon = ImageView(context)
whenever(holder.seamlessIcon).thenReturn(seamlessIcon) whenever(holder.seamlessIcon).thenReturn(seamlessIcon)
@@ -176,7 +181,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Test @Test
fun bindWhenUnattached() { fun bindWhenUnattached() {
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), 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) player.bind(state)
assertThat(player.isPlaying()).isFalse() assertThat(player.isPlaying()).isFalse()
} }
@@ -185,7 +190,8 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindText() { fun bindText() {
player.attach(holder) player.attach(holder)
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), 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) player.bind(state)
assertThat(appName.getText()).isEqualTo(APP) assertThat(appName.getText()).isEqualTo(APP)
assertThat(titleText.getText()).isEqualTo(TITLE) assertThat(titleText.getText()).isEqualTo(TITLE)
@@ -196,7 +202,8 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindBackgroundColor() { fun bindBackgroundColor() {
player.attach(holder) player.attach(holder)
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), 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) player.bind(state)
assertThat(background.getBackgroundTintList()).isEqualTo(ColorStateList.valueOf(BG_COLOR)) assertThat(background.getBackgroundTintList()).isEqualTo(ColorStateList.valueOf(BG_COLOR))
} }

View File

@@ -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();
}
}

View File

@@ -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()
}
}