From 23a33abaf404c9cd091f7d8e950a21b202894b7f Mon Sep 17 00:00:00 2001 From: Beth Thibodeau Date: Tue, 7 Apr 2020 20:51:57 -0400 Subject: [PATCH] Add media browser for resumption Doc: go/sysui-media-resumption-requirements The three main pieces are 1. When the user plays media from an app, check if that app implements a MediaBrowserService. If so, store that app's info for up to N=5 apps 2. When QSPanel is created, use a QSMediaBrowser to query the saved services for a playable media item and load those in the media controls panel 3. If the user taps the play button on one of those controls, use QSMediaBrowser to send a play command to the app's MediaBrowserService Also, if a media player does not have a MediaBrowserService that allows us to connect, auto-remove the controls when the media session has ended. Will explore adding a media button receiver back as an alternative in b/154127084 Bug: 151103474 Bug: 151737807 Test: manual- play from app, reboot, see controls, can play Change-Id: Ia1172316f1b0c301d794d93b77c7628a736fb153 --- .../systemui/media/MediaControlPanel.java | 242 +++++++++++----- .../android/systemui/qs/QSMediaBrowser.java | 259 ++++++++++++++++++ .../android/systemui/qs/QSMediaPlayer.java | 145 +++++++--- .../src/com/android/systemui/qs/QSPanel.java | 129 ++++++++- .../systemui/qs/QuickQSMediaPlayer.java | 3 +- .../NotificationMediaTemplateViewWrapper.java | 6 +- 6 files changed, 666 insertions(+), 118 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java index 9217eb161a875..f25de6a553e8d 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java @@ -21,20 +21,21 @@ 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.drawable.Drawable; import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.Icon; import android.graphics.drawable.RippleDrawable; +import android.media.MediaDescription; import android.media.MediaMetadata; 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.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnAttachStateChangeListener; @@ -55,6 +56,7 @@ import com.android.settingslib.widget.AdaptiveIcon; import com.android.systemui.Dependency; import com.android.systemui.R; import com.android.systemui.plugins.ActivityStarter; +import com.android.systemui.qs.QSMediaBrowser; import com.android.systemui.util.Assert; import java.util.List; @@ -67,7 +69,7 @@ public class MediaControlPanel { private static final String TAG = "MediaControlPanel"; @Nullable private final LocalMediaManager mLocalMediaManager; private final Executor mForegroundExecutor; - private final Executor mBackgroundExecutor; + protected final Executor mBackgroundExecutor; private Context mContext; protected LinearLayout mMediaNotifView; @@ -76,13 +78,18 @@ public class MediaControlPanel { private MediaController mController; private int mForegroundColor; private int mBackgroundColor; - protected ComponentName mRecvComponent; private MediaDevice mDevice; + protected ComponentName mServiceComponent; private boolean mIsRegistered = false; private String mKey; private final int[] mActionIds; + 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; + // Button IDs used in notifications protected static final int[] NOTIF_ACTION_IDS = { com.android.internal.R.id.action0, @@ -154,7 +161,6 @@ public class MediaControlPanel { * Initialize a new control panel * @param context * @param parent - * @param manager * @param routeManager Manager used to listen for device change events. * @param layoutId layout resource to use for this control panel * @param actionIds resource IDs for action buttons in the layout @@ -198,47 +204,50 @@ public class MediaControlPanel { /** * Update the media panel view for the given media session * @param token - * @param icon + * @param iconDrawable * @param iconColor * @param bgColor * @param contentIntent * @param appNameString * @param key */ - public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor, + public void setMediaSession(MediaSession.Token token, Drawable iconDrawable, int iconColor, int bgColor, PendingIntent contentIntent, String appNameString, String key) { - mToken = token; + // Ensure that component names are updated if token has changed + if (mToken == null || !mToken.equals(token)) { + mToken = token; + mServiceComponent = null; + mCheckedForResumption = false; + } + mForegroundColor = iconColor; mBackgroundColor = bgColor; mController = new MediaController(mContext, mToken); mKey = key; - MediaMetadata mediaMetadata = mController.getMetadata(); - - // Try to find a receiver for the media button that matches this app - PackageManager pm = mContext.getPackageManager(); - Intent it = new Intent(Intent.ACTION_MEDIA_BUTTON); - List info = pm.queryBroadcastReceiversAsUser(it, 0, mContext.getUser()); - if (info != null) { - for (ResolveInfo inf : info) { - if (inf.activityInfo.packageName.equals(mController.getPackageName())) { - mRecvComponent = inf.getComponentInfo().getComponentName(); + // 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 + String pkgName = mController.getPackageName(); + if (mServiceComponent == null && !mCheckedForResumption) { + Log.d(TAG, "Checking for service component"); + PackageManager pm = mContext.getPackageManager(); + Intent resumeIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE); + List resumeInfo = pm.queryIntentServices(resumeIntent, 0); + 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); - if (mediaMetadata == null) { - Log.e(TAG, "Media metadata was null"); - return; - } - - ImageView albumView = mMediaNotifView.findViewById(R.id.album_art); - if (albumView != null) { - // Resize art in a background thread - mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView)); - } mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor)); // Click action @@ -256,32 +265,9 @@ public class MediaControlPanel { // App icon ImageView appIcon = mMediaNotifView.findViewById(R.id.icon); - Drawable iconDrawable = icon.loadDrawable(mContext); iconDrawable.setTint(mForegroundColor); appIcon.setImageDrawable(iconDrawable); - // Song name - TextView titleText = mMediaNotifView.findViewById(R.id.header_title); - String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE); - titleText.setText(songName); - titleText.setTextColor(mForegroundColor); - - // Not in mini player: - // App title - TextView appName = mMediaNotifView.findViewById(R.id.app_name); - if (appName != null) { - appName.setText(appNameString); - appName.setTextColor(mForegroundColor); - } - - // Artist name - TextView artistText = mMediaNotifView.findViewById(R.id.header_artist); - if (artistText != null) { - String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST); - artistText.setText(artistName); - artistText.setTextColor(mForegroundColor); - } - // Transfer chip mSeamless = mMediaNotifView.findViewById(R.id.media_seamless); if (mSeamless != null && mLocalMediaManager != null) { @@ -300,6 +286,39 @@ public class MediaControlPanel { } makeActive(); + + // App title (not in mini player) + TextView appName = mMediaNotifView.findViewById(R.id.app_name); + if (appName != null) { + appName.setText(appNameString); + appName.setTextColor(mForegroundColor); + } + + MediaMetadata mediaMetadata = mController.getMetadata(); + if (mediaMetadata == null) { + Log.e(TAG, "Media metadata was null"); + return; + } + + ImageView albumView = mMediaNotifView.findViewById(R.id.album_art); + if (albumView != null) { + // Resize art in a background thread + mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView)); + } + + // Song name + TextView titleText = mMediaNotifView.findViewById(R.id.header_title); + String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE); + titleText.setText(songName); + titleText.setTextColor(mForegroundColor); + + // Artist name (not in mini player) + TextView artistText = mMediaNotifView.findViewById(R.id.header_artist); + if (artistText != null) { + String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST); + artistText.setText(artistName); + artistText.setTextColor(mForegroundColor); + } } /** @@ -320,9 +339,12 @@ public class MediaControlPanel { /** * Get the name of the package associated with the current media controller - * @return the package name + * @return the package name, or null if no controller */ public String getMediaPlayerPackage() { + if (mController == null) { + return null; + } return mController.getPackageName(); } @@ -368,6 +390,17 @@ public class MediaControlPanel { return (state.getState() == PlaybackState.STATE_PLAYING); } + /** + * Process album art for layout + * @param description media description + * @param albumView view to hold the album art + */ + protected void processAlbumArt(MediaDescription description, ImageView albumView) { + Bitmap albumArt = description.getIconBitmap(); + //TODO check other fields (b/151054111, b/152067055) + processAlbumArtInternal(albumArt, albumView); + } + /** * Process album art for layout * @param metadata media metadata @@ -375,6 +408,11 @@ public class MediaControlPanel { */ private void processAlbumArt(MediaMetadata metadata, ImageView albumView) { Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART); + //TODO check other fields (b/151054111, b/152067055) + processAlbumArtInternal(albumArt, albumView); + } + + private void processAlbumArtInternal(Bitmap albumArt, ImageView albumView) { float radius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius); RoundedBitmapDrawable roundedDrawable = null; if (albumArt != null) { @@ -449,10 +487,24 @@ public class MediaControlPanel { } /** - * Put controls into a resumption state + * 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() { // Hide all the old buttons for (int i = 0; i < mActionIds.length; i++) { ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]); @@ -465,27 +517,8 @@ public class MediaControlPanel { ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]); btn.setOnClickListener(v -> { Log.d(TAG, "Attempting to restart session"); - // Send a media button event to previously found receiver - if (mRecvComponent != null) { - Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); - intent.setComponent(mRecvComponent); - int keyCode = KeyEvent.KEYCODE_MEDIA_PLAY; - intent.putExtra( - Intent.EXTRA_KEY_EVENT, - new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); - mContext.sendBroadcast(intent); - } else { - // If we don't have a receiver, try relaunching the activity instead - if (mController.getSessionActivity() != null) { - try { - mController.getSessionActivity().send(); - } catch (PendingIntent.CanceledException e) { - Log.e(TAG, "Pending intent was canceled", e); - } - } else { - Log.e(TAG, "No receiver or activity to restart"); - } - } + QSMediaBrowser browser = new QSMediaBrowser(mContext, null, mServiceComponent); + browser.restart(); }); btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play)); btn.setImageTintList(ColorStateList.valueOf(mForegroundColor)); @@ -514,4 +547,65 @@ public class MediaControlPanel { } } + /** + * 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); + QSMediaBrowser.testConnection(mContext, + new QSMediaBrowser.Callback() { + @Override + public void onConnected() { + Log.d(TAG, "yes we can resume with " + componentName); + mServiceComponent = componentName; + updateResumptionList(componentName); + } + + @Override + public void onError() { + Log.d(TAG, "Cannot resume with " + componentName); + mServiceComponent = null; + clearControls(); + // remove + } + }, + componentName); + } + + /** + * 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() { } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java b/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java new file mode 100644 index 0000000000000..302b842036411 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java @@ -0,0 +1,259 @@ +/* + * 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.qs; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.media.MediaDescription; +import android.media.browse.MediaBrowser; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.os.Bundle; +import android.service.media.MediaBrowserService; +import android.util.Log; + +import java.util.List; + +/** + * Media browser for managing resumption in QS media controls + */ +public class QSMediaBrowser { + + /** Maximum number of controls to show on boot */ + public static final int MAX_RESUMPTION_CONTROLS = 5; + + /** Delimiter for saved component names */ + public static final String DELIMITER = ":"; + + private static final String TAG = "QSMediaBrowser"; + private final Context mContext; + private final Callback mCallback; + private MediaBrowser mMediaBrowser; + private ComponentName mComponentName; + + /** + * Initialize a new media browser + * @param context the context + * @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) { + mContext = context; + mCallback = callback; + mComponentName = componentName; + + Bundle rootHints = new Bundle(); + rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true); + mMediaBrowser = new MediaBrowser(mContext, + mComponentName, + mConnectionCallback, + rootHints); + } + + /** + * 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 + */ + public void findRecentMedia() { + Log.d(TAG, "Connecting to " + mComponentName); + mMediaBrowser.connect(); + } + + private final MediaBrowser.SubscriptionCallback mSubscriptionCallback = + new MediaBrowser.SubscriptionCallback() { + @Override + public void onChildrenLoaded(String parentId, + List children) { + if (children.size() == 0) { + Log.e(TAG, "No children found"); + return; + } + // We ask apps to return a playable item as the first child when sending + // a request with EXTRA_RECENT; if they don't, no resume controls + MediaBrowser.MediaItem child = children.get(0); + MediaDescription desc = child.getDescription(); + if (child.isPlayable()) { + mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(), QSMediaBrowser.this); + } else { + Log.e(TAG, "Child found but not playable for " + mComponentName); + } + mMediaBrowser.disconnect(); + } + + @Override + public void onError(String parentId) { + Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId); + mMediaBrowser.disconnect(); + } + + @Override + public void onError(String parentId, Bundle options) { + Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId + + ", options: " + options); + mMediaBrowser.disconnect(); + } + }; + + private final MediaBrowser.ConnectionCallback mConnectionCallback = + new MediaBrowser.ConnectionCallback() { + /** + * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed. + * For resumption controls, apps are expected to return a playable media item as the first + * child. If there are no children or it isn't playable it will be ignored. + */ + @Override + public void onConnected() { + if (mMediaBrowser.isConnected()) { + mCallback.onConnected(); + Log.d(TAG, "Service connected for " + mComponentName); + String root = mMediaBrowser.getRoot(); + mMediaBrowser.subscribe(root, mSubscriptionCallback); + } + } + + /** + * Invoked when the client is disconnected from the media browser. + */ + @Override + public void onConnectionSuspended() { + Log.d(TAG, "Connection suspended for " + mComponentName); + } + + /** + * Invoked when the connection to the media browser failed. + */ + @Override + public void onConnectionFailed() { + Log.e(TAG, "Connection failed for " + mComponentName); + mCallback.onError(); + } + }; + + /** + * Connects to the MediaBrowserService and starts playback + */ + public void restart() { + if (mMediaBrowser.isConnected()) { + mMediaBrowser.disconnect(); + } + Bundle rootHints = new Bundle(); + rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true); + mMediaBrowser = new MediaBrowser(mContext, mComponentName, + new MediaBrowser.ConnectionCallback() { + @Override + public void onConnected() { + Log.d(TAG, "Connected for restart " + mMediaBrowser.isConnected()); + MediaSession.Token token = mMediaBrowser.getSessionToken(); + MediaController controller = new MediaController(mContext, token); + controller.getTransportControls(); + controller.getTransportControls().prepare(); + controller.getTransportControls().play(); + } + }, rootHints); + mMediaBrowser.connect(); + } + + /** + * Get the media session token + * @return the token, or null if the MediaBrowser is null or disconnected + */ + public MediaSession.Token getToken() { + if (mMediaBrowser == null || !mMediaBrowser.isConnected()) { + return null; + } + return mMediaBrowser.getSessionToken(); + } + + /** + * Get an intent to launch the app associated with this browser service + * @return + */ + public PendingIntent getAppIntent() { + PackageManager pm = mContext.getPackageManager(); + Intent launchIntent = pm.getLaunchIntentForPackage(mComponentName.getPackageName()); + return PendingIntent.getActivity(mContext, 0, launchIntent, 0); + } + + /** + * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser + * @param mContext the context + * @param callback methods onConnected or onError will be called to indicate whether the + * connection was successful or not + * @param mComponentName Component name of the MediaBrowserService this browser will connect to + */ + public static MediaBrowser testConnection(Context mContext, Callback callback, + ComponentName mComponentName) { + final MediaBrowser.ConnectionCallback mConnectionCallback = + new MediaBrowser.ConnectionCallback() { + @Override + public void onConnected() { + Log.d(TAG, "connected"); + callback.onConnected(); + } + + @Override + public void onConnectionSuspended() { + Log.d(TAG, "suspended"); + callback.onError(); + } + + @Override + public void onConnectionFailed() { + Log.d(TAG, "failed"); + callback.onError(); + } + }; + Bundle rootHints = new Bundle(); + rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true); + MediaBrowser browser = new MediaBrowser(mContext, + mComponentName, + mConnectionCallback, + rootHints); + browser.connect(); + return browser; + } + + /** + * Interface to handle results from QSMediaBrowser + */ + public static class Callback { + /** + * Called when the browser has successfully connected to the service + */ + public void onConnected() { + } + + /** + * Called when the browser encountered an error connecting to the service + */ + public void onError() { + } + + /** + * Called when the browser finds a suitable track to add to the media carousel + * @param track media info for the item + * @param component component of the MediaBrowserService which returned this + * @param browser reference to the browser + */ + public void addTrack(MediaDescription track, ComponentName component, + QSMediaBrowser browser) { + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java index 89b22bc518bbc..0f065661a4700 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java @@ -18,11 +18,12 @@ package com.android.systemui.qs; import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle; -import android.app.Notification; +import android.app.PendingIntent; import android.content.Context; +import android.content.pm.PackageManager; import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; +import android.media.MediaDescription; import android.media.session.MediaController; import android.media.session.MediaSession; import android.util.Log; @@ -60,9 +61,11 @@ public class QSMediaPlayer extends MediaControlPanel { }; private final QSPanel mParent; + private final Executor mForegroundExecutor; private final DelayableExecutor mBackgroundExecutor; private final SeekBarViewModel mSeekBarViewModel; private final SeekBarObserver mSeekBarObserver; + private String mPackageName; /** * Initialize quick shade version of player @@ -77,6 +80,7 @@ public class QSMediaPlayer extends MediaControlPanel { super(context, parent, routeManager, R.layout.qs_media_panel, QS_ACTION_IDS, foregroundExecutor, backgroundExecutor); mParent = (QSPanel) parent; + mForegroundExecutor = foregroundExecutor; mBackgroundExecutor = backgroundExecutor; mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor); mSeekBarObserver = new SeekBarObserver(getView()); @@ -89,6 +93,58 @@ public class QSMediaPlayer extends MediaControlPanel { bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener()); } + /** + * Add a media panel view based on a media description. Used for resumption + * @param description + * @param iconColor + * @param bgColor + * @param contentIntent + * @param pkgName + */ + public void setMediaSession(MediaSession.Token token, MediaDescription description, + int iconColor, int bgColor, PendingIntent contentIntent, String pkgName) { + mPackageName = pkgName; + PackageManager pm = getContext().getPackageManager(); + Drawable icon = null; + CharSequence appName = pkgName.substring(pkgName.lastIndexOf(".")); + try { + icon = pm.getApplicationIcon(pkgName); + appName = pm.getApplicationLabel(pm.getApplicationInfo(pkgName, 0)); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Error getting package information", e); + } + + // Set what we can normally + super.setMediaSession(token, icon, iconColor, bgColor, contentIntent, appName.toString(), + null); + + // Then add info from MediaDescription + ImageView albumView = mMediaNotifView.findViewById(R.id.album_art); + if (albumView != null) { + // Resize art in a background thread + mBackgroundExecutor.execute(() -> processAlbumArt(description, albumView)); + } + + // Song name + TextView titleText = mMediaNotifView.findViewById(R.id.header_title); + CharSequence songName = description.getTitle(); + titleText.setText(songName); + titleText.setTextColor(iconColor); + + // Artist name (not in mini player) + TextView artistText = mMediaNotifView.findViewById(R.id.header_artist); + if (artistText != null) { + CharSequence artistName = description.getSubtitle(); + artistText.setText(artistName); + artistText.setTextColor(iconColor); + } + + initLongPressMenu(iconColor); + + // Set buttons to resume state + resetButtons(); + } + /** * Update media panel view for the given media session * @param token token for this media session @@ -96,41 +152,43 @@ public class QSMediaPlayer extends MediaControlPanel { * @param iconColor foreground color (for text, icons) * @param bgColor background color * @param actionsContainer a LinearLayout containing the media action buttons - * @param notif reference to original notification + * @param contentIntent Intent to send when user taps on player + * @param appName Application title * @param key original notification's key */ - public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor, - int bgColor, View actionsContainer, Notification notif, String key) { + public void setMediaSession(MediaSession.Token token, Drawable icon, int iconColor, + int bgColor, View actionsContainer, PendingIntent contentIntent, String appName, + String key) { - String appName = Notification.Builder.recoverBuilder(getContext(), notif) - .loadHeaderAppName(); - super.setMediaSession(token, icon, iconColor, bgColor, notif.contentIntent, appName, key); + super.setMediaSession(token, icon, iconColor, bgColor, contentIntent, appName, key); // Media controls - LinearLayout parentActionsLayout = (LinearLayout) actionsContainer; - int i = 0; - for (; i < parentActionsLayout.getChildCount() && i < QS_ACTION_IDS.length; i++) { - ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]); - ImageButton thatBtn = parentActionsLayout.findViewById(NOTIF_ACTION_IDS[i]); - if (thatBtn == null || thatBtn.getDrawable() == null - || thatBtn.getVisibility() != View.VISIBLE) { - thisBtn.setVisibility(View.GONE); - continue; + if (actionsContainer != null) { + LinearLayout parentActionsLayout = (LinearLayout) actionsContainer; + int i = 0; + for (; i < parentActionsLayout.getChildCount() && i < QS_ACTION_IDS.length; i++) { + ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]); + ImageButton thatBtn = parentActionsLayout.findViewById(NOTIF_ACTION_IDS[i]); + if (thatBtn == null || thatBtn.getDrawable() == null + || thatBtn.getVisibility() != View.VISIBLE) { + thisBtn.setVisibility(View.GONE); + continue; + } + + Drawable thatIcon = thatBtn.getDrawable(); + thisBtn.setImageDrawable(thatIcon.mutate()); + thisBtn.setVisibility(View.VISIBLE); + thisBtn.setOnClickListener(v -> { + Log.d(TAG, "clicking on other button"); + thatBtn.performClick(); + }); } - Drawable thatIcon = thatBtn.getDrawable(); - thisBtn.setImageDrawable(thatIcon.mutate()); - thisBtn.setVisibility(View.VISIBLE); - thisBtn.setOnClickListener(v -> { - Log.d(TAG, "clicking on other button"); - thatBtn.performClick(); - }); - } - - // Hide any unused buttons - for (; i < QS_ACTION_IDS.length; i++) { - ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]); - thisBtn.setVisibility(View.GONE); + // Hide any unused buttons + for (; i < QS_ACTION_IDS.length; i++) { + ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]); + thisBtn.setVisibility(View.GONE); + } } // Seek Bar @@ -138,6 +196,10 @@ public class QSMediaPlayer extends MediaControlPanel { mBackgroundExecutor.execute( () -> mSeekBarViewModel.updateController(controller, iconColor)); + initLongPressMenu(iconColor); + } + + private void initLongPressMenu(int iconColor) { // Set up long press menu View guts = mMediaNotifView.findViewById(R.id.media_guts); View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options); @@ -145,7 +207,7 @@ public class QSMediaPlayer extends MediaControlPanel { View clearView = options.findViewById(R.id.remove); clearView.setOnClickListener(b -> { - mParent.removeMediaPlayer(QSMediaPlayer.this); + removePlayer(); }); ImageView removeIcon = options.findViewById(R.id.remove_icon); removeIcon.setImageTintList(ColorStateList.valueOf(iconColor)); @@ -165,11 +227,9 @@ public class QSMediaPlayer extends MediaControlPanel { } @Override - public void clearControls() { - super.clearControls(); - + protected void resetButtons() { + super.resetButtons(); mSeekBarViewModel.clearController(); - View guts = mMediaNotifView.findViewById(R.id.media_guts); View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options); @@ -192,4 +252,19 @@ public class QSMediaPlayer extends MediaControlPanel { public void setListening(boolean listening) { mSeekBarViewModel.setListening(listening); } + + @Override + public void removePlayer() { + Log.d(TAG, "removing player from parent: " + mParent); + // Ensure this happens on the main thread (could happen in QSMediaBrowser callback) + mForegroundExecutor.execute(() -> mParent.removeMediaPlayer(QSMediaPlayer.this)); + } + + @Override + public String getMediaPlayerPackage() { + if (getController() == null) { + return mPackageName; + } + return super.getMediaPlayerPackage(); + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java index fee08389388dc..1eb577852a714 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java @@ -21,16 +21,25 @@ import static com.android.systemui.util.InjectionInflationController.VIEW_CONTEX import static com.android.systemui.util.Utils.useQsMediaPlayer; import android.annotation.Nullable; +import android.app.Notification; +import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; import android.content.res.Configuration; import android.content.res.Resources; -import android.graphics.drawable.Icon; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.media.MediaDescription; import android.media.session.MediaSession; import android.metrics.LogMaker; import android.os.Bundle; import android.os.Handler; import android.os.Message; +import android.os.UserHandle; +import android.os.UserManager; import android.service.notification.StatusBarNotification; import android.service.quicksettings.Tile; import android.util.AttributeSet; @@ -54,6 +63,7 @@ import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dump.DumpManager; +import com.android.systemui.media.MediaControlPanel; import com.android.systemui.plugins.qs.DetailAdapter; import com.android.systemui.plugins.qs.QSTile; import com.android.systemui.plugins.qs.QSTileView; @@ -90,6 +100,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne protected final Context mContext; protected final ArrayList mRecords = new ArrayList<>(); + private final BroadcastDispatcher mBroadcastDispatcher; private String mCachedSpecs = ""; protected final View mBrightnessView; private final H mHandler = new H(); @@ -123,6 +134,19 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne private BrightnessMirrorController mBrightnessMirrorController; private View mDivider; + private boolean mHasLoadedMediaControls; + + private final BroadcastReceiver mUserChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (Intent.ACTION_USER_UNLOCKED.equals(action)) { + if (!mHasLoadedMediaControls) { + loadMediaResumptionControls(); + } + } + } + }; @Inject public QSPanel( @@ -142,6 +166,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne mForegroundExecutor = foregroundExecutor; mBackgroundExecutor = backgroundExecutor; mLocalBluetoothManager = localBluetoothManager; + mBroadcastDispatcher = broadcastDispatcher; setOrientation(VERTICAL); @@ -176,7 +201,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne updateResources(); mBrightnessController = new BrightnessController(getContext(), - findViewById(R.id.brightness_slider), broadcastDispatcher); + findViewById(R.id.brightness_slider), mBroadcastDispatcher); } @Override @@ -206,7 +231,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne * @param notif * @param key */ - public void addMediaSession(MediaSession.Token token, Icon icon, int iconColor, int bgColor, + public void addMediaSession(MediaSession.Token token, Drawable icon, int iconColor, int bgColor, View actionsContainer, StatusBarNotification notif, String key) { if (!useQsMediaPlayer(mContext)) { // Shouldn't happen, but just in case @@ -221,7 +246,14 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne QSMediaPlayer player = null; String packageName = notif.getPackageName(); for (QSMediaPlayer p : mMediaPlayers) { - if (p.getMediaSessionToken().equals(token)) { + if (p.getKey() == null) { + // No notification key = loaded via mediabrowser, so just match on package + if (packageName.equals(p.getMediaPlayerPackage())) { + Log.d(TAG, "Found matching resume player by package: " + packageName); + player = p; + break; + } + } else if (p.getMediaSessionToken().equals(token)) { Log.d(TAG, "Found matching player by token " + packageName); player = p; break; @@ -262,8 +294,10 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne } Log.d(TAG, "setting player session"); + String appName = Notification.Builder.recoverBuilder(getContext(), notif.getNotification()) + .loadHeaderAppName(); player.setMediaSession(token, icon, iconColor, bgColor, actionsContainer, - notif.getNotification(), key); + notif.getNotification().contentIntent, appName, key); if (mMediaPlayers.size() > 0) { ((View) mMediaCarousel.getParent()).setVisibility(View.VISIBLE); @@ -293,6 +327,74 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne return true; } + private final QSMediaBrowser.Callback mMediaBrowserCallback = new QSMediaBrowser.Callback() { + @Override + public void addTrack(MediaDescription desc, ComponentName component, + QSMediaBrowser browser) { + if (component == null) { + Log.e(TAG, "Component cannot be null"); + return; + } + + Log.d(TAG, "adding track from browser: " + desc + ", " + component); + QSMediaPlayer player = new QSMediaPlayer(mContext, QSPanel.this, + null, mForegroundExecutor, mBackgroundExecutor); + + String pkgName = component.getPackageName(); + + // Add controls to carousel + int playerWidth = (int) getResources().getDimension(R.dimen.qs_media_width); + int padding = (int) getResources().getDimension(R.dimen.qs_media_padding); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(playerWidth, + LayoutParams.MATCH_PARENT); + lp.setMarginStart(padding); + lp.setMarginEnd(padding); + mMediaCarousel.addView(player.getView(), lp); + ((View) mMediaCarousel.getParent()).setVisibility(View.VISIBLE); + mMediaPlayers.add(player); + + int iconColor = Color.DKGRAY; + int bgColor = Color.LTGRAY; + + MediaSession.Token token = browser.getToken(); + player.setMediaSession(token, desc, iconColor, bgColor, browser.getAppIntent(), + pkgName); + } + }; + + /** + * Load controls for resuming media, if available + */ + private void loadMediaResumptionControls() { + if (!useQsMediaPlayer(mContext)) { + return; + } + Log.d(TAG, "Loading resumption controls"); + + // Look up saved components to resume + Context userContext = mContext.createContextAsUser(mContext.getUser(), 0); + SharedPreferences prefs = userContext.getSharedPreferences( + MediaControlPanel.MEDIA_PREFERENCES, Context.MODE_PRIVATE); + String listString = prefs.getString(MediaControlPanel.MEDIA_PREFERENCE_KEY, null); + if (listString == null) { + Log.d(TAG, "No saved media components"); + return; + } + + String[] components = listString.split(QSMediaBrowser.DELIMITER); + Log.d(TAG, "components are: " + listString + " count " + components.length); + for (int i = 0; i < components.length && i < QSMediaBrowser.MAX_RESUMPTION_CONTROLS; i++) { + String[] info = components[i].split("/"); + String packageName = info[0]; + String className = info[1]; + ComponentName component = new ComponentName(packageName, className); + QSMediaBrowser browser = new QSMediaBrowser(mContext, mMediaBrowserCallback, + component); + browser.findRecentMedia(); + } + mHasLoadedMediaControls = true; + } + protected void addDivider() { mDivider = LayoutInflater.from(mContext).inflate(R.layout.qs_divider, this, false); mDivider.setBackgroundColor(Utils.applyAlpha(mDivider.getAlpha(), @@ -343,6 +445,22 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne mBrightnessMirrorController.addCallback(this); } mDumpManager.registerDumpable(getDumpableTag(), this); + + if (getClass() == QSPanel.class) { + //TODO(ethibodeau) remove class check after media refactor in ag/11059751 + // Only run this in QSPanel proper, not QQS + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_UNLOCKED); + mBroadcastDispatcher.registerReceiver(mUserChangeReceiver, filter, null, + UserHandle.ALL); + mHasLoadedMediaControls = false; + + UserManager userManager = mContext.getSystemService(UserManager.class); + if (userManager.isUserUnlocked(mContext.getUserId())) { + // If it's already unlocked (like if dark theme was toggled), we can load now + loadMediaResumptionControls(); + } + } } @Override @@ -358,6 +476,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne mBrightnessMirrorController.removeCallback(this); } mDumpManager.unregisterDumpable(getDumpableTag()); + mBroadcastDispatcher.unregisterReceiver(mUserChangeReceiver); super.onDetachedFromWindow(); } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java b/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java index 62296720213be..7ba7c5fe499e8 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java @@ -19,7 +19,6 @@ package com.android.systemui.qs; import android.app.PendingIntent; import android.content.Context; import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; import android.media.session.MediaController; import android.media.session.MediaSession; import android.view.View; @@ -67,7 +66,7 @@ public class QuickQSMediaPlayer extends MediaControlPanel { * @param contentIntent Intent to send when user taps on the view * @param key original notification's key */ - public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor, int bgColor, + public void setMediaSession(MediaSession.Token token, Drawable icon, int iconColor, int bgColor, View actionsContainer, int[] actionsToShow, PendingIntent contentIntent, String key) { // Only update if this is a different session and currently playing String oldPackage = ""; diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java index 2da2724aacb20..796f22cbb3cbf 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java @@ -22,6 +22,7 @@ import android.annotation.Nullable; import android.app.Notification; import android.content.Context; import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; import android.media.MediaMetadata; import android.media.session.MediaController; import android.media.session.MediaSession; @@ -187,8 +188,9 @@ public class NotificationMediaTemplateViewWrapper extends NotificationTemplateVi com.android.systemui.R.id.quick_qs_panel); StatusBarNotification sbn = mRow.getEntry().getSbn(); Notification notif = sbn.getNotification(); + Drawable iconDrawable = notif.getSmallIcon().loadDrawable(mContext); panel.getMediaPlayer().setMediaSession(token, - notif.getSmallIcon(), + iconDrawable, tintColor, mBackgroundColor, mActions, @@ -198,7 +200,7 @@ public class NotificationMediaTemplateViewWrapper extends NotificationTemplateVi QSPanel bigPanel = ctrl.getNotificationShadeView().findViewById( com.android.systemui.R.id.quick_settings_panel); bigPanel.addMediaSession(token, - notif.getSmallIcon(), + iconDrawable, tintColor, mBackgroundColor, mActions,