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