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
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index 9217eb1..f25de6a 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.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.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 @@
     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 @@
     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 @@
      * 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 @@
     /**
      * 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<ResolveInfo> 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<ResolveInfo> 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 @@
 
         // 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 @@
         }
 
         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 @@
 
     /**
      * 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();
     }
 
@@ -370,11 +392,27 @@
 
     /**
      * 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
      * @param albumView view to hold the album art
      */
     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 @@
     }
 
     /**
-     * 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 @@
         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 @@
         }
     }
 
+    /**
+     * 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 0000000..302b8420
--- /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<MediaBrowser.MediaItem> 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 89b22bc..0f06566 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 @@
 
 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 @@
     };
 
     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 @@
         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());
@@ -90,47 +94,101 @@
     }
 
     /**
+     * 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
      * @param icon app notification icon
      * @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 @@
         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 @@
 
         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 @@
     }
 
     @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 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 fee0838..1eb5778 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.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.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 @@
 
     protected final Context mContext;
     protected final ArrayList<TileRecord> mRecords = new ArrayList<>();
+    private final BroadcastDispatcher mBroadcastDispatcher;
     private String mCachedSpecs = "";
     protected final View mBrightnessView;
     private final H mHandler = new H();
@@ -123,6 +134,19 @@
 
     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 @@
         mForegroundExecutor = foregroundExecutor;
         mBackgroundExecutor = backgroundExecutor;
         mLocalBluetoothManager = localBluetoothManager;
+        mBroadcastDispatcher = broadcastDispatcher;
 
         setOrientation(VERTICAL);
 
@@ -176,7 +201,7 @@
         updateResources();
 
         mBrightnessController = new BrightnessController(getContext(),
-                findViewById(R.id.brightness_slider), broadcastDispatcher);
+                findViewById(R.id.brightness_slider), mBroadcastDispatcher);
     }
 
     @Override
@@ -206,7 +231,7 @@
      * @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 @@
         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 @@
         }
 
         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 @@
         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 @@
             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 @@
             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 6229672..7ba7c5f 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 @@
 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 @@
      * @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 2da2724..796f22c 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.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 @@
                     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 @@
             QSPanel bigPanel = ctrl.getNotificationShadeView().findViewById(
                     com.android.systemui.R.id.quick_settings_panel);
             bigPanel.addMediaSession(token,
-                    notif.getSmallIcon(),
+                    iconDrawable,
                     tintColor,
                     mBackgroundColor,
                     mActions,