MediaBrowser.java revision 17d47989ee53c9e54f250d29a343ba949edf0ff9
1/* 2 * Copyright (C) 2014 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 17package android.media.browse; 18 19import android.annotation.NonNull; 20import android.content.ComponentName; 21import android.content.Context; 22import android.content.Intent; 23import android.content.ServiceConnection; 24import android.content.pm.ParceledListSlice; 25import android.graphics.Bitmap; 26import android.media.session.MediaSession; 27import android.net.Uri; 28import android.os.Bundle; 29import android.os.Handler; 30import android.os.IBinder; 31import android.os.RemoteException; 32import android.util.ArrayMap; 33import android.util.Log; 34 35import java.lang.ref.WeakReference; 36import java.util.ArrayList; 37import java.util.Collections; 38import java.util.HashMap; 39import java.util.List; 40 41/** 42 * Browses media content offered by a link MediaBrowserService. 43 * <p> 44 * This object is not thread-safe. All calls should happen on the thread on which the browser 45 * was constructed. 46 * </p> 47 */ 48public final class MediaBrowser { 49 private static final String TAG = "MediaBrowser"; 50 private static final boolean DBG = false; 51 52 private static final int CONNECT_STATE_DISCONNECTED = 0; 53 private static final int CONNECT_STATE_CONNECTING = 1; 54 private static final int CONNECT_STATE_CONNECTED = 2; 55 private static final int CONNECT_STATE_SUSPENDED = 3; 56 57 private final Context mContext; 58 private final ComponentName mServiceComponent; 59 private final ConnectionCallback mCallback; 60 private final Bundle mRootHints; 61 private final Handler mHandler = new Handler(); 62 private final ArrayMap<Uri,Subscription> mSubscriptions = 63 new ArrayMap<Uri, MediaBrowser.Subscription>(); 64 65 private int mState = CONNECT_STATE_DISCONNECTED; 66 private MediaServiceConnection mServiceConnection; 67 private IMediaBrowserService mServiceBinder; 68 private IMediaBrowserServiceCallbacks mServiceCallbacks; 69 private Uri mRootUri; 70 private MediaSession.Token mMediaSessionToken; 71 72 /** 73 * Creates a media browser for the specified media browse service. 74 * 75 * @param context The context. 76 * @param serviceComponent The component name of the media browse service. 77 * @param callback The connection callback. 78 * @param rootHints An optional bundle of service-specific arguments to send 79 * to the media browse service when connecting and retrieving the root uri 80 * for browsing, or null if none. The contents of this bundle may affect 81 * the information returned when browsing. 82 */ 83 public MediaBrowser(Context context, ComponentName serviceComponent, 84 ConnectionCallback callback, Bundle rootHints) { 85 if (context == null) { 86 throw new IllegalArgumentException("context must not be null"); 87 } 88 if (serviceComponent == null) { 89 throw new IllegalArgumentException("service component must not be null"); 90 } 91 if (callback == null) { 92 throw new IllegalArgumentException("connection callback must not be null"); 93 } 94 mContext = context; 95 mServiceComponent = serviceComponent; 96 mCallback = callback; 97 mRootHints = rootHints; 98 } 99 100 /** 101 * Connects to the media browse service. 102 * <p> 103 * The connection callback specified in the constructor will be invoked 104 * when the connection completes or fails. 105 * </p> 106 */ 107 public void connect() { 108 if (mState != CONNECT_STATE_DISCONNECTED) { 109 throw new IllegalStateException("connect() called while not disconnected (state=" 110 + getStateLabel(mState) + ")"); 111 } 112 // TODO: remove this extra check. 113 if (DBG) { 114 if (mServiceConnection != null) { 115 throw new RuntimeException("mServiceConnection should be null. Instead it is " 116 + mServiceConnection); 117 } 118 } 119 if (mServiceBinder != null) { 120 throw new RuntimeException("mServiceBinder should be null. Instead it is " 121 + mServiceBinder); 122 } 123 if (mServiceCallbacks != null) { 124 throw new RuntimeException("mServiceCallbacks should be null. Instead it is " 125 + mServiceCallbacks); 126 } 127 128 mState = CONNECT_STATE_CONNECTING; 129 130 final Intent intent = new Intent(MediaBrowserService.SERVICE_ACTION); 131 intent.setComponent(mServiceComponent); 132 133 final ServiceConnection thisConnection = mServiceConnection = new MediaServiceConnection(); 134 135 try { 136 mContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE); 137 } catch (Exception ex) { 138 Log.e(TAG, "Failed binding to service " + mServiceComponent); 139 140 // Tell them that it didn't work. We are already on the main thread, 141 // but we don't want to do callbacks inside of connect(). So post it, 142 // and then check that we are on the same ServiceConnection. We know 143 // we won't also get an onServiceConnected or onServiceDisconnected, 144 // so we won't be doing double callbacks. 145 mHandler.post(new Runnable() { 146 @Override 147 public void run() { 148 // Ensure that nobody else came in or tried to connect again. 149 if (thisConnection == mServiceConnection) { 150 forceCloseConnection(); 151 mCallback.onConnectionFailed(); 152 } 153 } 154 }); 155 } 156 157 if (DBG) { 158 Log.d(TAG, "connect..."); 159 dump(); 160 } 161 } 162 163 /** 164 * Disconnects from the media browse service. 165 * @more 166 * After this, no more callbacks will be received. 167 */ 168 public void disconnect() { 169 // It's ok to call this any state, because allowing this lets apps not have 170 // to check isConnected() unnecessarily. They won't appreciate the extra 171 // assertions for this. We do everything we can here to go back to a sane state. 172 if (mServiceCallbacks != null) { 173 try { 174 mServiceBinder.disconnect(mServiceCallbacks); 175 } catch (RemoteException ex) { 176 // We are disconnecting anyway. Log, just for posterity but it's not 177 // a big problem. 178 Log.w(TAG, "RemoteException during connect for " + mServiceComponent); 179 } 180 } 181 forceCloseConnection(); 182 183 if (DBG) { 184 Log.d(TAG, "disconnect..."); 185 dump(); 186 } 187 } 188 189 /** 190 * Null out the variables and unbind from the service. This doesn't include 191 * calling disconnect on the service, because we only try to do that in the 192 * clean shutdown cases. 193 * <p> 194 * Everywhere that calls this EXCEPT for disconnect() should follow it with 195 * a call to mCallback.onConnectionFailed(). Disconnect doesn't do that callback 196 * for a clean shutdown, but everywhere else is a dirty shutdown and should 197 * notify the app. 198 */ 199 private void forceCloseConnection() { 200 if (mServiceConnection != null) { 201 mContext.unbindService(mServiceConnection); 202 } 203 mState = CONNECT_STATE_DISCONNECTED; 204 mServiceConnection = null; 205 mServiceBinder = null; 206 mServiceCallbacks = null; 207 mRootUri = null; 208 mMediaSessionToken = null; 209 } 210 211 /** 212 * Returns whether the browser is connected to the service. 213 */ 214 public boolean isConnected() { 215 return mState == CONNECT_STATE_CONNECTED; 216 } 217 218 /** 219 * Gets the root Uri. 220 * <p> 221 * Note that the root uri may become invalid or change when when the 222 * browser is disconnected. 223 * </p> 224 * 225 * @throws IllegalStateException if not connected. 226 */ 227 public @NonNull Uri getRoot() { 228 if (mState != CONNECT_STATE_CONNECTED) { 229 throw new IllegalStateException("getSessionToken() called while not connected (state=" 230 + getStateLabel(mState) + ")"); 231 } 232 return mRootUri; 233 } 234 235 /** 236 * Gets the media session token associated with the media browser. 237 * <p> 238 * Note that the session token may become invalid or change when when the 239 * browser is disconnected. 240 * </p> 241 * 242 * @return The session token for the browser, never null. 243 * 244 * @throws IllegalStateException if not connected. 245 */ 246 public @NonNull MediaSession.Token getSessionToken() { 247 if (mState != CONNECT_STATE_CONNECTED) { 248 throw new IllegalStateException("getSessionToken() called while not connected (state=" 249 + mState + ")"); 250 } 251 return mMediaSessionToken; 252 } 253 254 /** 255 * Queries for information about the media items that are contained within 256 * the specified Uri and subscribes to receive updates when they change. 257 * <p> 258 * The list of subscriptions is maintained even when not connected and is 259 * restored after reconnection. It is ok to subscribe while not connected 260 * but the results will not be returned until the connection completes. 261 * </p><p> 262 * If the uri is already subscribed with a different callback then the new 263 * callback will replace the previous one. 264 * </p> 265 * 266 * @param parentUri The uri of the parent media item whose list of children 267 * will be subscribed. 268 * @param callback The callback to receive the list of children. 269 */ 270 public void subscribe(@NonNull Uri parentUri, @NonNull SubscriptionCallback callback) { 271 // Check arguments. 272 if (parentUri == null) { 273 throw new IllegalArgumentException("parentUri is null"); 274 } 275 if (callback == null) { 276 throw new IllegalArgumentException("callback is null"); 277 } 278 279 // Update or create the subscription. 280 Subscription sub = mSubscriptions.get(parentUri); 281 boolean newSubscription = sub == null; 282 if (newSubscription) { 283 sub = new Subscription(parentUri); 284 mSubscriptions.put(parentUri, sub); 285 } 286 sub.callback = callback; 287 288 // If we are connected, tell the service that we are watching. If we aren't 289 // connected, the service will be told when we connect. 290 if (mState == CONNECT_STATE_CONNECTED && newSubscription) { 291 try { 292 mServiceBinder.addSubscription(parentUri, mServiceCallbacks); 293 } catch (RemoteException ex) { 294 // Process is crashing. We will disconnect, and upon reconnect we will 295 // automatically reregister. So nothing to do here. 296 Log.d(TAG, "addSubscription failed with RemoteException parentUri=" + parentUri); 297 } 298 } 299 } 300 301 /** 302 * Unsubscribes for changes to the children of the specified Uri. 303 * <p> 304 * The query callback will no longer be invoked for results associated with 305 * this Uri once this method returns. 306 * </p> 307 * 308 * @param parentUri The uri of the parent media item whose list of children 309 * will be unsubscribed. 310 */ 311 public void unsubscribe(@NonNull Uri parentUri) { 312 // Check arguments. 313 if (parentUri == null) { 314 throw new IllegalArgumentException("parentUri is null"); 315 } 316 317 // Remove from our list. 318 final Subscription sub = mSubscriptions.remove(parentUri); 319 320 // Tell the service if necessary. 321 if (mState == CONNECT_STATE_CONNECTED && sub != null) { 322 try { 323 mServiceBinder.removeSubscription(parentUri, mServiceCallbacks); 324 } catch (RemoteException ex) { 325 // Process is crashing. We will disconnect, and upon reconnect we will 326 // automatically reregister. So nothing to do here. 327 Log.d(TAG, "removeSubscription failed with RemoteException parentUri=" + parentUri); 328 } 329 } 330 } 331 332 /** 333 * Loads the thumbnail of a media item. 334 * 335 * @param uri The uri of the media item. 336 * @param width The preferred width of the icon in dp. 337 * @param height The preferred width of the icon in dp. 338 * @param density The preferred density of the icon. Must be one of the android 339 * density buckets. 340 * @param callback The callback to receive the thumbnail. 341 * 342 * @throws IllegalStateException if not connected. TODO: Is this restriction necessary? 343 */ 344 public void loadThumbnail(@NonNull Uri uri, int width, int height, int density, 345 @NonNull ThumbnailCallback callback) { 346 throw new RuntimeException("implement me"); 347 } 348 349 /** 350 * For debugging. 351 */ 352 private static String getStateLabel(int state) { 353 switch (state) { 354 case CONNECT_STATE_DISCONNECTED: 355 return "CONNECT_STATE_DISCONNECTED"; 356 case CONNECT_STATE_CONNECTING: 357 return "CONNECT_STATE_CONNECTING"; 358 case CONNECT_STATE_CONNECTED: 359 return "CONNECT_STATE_CONNECTED"; 360 case CONNECT_STATE_SUSPENDED: 361 return "CONNECT_STATE_SUSPENDED"; 362 default: 363 return "UNKNOWN/" + state; 364 } 365 } 366 367 private final void onServiceConnected(final IMediaBrowserServiceCallbacks callback, 368 final Uri root, final MediaSession.Token session) { 369 mHandler.post(new Runnable() { 370 @Override 371 public void run() { 372 // Check to make sure there hasn't been a disconnect or a different 373 // ServiceConnection. 374 if (!isCurrent(callback, "onConnect")) { 375 return; 376 } 377 // Don't allow them to call us twice. 378 if (mState != CONNECT_STATE_CONNECTING) { 379 Log.w(TAG, "onConnect from service while mState=" 380 + getStateLabel(mState) + "... ignoring"); 381 return; 382 } 383 mRootUri = root; 384 mMediaSessionToken = session; 385 mState = CONNECT_STATE_CONNECTED; 386 387 if (DBG) { 388 Log.d(TAG, "ServiceCallbacks.onConnect..."); 389 dump(); 390 } 391 mCallback.onConnected(); 392 393 // we may receive some subscriptions before we are connected, so re-subscribe 394 // everything now 395 for (Uri uri : mSubscriptions.keySet()) { 396 try { 397 mServiceBinder.addSubscription(uri, mServiceCallbacks); 398 } catch (RemoteException ex) { 399 // Process is crashing. We will disconnect, and upon reconnect we will 400 // automatically reregister. So nothing to do here. 401 Log.d(TAG, "addSubscription failed with RemoteException parentUri=" + uri); 402 } 403 } 404 405 } 406 }); 407 } 408 409 private final void onConnectionFailed(final IMediaBrowserServiceCallbacks callback) { 410 mHandler.post(new Runnable() { 411 @Override 412 public void run() { 413 Log.e(TAG, "onConnectFailed for " + mServiceComponent); 414 415 // Check to make sure there hasn't been a disconnect or a different 416 // ServiceConnection. 417 if (!isCurrent(callback, "onConnectFailed")) { 418 return; 419 } 420 // Don't allow them to call us twice. 421 if (mState != CONNECT_STATE_CONNECTING) { 422 Log.w(TAG, "onConnect from service while mState=" 423 + getStateLabel(mState) + "... ignoring"); 424 return; 425 } 426 427 // Clean up 428 forceCloseConnection(); 429 430 // Tell the app. 431 mCallback.onConnectionFailed(); 432 } 433 }); 434 } 435 436 private final void onLoadChildren(final IMediaBrowserServiceCallbacks callback, final Uri uri, 437 final ParceledListSlice list) { 438 mHandler.post(new Runnable() { 439 @Override 440 public void run() { 441 // Check that there hasn't been a disconnect or a different 442 // ServiceConnection. 443 if (!isCurrent(callback, "onLoadChildren")) { 444 return; 445 } 446 447 List<MediaBrowserItem> data = list.getList(); 448 if (DBG) { 449 Log.d(TAG, "onLoadChildren for " + mServiceComponent + " uri=" + uri); 450 } 451 if (data == null) { 452 data = Collections.emptyList(); 453 } 454 455 // Check that the subscription is still subscribed. 456 final Subscription subscription = mSubscriptions.get(uri); 457 if (subscription == null) { 458 if (DBG) { 459 Log.d(TAG, "onLoadChildren for uri that isn't subscribed uri=" 460 + uri); 461 } 462 return; 463 } 464 465 // Tell the app. 466 subscription.callback.onChildrenLoaded(uri, data); 467 } 468 }); 469 } 470 471 472 /** 473 * Return true if {@code callback} is the current ServiceCallbacks. Also logs if it's not. 474 */ 475 private boolean isCurrent(IMediaBrowserServiceCallbacks callback, String funcName) { 476 if (mServiceCallbacks != callback) { 477 if (mState != CONNECT_STATE_DISCONNECTED) { 478 Log.i(TAG, funcName + " for " + mServiceComponent + " with mServiceConnection=" 479 + mServiceCallbacks + " this=" + this); 480 } 481 return false; 482 } 483 return true; 484 } 485 486 private ServiceCallbacks getNewServiceCallbacks() { 487 return new ServiceCallbacks(this); 488 } 489 490 /** 491 * Log internal state. 492 * @hide 493 */ 494 void dump() { 495 Log.d(TAG, "MediaBrowser..."); 496 Log.d(TAG, " mServiceComponent=" + mServiceComponent); 497 Log.d(TAG, " mCallback=" + mCallback); 498 Log.d(TAG, " mRootHints=" + mRootHints); 499 Log.d(TAG, " mState=" + getStateLabel(mState)); 500 Log.d(TAG, " mServiceConnection=" + mServiceConnection); 501 Log.d(TAG, " mServiceBinder=" + mServiceBinder); 502 Log.d(TAG, " mServiceCallbacks=" + mServiceCallbacks); 503 Log.d(TAG, " mRootUri=" + mRootUri); 504 Log.d(TAG, " mMediaSessionToken=" + mMediaSessionToken); 505 } 506 507 508 /** 509 * Callbacks for connection related events. 510 */ 511 public static class ConnectionCallback { 512 /** 513 * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed. 514 */ 515 public void onConnected() { 516 } 517 518 /** 519 * Invoked when the client is disconnected from the media browser. 520 */ 521 public void onConnectionSuspended() { 522 } 523 524 /** 525 * Invoked when the connection to the media browser failed. 526 */ 527 public void onConnectionFailed() { 528 } 529 } 530 531 /** 532 * Callbacks for subscription related events. 533 */ 534 public static abstract class SubscriptionCallback { 535 /** 536 * Called when the list of children is loaded or updated. 537 */ 538 public void onChildrenLoaded(@NonNull Uri parentUri, 539 @NonNull List<MediaBrowserItem> children) { 540 } 541 542 /** 543 * Called when the Uri doesn't exist or other errors in subscribing. 544 * <p> 545 * If this is called, the subscription remains until {@link MediaBrowser#unsubscribe} 546 * called, because some errors may heal themselves. 547 * </p> 548 */ 549 public void onError(@NonNull Uri uri) { 550 } 551 } 552 553 /** 554 * Callbacks for thumbnail loading. 555 */ 556 public static abstract class ThumbnailCallback { 557 /** 558 * Called when the thumbnail is loaded. 559 */ 560 public void onThumbnailLoaded(@NonNull Uri uri, @NonNull Bitmap bitmap) { 561 } 562 563 /** 564 * Called when the Uri doesn’t exist or the bitmap cannot be loaded. 565 */ 566 public void onError(@NonNull Uri uri) { 567 } 568 } 569 570 /** 571 * ServiceConnection to the other app. 572 */ 573 private class MediaServiceConnection implements ServiceConnection { 574 @Override 575 public void onServiceConnected(ComponentName name, IBinder binder) { 576 if (DBG) { 577 Log.d(TAG, "MediaServiceConnection.onServiceConnected name=" + name 578 + " binder=" + binder); 579 dump(); 580 } 581 582 // Make sure we are still the current connection, and that they haven't called 583 // disconnect(). 584 if (!isCurrent("onServiceConnected")) { 585 return; 586 } 587 588 // Save their binder 589 mServiceBinder = IMediaBrowserService.Stub.asInterface(binder); 590 591 // We make a new mServiceCallbacks each time we connect so that we can drop 592 // responses from previous connections. 593 mServiceCallbacks = getNewServiceCallbacks(); 594 595 // Call connect, which is async. When we get a response from that we will 596 // say that we're connected. 597 try { 598 if (DBG) { 599 Log.d(TAG, "ServiceCallbacks.onConnect..."); 600 dump(); 601 } 602 mServiceBinder.connect(mContext.getPackageName(), mRootHints, mServiceCallbacks); 603 } catch (RemoteException ex) { 604 // Connect failed, which isn't good. But the auto-reconnect on the service 605 // will take over and we will come back. We will also get the 606 // onServiceDisconnected, which has all the cleanup code. So let that do it. 607 Log.w(TAG, "RemoteException during connect for " + mServiceComponent); 608 if (DBG) { 609 Log.d(TAG, "ServiceCallbacks.onConnect..."); 610 dump(); 611 } 612 } 613 } 614 615 @Override 616 public void onServiceDisconnected(ComponentName name) { 617 if (DBG) { 618 Log.d(TAG, "MediaServiceConnection.onServiceDisconnected name=" + name 619 + " this=" + this + " mServiceConnection=" + mServiceConnection); 620 dump(); 621 } 622 623 // Make sure we are still the current connection, and that they haven't called 624 // disconnect(). 625 if (!isCurrent("onServiceDisconnected")) { 626 return; 627 } 628 629 // Clear out what we set in onServiceConnected 630 mServiceBinder = null; 631 mServiceCallbacks = null; 632 633 // And tell the app that it's suspended. 634 mState = CONNECT_STATE_SUSPENDED; 635 mCallback.onConnectionSuspended(); 636 } 637 638 /** 639 * Return true if this is the current ServiceConnection. Also logs if it's not. 640 */ 641 private boolean isCurrent(String funcName) { 642 if (mServiceConnection != this) { 643 if (mState != CONNECT_STATE_DISCONNECTED) { 644 // Check mState, because otherwise this log is noisy. 645 Log.i(TAG, funcName + " for " + mServiceComponent + " with mServiceConnection=" 646 + mServiceConnection + " this=" + this); 647 } 648 return false; 649 } 650 return true; 651 } 652 }; 653 654 /** 655 * Callbacks from the service. 656 */ 657 private static class ServiceCallbacks extends IMediaBrowserServiceCallbacks.Stub { 658 private WeakReference<MediaBrowser> mMediaBrowser; 659 660 public ServiceCallbacks(MediaBrowser mediaBrowser) { 661 mMediaBrowser = new WeakReference<MediaBrowser>(mediaBrowser); 662 } 663 664 /** 665 * The other side has acknowledged our connection. The parameters to this function 666 * are the initial data as requested. 667 */ 668 @Override 669 public void onConnect(final Uri root, final MediaSession.Token session) { 670 MediaBrowser mediaBrowser = mMediaBrowser.get(); 671 if (mediaBrowser != null) { 672 mediaBrowser.onServiceConnected(this, root, session); 673 } 674 } 675 676 /** 677 * The other side does not like us. Tell the app via onConnectionFailed. 678 */ 679 @Override 680 public void onConnectFailed() { 681 MediaBrowser mediaBrowser = mMediaBrowser.get(); 682 if (mediaBrowser != null) { 683 mediaBrowser.onConnectionFailed(this); 684 } 685 } 686 687 @Override 688 public void onLoadChildren(final Uri uri, final ParceledListSlice list) { 689 MediaBrowser mediaBrowser = mMediaBrowser.get(); 690 if (mediaBrowser != null) { 691 mediaBrowser.onLoadChildren(this, uri, list); 692 } 693 } 694 } 695 696 private static class Subscription { 697 final Uri uri; 698 SubscriptionCallback callback; 699 700 Subscription(Uri u) { 701 this.uri = u; 702 } 703 } 704} 705