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