Merge "Adding back media resumption" into rvc-dev
This commit is contained in:
committed by
Android (Google) Code Review
commit
ad80df940b
24
packages/SystemUI/res/drawable/ic_music_note.xml
Normal file
24
packages/SystemUI/res/drawable/ic_music_note.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user