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] -->
<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] -->
<string name="controls_error_timeout">Inactive, check app</string>

View File

@@ -17,12 +17,8 @@
package com.android.systemui.media;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
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.graphics.Bitmap;
import android.graphics.Canvas;
@@ -35,7 +31,6 @@ import android.graphics.drawable.RippleDrawable;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.service.media.MediaBrowserService;
import android.util.Log;
import android.view.View;
import android.widget.ImageButton;
@@ -55,7 +50,6 @@ 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.animation.TransitionLayout;
import com.android.systemui.util.concurrency.DelayableExecutor;
@@ -81,7 +75,6 @@ public class MediaControlPanel {
private final SeekBarViewModel mSeekBarViewModel;
private SeekBarObserver mSeekBarObserver;
private final Executor mForegroundExecutor;
protected final Executor mBackgroundExecutor;
private final ActivityStarter mActivityStarter;
@@ -91,48 +84,18 @@ public class MediaControlPanel {
private MediaSession.Token mToken;
private MediaController mController;
private int mBackgroundColor;
protected ComponentName mServiceComponent;
private boolean mIsRegistered = false;
private String mKey;
private int mAlbumArtSize;
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
* @param context
* @param foregroundExecutor foreground executor
* @param backgroundExecutor background executor, used for processing artwork
* @param activityStarter activity starter
*/
public MediaControlPanel(Context context, Executor foregroundExecutor,
DelayableExecutor backgroundExecutor, ActivityStarter activityStarter,
MediaHostStatesManager mediaHostStatesManager) {
public MediaControlPanel(Context context, DelayableExecutor backgroundExecutor,
ActivityStarter activityStarter, MediaHostStatesManager mediaHostStatesManager) {
mContext = context;
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter;
mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
@@ -214,45 +177,18 @@ public class MediaControlPanel {
MediaSession.Token token = data.getToken();
mBackgroundColor = data.getBackgroundColor();
if (mToken == null || !mToken.equals(token)) {
if (mQSMediaBrowser != null) {
Log.d(TAG, "Disconnecting old media browser");
mQSMediaBrowser.disconnect();
mQSMediaBrowser = null;
}
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 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(
ColorStateList.valueOf(mBackgroundColor));
@@ -267,12 +203,22 @@ public class MediaControlPanel {
ImageView albumView = mViewHolder.getAlbumView();
// TODO: migrate this to a view with rounded corners instead of baking the rounding
// into the bitmap
Drawable artwork = createRoundedBitmap(data.getArtwork());
albumView.setImageDrawable(artwork);
boolean hasArtwork = data.getArtwork() != null;
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
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
TextView titleText = mViewHolder.getTitleText();
@@ -294,7 +240,7 @@ public class MediaControlPanel {
final Intent intent = new Intent()
.setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
.putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
mController.getPackageName())
data.getPackageName())
.putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
mActivityStarter.startActivity(intent, false, true /* dismissShade */,
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
@@ -350,15 +296,11 @@ public class MediaControlPanel {
MediaAction mediaAction = actionIcons.get(i);
button.setImageDrawable(mediaAction.getDrawable());
button.setContentDescription(mediaAction.getContentDescription());
PendingIntent actionIntent = mediaAction.getIntent();
Runnable action = mediaAction.getAction();
button.setOnClickListener(v -> {
if (actionIntent != null) {
try {
actionIntent.send();
} catch (PendingIntent.CanceledException e) {
e.printStackTrace();
}
if (action != null) {
action.run();
}
});
boolean visibleInCompat = actionsWhenCollapsed.contains(i);
@@ -443,14 +385,6 @@ public class MediaControlPanel {
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.
* @return whether there is a controller with a current media session.
@@ -485,150 +419,8 @@ public class MediaControlPanel {
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) {
set.setVisibility(actionId, visible? ConstraintSet.VISIBLE : ConstraintSet.GONE);
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 actions: List<MediaAction>,
val actionsToShowInCompact: List<Int>,
val packageName: String?,
val packageName: String,
val token: MediaSession.Token?,
val clickIntent: PendingIntent?,
val device: MediaDeviceData?,
val notificationKey: String = "INVALID"
var resumeAction: Runnable?,
val notificationKey: String = "INVALID",
var hasCheckedForResume: Boolean = false
)
/** State of a media action. */
data class MediaAction(
val drawable: Drawable?,
val intent: PendingIntent?,
val action: Runnable?,
val contentDescription: CharSequence?
)

View File

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

View File

@@ -17,6 +17,7 @@
package com.android.systemui.media
import android.app.Notification
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
@@ -25,6 +26,7 @@ import android.graphics.Color
import android.graphics.ImageDecoder
import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.media.MediaDescription
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.net.Uri
@@ -32,8 +34,10 @@ import android.service.notification.StatusBarNotification
import android.text.TextUtils
import android.util.Log
import com.android.internal.graphics.ColorUtils
import com.android.systemui.R
import com.android.systemui.dagger.qualifiers.Background
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.NotificationEntryManager
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 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 {
if (!sbn.notification.hasMediaSession()) {
@@ -81,34 +85,92 @@ class MediaDataManager @Inject constructor(
private val mediaControllerFactory: MediaControllerFactory,
private val mediaTimeoutListener: MediaTimeoutListener,
private val notificationEntryManager: NotificationEntryManager,
private val mediaResumeListener: MediaResumeListener,
@Background private val backgroundExecutor: Executor,
@Main private val foregroundExecutor: Executor
) {
private val listeners: MutableSet<Listener> = mutableSetOf()
private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
private val useMediaResumption: Boolean = Utils.useMediaResumption(context)
init {
mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean ->
setTimedOut(token, timedOut) }
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) {
if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) {
Assert.isMainThread()
if (!mediaEntries.containsKey(key)) {
mediaEntries.put(key, LOADING)
val oldKey = findExistingEntry(key, sbn.packageName)
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 {
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 {
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)
as MediaSession.Token?
val metadata = mediaControllerFactory.create(token).metadata
@@ -234,16 +339,23 @@ class MediaDataManager @Inject constructor(
}
val mediaAction = MediaAction(
action.getIcon().loadDrawable(packageContext),
action.actionIntent,
Runnable {
try {
action.actionIntent.send()
} catch (e: PendingIntent.CanceledException) {
Log.d(TAG, "Intent canceled", e)
}
},
action.title)
actionIcons.add(mediaAction)
}
}
val resumeAction: Runnable? = mediaEntries.get(key)?.resumeAction
foregroundExecutor.execute {
onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song,
artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
notif.contentIntent, null, key))
onMediaDataLoaded(key, oldKey, MediaData(true, bgColor, app, smallIconDrawable, artist,
song, artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
notif.contentIntent, null, resumeAction, key))
}
}
@@ -257,7 +369,7 @@ class MediaDataManager @Inject constructor(
val albumArt = loadBitmapFromUri(Uri.parse(uriString))
if (albumArt != null) {
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)
return try {
ImageDecoder.decodeBitmap(source)
ImageDecoder.decodeBitmap(source) {
decoder, info, source -> decoder.isMutableRequired = true
}
} catch (e: IOException) {
e.printStackTrace()
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()
if (mediaEntries.containsKey(key)) {
// Otherwise this was removed already
mediaEntries.put(key, data)
val listenersCopy = listeners.toSet()
listenersCopy.forEach {
it.onMediaDataLoaded(key, data)
it.onMediaDataLoaded(key, oldKey, data)
}
}
}
fun onNotificationRemoved(key: String) {
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)
if (removed != null) {
val listenersCopy = listeners.toSet()
@@ -316,19 +453,32 @@ class MediaDataManager @Inject constructor(
/**
* Are there any media notifications active?
*/
fun hasActiveMedia() = mediaEntries.isNotEmpty()
fun hasActiveMedia() = mediaEntries.any({ isActive(it.value) })
fun hasAnyMedia(): Boolean {
// TODO: implement this when we implemented resumption
return hasActiveMedia()
fun isActive(data: MediaData): Boolean {
if (data.token == null) {
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 {
/**
* 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

View File

@@ -16,11 +16,8 @@
package com.android.systemui.media
import android.app.Notification
import android.content.Context
import android.service.notification.StatusBarNotification
import android.media.MediaRouter2Manager
import android.media.session.MediaSession
import android.media.session.MediaController
import com.android.settingslib.media.LocalMediaManager
import com.android.settingslib.media.MediaDevice
@@ -38,11 +35,16 @@ class MediaDeviceManager @Inject constructor(
private val localMediaManagerFactory: LocalMediaManagerFactory,
private val mr2manager: MediaRouter2Manager,
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 entries: MutableMap<String, Token> = mutableMapOf()
init {
mediaDataManager.addListener(this)
}
/**
* 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 onNotificationAdded(key: String, sbn: StatusBarNotification) {
if (featureFlag.enabled && isMediaNotification(sbn)) {
override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (featureFlag.enabled) {
if (oldKey != null && oldKey != key) {
val oldToken = entries.remove(oldKey)
oldToken?.stop()
}
var tok = entries[key]
if (tok == null) {
val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
as MediaSession.Token?
val controller = MediaController(context, token)
tok = Token(key, controller, localMediaManagerFactory.create(sbn.packageName))
if (tok == null && data.token != null) {
val controller = MediaController(context, data.token!!)
tok = Token(key, controller, localMediaManagerFactory.create(data.packageName))
entries[key] = tok
tok.start()
}
} else {
onNotificationRemoved(key)
onMediaDataRemoved(key)
}
}
fun onNotificationRemoved(key: String) {
override fun onMediaDataRemoved(key: String) {
val token = entries.remove(key)
token?.stop()
token?.let {

View File

@@ -50,7 +50,7 @@ class MediaHost @Inject constructor(
}
private val listener = object : MediaDataManager.Listener {
override fun onMediaDataLoaded(key: String, data: MediaData) {
override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
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
override fun onMediaDataLoaded(key: String, data: MediaData) {
override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
if (mediaListeners.containsKey(key)) {
return
}
@@ -67,15 +67,20 @@ class MediaTimeoutListener @Inject constructor(
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
init {
mediaController.registerCallback(this)
mediaController?.registerCallback(this)
}
fun destroy() {
mediaController.unregisterCallback(this)
mediaController?.unregisterCallback(this)
}
override fun onPlaybackStateChanged(state: PlaybackState?) {

View File

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

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.android.systemui.qs;
package com.android.systemui.media;
import android.app.PendingIntent;
import android.content.ComponentName;
@@ -27,14 +27,17 @@ import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.os.Bundle;
import android.service.media.MediaBrowserService;
import android.text.TextUtils;
import android.util.Log;
import com.android.systemui.util.Utils;
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 */
public static final int MAX_RESUMPTION_CONTROLS = 5;
@@ -42,7 +45,8 @@ public class QSMediaBrowser {
/** Delimiter for saved component names */
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 Callback mCallback;
private MediaBrowser mMediaBrowser;
@@ -54,21 +58,25 @@ public class QSMediaBrowser {
* @param callback used to report media items found
* @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;
mCallback = callback;
mComponentName = componentName;
}
/**
* 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.
* QSMediaBrowser.Callback#onConnected and QSMediaBrowser.Callback#onError will also be called
* when the initial connection is successful, or an error occurs. Note that it is possible for
* the service to connect but for no playable tracks to be found later.
* QSMediaBrowser#disconnect will be called automatically with this function.
* Connects to the MediaBrowserService and looks for valid media. If a media item is returned,
* ResumeMediaBrowser.Callback#addTrack will be called with the MediaDescription.
* ResumeMediaBrowser.Callback#onConnected and ResumeMediaBrowser.Callback#onError will also be
* called when the initial connection is successful, or an error occurs.
* Note that it is possible for the service to connect but for no playable tracks to be found.
* ResumeMediaBrowser#disconnect will be called automatically with this function.
*/
public void findRecentMedia() {
if (!mIsEnabled) {
return;
}
Log.d(TAG, "Connecting to " + mComponentName);
disconnect();
Bundle rootHints = new Bundle();
@@ -86,7 +94,7 @@ public class QSMediaBrowser {
public void onChildrenLoaded(String parentId,
List<MediaBrowser.MediaItem> children) {
if (children.size() == 0) {
Log.e(TAG, "No children found for " + mComponentName);
Log.d(TAG, "No children found for " + mComponentName);
return;
}
// 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);
MediaDescription desc = child.getDescription();
if (child.isPlayable()) {
mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(), QSMediaBrowser.this);
mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(),
ResumeMediaBrowser.this);
} else {
Log.e(TAG, "Child found but not playable for " + mComponentName);
Log.d(TAG, "Child found but not playable for " + mComponentName);
}
disconnect();
}
@Override
public void onError(String parentId) {
Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
mCallback.onError();
disconnect();
}
@Override
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);
mCallback.onError();
disconnect();
@@ -149,7 +158,7 @@ public class QSMediaBrowser {
*/
@Override
public void onConnectionFailed() {
Log.e(TAG, "Connection failed for " + mComponentName);
Log.d(TAG, "Connection failed for " + mComponentName);
mCallback.onError();
disconnect();
}
@@ -167,11 +176,15 @@ public class QSMediaBrowser {
}
/**
* Connects to the MediaBrowserService and starts playback. QSMediaBrowser.Callback#onError or
* QSMediaBrowser.Callback#onConnected will be called depending on whether it was successful.
* QSMediaBrowser#disconnect should be called after this to ensure the connection is closed.
* Connects to the MediaBrowserService and starts playback.
* ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
* depending on whether it was successful.
* ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
*/
public void restart() {
if (!mIsEnabled) {
return;
}
disconnect();
Bundle rootHints = new Bundle();
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.
* 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.
* 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() {
if (!mIsEnabled) {
return;
}
disconnect();
final MediaBrowser.ConnectionCallback connectionCallback =
new MediaBrowser.ConnectionCallback() {
@Override
public void onConnected() {
Log.d(TAG, "connected");
if (mMediaBrowser.getRoot() == null) {
if (TextUtils.isEmpty(mMediaBrowser.getRoot())) {
mCallback.onError();
} else {
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 {
/**
@@ -286,7 +302,7 @@ public class QSMediaBrowser {
* @param browser reference to the browser
*/
public void addTrack(MediaDescription track, ComponentName component,
QSMediaBrowser browser) {
ResumeMediaBrowser browser) {
}
}
}

View File

@@ -91,8 +91,14 @@ class SeekBarViewModel(val bgExecutor: DelayableExecutor) {
playbackState = state
if (shouldPollPlaybackPosition()) {
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. */

View File

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

View File

@@ -52,7 +52,6 @@ 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;
@@ -102,6 +101,12 @@ public class NotificationMediaManager implements Dumpable {
PAUSED_MEDIA_STATES.add(PlaybackState.STATE_PAUSED);
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 MediaDataManager mMediaDataManager;
@@ -190,8 +195,7 @@ public class NotificationMediaManager implements Dumpable {
KeyguardBypassController keyguardBypassController,
@Main DelayableExecutor mainExecutor,
DeviceConfigProxy deviceConfig,
MediaDataManager mediaDataManager,
MediaDeviceManager mediaDeviceManager) {
MediaDataManager mediaDataManager) {
mContext = context;
mMediaArtworkProcessor = mediaArtworkProcessor;
mKeyguardBypassController = keyguardBypassController;
@@ -212,13 +216,11 @@ 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
@@ -239,7 +241,6 @@ public class NotificationMediaManager implements Dumpable {
int reason) {
onNotificationRemoved(entry.getKey());
mediaDataManager.onNotificationRemoved(entry.getKey());
mediaDeviceManager.onNotificationRemoved(entry.getKey());
}
});
@@ -252,10 +253,24 @@ public class NotificationMediaManager implements Dumpable {
mPropertiesChangedListener);
}
/**
* Check if a state should be considered actively playing
* @param state a PlaybackState
* @return true if playing
*/
public static boolean isPlayingState(int 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) {
mPresenter = presenter;
}

View File

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

View File

@@ -133,4 +133,13 @@ public class Utils {
Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1);
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 fgExecutor: FakeExecutor
private lateinit var bgExecutor: FakeExecutor
@Mock private lateinit var activityStarter: ActivityStarter
@@ -97,14 +96,12 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Before
fun setUp() {
fgExecutor = FakeExecutor(FakeSystemClock())
bgExecutor = FakeExecutor(FakeSystemClock())
activityStarter = mock(ActivityStarter::class.java)
mediaHostStatesManager = mock(MediaHostStatesManager::class.java)
player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter,
mediaHostStatesManager)
player = MediaControlPanel(context, bgExecutor, activityStarter, mediaHostStatesManager)
// Mock out a view holder for the player to attach to.
holder = mock(PlayerViewHolder::class.java)
@@ -171,7 +168,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Test
fun bindWhenUnattached() {
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)
assertThat(player.isPlaying()).isFalse()
}
@@ -180,7 +177,7 @@ 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, device)
emptyList(), PACKAGE, session.getSessionToken(), null, device, null)
player.bind(state)
assertThat(appName.getText()).isEqualTo(APP)
assertThat(titleText.getText()).isEqualTo(TITLE)
@@ -191,7 +188,7 @@ 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, device)
emptyList(), PACKAGE, session.getSessionToken(), null, device, null)
player.bind(state)
val list = ArgumentCaptor.forClass(ColorStateList::class.java)
verify(view).setBackgroundTintList(list.capture())
@@ -202,7 +199,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindDevice() {
player.attach(holder)
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)
assertThat(seamlessText.getText()).isEqualTo(DEVICE_NAME)
assertThat(seamless.isEnabled()).isTrue()
@@ -212,7 +209,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindDisabledDevice() {
player.attach(holder)
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)
assertThat(seamless.isEnabled()).isFalse()
assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString(
@@ -223,7 +220,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindNullDevice() {
player.attach(holder)
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)
assertThat(seamless.isEnabled()).isTrue()
assertThat(seamlessText.getText()).isEqualTo(context.getResources().getString(

View File

@@ -79,16 +79,16 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
mManager.addListener(mListener);
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);
}
@Test
public void eventNotEmittedWithoutDevice() {
// WHEN data source emits an event without device data
mDataListener.onMediaDataLoaded(KEY, mMediaData);
mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
// THEN an event isn't emitted
verify(mListener, never()).onMediaDataLoaded(eq(KEY), any());
verify(mListener, never()).onMediaDataLoaded(eq(KEY), any(), any());
}
@Test
@@ -96,7 +96,7 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
// 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());
verify(mListener, never()).onMediaDataLoaded(eq(KEY), any(), any());
}
@Test
@@ -104,22 +104,22 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
// GIVEN that a device event has already been received
mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData);
// WHEN media event is received
mDataListener.onMediaDataLoaded(KEY, mMediaData);
mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
// THEN the listener receives a combined event
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();
}
@Test
public void emitEventAfterMediaFirst() {
// GIVEN that media event has already been received
mDataListener.onMediaDataLoaded(KEY, mMediaData);
mDataListener.onMediaDataLoaded(KEY, null, 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());
verify(mListener).onMediaDataLoaded(eq(KEY), any(), captor.capture());
assertThat(captor.getValue().getDevice()).isNotNull();
}
@@ -133,7 +133,7 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
@Test
public void mediaDataRemovedAfterMediaEvent() {
mDataListener.onMediaDataLoaded(KEY, mMediaData);
mDataListener.onMediaDataLoaded(KEY, null, mMediaData);
mDataListener.onMediaDataRemoved(KEY);
verify(mListener).onMediaDataRemoved(eq(KEY));
}
@@ -145,6 +145,18 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
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() {
ArgumentCaptor<MediaDataManager.Listener> captor = ArgumentCaptor.forClass(
MediaDataManager.Listener.class);

View File

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

View File

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