Merge "Adding back media resumption" into rvc-dev

This commit is contained in:
Beth Thibodeau
2020-06-04 23:20:31 +00:00
committed by Android (Google) Code Review
21 changed files with 687 additions and 373 deletions

View File

@@ -0,0 +1,24 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>

View File

@@ -2781,6 +2781,8 @@
<!-- Close the controls associated with a specific media session [CHAR_LIMIT=NONE] --> <!-- Close the controls associated with a specific media session [CHAR_LIMIT=NONE] -->
<string name="controls_media_close_session">Close this media session</string> <string name="controls_media_close_session">Close this media session</string>
<!-- Label for button to resume media playback [CHAR_LIMIT=NONE] -->
<string name="controls_media_resume">Resume</string>
<!-- Error message indicating that a control timed out while waiting for an update [CHAR_LIMIT=30] --> <!-- Error message indicating that a control timed out while waiting for an update [CHAR_LIMIT=30] -->
<string name="controls_error_timeout">Inactive, check app</string> <string name="controls_error_timeout">Inactive, check app</string>

View File

@@ -17,12 +17,8 @@
package com.android.systemui.media; package com.android.systemui.media;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.ColorStateList; import android.content.res.ColorStateList;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
@@ -35,7 +31,6 @@ import android.graphics.drawable.RippleDrawable;
import android.media.session.MediaController; import android.media.session.MediaController;
import android.media.session.MediaSession; import android.media.session.MediaSession;
import android.media.session.PlaybackState; import android.media.session.PlaybackState;
import android.service.media.MediaBrowserService;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.ImageButton; import android.widget.ImageButton;
@@ -55,7 +50,6 @@ 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.util.animation.TransitionLayout; import com.android.systemui.util.animation.TransitionLayout;
import com.android.systemui.util.concurrency.DelayableExecutor; import com.android.systemui.util.concurrency.DelayableExecutor;
@@ -81,7 +75,6 @@ public class MediaControlPanel {
private final SeekBarViewModel mSeekBarViewModel; private final SeekBarViewModel mSeekBarViewModel;
private SeekBarObserver mSeekBarObserver; private SeekBarObserver mSeekBarObserver;
private final Executor mForegroundExecutor;
protected final Executor mBackgroundExecutor; protected final Executor mBackgroundExecutor;
private final ActivityStarter mActivityStarter; private final ActivityStarter mActivityStarter;
@@ -91,48 +84,18 @@ public class MediaControlPanel {
private MediaSession.Token mToken; private MediaSession.Token mToken;
private MediaController mController; private MediaController mController;
private int mBackgroundColor; private int mBackgroundColor;
protected ComponentName mServiceComponent;
private boolean mIsRegistered = false;
private String mKey;
private int mAlbumArtSize; private int mAlbumArtSize;
private int mAlbumArtRadius; private int mAlbumArtRadius;
private int mViewWidth;
public static final String MEDIA_PREFERENCES = "media_control_prefs";
public static final String MEDIA_PREFERENCE_KEY = "browser_components";
private SharedPreferences mSharedPrefs;
private boolean mCheckedForResumption = false;
private QSMediaBrowser mQSMediaBrowser;
private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
@Override
public void onSessionDestroyed() {
Log.d(TAG, "session destroyed");
mController.unregisterCallback(mSessionCallback);
clearControls();
}
@Override
public void onPlaybackStateChanged(PlaybackState state) {
final int s = state != null ? state.getState() : PlaybackState.STATE_NONE;
if (s == PlaybackState.STATE_NONE) {
Log.d(TAG, "playback state change will trigger resumption, state=" + state);
clearControls();
}
}
};
/** /**
* Initialize a new control panel * Initialize a new control panel
* @param context * @param context
* @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, Executor foregroundExecutor, public MediaControlPanel(Context context, DelayableExecutor backgroundExecutor,
DelayableExecutor backgroundExecutor, ActivityStarter activityStarter, ActivityStarter activityStarter, MediaHostStatesManager mediaHostStatesManager) {
MediaHostStatesManager mediaHostStatesManager) {
mContext = context; mContext = context;
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor; mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter; mActivityStarter = activityStarter;
mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor); mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
@@ -214,45 +177,18 @@ public class MediaControlPanel {
MediaSession.Token token = data.getToken(); MediaSession.Token token = data.getToken();
mBackgroundColor = data.getBackgroundColor(); mBackgroundColor = data.getBackgroundColor();
if (mToken == null || !mToken.equals(token)) { if (mToken == null || !mToken.equals(token)) {
if (mQSMediaBrowser != null) {
Log.d(TAG, "Disconnecting old media browser");
mQSMediaBrowser.disconnect();
mQSMediaBrowser = null;
}
mToken = token; mToken = token;
mServiceComponent = null;
mCheckedForResumption = false;
} }
mController = new MediaController(mContext, mToken); if (mToken != null) {
mController = new MediaController(mContext, mToken);
} else {
mController = null;
}
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout(); ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout(); ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
// Try to find a browser service component for this app
// TODO also check for a media button receiver intended for restarting (b/154127084)
// Only check if we haven't tried yet or the session token changed
final String pkgName = data.getPackageName();
if (mServiceComponent == null && !mCheckedForResumption) {
Log.d(TAG, "Checking for service component");
PackageManager pm = mContext.getPackageManager();
Intent resumeIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
List<ResolveInfo> resumeInfo = pm.queryIntentServices(resumeIntent, 0);
// TODO: look into this resumption
if (resumeInfo != null) {
for (ResolveInfo inf : resumeInfo) {
if (inf.serviceInfo.packageName.equals(mController.getPackageName())) {
mBackgroundExecutor.execute(() ->
tryUpdateResumptionList(inf.getComponentInfo().getComponentName()));
break;
}
}
}
mCheckedForResumption = true;
}
mController.registerCallback(mSessionCallback);
mViewHolder.getPlayer().setBackgroundTintList( mViewHolder.getPlayer().setBackgroundTintList(
ColorStateList.valueOf(mBackgroundColor)); ColorStateList.valueOf(mBackgroundColor));
@@ -267,12 +203,22 @@ public class MediaControlPanel {
ImageView albumView = mViewHolder.getAlbumView(); ImageView albumView = mViewHolder.getAlbumView();
// TODO: migrate this to a view with rounded corners instead of baking the rounding // TODO: migrate this to a view with rounded corners instead of baking the rounding
// into the bitmap // into the bitmap
Drawable artwork = createRoundedBitmap(data.getArtwork()); boolean hasArtwork = data.getArtwork() != null;
albumView.setImageDrawable(artwork); if (hasArtwork) {
Drawable artwork = createRoundedBitmap(data.getArtwork());
albumView.setImageDrawable(artwork);
}
setVisibleAndAlpha(collapsedSet, R.id.album_art, hasArtwork);
setVisibleAndAlpha(expandedSet, R.id.album_art, hasArtwork);
// App icon // App icon
ImageView appIcon = mViewHolder.getAppIcon(); ImageView appIcon = mViewHolder.getAppIcon();
appIcon.setImageDrawable(data.getAppIcon()); if (data.getAppIcon() != null) {
appIcon.setImageDrawable(data.getAppIcon());
} else {
Drawable iconDrawable = mContext.getDrawable(R.drawable.ic_music_note);
appIcon.setImageDrawable(iconDrawable);
}
// Song name // Song name
TextView titleText = mViewHolder.getTitleText(); TextView titleText = mViewHolder.getTitleText();
@@ -294,7 +240,7 @@ public class MediaControlPanel {
final Intent intent = new Intent() final Intent intent = new Intent()
.setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT) .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
.putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME, .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
mController.getPackageName()) data.getPackageName())
.putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken); .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
mActivityStarter.startActivity(intent, false, true /* dismissShade */, mActivityStarter.startActivity(intent, false, true /* dismissShade */,
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
@@ -350,15 +296,11 @@ public class MediaControlPanel {
MediaAction mediaAction = actionIcons.get(i); MediaAction mediaAction = actionIcons.get(i);
button.setImageDrawable(mediaAction.getDrawable()); button.setImageDrawable(mediaAction.getDrawable());
button.setContentDescription(mediaAction.getContentDescription()); button.setContentDescription(mediaAction.getContentDescription());
PendingIntent actionIntent = mediaAction.getIntent(); Runnable action = mediaAction.getAction();
button.setOnClickListener(v -> { button.setOnClickListener(v -> {
if (actionIntent != null) { if (action != null) {
try { action.run();
actionIntent.send();
} catch (PendingIntent.CanceledException e) {
e.printStackTrace();
}
} }
}); });
boolean visibleInCompat = actionsWhenCollapsed.contains(i); boolean visibleInCompat = actionsWhenCollapsed.contains(i);
@@ -443,14 +385,6 @@ public class MediaControlPanel {
return mController.getPackageName(); return mController.getPackageName();
} }
/**
* Return the original notification's key
* @return The notification key
*/
public String getKey() {
return mKey;
}
/** /**
* Check whether this player has an attached media session. * Check whether this player has an attached media session.
* @return whether there is a controller with a current media session. * @return whether there is a controller with a current media session.
@@ -485,150 +419,8 @@ public class MediaControlPanel {
return (state.getState() == PlaybackState.STATE_PLAYING); return (state.getState() == PlaybackState.STATE_PLAYING);
} }
/**
* Puts controls into a resumption state if possible, or calls removePlayer if no component was
* found that could resume playback
*/
public void clearControls() {
Log.d(TAG, "clearControls to resumption state package=" + getMediaPlayerPackage());
if (mServiceComponent == null) {
// If we don't have a way to resume, just remove the player altogether
Log.d(TAG, "Removing unresumable controls");
removePlayer();
return;
}
resetButtons();
}
/**
* Hide the media buttons and show only a restart button
*/
protected void resetButtons() {
if (mViewHolder == null) {
return;
}
// Hide all the old buttons
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
for (int i = 1; i < ACTION_IDS.length; i++) {
setVisibleAndAlpha(expandedSet, ACTION_IDS[i], false /*visible */);
setVisibleAndAlpha(collapsedSet, ACTION_IDS[i], false /*visible */);
}
// Add a restart button
ImageButton btn = mViewHolder.getAction0();
btn.setOnClickListener(v -> {
Log.d(TAG, "Attempting to restart session");
if (mQSMediaBrowser != null) {
mQSMediaBrowser.disconnect();
}
mQSMediaBrowser = new QSMediaBrowser(mContext, new QSMediaBrowser.Callback(){
@Override
public void onConnected() {
Log.d(TAG, "Successfully restarted");
}
@Override
public void onError() {
Log.e(TAG, "Error restarting");
mQSMediaBrowser.disconnect();
mQSMediaBrowser = null;
}
}, mServiceComponent);
mQSMediaBrowser.restart();
});
btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
setVisibleAndAlpha(expandedSet, ACTION_IDS[0], true /*visible */);
setVisibleAndAlpha(collapsedSet, ACTION_IDS[0], true /*visible */);
mSeekBarViewModel.clearController();
// TODO: fix guts
// View guts = mMediaNotifView.findViewById(R.id.media_guts);
View options = mViewHolder.getOptions();
mViewHolder.getPlayer().setOnLongClickListener(v -> {
// Replace player view with close/cancel view
// guts.setVisibility(View.GONE);
options.setVisibility(View.VISIBLE);
return true; // consumed click
});
mMediaViewController.refreshState();
}
private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible) { private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible) {
set.setVisibility(actionId, visible? ConstraintSet.VISIBLE : ConstraintSet.GONE); set.setVisibility(actionId, visible? ConstraintSet.VISIBLE : ConstraintSet.GONE);
set.setAlpha(actionId, visible ? 1.0f : 0.0f); set.setAlpha(actionId, visible ? 1.0f : 0.0f);
} }
/**
* Verify that we can connect to the given component with a MediaBrowser, and if so, add that
* component to the list of resumption components
*/
private void tryUpdateResumptionList(ComponentName componentName) {
Log.d(TAG, "Testing if we can connect to " + componentName);
if (mQSMediaBrowser != null) {
mQSMediaBrowser.disconnect();
}
mQSMediaBrowser = new QSMediaBrowser(mContext,
new QSMediaBrowser.Callback() {
@Override
public void onConnected() {
Log.d(TAG, "yes we can resume with " + componentName);
mServiceComponent = componentName;
updateResumptionList(componentName);
mQSMediaBrowser.disconnect();
mQSMediaBrowser = null;
}
@Override
public void onError() {
Log.d(TAG, "Cannot resume with " + componentName);
mServiceComponent = null;
if (!hasMediaSession()) {
// If it's not active and we can't resume, remove
removePlayer();
}
mQSMediaBrowser.disconnect();
mQSMediaBrowser = null;
}
},
componentName);
mQSMediaBrowser.testConnection();
}
/**
* Add the component to the saved list of media browser services, checking for duplicates and
* removing older components that exceed the maximum limit
* @param componentName
*/
private synchronized void updateResumptionList(ComponentName componentName) {
// Add to front of saved list
if (mSharedPrefs == null) {
mSharedPrefs = mContext.getSharedPreferences(MEDIA_PREFERENCES, 0);
}
String componentString = componentName.flattenToString();
String listString = mSharedPrefs.getString(MEDIA_PREFERENCE_KEY, null);
if (listString == null) {
listString = componentString;
} else {
String[] components = listString.split(QSMediaBrowser.DELIMITER);
StringBuilder updated = new StringBuilder(componentString);
int nBrowsers = 1;
for (int i = 0; i < components.length
&& nBrowsers < QSMediaBrowser.MAX_RESUMPTION_CONTROLS; i++) {
if (componentString.equals(components[i])) {
continue;
}
updated.append(QSMediaBrowser.DELIMITER).append(components[i]);
nBrowsers++;
}
listString = updated.toString();
}
mSharedPrefs.edit().putString(MEDIA_PREFERENCE_KEY, listString).apply();
}
/**
* Called when a player can't be resumed to give it an opportunity to hide or remove itself
*/
protected void removePlayer() { }
} }

