Merge "towards MR2: update to new backend" into rvc-dev
This commit is contained in:
committed by
Android (Google) Code Review
commit
19f6945f56
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -514,60 +512,6 @@ public class MediaControlPanel {
|
||||
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
|
||||
* 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
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** */
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user