1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.media;
18 
19 import android.annotation.Nullable;
20 import android.app.PendingIntent;
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.content.pm.PackageManager;
25 import android.media.MediaDescription;
26 import android.media.browse.MediaBrowser;
27 import android.media.session.MediaController;
28 import android.media.session.MediaSession;
29 import android.os.Bundle;
30 import android.service.media.MediaBrowserService;
31 import android.text.TextUtils;
32 import android.util.Log;
33 
34 import com.android.internal.annotations.VisibleForTesting;
35 
36 import java.util.List;
37 
38 /**
39  * Media browser for managing resumption in media controls
40  */
41 public class ResumeMediaBrowser {
42 
43     /** Maximum number of controls to show on boot */
44     public static final int MAX_RESUMPTION_CONTROLS = 5;
45 
46     /** Delimiter for saved component names */
47     public static final String DELIMITER = ":";
48 
49     private static final String TAG = "ResumeMediaBrowser";
50     private final Context mContext;
51     @Nullable private final Callback mCallback;
52     private MediaBrowserFactory mBrowserFactory;
53     private MediaBrowser mMediaBrowser;
54     private ComponentName mComponentName;
55 
56     /**
57      * Initialize a new media browser
58      * @param context the context
59      * @param callback used to report media items found
60      * @param componentName Component name of the MediaBrowserService this browser will connect to
61      */
ResumeMediaBrowser(Context context, @Nullable Callback callback, ComponentName componentName, MediaBrowserFactory browserFactory)62     public ResumeMediaBrowser(Context context, @Nullable Callback callback,
63             ComponentName componentName, MediaBrowserFactory browserFactory) {
64         mContext = context;
65         mCallback = callback;
66         mComponentName = componentName;
67         mBrowserFactory = browserFactory;
68     }
69 
70     /**
71      * Connects to the MediaBrowserService and looks for valid media. If a media item is returned,
72      * ResumeMediaBrowser.Callback#addTrack will be called with the MediaDescription.
73      * ResumeMediaBrowser.Callback#onConnected and ResumeMediaBrowser.Callback#onError will also be
74      * called when the initial connection is successful, or an error occurs.
75      * Note that it is possible for the service to connect but for no playable tracks to be found.
76      * ResumeMediaBrowser#disconnect will be called automatically with this function.
77      */
findRecentMedia()78     public void findRecentMedia() {
79         Log.d(TAG, "Connecting to " + mComponentName);
80         disconnect();
81         Bundle rootHints = new Bundle();
82         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
83         mMediaBrowser = mBrowserFactory.create(
84                 mComponentName,
85                 mConnectionCallback,
86                 rootHints);
87         mMediaBrowser.connect();
88     }
89 
90     private final MediaBrowser.SubscriptionCallback mSubscriptionCallback =
91             new MediaBrowser.SubscriptionCallback() {
92         @Override
93         public void onChildrenLoaded(String parentId,
94                 List<MediaBrowser.MediaItem> children) {
95             if (children.size() == 0) {
96                 Log.d(TAG, "No children found for " + mComponentName);
97                 if (mCallback != null) {
98                     mCallback.onError();
99                 }
100             } else {
101                 // We ask apps to return a playable item as the first child when sending
102                 // a request with EXTRA_RECENT; if they don't, no resume controls
103                 MediaBrowser.MediaItem child = children.get(0);
104                 MediaDescription desc = child.getDescription();
105                 if (child.isPlayable() && mMediaBrowser != null) {
106                     if (mCallback != null) {
107                         mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(),
108                                 ResumeMediaBrowser.this);
109                     }
110                 } else {
111                     Log.d(TAG, "Child found but not playable for " + mComponentName);
112                     if (mCallback != null) {
113                         mCallback.onError();
114                     }
115                 }
116             }
117             disconnect();
118         }
119 
120         @Override
121         public void onError(String parentId) {
122             Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
123             if (mCallback != null) {
124                 mCallback.onError();
125             }
126             disconnect();
127         }
128 
129         @Override
130         public void onError(String parentId, Bundle options) {
131             Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId
132                     + ", options: " + options);
133             if (mCallback != null) {
134                 mCallback.onError();
135             }
136             disconnect();
137         }
138     };
139 
140     private final MediaBrowser.ConnectionCallback mConnectionCallback =
141             new MediaBrowser.ConnectionCallback() {
142         /**
143          * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed.
144          * For resumption controls, apps are expected to return a playable media item as the first
145          * child. If there are no children or it isn't playable it will be ignored.
146          */
147         @Override
148         public void onConnected() {
149             Log.d(TAG, "Service connected for " + mComponentName);
150             if (mMediaBrowser != null && mMediaBrowser.isConnected()) {
151                 String root = mMediaBrowser.getRoot();
152                 if (!TextUtils.isEmpty(root)) {
153                     if (mCallback != null) {
154                         mCallback.onConnected();
155                     }
156                     if (mMediaBrowser != null) {
157                         mMediaBrowser.subscribe(root, mSubscriptionCallback);
158                     }
159                     return;
160                 }
161             }
162             if (mCallback != null) {
163                 mCallback.onError();
164             }
165             disconnect();
166         }
167 
168         /**
169          * Invoked when the client is disconnected from the media browser.
170          */
171         @Override
172         public void onConnectionSuspended() {
173             Log.d(TAG, "Connection suspended for " + mComponentName);
174             if (mCallback != null) {
175                 mCallback.onError();
176             }
177             disconnect();
178         }
179 
180         /**
181          * Invoked when the connection to the media browser failed.
182          */
183         @Override
184         public void onConnectionFailed() {
185             Log.d(TAG, "Connection failed for " + mComponentName);
186             if (mCallback != null) {
187                 mCallback.onError();
188             }
189             disconnect();
190         }
191     };
192 
193     /**
194      * Disconnect the media browser. This should be done after callbacks have completed to
195      * disconnect from the media browser service.
196      */
disconnect()197     protected void disconnect() {
198         if (mMediaBrowser != null) {
199             mMediaBrowser.disconnect();
200         }
201         mMediaBrowser = null;
202     }
203 
204     /**
205      * Connects to the MediaBrowserService and starts playback.
206      * ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
207      * depending on whether it was successful.
208      * If the connection is successful, the listener should call ResumeMediaBrowser#disconnect after
209      * getting a media update from the app
210      */
restart()211     public void restart() {
212         disconnect();
213         Bundle rootHints = new Bundle();
214         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
215         mMediaBrowser = mBrowserFactory.create(mComponentName,
216                 new MediaBrowser.ConnectionCallback() {
217                     @Override
218                     public void onConnected() {
219                         Log.d(TAG, "Connected for restart " + mMediaBrowser.isConnected());
220                         if (mMediaBrowser == null || !mMediaBrowser.isConnected()) {
221                             if (mCallback != null) {
222                                 mCallback.onError();
223                             }
224                             disconnect();
225                             return;
226                         }
227                         MediaSession.Token token = mMediaBrowser.getSessionToken();
228                         MediaController controller = createMediaController(token);
229                         controller.getTransportControls();
230                         controller.getTransportControls().prepare();
231                         controller.getTransportControls().play();
232                         if (mCallback != null) {
233                             mCallback.onConnected();
234                         }
235                         // listener should disconnect after media player update
236                     }
237 
238                     @Override
239                     public void onConnectionFailed() {
240                         if (mCallback != null) {
241                             mCallback.onError();
242                         }
243                         disconnect();
244                     }
245 
246                     @Override
247                     public void onConnectionSuspended() {
248                         if (mCallback != null) {
249                             mCallback.onError();
250                         }
251                         disconnect();
252                     }
253                 }, rootHints);
254         mMediaBrowser.connect();
255     }
256 
257     @VisibleForTesting
createMediaController(MediaSession.Token token)258     protected MediaController createMediaController(MediaSession.Token token) {
259         return new MediaController(mContext, token);
260     }
261 
262     /**
263      * Get the media session token
264      * @return the token, or null if the MediaBrowser is null or disconnected
265      */
getToken()266     public MediaSession.Token getToken() {
267         if (mMediaBrowser == null || !mMediaBrowser.isConnected()) {
268             return null;
269         }
270         return mMediaBrowser.getSessionToken();
271     }
272 
273     /**
274      * Get an intent to launch the app associated with this browser service
275      * @return
276      */
getAppIntent()277     public PendingIntent getAppIntent() {
278         PackageManager pm = mContext.getPackageManager();
279         Intent launchIntent = pm.getLaunchIntentForPackage(mComponentName.getPackageName());
280         return PendingIntent.getActivity(mContext, 0, launchIntent, PendingIntent.FLAG_MUTABLE_UNAUDITED);
281     }
282 
283     /**
284      * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser.
285      * If it can connect, ResumeMediaBrowser.Callback#onConnected will be called. If valid media is
286      * found, then ResumeMediaBrowser.Callback#addTrack will also be called. This allows for more
287      * detailed logging if the service has issues. If it cannot connect, or cannot find valid media,
288      * then ResumeMediaBrowser.Callback#onError will be called.
289      * ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
290      */
testConnection()291     public void testConnection() {
292         disconnect();
293         Bundle rootHints = new Bundle();
294         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
295         mMediaBrowser = mBrowserFactory.create(
296                 mComponentName,
297                 mConnectionCallback,
298                 rootHints);
299         mMediaBrowser.connect();
300     }
301 
302     /**
303      * Interface to handle results from ResumeMediaBrowser
304      */
305     public static class Callback {
306         /**
307          * Called when the browser has successfully connected to the service
308          */
onConnected()309         public void onConnected() {
310         }
311 
312         /**
313          * Called when the browser encountered an error connecting to the service
314          */
onError()315         public void onError() {
316         }
317 
318         /**
319          * Called when the browser finds a suitable track to add to the media carousel
320          * @param track media info for the item
321          * @param component component of the MediaBrowserService which returned this
322          * @param browser reference to the browser
323          */
addTrack(MediaDescription track, ComponentName component, ResumeMediaBrowser browser)324         public void addTrack(MediaDescription track, ComponentName component,
325                 ResumeMediaBrowser browser) {
326         }
327     }
328 }
329