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.controls.domain.resume;
18 
19 import android.annotation.Nullable;
20 import android.annotation.UserIdInt;
21 import android.app.PendingIntent;
22 import android.content.ComponentName;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.pm.PackageManager;
26 import android.media.MediaDescription;
27 import android.media.browse.MediaBrowser;
28 import android.media.session.MediaController;
29 import android.media.session.MediaSession;
30 import android.os.Bundle;
31 import android.service.media.MediaBrowserService;
32 import android.text.TextUtils;
33 import android.util.Log;
34 
35 import com.android.internal.annotations.VisibleForTesting;
36 
37 import java.util.List;
38 
39 /**
40  * Media browser for managing resumption in media controls
41  */
42 public class ResumeMediaBrowser {
43 
44     /** Maximum number of controls to show on boot */
45     public static final int MAX_RESUMPTION_CONTROLS = 5;
46 
47     /** Delimiter for saved component names */
48     public static final String DELIMITER = ":";
49 
50     private static final String TAG = "ResumeMediaBrowser";
51     private final Context mContext;
52     @Nullable private final Callback mCallback;
53     private final MediaBrowserFactory mBrowserFactory;
54     private final ResumeMediaBrowserLogger mLogger;
55     private final ComponentName mComponentName;
56     private final MediaController.Callback mMediaControllerCallback = new SessionDestroyCallback();
57     @UserIdInt private final int mUserId;
58 
59     private MediaBrowser mMediaBrowser;
60     @Nullable private MediaController mMediaController;
61 
62     /**
63      * Initialize a new media browser
64      * @param context the context
65      * @param callback used to report media items found
66      * @param componentName Component name of the MediaBrowserService this browser will connect to
67      * @param userId ID of the current user
68      */
ResumeMediaBrowser( Context context, @Nullable Callback callback, ComponentName componentName, MediaBrowserFactory browserFactory, ResumeMediaBrowserLogger logger, @UserIdInt int userId)69     public ResumeMediaBrowser(
70             Context context,
71             @Nullable Callback callback,
72             ComponentName componentName,
73             MediaBrowserFactory browserFactory,
74             ResumeMediaBrowserLogger logger,
75             @UserIdInt int userId) {
76         mContext = context;
77         mCallback = callback;
78         mComponentName = componentName;
79         mBrowserFactory = browserFactory;
80         mLogger = logger;
81         mUserId = userId;
82     }
83 
84     /**
85      * Connects to the MediaBrowserService and looks for valid media. If a media item is returned,
86      * ResumeMediaBrowser.Callback#addTrack will be called with the MediaDescription.
87      * ResumeMediaBrowser.Callback#onConnected and ResumeMediaBrowser.Callback#onError will also be
88      * called when the initial connection is successful, or an error occurs.
89      * Note that it is possible for the service to connect but for no playable tracks to be found.
90      * ResumeMediaBrowser#disconnect will be called automatically with this function.
91      */
findRecentMedia()92     public void findRecentMedia() {
93         Bundle rootHints = new Bundle();
94         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
95         MediaBrowser browser = mBrowserFactory.create(
96                 mComponentName,
97                 mConnectionCallback,
98                 rootHints);
99         connectBrowser(browser, "findRecentMedia");
100     }
101 
102     private final MediaBrowser.SubscriptionCallback mSubscriptionCallback =
103             new MediaBrowser.SubscriptionCallback() {
104         @Override
105         public void onChildrenLoaded(String parentId,
106                 List<MediaBrowser.MediaItem> children) {
107             if (children.size() == 0) {
108                 Log.d(TAG, "No children found for " + mComponentName);
109                 if (mCallback != null) {
110                     mCallback.onError();
111                 }
112             } else {
113                 // We ask apps to return a playable item as the first child when sending
114                 // a request with EXTRA_RECENT; if they don't, no resume controls
115                 MediaBrowser.MediaItem child = children.get(0);
116                 MediaDescription desc = child.getDescription();
117                 if (child.isPlayable() && mMediaBrowser != null) {
118                     if (mCallback != null) {
119                         mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(),
120                                 ResumeMediaBrowser.this);
121                     }
122                 } else {
123                     Log.d(TAG, "Child found but not playable for " + mComponentName);
124                     if (mCallback != null) {
125                         mCallback.onError();
126                     }
127                 }
128             }
129             disconnect();
130         }
131 
132         @Override
133         public void onError(String parentId) {
134             Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
135             if (mCallback != null) {
136                 mCallback.onError();
137             }
138             disconnect();
139         }
140 
141         @Override
142         public void onError(String parentId, Bundle options) {
143             Log.d(TAG, "Subscribe error for " + mComponentName + ": " + parentId
144                     + ", options: " + options);
145             if (mCallback != null) {
146                 mCallback.onError();
147             }
148             disconnect();
149         }
150     };
151 
152     private final MediaBrowser.ConnectionCallback mConnectionCallback =
153             new MediaBrowser.ConnectionCallback() {
154         /**
155          * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed.
156          * For resumption controls, apps are expected to return a playable media item as the first
157          * child. If there are no children or it isn't playable it will be ignored.
158          */
159         @Override
160         public void onConnected() {
161             Log.d(TAG, "Service connected for " + mComponentName);
162             updateMediaController();
163             if (isBrowserConnected()) {
164                 String root = mMediaBrowser.getRoot();
165                 if (!TextUtils.isEmpty(root)) {
166                     if (mCallback != null) {
167                         mCallback.onConnected();
168                     }
169                     if (mMediaBrowser != null) {
170                         mMediaBrowser.subscribe(root, mSubscriptionCallback);
171                     }
172                     return;
173                 }
174             }
175             if (mCallback != null) {
176                 mCallback.onError();
177             }
178             disconnect();
179         }
180 
181         /**
182          * Invoked when the client is disconnected from the media browser.
183          */
184         @Override
185         public void onConnectionSuspended() {
186             Log.d(TAG, "Connection suspended for " + mComponentName);
187             if (mCallback != null) {
188                 mCallback.onError();
189             }
190             disconnect();
191         }
192 
193         /**
194          * Invoked when the connection to the media browser failed.
195          */
196         @Override
197         public void onConnectionFailed() {
198             Log.d(TAG, "Connection failed for " + mComponentName);
199             if (mCallback != null) {
200                 mCallback.onError();
201             }
202             disconnect();
203         }
204     };
205 
206     /**
207      * Connect using a new media browser. Disconnects the existing browser first, if it exists.
208      * @param browser media browser to connect
209      * @param reason Reason to log for connection
210      */
connectBrowser(MediaBrowser browser, String reason)211     private void connectBrowser(MediaBrowser browser, String reason) {
212         mLogger.logConnection(mComponentName, reason);
213         disconnect();
214         mMediaBrowser = browser;
215         if (browser != null) {
216             browser.connect();
217         }
218         updateMediaController();
219     }
220 
221     /**
222      * Disconnect the media browser. This should be done after callbacks have completed to
223      * disconnect from the media browser service.
224      */
disconnect()225     protected void disconnect() {
226         if (mMediaBrowser != null) {
227             mLogger.logDisconnect(mComponentName);
228             mMediaBrowser.disconnect();
229         }
230         mMediaBrowser = null;
231         updateMediaController();
232     }
233 
234     /**
235      * Connects to the MediaBrowserService and starts playback.
236      * ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
237      * depending on whether it was successful.
238      * If the connection is successful, the listener should call ResumeMediaBrowser#disconnect after
239      * getting a media update from the app
240      */
restart()241     public void restart() {
242         Bundle rootHints = new Bundle();
243         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
244         MediaBrowser browser = mBrowserFactory.create(mComponentName,
245                 new MediaBrowser.ConnectionCallback() {
246                     @Override
247                     public void onConnected() {
248                         Log.d(TAG, "Connected for restart " + mMediaBrowser.isConnected());
249                         updateMediaController();
250                         if (!isBrowserConnected()) {
251                             if (mCallback != null) {
252                                 mCallback.onError();
253                             }
254                             disconnect();
255                             return;
256                         }
257                         MediaSession.Token token = mMediaBrowser.getSessionToken();
258                         MediaController controller = createMediaController(token);
259                         controller.getTransportControls();
260                         controller.getTransportControls().prepare();
261                         controller.getTransportControls().play();
262                         if (mCallback != null) {
263                             mCallback.onConnected();
264                         }
265                         // listener should disconnect after media player update
266                     }
267 
268                     @Override
269                     public void onConnectionFailed() {
270                         if (mCallback != null) {
271                             mCallback.onError();
272                         }
273                         disconnect();
274                     }
275 
276                     @Override
277                     public void onConnectionSuspended() {
278                         if (mCallback != null) {
279                             mCallback.onError();
280                         }
281                         disconnect();
282                     }
283                 }, rootHints);
284         connectBrowser(browser, "restart");
285     }
286 
287     @VisibleForTesting
createMediaController(MediaSession.Token token)288     protected MediaController createMediaController(MediaSession.Token token) {
289         return new MediaController(mContext, token);
290     }
291 
292     /**
293      * Get the ID of the user associated with this broswer
294      * @return the user ID
295      */
getUserId()296     public @UserIdInt int getUserId() {
297         return mUserId;
298     }
299 
300     /**
301      * Get the media session token
302      * @return the token, or null if the MediaBrowser is null or disconnected
303      */
getToken()304     public MediaSession.Token getToken() {
305         if (!isBrowserConnected()) {
306             return null;
307         }
308         return mMediaBrowser.getSessionToken();
309     }
310 
311     /**
312      * Get an intent to launch the app associated with this browser service
313      * @return
314      */
getAppIntent()315     public PendingIntent getAppIntent() {
316         PackageManager pm = mContext.getPackageManager();
317         Intent launchIntent = pm.getLaunchIntentForPackage(mComponentName.getPackageName());
318         return PendingIntent.getActivity(mContext, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE);
319     }
320 
321     /**
322      * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser.
323      * If it can connect, ResumeMediaBrowser.Callback#onConnected will be called. If valid media is
324      * found, then ResumeMediaBrowser.Callback#addTrack will also be called. This allows for more
325      * detailed logging if the service has issues. If it cannot connect, or cannot find valid media,
326      * then ResumeMediaBrowser.Callback#onError will be called.
327      * ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
328      */
testConnection()329     public void testConnection() {
330         Bundle rootHints = new Bundle();
331         rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
332         MediaBrowser browser = mBrowserFactory.create(
333                 mComponentName,
334                 mConnectionCallback,
335                 rootHints);
336         connectBrowser(browser, "testConnection");
337     }
338 
339     /** Updates mMediaController based on our current browser values. */
updateMediaController()340     private void updateMediaController() {
341         MediaSession.Token controllerToken =
342                 mMediaController != null ? mMediaController.getSessionToken() : null;
343         MediaSession.Token currentToken = getToken();
344         boolean areEqual = (controllerToken == null && currentToken == null)
345                 || (controllerToken != null && controllerToken.equals(currentToken));
346         if (areEqual) {
347             return;
348         }
349 
350         // Whenever the token changes, un-register the callback on the old controller (if we have
351         // one) and create a new controller with the callback attached.
352         if (mMediaController != null) {
353             mMediaController.unregisterCallback(mMediaControllerCallback);
354         }
355         if (currentToken != null) {
356             mMediaController = createMediaController(currentToken);
357             mMediaController.registerCallback(mMediaControllerCallback);
358         } else {
359             mMediaController = null;
360         }
361     }
362 
isBrowserConnected()363     private boolean isBrowserConnected() {
364         return mMediaBrowser != null && mMediaBrowser.isConnected();
365     }
366 
367     /**
368      * Interface to handle results from ResumeMediaBrowser
369      */
370     public static class Callback {
371         /**
372          * Called when the browser has successfully connected to the service
373          */
onConnected()374         public void onConnected() {
375         }
376 
377         /**
378          * Called when the browser encountered an error connecting to the service
379          */
onError()380         public void onError() {
381         }
382 
383         /**
384          * Called when the browser finds a suitable track to add to the media carousel
385          * @param track media info for the item
386          * @param component component of the MediaBrowserService which returned this
387          * @param browser reference to the browser
388          */
addTrack(MediaDescription track, ComponentName component, ResumeMediaBrowser browser)389         public void addTrack(MediaDescription track, ComponentName component,
390                 ResumeMediaBrowser browser) {
391         }
392     }
393 
394     private class SessionDestroyCallback extends MediaController.Callback {
395         @Override
onSessionDestroyed()396         public void onSessionDestroyed() {
397             mLogger.logSessionDestroyed(isBrowserConnected(), mComponentName);
398             disconnect();
399         }
400     }
401 }
402