View File

@@ -32,17 +32,19 @@ data class MediaData(
val artwork: Icon?, val artwork: Icon?,
val actions: List<MediaAction>, val actions: List<MediaAction>,
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?, val device: MediaDeviceData?,
val notificationKey: String = "INVALID" var resumeAction: Runnable?,
val notificationKey: String = "INVALID",
var hasCheckedForResume: Boolean = false
) )
/** State of a media action. */ /** State of a media action. */
data class MediaAction( data class MediaAction(
val drawable: Drawable?, val drawable: Drawable?,
val intent: PendingIntent?, val action: Runnable?,
val contentDescription: CharSequence? val contentDescription: CharSequence?
) )

View File

@@ -32,9 +32,15 @@ class MediaDataCombineLatest @Inject constructor(
init { init {
dataSource.addListener(object : MediaDataManager.Listener { dataSource.addListener(object : MediaDataManager.Listener {
override fun onMediaDataLoaded(key: String, data: MediaData) { override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
entries[key] = data to entries[key]?.second if (oldKey != null && !oldKey.equals(key)) {
update(key) val s = entries[oldKey]?.second
entries[key] = data to entries[oldKey]?.second
entries.remove(oldKey)
} else {
entries[key] = data to entries[key]?.second
}
update(key, oldKey)
} }
override fun onMediaDataRemoved(key: String) { override fun onMediaDataRemoved(key: String) {
remove(key) remove(key)
@@ -43,7 +49,7 @@ class MediaDataCombineLatest @Inject constructor(
deviceSource.addListener(object : MediaDeviceManager.Listener { deviceSource.addListener(object : MediaDeviceManager.Listener {
override fun onMediaDeviceChanged(key: String, data: MediaDeviceData?) { override fun onMediaDeviceChanged(key: String, data: MediaDeviceData?) {
entries[key] = entries[key]?.first to data entries[key] = entries[key]?.first to data
update(key) update(key, key)
} }
override fun onKeyRemoved(key: String) { override fun onKeyRemoved(key: String) {
remove(key) remove(key)
@@ -61,13 +67,13 @@ class MediaDataCombineLatest @Inject constructor(
*/ */
fun removeListener(listener: MediaDataManager.Listener) = listeners.remove(listener) fun removeListener(listener: MediaDataManager.Listener) = listeners.remove(listener)
private fun update(key: String) { private fun update(key: String, oldKey: String?) {
val (entry, device) = entries[key] ?: null to null val (entry, device) = entries[key] ?: null to null
if (entry != null && device != null) { if (entry != null && device != null) {
val data = entry.copy(device = device) val data = entry.copy(device = device)
val listenersCopy = listeners.toSet() val listenersCopy = listeners.toSet()
listenersCopy.forEach { listenersCopy.forEach {
it.onMediaDataLoaded(key, data) it.onMediaDataLoaded(key, oldKey, data)
} }
} }
} }

View File

@@ -17,6 +17,7 @@
package com.android.systemui.media package com.android.systemui.media
import android.app.Notification import android.app.Notification
import android.app.PendingIntent
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -25,6 +26,7 @@ import android.graphics.Color
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.media.MediaDescription
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.session.MediaSession import android.media.session.MediaSession
import android.net.Uri import android.net.Uri
@@ -32,8 +34,10 @@ import android.service.notification.StatusBarNotification
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import com.android.internal.graphics.ColorUtils import com.android.internal.graphics.ColorUtils
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
import com.android.systemui.statusbar.NotificationMediaManager
import com.android.systemui.statusbar.notification.MediaNotificationProcessor import com.android.systemui.statusbar.notification.MediaNotificationProcessor
import com.android.systemui.statusbar.notification.NotificationEntryManager import com.android.systemui.statusbar.notification.NotificationEntryManager
import com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON import com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON
@@ -58,7 +62,7 @@ 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, null) emptyList(), emptyList(), "INVALID", null, null, null, null)
fun isMediaNotification(sbn: StatusBarNotification): Boolean { fun isMediaNotification(sbn: StatusBarNotification): Boolean {
if (!sbn.notification.hasMediaSession()) { if (!sbn.notification.hasMediaSession()) {
@@ -81,34 +85,92 @@ class MediaDataManager @Inject constructor(
private val mediaControllerFactory: MediaControllerFactory, private val mediaControllerFactory: MediaControllerFactory,
private val mediaTimeoutListener: MediaTimeoutListener, private val mediaTimeoutListener: MediaTimeoutListener,
private val notificationEntryManager: NotificationEntryManager, private val notificationEntryManager: NotificationEntryManager,
private val mediaResumeListener: MediaResumeListener,
@Background private val backgroundExecutor: Executor, @Background private val backgroundExecutor: Executor,
@Main private val foregroundExecutor: Executor @Main private val foregroundExecutor: Executor
) { ) {
private val listeners: MutableSet<Listener> = mutableSetOf() private val listeners: MutableSet<Listener> = mutableSetOf()
private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap() private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
private val useMediaResumption: Boolean = Utils.useMediaResumption(context)
init { init {
mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean -> mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean ->
setTimedOut(token, timedOut) } setTimedOut(token, timedOut) }
addListener(mediaTimeoutListener) addListener(mediaTimeoutListener)
if (useMediaResumption) {
mediaResumeListener.addTrackToResumeCallback = { desc: MediaDescription,
resumeAction: Runnable, token: MediaSession.Token, appName: String,
appIntent: PendingIntent, packageName: String ->
addResumptionControls(desc, resumeAction, token, appName, appIntent, packageName)
}
mediaResumeListener.resumeComponentFoundCallback = { key: String, action: Runnable? ->
mediaEntries.get(key)?.resumeAction = action
mediaEntries.get(key)?.hasCheckedForResume = true
}
addListener(mediaResumeListener)
}
} }
fun onNotificationAdded(key: String, sbn: StatusBarNotification) { fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) { if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) {
Assert.isMainThread() Assert.isMainThread()
if (!mediaEntries.containsKey(key)) { val oldKey = findExistingEntry(key, sbn.packageName)
mediaEntries.put(key, LOADING) if (oldKey == null) {
val temp = LOADING.copy(packageName = sbn.packageName)
mediaEntries.put(key, temp)
} else if (oldKey != key) {
// Move to new key
val oldData = mediaEntries.remove(oldKey)!!
mediaEntries.put(key, oldData)
} }
loadMediaData(key, sbn) loadMediaData(key, sbn, oldKey)
} else { } else {
onNotificationRemoved(key) onNotificationRemoved(key)
} }
} }
private fun loadMediaData(key: String, sbn: StatusBarNotification) { private fun addResumptionControls(
desc: MediaDescription,
action: Runnable,
token: MediaSession.Token,
appName: String,
appIntent: PendingIntent,
packageName: String
) {
// Resume controls don't have a notification key, so store by package name instead
if (!mediaEntries.containsKey(packageName)) {
val resumeData = LOADING.copy(packageName = packageName, resumeAction = action)
mediaEntries.put(packageName, resumeData)
}
backgroundExecutor.execute { backgroundExecutor.execute {
loadMediaDataInBg(key, sbn) loadMediaDataInBg(desc, action, token, appName, appIntent, packageName)
}
}
/**
* Check if there is an existing entry that matches the key or package name.
* Returns the key that matches, or null if not found.
*/
private fun findExistingEntry(key: String, packageName: String): String? {
if (mediaEntries.containsKey(key)) {
return key
}
// Check if we already had a resume player
if (mediaEntries.containsKey(packageName)) {
return packageName
}
return null
}
private fun loadMediaData(
key: String,
sbn: StatusBarNotification,
oldKey: String?
) {
backgroundExecutor.execute {
loadMediaDataInBg(key, sbn, oldKey)
} }
} }
@@ -132,7 +194,50 @@ class MediaDataManager @Inject constructor(
} }
} }
private fun loadMediaDataInBg(key: String, sbn: StatusBarNotification) { private fun loadMediaDataInBg(
desc: MediaDescription,
resumeAction: Runnable,
token: MediaSession.Token,
appName: String,
appIntent: PendingIntent,
packageName: String
) {
if (resumeAction == null) {
Log.e(TAG, "Resume action cannot be null")
return
}
if (TextUtils.isEmpty(desc.title)) {
Log.e(TAG, "Description incomplete")
return
}
Log.d(TAG, "adding track from browser: $desc")
// Album art
var artworkBitmap = desc.iconBitmap
if (artworkBitmap == null && desc.iconUri != null) {
artworkBitmap = loadBitmapFromUri(desc.iconUri!!)
}
val artworkIcon = if (artworkBitmap != null) {
Icon.createWithBitmap(artworkBitmap)
} else {
null
}
val mediaAction = getResumeMediaAction(resumeAction)
foregroundExecutor.execute {
onMediaDataLoaded(packageName, null, MediaData(true, Color.DKGRAY, appName,
null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0),
packageName, token, appIntent, null, resumeAction, packageName))
}
}
private fun loadMediaDataInBg(
key: String,
sbn: StatusBarNotification,
oldKey: String?
) {
val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
as MediaSession.Token? as MediaSession.Token?
val metadata = mediaControllerFactory.create(token).metadata val metadata = mediaControllerFactory.create(token).metadata
@@ -234,16 +339,23 @@ class MediaDataManager @Inject constructor(
} }
val mediaAction = MediaAction( val mediaAction = MediaAction(
action.getIcon().loadDrawable(packageContext), action.getIcon().loadDrawable(packageContext),
action.actionIntent, Runnable {
try {
action.actionIntent.send()
} catch (e: PendingIntent.CanceledException) {
Log.d(TAG, "Intent canceled", e)
}
},
action.title) action.title)
actionIcons.add(mediaAction) actionIcons.add(mediaAction)
} }
} }
val resumeAction: Runnable? = mediaEntries.get(key)?.resumeAction
foregroundExecutor.execute { foregroundExecutor.execute {
onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song, onMediaDataLoaded(key, oldKey, MediaData(true, bgColor, app, smallIconDrawable, artist,
artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token, song, artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
notif.contentIntent, null, key)) notif.contentIntent, null, resumeAction, key))
} }
} }
@@ -257,7 +369,7 @@ class MediaDataManager @Inject constructor(
val albumArt = loadBitmapFromUri(Uri.parse(uriString)) val albumArt = loadBitmapFromUri(Uri.parse(uriString))
if (albumArt != null) { if (albumArt != null) {
Log.d(TAG, "loaded art from $uri") Log.d(TAG, "loaded art from $uri")
break return albumArt
} }
} }
} }
@@ -283,27 +395,52 @@ class MediaDataManager @Inject constructor(
val source = ImageDecoder.createSource(context.getContentResolver(), uri) val source = ImageDecoder.createSource(context.getContentResolver(), uri)
return try { return try {
ImageDecoder.decodeBitmap(source) ImageDecoder.decodeBitmap(source) {
decoder, info, source -> decoder.isMutableRequired = true
}
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
null null
} }
} }
fun onMediaDataLoaded(key: String, data: MediaData) { private fun getResumeMediaAction(action: Runnable): MediaAction {
return MediaAction(
context.getDrawable(R.drawable.lb_ic_play),
action,
context.getString(R.string.controls_media_resume)
)
}
fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
Assert.isMainThread() Assert.isMainThread()
if (mediaEntries.containsKey(key)) { if (mediaEntries.containsKey(key)) {
// Otherwise this was removed already // Otherwise this was removed already
mediaEntries.put(key, data) mediaEntries.put(key, data)
val listenersCopy = listeners.toSet() val listenersCopy = listeners.toSet()
listenersCopy.forEach { listenersCopy.forEach {
it.onMediaDataLoaded(key, data) it.onMediaDataLoaded(key, oldKey, data)
} }
} }
} }
fun onNotificationRemoved(key: String) { fun onNotificationRemoved(key: String) {
Assert.isMainThread() Assert.isMainThread()
if (useMediaResumption && mediaEntries.get(key)?.resumeAction != null) {
Log.d(TAG, "Not removing $key because resumable")
// Move to resume key aka package name
val data = mediaEntries.remove(key)!!
val resumeAction = getResumeMediaAction(data.resumeAction!!)
val updated = data.copy(token = null, actions = listOf(resumeAction),
actionsToShowInCompact = listOf(0))
mediaEntries.put(data.packageName, updated)
// Notify listeners of "new" controls
val listenersCopy = listeners.toSet()
listenersCopy.forEach {
it.onMediaDataLoaded(data.packageName, key, updated)
}
return
}
val removed = mediaEntries.remove(key) val removed = mediaEntries.remove(key)
if (removed != null) { if (removed != null) {
val listenersCopy = listeners.toSet() val listenersCopy = listeners.toSet()
@@ -316,19 +453,32 @@ class MediaDataManager @Inject constructor(
/** /**
* Are there any media notifications active? * Are there any media notifications active?
*/ */
fun hasActiveMedia() = mediaEntries.isNotEmpty() fun hasActiveMedia() = mediaEntries.any({ isActive(it.value) })
fun hasAnyMedia(): Boolean { fun isActive(data: MediaData): Boolean {
// TODO: implement this when we implemented resumption if (data.token == null) {
return hasActiveMedia() return false
}
val controller = mediaControllerFactory.create(data.token)
val state = controller?.playbackState?.state
return state != null && NotificationMediaManager.isActiveState(state)
} }
/**
* Are there any media entries, including resume controls?
*/
fun hasAnyMedia() = mediaEntries.isNotEmpty()
interface Listener { interface Listener {
/** /**
* Called whenever there's new MediaData Loaded for the consumption in views * Called whenever there's new MediaData Loaded for the consumption in views.
*
* oldKey is provided to check whether the view has changed keys, which can happen when a
* player has gone from resume state (key is package name) to active state (key is
* notification key) or vice versa.
*/ */
fun onMediaDataLoaded(key: String, data: MediaData) {} fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {}
/** /**
* Called whenever a previously existing Media notification was removed * Called whenever a previously existing Media notification was removed

View File

@@ -16,11 +16,8 @@
package com.android.systemui.media package com.android.systemui.media
import android.app.Notification
import android.content.Context import android.content.Context
import android.service.notification.StatusBarNotification
import android.media.MediaRouter2Manager import android.media.MediaRouter2Manager
import android.media.session.MediaSession
import android.media.session.MediaController import android.media.session.MediaController
import com.android.settingslib.media.LocalMediaManager import com.android.settingslib.media.LocalMediaManager
import com.android.settingslib.media.MediaDevice import com.android.settingslib.media.MediaDevice
@@ -38,11 +35,16 @@ class MediaDeviceManager @Inject constructor(
private val localMediaManagerFactory: LocalMediaManagerFactory, private val localMediaManagerFactory: LocalMediaManagerFactory,
private val mr2manager: MediaRouter2Manager, private val mr2manager: MediaRouter2Manager,
private val featureFlag: MediaFeatureFlag, private val featureFlag: MediaFeatureFlag,
@Main private val fgExecutor: Executor @Main private val fgExecutor: Executor,
) { private val mediaDataManager: MediaDataManager
) : MediaDataManager.Listener {
private val listeners: MutableSet<Listener> = mutableSetOf() private val listeners: MutableSet<Listener> = mutableSetOf()
private val entries: MutableMap<String, Token> = mutableMapOf() private val entries: MutableMap<String, Token> = mutableMapOf()
init {
mediaDataManager.addListener(this)
}
/** /**
* Add a listener for changes to the media route (ie. device). * Add a listener for changes to the media route (ie. device).
*/ */
@@ -53,23 +55,25 @@ class MediaDeviceManager @Inject constructor(
*/ */
fun removeListener(listener: Listener) = listeners.remove(listener) fun removeListener(listener: Listener) = listeners.remove(listener)
fun onNotificationAdded(key: String, sbn: StatusBarNotification) { override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (featureFlag.enabled && isMediaNotification(sbn)) { if (featureFlag.enabled) {
if (oldKey != null && oldKey != key) {
val oldToken = entries.remove(oldKey)
oldToken?.stop()
}
var tok = entries[key] var tok = entries[key]
if (tok == null) { if (tok == null && data.token != null) {
val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) val controller = MediaController(context, data.token!!)
as MediaSession.Token? tok = Token(key, controller, localMediaManagerFactory.create(data.packageName))
val controller = MediaController(context, token)
tok = Token(key, controller, localMediaManagerFactory.create(sbn.packageName))
entries[key] = tok entries[key] = tok
tok.start() tok.start()
} }
} else { } else {
onNotificationRemoved(key) onMediaDataRemoved(key)
} }
} }
fun onNotificationRemoved(key: String) { override fun onMediaDataRemoved(key: String) {
val token = entries.remove(key) val token = entries.remove(key)
token?.stop() token?.stop()
token?.let { token?.let {

View File

@@ -50,7 +50,7 @@ class MediaHost @Inject constructor(
} }
private val listener = object : MediaDataManager.Listener { private val listener = object : MediaDataManager.Listener {
override fun onMediaDataLoaded(key: String, data: MediaData) { override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
updateViewVisibility() updateViewVisibility()
} }

View File

@@ -0,0 +1,252 @@
/*
* 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.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.MediaDescription
import android.media.session.MediaController
import android.media.session.MediaSession
import android.os.UserHandle
import android.service.media.MediaBrowserService
import android.util.Log
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.util.Utils
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executor
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "MediaResumeListener"
private const val MEDIA_PREFERENCES = "media_control_prefs"
private const val MEDIA_PREFERENCE_KEY = "browser_components"
@Singleton
class MediaResumeListener @Inject constructor(
private val context: Context,
private val broadcastDispatcher: BroadcastDispatcher,
@Background private val backgroundExecutor: Executor
) : MediaDataManager.Listener {
private val useMediaResumption: Boolean = Utils.useMediaResumption(context)
private val resumeComponents: ConcurrentLinkedQueue<ComponentName> = ConcurrentLinkedQueue()
lateinit var addTrackToResumeCallback: (
MediaDescription,
Runnable,
MediaSession.Token,
String,
PendingIntent,
String
) -> Unit
lateinit var resumeComponentFoundCallback: (String, Runnable?) -> Unit
private var mediaBrowser: ResumeMediaBrowser? = null
private val unlockReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_USER_UNLOCKED == intent.action) {
loadMediaResumptionControls()
}
}
}
private val mediaBrowserCallback = object : ResumeMediaBrowser.Callback() {
override fun addTrack(
desc: MediaDescription,
component: ComponentName,
browser: ResumeMediaBrowser
) {
val token = browser.token
val appIntent = browser.appIntent
val pm = context.getPackageManager()
var appName: CharSequence = component.packageName
val resumeAction = getResumeAction(component)
try {
appName = pm.getApplicationLabel(
pm.getApplicationInfo(component.packageName, 0))
} catch (e: PackageManager.NameNotFoundException) {
Log.e(TAG, "Error getting package information", e)
}
Log.d(TAG, "Adding resume controls $desc")
addTrackToResumeCallback(desc, resumeAction, token, appName.toString(), appIntent,
component.packageName)
}
}
init {
if (useMediaResumption) {
val unlockFilter = IntentFilter()
unlockFilter.addAction(Intent.ACTION_USER_UNLOCKED)
broadcastDispatcher.registerReceiver(unlockReceiver, unlockFilter, null, UserHandle.ALL)
loadSavedComponents()
}
}
private fun loadSavedComponents() {
val userContext = context.createContextAsUser(context.getUser(), 0)
val prefs = userContext.getSharedPreferences(MEDIA_PREFERENCES, Context.MODE_PRIVATE)
val listString = prefs.getString(MEDIA_PREFERENCE_KEY, null)
val components = listString?.split(ResumeMediaBrowser.DELIMITER.toRegex())
?.dropLastWhile { it.isEmpty() }
components?.forEach {
val info = it.split("/")
val packageName = info[0]
val className = info[1]
val component = ComponentName(packageName, className)
resumeComponents.add(component)
}
Log.d(TAG, "loaded resume components ${resumeComponents.toArray().contentToString()}")
}
/**
* Load controls for resuming media, if available
*/
private fun loadMediaResumptionControls() {
if (!useMediaResumption) {
return
}
resumeComponents.forEach {
val browser = ResumeMediaBrowser(context, mediaBrowserCallback, it)
browser.findRecentMedia()
}
broadcastDispatcher.unregisterReceiver(unlockReceiver) // only need to load once
}
override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (useMediaResumption) {
// If this had been started from a resume state, disconnect now that it's live
mediaBrowser?.disconnect()
// If we don't have a resume action, check if we haven't already
if (data.resumeAction == null && !data.hasCheckedForResume) {
// TODO also check for a media button receiver intended for restarting (b/154127084)
Log.d(TAG, "Checking for service component for " + data.packageName)
val pm = context.packageManager
val serviceIntent = Intent(MediaBrowserService.SERVICE_INTERFACE)
val resumeInfo = pm.queryIntentServices(serviceIntent, 0)
val inf = resumeInfo?.filter {
it.serviceInfo.packageName == data.packageName
}
if (inf != null && inf.size > 0) {
backgroundExecutor.execute {
tryUpdateResumptionList(key, inf!!.get(0).componentInfo.componentName)
}
} else {
// No service found
resumeComponentFoundCallback(key, null)
}
}
}
}
/**
* Verify that we can connect to the given component with a MediaBrowser, and if so, add that
* component to the list of resumption components
*/
private fun tryUpdateResumptionList(key: String, componentName: ComponentName) {
Log.d(TAG, "Testing if we can connect to $componentName")
mediaBrowser?.disconnect()
mediaBrowser = ResumeMediaBrowser(context,
object : ResumeMediaBrowser.Callback() {
override fun onConnected() {
Log.d(TAG, "yes we can resume with $componentName")
resumeComponentFoundCallback(key, getResumeAction(componentName))
updateResumptionList(componentName)
mediaBrowser?.disconnect()
mediaBrowser = null
}
override fun onError() {
Log.e(TAG, "Cannot resume with $componentName")
resumeComponentFoundCallback(key, null)
mediaBrowser?.disconnect()
mediaBrowser = null
}
},
componentName)
mediaBrowser?.testConnection()
}
/**
* Add the component to the saved list of media browser services, checking for duplicates and
* removing older components that exceed the maximum limit
* @param componentName
*/
private fun updateResumptionList(componentName: ComponentName) {
// Remove if exists
resumeComponents.remove(componentName)
// Insert at front of queue
resumeComponents.add(componentName)
// Remove old components if over the limit
if (resumeComponents.size > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
resumeComponents.remove()
}
// Save changes
val sb = StringBuilder()
resumeComponents.forEach {
sb.append(it.flattenToString())
sb.append(ResumeMediaBrowser.DELIMITER)
}
val userContext = context.createContextAsUser(context.getUser(), 0)
val prefs = userContext.getSharedPreferences(MEDIA_PREFERENCES, Context.MODE_PRIVATE)
prefs.edit().putString(MEDIA_PREFERENCE_KEY, sb.toString()).apply()
}
/**
* Get a runnable which will resume media playback
*/
private fun getResumeAction(componentName: ComponentName): Runnable {
return Runnable {
mediaBrowser?.disconnect()
mediaBrowser = ResumeMediaBrowser(context,
object : ResumeMediaBrowser.Callback() {
override fun onConnected() {
if (mediaBrowser?.token == null) {
Log.e(TAG, "Error after connect")
mediaBrowser?.disconnect()
mediaBrowser = null
return
}
Log.d(TAG, "Connected for restart $componentName")
val controller = MediaController(context, mediaBrowser!!.token)
val controls = controller.transportControls
controls.prepare()
controls.play()
}
override fun onError() {
Log.e(TAG, "Resume failed for $componentName")
mediaBrowser?.disconnect()
mediaBrowser = null
}
},
componentName)
mediaBrowser?.restart()
}
}
}

View File

@@ -45,7 +45,7 @@ class MediaTimeoutListener @Inject constructor(
lateinit var timeoutCallback: (String, Boolean) -> Unit lateinit var timeoutCallback: (String, Boolean) -> Unit
override fun onMediaDataLoaded(key: String, data: MediaData) { override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (mediaListeners.containsKey(key)) { if (mediaListeners.containsKey(key)) {
return return
} }
@@ -67,15 +67,20 @@ class MediaTimeoutListener @Inject constructor(
var timedOut = false var timedOut = false
private val mediaController = mediaControllerFactory.create(data.token) // Resume controls may have null token
private val mediaController = if (data.token != null) {
mediaControllerFactory.create(data.token)
} else {
null
}
private var cancellation: Runnable? = null private var cancellation: Runnable? = null
init { init {
mediaController.registerCallback(this) mediaController?.registerCallback(this)
} }
fun destroy() { fun destroy() {
mediaController.unregisterCallback(this) mediaController?.unregisterCallback(this)
} }
override fun onPlaybackStateChanged(state: PlaybackState?) { override fun onPlaybackStateChanged(state: PlaybackState?) {

View File

@@ -12,14 +12,12 @@ import android.widget.LinearLayout
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
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.plugins.ActivityStarter import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.qs.PageIndicator import com.android.systemui.qs.PageIndicator
import com.android.systemui.statusbar.notification.VisualStabilityManager import com.android.systemui.statusbar.notification.VisualStabilityManager
import com.android.systemui.util.animation.UniqueObjectHostView import com.android.systemui.util.animation.UniqueObjectHostView
import com.android.systemui.util.animation.requiresRemeasuring import com.android.systemui.util.animation.requiresRemeasuring
import com.android.systemui.util.concurrency.DelayableExecutor import com.android.systemui.util.concurrency.DelayableExecutor
import java.util.concurrent.Executor
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -32,7 +30,6 @@ private const val FLING_SLOP = 1000000
@Singleton @Singleton
class MediaViewManager @Inject constructor( class MediaViewManager @Inject constructor(
private val context: Context, private val context: Context,
@Main private val foregroundExecutor: Executor,
@Background private val backgroundExecutor: DelayableExecutor, @Background private val backgroundExecutor: DelayableExecutor,
private val visualStabilityManager: VisualStabilityManager, private val visualStabilityManager: VisualStabilityManager,
private val activityStarter: ActivityStarter, private val activityStarter: ActivityStarter,
@@ -147,8 +144,8 @@ class MediaViewManager @Inject constructor(
visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback, visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback,
true /* persistent */) true /* persistent */)
mediaManager.addListener(object : MediaDataManager.Listener { mediaManager.addListener(object : MediaDataManager.Listener {
override fun onMediaDataLoaded(key: String, data: MediaData) { override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
updateView(key, data) updateView(key, oldKey, data)
updatePlayerVisibilities() updatePlayerVisibilities()
mediaCarousel.requiresRemeasuring = true mediaCarousel.requiresRemeasuring = true
} }
@@ -259,11 +256,17 @@ class MediaViewManager @Inject constructor(
} }
} }
private fun updateView(key: String, data: MediaData) { private fun updateView(key: String, oldKey: String?, data: MediaData) {
// If the key was changed, update entry
val oldData = mediaPlayers[oldKey]
if (oldData != null) {
val oldData = mediaPlayers.remove(oldKey)
mediaPlayers.put(key, oldData!!)
}
var existingPlayer = mediaPlayers[key] var existingPlayer = mediaPlayers[key]
if (existingPlayer == null) { if (existingPlayer == null) {
existingPlayer = MediaControlPanel(context, foregroundExecutor, backgroundExecutor, existingPlayer = MediaControlPanel(context, backgroundExecutor, activityStarter,
activityStarter, mediaHostStatesManager) mediaHostStatesManager)
existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context), existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context),
mediaContent)) mediaContent))
mediaPlayers[key] = existingPlayer mediaPlayers[key] = existingPlayer

View File

@@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.android.systemui.qs; package com.android.systemui.media;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.ComponentName; import android.content.ComponentName;
@@ -27,14 +27,17 @@ import android.media.session.MediaController;
import android.media.session.MediaSession; import android.media.session.MediaSession;
import android.os.Bundle; import android.os.Bundle;
import android.service.media.MediaBrowserService; import android.service.media.MediaBrowserService;
import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import com.android.systemui.util.Utils;
import java.util.List; import java.util.List;
/** /**
* Media browser for managing resumption in QS media controls * Media browser for managing resumption in media controls
*/ */
public class QSMediaBrowser { public class ResumeMediaBrowser {
/** Maximum number of controls to show on boot */ /** Maximum number of controls to show on boot */
public static final int MAX_RESUMPTION_CONTROLS = 5; public static final int MAX_RESUMPTION_CONTROLS = 5;
@@ -42,7 +45,8 @@ public class QSMediaBrowser {
/** Delimiter for saved component names */ /** Delimiter for saved component names */
public static final String DELIMITER = ":"; public static final String DELIMITER = ":";
private static final String TAG = "QSMediaBrowser"; private static final String TAG = "ResumeMediaBrowser";
private boolean mIsEnabled = false;
private final Context mContext; private final Context mContext;
private final Callback mCallback; private final Callback mCallback;
private MediaBrowser mMediaBrowser; private MediaBrowser mMediaBrowser;
@@ -54,21 +58,25 @@ public class QSMediaBrowser {
* @param callback used to report media items found * @param callback used to report media items found
* @param componentName Component name of the MediaBrowserService this browser will connect to * @param componentName Component name of the MediaBrowserService this browser will connect to
*/ */
public QSMediaBrowser(Context context, Callback callback, ComponentName componentName) { public ResumeMediaBrowser(Context context, Callback callback, ComponentName componentName) {
mIsEnabled = Utils.useMediaResumption(context);
mContext = context; mContext = context;
mCallback = callback; mCallback = callback;
mComponentName = componentName; mComponentName = componentName;
} }
/** /**
* Connects to the MediaBrowserService and looks for valid media. If a media item is returned * Connects to the MediaBrowserService and looks for valid media. If a media item is returned,
* by the service, QSMediaBrowser.Callback#addTrack will be called with its MediaDescription. * ResumeMediaBrowser.Callback#addTrack will be called with the MediaDescription.
* QSMediaBrowser.Callback#onConnected and QSMediaBrowser.Callback#onError will also be called * ResumeMediaBrowser.Callback#onConnected and ResumeMediaBrowser.Callback#onError will also be
* when the initial connection is successful, or an error occurs. Note that it is possible for * called when the initial connection is successful, or an error occurs.
* the service to connect but for no playable tracks to be found later. * Note that it is possible for the service to connect but for no playable tracks to be found.
* QSMediaBrowser#disconnect will be called automatically with this function. * ResumeMediaBrowser#disconnect will be called automatically with this function.
*/ */
public void findRecentMedia() { public void findRecentMedia() {
if (!mIsEnabled) {
return;
}
Log.d(TAG, "Connecting to " + mComponentName); Log.d(TAG, "Connecting to " + mComponentName);
disconnect(); disconnect();
Bundle rootHints = new Bundle(); Bundle rootHints = new Bundle();
@@ -86,7 +94,7 @@ public class QSMediaBrowser {
public void onChildrenLoaded(String parentId, public void onChildrenLoaded(String parentId,
List<MediaBrowser.MediaItem> children) { List<MediaBrowser.MediaItem> children) {
if (children.size() == 0) { if (children.size() == 0) {
Log.e(TAG, "No children found for " + mComponentName); Log.d(TAG, "No children found for " + mComponentName);
return; return;
} }
// We ask apps to return a playable item as the first child when sending // We ask apps to return a playable item as the first child when sending
@@ -94,23 +102,24 @@ public class QSMediaBrowser {
MediaBrowser.MediaItem child = children.get(0); MediaBrowser.MediaItem child = children.get(0);
MediaDescription desc = child.getDescription(); MediaDescription desc = child.getDescription();
if (child.isPlayable()) { if (child.isPlayable()) {
mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(), QSMediaBrowser.this); mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(),
ResumeMediaBrowser.this);
} else { } else {
Log.e(TAG, "Child found but not playable for " + mComponentName); Log.d(TAG, "Child found but not playable for " + mComponentName);
} }
disconnect(); disconnect();
} }
@Override @Override
public void onError(String parentId) { public void onError(String parentId) {
Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId); Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
mCallback.onError(); mCallback.onError();
disconnect(); disconnect();
} }
@Override @Override
public void onError(String parentId, Bundle options) { public void onError(String parentId, Bundle options) {
Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId
+ ", options: " + options); + ", options: " + options);
mCallback.onError(); mCallback.onError();
disconnect(); disconnect();
@@ -149,7 +158,7 @@ public class QSMediaBrowser {
*/ */
@Override @Override
public void onConnectionFailed() { public void onConnectionFailed() {
Log.e(TAG, "Connection failed for " + mComponentName); Log.d(TAG, "Connection failed for " + mComponentName);
mCallback.onError(); mCallback.onError();
disconnect(); disconnect();
} }
@@ -167,11 +176,15 @@ public class QSMediaBrowser {
} }
/** /**
* Connects to the MediaBrowserService and starts playback. QSMediaBrowser.Callback#onError or * Connects to the MediaBrowserService and starts playback.
* QSMediaBrowser.Callback#onConnected will be called depending on whether it was successful. * ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
* QSMediaBrowser#disconnect should be called after this to ensure the connection is closed. * depending on whether it was successful.
* ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
*/ */
public void restart() { public void restart() {
if (!mIsEnabled) {
return;
}
disconnect(); disconnect();
Bundle rootHints = new Bundle(); Bundle rootHints = new Bundle();
rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true); rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
@@ -224,18 +237,21 @@ public class QSMediaBrowser {
/** /**
* Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser. * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser.
* QSMediaBrowser.Callback#onError or QSMediaBrowser.Callback#onConnected will be called * ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
* depending on whether it was successful. * depending on whether it was successful.
* QSMediaBrowser#disconnect should be called after this to ensure the connection is closed. * ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
*/ */
public void testConnection() { public void testConnection() {
if (!mIsEnabled) {
return;
}
disconnect(); disconnect();
final MediaBrowser.ConnectionCallback connectionCallback = final MediaBrowser.ConnectionCallback connectionCallback =
new MediaBrowser.ConnectionCallback() { new MediaBrowser.ConnectionCallback() {
@Override @Override
public void onConnected() { public void onConnected() {
Log.d(TAG, "connected"); Log.d(TAG, "connected");
if (mMediaBrowser.getRoot() == null) { if (TextUtils.isEmpty(mMediaBrowser.getRoot())) {
mCallback.onError(); mCallback.onError();
} else { } else {
mCallback.onConnected(); mCallback.onConnected();
@@ -264,7 +280,7 @@ public class QSMediaBrowser {
} }
/** /**
* Interface to handle results from QSMediaBrowser * Interface to handle results from ResumeMediaBrowser
*/ */
public static class Callback { public static class Callback {
/** /**
@@ -286,7 +302,7 @@ public class QSMediaBrowser {
* @param browser reference to the browser * @param browser reference to the browser
*/ */
public void addTrack(MediaDescription track, ComponentName component, public void addTrack(MediaDescription track, ComponentName component,
QSMediaBrowser browser) { ResumeMediaBrowser browser) {
} }
} }
} }

View File

@@ -91,8 +91,14 @@ class SeekBarViewModel(val bgExecutor: DelayableExecutor) {
playbackState = state playbackState = state
if (shouldPollPlaybackPosition()) { if (shouldPollPlaybackPosition()) {
checkPlaybackPosition() checkPlaybackPosition()
} else if (PlaybackState.STATE_NONE.equals(playbackState)) {
clearController()
} }
} }
override fun onSessionDestroyed() {
clearController()
}
} }
/** Listening state (QS open or closed) is used to control polling of progress. */ /** Listening state (QS open or closed) is used to control polling of progress. */

View File

@@ -41,7 +41,6 @@ import com.android.systemui.qs.customize.QSCustomizer;
import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.qs.logging.QSLogger;
import com.android.systemui.tuner.TunerService; import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable; import com.android.systemui.tuner.TunerService.Tunable;
import com.android.systemui.util.Utils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
@@ -82,8 +81,7 @@ public class QuickQSPanel extends QSPanel {
MediaHost mediaHost, MediaHost mediaHost,
UiEventLogger uiEventLogger UiEventLogger uiEventLogger
) { ) {
super(context, attrs, dumpManager, broadcastDispatcher, qsLogger, mediaHost, super(context, attrs, dumpManager, broadcastDispatcher, qsLogger, mediaHost, uiEventLogger);
uiEventLogger);
if (mFooter != null) { if (mFooter != null) {
removeView(mFooter.getView()); removeView(mFooter.getView());
} }

View File

@@ -52,7 +52,6 @@ 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;
@@ -102,6 +101,12 @@ public class NotificationMediaManager implements Dumpable {
PAUSED_MEDIA_STATES.add(PlaybackState.STATE_PAUSED); PAUSED_MEDIA_STATES.add(PlaybackState.STATE_PAUSED);
PAUSED_MEDIA_STATES.add(PlaybackState.STATE_ERROR); PAUSED_MEDIA_STATES.add(PlaybackState.STATE_ERROR);
} }
private static final HashSet<Integer> INACTIVE_MEDIA_STATES = new HashSet<>();
static {
INACTIVE_MEDIA_STATES.add(PlaybackState.STATE_NONE);
INACTIVE_MEDIA_STATES.add(PlaybackState.STATE_STOPPED);
INACTIVE_MEDIA_STATES.add(PlaybackState.STATE_ERROR);
}
private final NotificationEntryManager mEntryManager; private final NotificationEntryManager mEntryManager;
private final MediaDataManager mMediaDataManager; private final MediaDataManager mMediaDataManager;
@@ -190,8 +195,7 @@ 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;
@@ -212,13 +216,11 @@ 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
@@ -239,7 +241,6 @@ 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());
} }
}); });
@@ -252,10 +253,24 @@ public class NotificationMediaManager implements Dumpable {
mPropertiesChangedListener); mPropertiesChangedListener);
} }
/**
* Check if a state should be considered actively playing
* @param state a PlaybackState
* @return true if playing
*/
public static boolean isPlayingState(int state) { public static boolean isPlayingState(int state) {
return !PAUSED_MEDIA_STATES.contains(state); return !PAUSED_MEDIA_STATES.contains(state);
} }
/**
* Check if a state should be considered active (playing or paused)
* @param state a PlaybackState
* @return true if active
*/
public static boolean isActiveState(int state) {
return !INACTIVE_MEDIA_STATES.contains(state);
}
public void setUpWithPresenter(NotificationPresenter presenter) { public void setUpWithPresenter(NotificationPresenter presenter) {
mPresenter = presenter; mPresenter = presenter;
} }

View File

@@ -24,7 +24,6 @@ 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;
@@ -51,8 +50,6 @@ import com.android.systemui.tracing.ProtoTracer;
import com.android.systemui.util.DeviceConfigProxy; import com.android.systemui.util.DeviceConfigProxy;
import com.android.systemui.util.concurrency.DelayableExecutor; import com.android.systemui.util.concurrency.DelayableExecutor;
import java.util.concurrent.Executor;
import javax.inject.Singleton; import javax.inject.Singleton;
import dagger.Lazy; import dagger.Lazy;
@@ -105,8 +102,7 @@ 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,
@@ -116,8 +112,7 @@ public interface StatusBarDependenciesModule {
keyguardBypassController, keyguardBypassController,
mainExecutor, mainExecutor,
deviceConfigProxy, deviceConfigProxy,
mediaDataManager, mediaDataManager);
mediaDeviceManager);
} }
/** */ /** */

View File

@@ -133,4 +133,13 @@ public class Utils {
Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1); Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1);
return flag > 0; return flag > 0;
} }
/**
* Allow media resumption controls. Requires {@link #useQsMediaPlayer(Context)} to be enabled.
* Off by default, but can be enabled by setting to 1
*/
public static boolean useMediaResumption(Context context) {
int flag = Settings.System.getInt(context.getContentResolver(), "qs_media_resumption", 0);
return useQsMediaPlayer(context) && flag > 0;
}
} }

View File

@@ -67,7 +67,6 @@ public class MediaControlPanelTest : SysuiTestCase() {
private lateinit var player: MediaControlPanel private lateinit var player: MediaControlPanel
private lateinit var fgExecutor: FakeExecutor
private lateinit var bgExecutor: FakeExecutor private lateinit var bgExecutor: FakeExecutor
@Mock private lateinit var activityStarter: ActivityStarter @Mock private lateinit var activityStarter: ActivityStarter
@@ -97,14 +96,12 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Before @Before
fun setUp() { fun setUp() {
fgExecutor = FakeExecutor(FakeSystemClock())
bgExecutor = FakeExecutor(FakeSystemClock()) bgExecutor = FakeExecutor(FakeSystemClock())
activityStarter = mock(ActivityStarter::class.java) activityStarter = mock(ActivityStarter::class.java)
mediaHostStatesManager = mock(MediaHostStatesManager::class.java) mediaHostStatesManager = mock(MediaHostStatesManager::class.java)
player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter, player = MediaControlPanel(context, bgExecutor, activityStarter, mediaHostStatesManager)
mediaHostStatesManager)
// 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)
@@ -171,7 +168,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, device) emptyList(), PACKAGE, null, null, device, null)
player.bind(state) player.bind(state)
assertThat(player.isPlaying()).isFalse() assertThat(player.isPlaying()).isFalse()
} }
@@ -180,7 +177,7 @@ 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, device) emptyList(), PACKAGE, session.getSessionToken(), null, device, null)
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)
@@ -191,7 +188,7 @@ 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, device) emptyList(), PACKAGE, session.getSessionToken(), null, device, null)
player.bind(state) player.bind(state)
val list = ArgumentCaptor.forClass(ColorStateList::class.java) val list = ArgumentCaptor.forClass(ColorStateList::class.java)
verify(view).setBackgroundTintList(list.capture()) verify(view).setBackgroundTintList(list.capture())
@@ -202,7 +199,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindDevice() { fun bindDevice() {
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, device) emptyList(), PACKAGE, session.getSessionToken(), null, device, null)
player.bind(state) player.bind(state)
assertThat(seamlessText.getText()).isEqualTo(DEVICE_NAME) assertThat(seamlessText.getText()).isEqualTo(DEVICE_NAME)
assertThat(seamless.isEnabled()).isTrue() assertThat(seamless.isEnabled()).isTrue()
@@ -212,7 +209,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindDisabledDevice() { fun bindDisabledDevice() {
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, disabledDevice) emptyList(), PACKAGE, session.getSessionToken(), null, disabledDevice, null)
player.bind(state) player.bind(state)
assertThat(seamless.isEnabled()).isFalse() assertThat(seamless.isEnabled()).isFalse()
assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString( assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString(
@@ -223,7 +220,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindNullDevice() { fun bindNullDevice() {
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, null) emptyList(), PACKAGE, session.getSessionToken(), null, null, null)
player.bind(state) player.bind(state)
assertThat(seamless.isEnabled()).isTrue() assertThat(seamless.isEnabled()).isTrue()
assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString( assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString(

View File

@@ -79,16 +79,16 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
mManager.addListener(mListener); mManager.addListener(mListener);
mMediaData = new MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, mMediaData = new MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null,
new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, KEY); new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, null, KEY, false);
mDeviceData = new MediaDeviceData(true, null, DEVICE_NAME); mDeviceData = new MediaDeviceData(true, null, DEVICE_NAME);
} }
@Test @Test
public void eventNotEmittedWithoutDevice() { public void eventNotEmittedWithoutDevice() {
// WHEN data source emits an event without device data // WHEN data source emits an event without device data
mDataListener.onMediaDataLoaded(KEY, mMediaData); mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
// THEN an event isn't emitted // THEN an event isn't emitted
verify(mListener, never()).onMediaDataLoaded(eq(KEY), any()); verify(mListener, never()).onMediaDataLoaded(eq(KEY), any(), any());
} }
@Test @Test
@@ -96,7 +96,7 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
// WHEN device source emits an event without media data // WHEN device source emits an event without media data
mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
// THEN an event isn't emitted // THEN an event isn't emitted
verify(mListener, never()).onMediaDataLoaded(eq(KEY), any()); verify(mListener, never()).onMediaDataLoaded(eq(KEY), any(), any());
} }
@Test @Test
@@ -104,22 +104,22 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
// GIVEN that a device event has already been received // GIVEN that a device event has already been received
mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
// WHEN media event is received // WHEN media event is received
mDataListener.onMediaDataLoaded(KEY, mMediaData); mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
// THEN the listener receives a combined event // THEN the listener receives a combined event
ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class); ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class);
verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture()); verify(mListener).onMediaDataLoaded(eq(KEY), any(), captor.capture());
assertThat(captor.getValue().getDevice()).isNotNull(); assertThat(captor.getValue().getDevice()).isNotNull();
} }
@Test @Test
public void emitEventAfterMediaFirst() { public void emitEventAfterMediaFirst() {
// GIVEN that media event has already been received // GIVEN that media event has already been received
mDataListener.onMediaDataLoaded(KEY, mMediaData); mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
// WHEN device event is received // WHEN device event is received
mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
// THEN the listener receives a combined event // THEN the listener receives a combined event
ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class); ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class);
verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture()); verify(mListener).onMediaDataLoaded(eq(KEY), any(), captor.capture());
assertThat(captor.getValue().getDevice()).isNotNull(); assertThat(captor.getValue().getDevice()).isNotNull();
} }
@@ -133,7 +133,7 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
@Test @Test
public void mediaDataRemovedAfterMediaEvent() { public void mediaDataRemovedAfterMediaEvent() {
mDataListener.onMediaDataLoaded(KEY, mMediaData); mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
mDataListener.onMediaDataRemoved(KEY); mDataListener.onMediaDataRemoved(KEY);
verify(mListener).onMediaDataRemoved(eq(KEY)); verify(mListener).onMediaDataRemoved(eq(KEY));
} }
@@ -145,6 +145,18 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
verify(mListener).onMediaDataRemoved(eq(KEY)); verify(mListener).onMediaDataRemoved(eq(KEY));
} }
@Test
public void mediaDataKeyUpdated() {
// GIVEN that device and media events have already been received
mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
// WHEN the key is changed
mDataListener.onMediaDataLoaded("NEW_KEY", KEY, mMediaData);
// THEN the listener gets a load event with the correct keys
ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class);
verify(mListener).onMediaDataLoaded(eq("NEW_KEY"), any(), captor.capture());
}
private MediaDataManager.Listener captureDataListener() { private MediaDataManager.Listener captureDataListener() {
ArgumentCaptor<MediaDataManager.Listener> captor = ArgumentCaptor.forClass( ArgumentCaptor<MediaDataManager.Listener> captor = ArgumentCaptor.forClass(
MediaDataManager.Listener.class); MediaDataManager.Listener.class);

View File

@@ -23,8 +23,6 @@ import android.media.MediaRouter2Manager
import android.media.RoutingSessionInfo import android.media.RoutingSessionInfo
import android.media.session.MediaSession import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Process
import android.service.notification.StatusBarNotification
import android.testing.AndroidTestingRunner import android.testing.AndroidTestingRunner
import android.testing.TestableLooper import android.testing.TestableLooper
import androidx.test.filters.SmallTest import androidx.test.filters.SmallTest
@@ -67,6 +65,7 @@ private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
public class MediaDeviceManagerTest : SysuiTestCase() { public class MediaDeviceManagerTest : SysuiTestCase() {
private lateinit var manager: MediaDeviceManager private lateinit var manager: MediaDeviceManager
@Mock private lateinit var mediaDataManager: MediaDataManager
@Mock private lateinit var lmmFactory: LocalMediaManagerFactory @Mock private lateinit var lmmFactory: LocalMediaManagerFactory
@Mock private lateinit var lmm: LocalMediaManager @Mock private lateinit var lmm: LocalMediaManager
@Mock private lateinit var mr2: MediaRouter2Manager @Mock private lateinit var mr2: MediaRouter2Manager
@@ -80,13 +79,14 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
private lateinit var metadataBuilder: MediaMetadata.Builder private lateinit var metadataBuilder: MediaMetadata.Builder
private lateinit var playbackBuilder: PlaybackState.Builder private lateinit var playbackBuilder: PlaybackState.Builder
private lateinit var notifBuilder: Notification.Builder private lateinit var notifBuilder: Notification.Builder
private lateinit var sbn: StatusBarNotification private lateinit var mediaData: MediaData
@JvmField @Rule val mockito = MockitoJUnit.rule() @JvmField @Rule val mockito = MockitoJUnit.rule()
@Before @Before
fun setUp() { fun setUp() {
fakeExecutor = FakeExecutor(FakeSystemClock()) fakeExecutor = FakeExecutor(FakeSystemClock())
manager = MediaDeviceManager(context, lmmFactory, mr2, featureFlag, fakeExecutor) manager = MediaDeviceManager(context, lmmFactory, mr2, featureFlag, fakeExecutor,
mediaDataManager)
manager.addListener(listener) manager.addListener(listener)
// Configure mocks. // Configure mocks.
@@ -117,8 +117,8 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
setSmallIcon(android.R.drawable.ic_media_pause) setSmallIcon(android.R.drawable.ic_media_pause)
setStyle(Notification.MediaStyle().setMediaSession(session.getSessionToken())) setStyle(Notification.MediaStyle().setMediaSession(session.getSessionToken()))
} }
sbn = StatusBarNotification(PACKAGE, PACKAGE, 0, "TAG", Process.myUid(), 0, 0, mediaData = MediaData(true, 0, PACKAGE, null, null, SESSION_TITLE, null,
notifBuilder.build(), Process.myUserHandle(), 0) emptyList(), emptyList(), PACKAGE, session.sessionToken, null, null, null)
} }
@After @After
@@ -128,33 +128,33 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun removeUnknown() { fun removeUnknown() {
manager.onNotificationRemoved("unknown") manager.onMediaDataRemoved("unknown")
} }
@Test @Test
fun addNotification() { fun addNotification() {
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
verify(lmmFactory).create(PACKAGE) verify(lmmFactory).create(PACKAGE)
} }
@Test @Test
fun featureDisabled() { fun featureDisabled() {
whenever(featureFlag.enabled).thenReturn(false) whenever(featureFlag.enabled).thenReturn(false)
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
verify(lmmFactory, never()).create(PACKAGE) verify(lmmFactory, never()).create(PACKAGE)
} }
@Test @Test
fun addAndRemoveNotification() { fun addAndRemoveNotification() {
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
manager.onNotificationRemoved(KEY) manager.onMediaDataRemoved(KEY)
verify(lmm).unregisterCallback(any()) verify(lmm).unregisterCallback(any())
} }
@Test @Test
fun deviceEventOnAddNotification() { fun deviceEventOnAddNotification() {
// WHEN a notification is added // WHEN a notification is added
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
val deviceCallback = captureCallback() val deviceCallback = captureCallback()
// THEN the update is dispatched to the listener // THEN the update is dispatched to the listener
val data = captureDeviceData(KEY) val data = captureDeviceData(KEY)
@@ -165,7 +165,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun deviceListUpdate() { fun deviceListUpdate() {
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
val deviceCallback = captureCallback() val deviceCallback = captureCallback()
// WHEN the device list changes // WHEN the device list changes
deviceCallback.onDeviceListUpdate(mutableListOf(device)) deviceCallback.onDeviceListUpdate(mutableListOf(device))
@@ -179,7 +179,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun selectedDeviceStateChanged() { fun selectedDeviceStateChanged() {
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
val deviceCallback = captureCallback() val deviceCallback = captureCallback()
// WHEN the selected device changes state // WHEN the selected device changes state
deviceCallback.onSelectedDeviceStateChanged(device, 1) deviceCallback.onSelectedDeviceStateChanged(device, 1)
@@ -193,9 +193,9 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun listenerReceivesKeyRemoved() { fun listenerReceivesKeyRemoved() {
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
// WHEN the notification is removed // WHEN the notification is removed
manager.onNotificationRemoved(KEY) manager.onMediaDataRemoved(KEY)
// THEN the listener receives key removed event // THEN the listener receives key removed event
verify(listener).onKeyRemoved(eq(KEY)) verify(listener).onKeyRemoved(eq(KEY))
} }
@@ -205,7 +205,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
// GIVEN that MR2Manager returns null for routing session // GIVEN that MR2Manager returns null for routing session
whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null) whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null)
// WHEN a notification is added // WHEN a notification is added
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
// THEN the device is disabled // THEN the device is disabled
val data = captureDeviceData(KEY) val data = captureDeviceData(KEY)
assertThat(data.enabled).isFalse() assertThat(data.enabled).isFalse()
@@ -216,7 +216,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun deviceDisabledWhenMR2ReturnsNullRouteInfoOnDeviceChanged() { fun deviceDisabledWhenMR2ReturnsNullRouteInfoOnDeviceChanged() {
// GIVEN a notif is added // GIVEN a notif is added
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
reset(listener) reset(listener)
// AND MR2Manager returns null for routing session // AND MR2Manager returns null for routing session
whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null) whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null)
@@ -234,7 +234,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
@Test @Test
fun deviceDisabledWhenMR2ReturnsNullRouteInfoOnDeviceListUpdate() { fun deviceDisabledWhenMR2ReturnsNullRouteInfoOnDeviceListUpdate() {
// GIVEN a notif is added // GIVEN a notif is added
manager.onNotificationAdded(KEY, sbn) manager.onMediaDataLoaded(KEY, null, mediaData)
reset(listener) reset(listener)
// GIVEN that MR2Manager returns null for routing session // GIVEN that MR2Manager returns null for routing session
whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null) whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(null)

View File

@@ -16,7 +16,9 @@
package com.android.systemui.media package com.android.systemui.media
import android.media.MediaMetadata
import android.media.session.MediaController import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.testing.AndroidTestingRunner import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest import androidx.test.filters.SmallTest
@@ -41,6 +43,10 @@ import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoJUnit
private const val KEY = "KEY" private const val KEY = "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 fun <T> eq(value: T): T = Mockito.eq(value) ?: value private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
private fun <T> anyObject(): T { private fun <T> anyObject(): T {
@@ -54,12 +60,15 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
@Mock private lateinit var mediaControllerFactory: MediaControllerFactory @Mock private lateinit var mediaControllerFactory: MediaControllerFactory
@Mock private lateinit var mediaController: MediaController @Mock private lateinit var mediaController: MediaController
@Mock private lateinit var executor: DelayableExecutor @Mock private lateinit var executor: DelayableExecutor
@Mock private lateinit var mediaData: MediaData
@Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit @Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit
@Mock private lateinit var cancellationRunnable: Runnable @Mock private lateinit var cancellationRunnable: Runnable
@Captor private lateinit var timeoutCaptor: ArgumentCaptor<Runnable> @Captor private lateinit var timeoutCaptor: ArgumentCaptor<Runnable>
@Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback> @Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback>
@JvmField @Rule val mockito = MockitoJUnit.rule() @JvmField @Rule val mockito = MockitoJUnit.rule()
private lateinit var metadataBuilder: MediaMetadata.Builder
private lateinit var playbackBuilder: PlaybackState.Builder
private lateinit var session: MediaSession
private lateinit var mediaData: MediaData
private lateinit var mediaTimeoutListener: MediaTimeoutListener private lateinit var mediaTimeoutListener: MediaTimeoutListener
@Before @Before
@@ -68,22 +77,39 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
`when`(executor.executeDelayed(any(), anyLong())).thenReturn(cancellationRunnable) `when`(executor.executeDelayed(any(), anyLong())).thenReturn(cancellationRunnable)
mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor) mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor)
mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.timeoutCallback = timeoutCallback
// Create a media session 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)
mediaData = MediaData(true, 0, PACKAGE, null, null, SESSION_TITLE, null,
emptyList(), emptyList(), PACKAGE, session.sessionToken, null, null, null)
} }
@Test @Test
fun testOnMediaDataLoaded_registersPlaybackListener() { fun testOnMediaDataLoaded_registersPlaybackListener() {
mediaTimeoutListener.onMediaDataLoaded(KEY, mediaData) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData)
verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) verify(mediaController).registerCallback(capture(mediaCallbackCaptor))
// Ignores is same key // Ignores is same key
clearInvocations(mediaController) clearInvocations(mediaController)
mediaTimeoutListener.onMediaDataLoaded(KEY, mediaData) mediaTimeoutListener.onMediaDataLoaded(KEY, KEY, mediaData)
verify(mediaController, never()).registerCallback(anyObject()) verify(mediaController, never()).registerCallback(anyObject())
} }
@Test @Test
fun testOnMediaDataRemoved_unregistersPlaybackListener() { fun testOnMediaDataRemoved_unregistersPlaybackListener() {
mediaTimeoutListener.onMediaDataLoaded(KEY, mediaData) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData)
mediaTimeoutListener.onMediaDataRemoved(KEY) mediaTimeoutListener.onMediaDataRemoved(KEY)
verify(mediaController).unregisterCallback(anyObject()) verify(mediaController).unregisterCallback(anyObject())
@@ -124,7 +150,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
@Test @Test
fun testIsTimedOut() { fun testIsTimedOut() {
mediaTimeoutListener.onMediaDataLoaded(KEY, mediaData) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData)
assertThat(mediaTimeoutListener.isTimedOut(KEY)).isFalse() assertThat(mediaTimeoutListener.isTimedOut(KEY)).isFalse()
} }
} }