MediaBrowserServiceCompat.java revision 493571364635be0190cea8ee230a601070391e6f
1/* 2 * Copyright (C) 2015 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.support.v4.media; 18 19import android.app.Service; 20import android.content.Intent; 21import android.content.pm.PackageManager; 22import android.os.Binder; 23import android.os.Bundle; 24import android.os.Handler; 25import android.os.IBinder; 26import android.os.RemoteException; 27import android.support.annotation.NonNull; 28import android.support.annotation.Nullable; 29import android.support.v4.media.IMediaBrowserServiceCompat; 30import android.support.v4.media.IMediaBrowserServiceCompatCallbacks; 31import android.support.v4.media.session.MediaSessionCompat; 32import android.support.v4.os.ResultReceiver; 33import android.support.v4.util.ArrayMap; 34import android.text.TextUtils; 35import android.util.Log; 36 37import java.io.FileDescriptor; 38import java.io.PrintWriter; 39import java.util.HashSet; 40import java.util.List; 41 42/** 43 * Base class for media browse services. 44 * <p> 45 * Media browse services enable applications to browse media content provided by an application 46 * and ask the application to start playing it. They may also be used to control content that 47 * is already playing by way of a {@link MediaSessionCompat}. 48 * </p> 49 * 50 * To extend this class, you must declare the service in your manifest file with 51 * an intent filter with the {@link #SERVICE_INTERFACE} action. 52 * 53 * For example: 54 * </p><pre> 55 * <service android:name=".MyMediaBrowserServiceCompat" 56 * android:label="@string/service_name" > 57 * <intent-filter> 58 * <action android:name="android.media.browse.MediaBrowserServiceCompat" /> 59 * </intent-filter> 60 * </service> 61 * </pre> 62 * @hide 63 */ 64public abstract class MediaBrowserServiceCompat extends Service { 65 private static final String TAG = "MediaBrowserServiceCompat"; 66 private static final boolean DBG = false; 67 68 /** 69 * The {@link Intent} that must be declared as handled by the service. 70 */ 71 public static final String SERVICE_INTERFACE = "android.media.browse.MediaBrowserServiceCompat"; 72 73 /** 74 * A key for passing the MediaItem to the ResultReceiver in getItem. 75 * 76 * @hide 77 */ 78 public static final String KEY_MEDIA_ITEM = "media_item"; 79 80 private final ArrayMap<IBinder, ConnectionRecord> mConnections = new ArrayMap(); 81 private final Handler mHandler = new Handler(); 82 private ServiceBinder mBinder; 83 MediaSessionCompat.Token mSession; 84 85 /** 86 * All the info about a connection. 87 */ 88 private class ConnectionRecord { 89 String pkg; 90 Bundle rootHints; 91 IMediaBrowserServiceCompatCallbacks callbacks; 92 BrowserRoot root; 93 HashSet<String> subscriptions = new HashSet(); 94 } 95 96 /** 97 * Completion handler for asynchronous callback methods in {@link MediaBrowserServiceCompat}. 98 * <p> 99 * Each of the methods that takes one of these to send the result must call 100 * {@link #sendResult} to respond to the caller with the given results. If those 101 * functions return without calling {@link #sendResult}, they must instead call 102 * {@link #detach} before returning, and then may call {@link #sendResult} when 103 * they are done. If more than one of those methods is called, an exception will 104 * be thrown. 105 * 106 * @see MediaBrowserServiceCompat#onLoadChildren 107 * @see MediaBrowserServiceCompat#onLoadItem 108 */ 109 public class Result<T> { 110 private Object mDebug; 111 private boolean mDetachCalled; 112 private boolean mSendResultCalled; 113 114 Result(Object debug) { 115 mDebug = debug; 116 } 117 118 /** 119 * Send the result back to the caller. 120 */ 121 public void sendResult(T result) { 122 if (mSendResultCalled) { 123 throw new IllegalStateException("sendResult() called twice for: " + mDebug); 124 } 125 mSendResultCalled = true; 126 onResultSent(result); 127 } 128 129 /** 130 * Detach this message from the current thread and allow the {@link #sendResult} 131 * call to happen later. 132 */ 133 public void detach() { 134 if (mDetachCalled) { 135 throw new IllegalStateException("detach() called when detach() had already" 136 + " been called for: " + mDebug); 137 } 138 if (mSendResultCalled) { 139 throw new IllegalStateException("detach() called when sendResult() had already" 140 + " been called for: " + mDebug); 141 } 142 mDetachCalled = true; 143 } 144 145 boolean isDone() { 146 return mDetachCalled || mSendResultCalled; 147 } 148 149 /** 150 * Called when the result is sent, after assertions about not being called twice 151 * have happened. 152 */ 153 void onResultSent(T result) { 154 } 155 } 156 157 private class ServiceBinder extends IMediaBrowserServiceCompat.Stub { 158 @Override 159 public void connect(final String pkg, final Bundle rootHints, 160 final IMediaBrowserServiceCompatCallbacks callbacks) { 161 162 final int uid = Binder.getCallingUid(); 163 if (!isValidPackage(pkg, uid)) { 164 throw new IllegalArgumentException("Package/uid mismatch: uid=" + uid 165 + " package=" + pkg); 166 } 167 168 mHandler.post(new Runnable() { 169 @Override 170 public void run() { 171 final IBinder b = callbacks.asBinder(); 172 173 // Clear out the old subscriptions. We are getting new ones. 174 mConnections.remove(b); 175 176 final ConnectionRecord connection = new ConnectionRecord(); 177 connection.pkg = pkg; 178 connection.rootHints = rootHints; 179 connection.callbacks = callbacks; 180 181 connection.root = 182 MediaBrowserServiceCompat.this.onGetRoot(pkg, uid, rootHints); 183 184 // If they didn't return something, don't allow this client. 185 if (connection.root == null) { 186 Log.i(TAG, "No root for client " + pkg + " from service " 187 + getClass().getName()); 188 try { 189 callbacks.onConnectFailed(); 190 } catch (RemoteException ex) { 191 Log.w(TAG, "Calling onConnectFailed() failed. Ignoring. " 192 + "pkg=" + pkg); 193 } 194 } else { 195 try { 196 mConnections.put(b, connection); 197 if (mSession != null) { 198 callbacks.onConnect(connection.root.getRootId(), 199 mSession, connection.root.getExtras()); 200 } 201 } catch (RemoteException ex) { 202 Log.w(TAG, "Calling onConnect() failed. Dropping client. " 203 + "pkg=" + pkg); 204 mConnections.remove(b); 205 } 206 } 207 } 208 }); 209 } 210 211 @Override 212 public void disconnect(final IMediaBrowserServiceCompatCallbacks callbacks) { 213 mHandler.post(new Runnable() { 214 @Override 215 public void run() { 216 final IBinder b = callbacks.asBinder(); 217 218 // Clear out the old subscriptions. We are getting new ones. 219 final ConnectionRecord old = mConnections.remove(b); 220 if (old != null) { 221 // TODO 222 } 223 } 224 }); 225 } 226 227 228 @Override 229 public void addSubscription( 230 final String id, final IMediaBrowserServiceCompatCallbacks callbacks) { 231 mHandler.post(new Runnable() { 232 @Override 233 public void run() { 234 final IBinder b = callbacks.asBinder(); 235 236 // Get the record for the connection 237 final ConnectionRecord connection = mConnections.get(b); 238 if (connection == null) { 239 Log.w(TAG, "addSubscription for callback that isn't registered id=" 240 + id); 241 return; 242 } 243 244 MediaBrowserServiceCompat.this.addSubscription(id, connection); 245 } 246 }); 247 } 248 249 @Override 250 public void removeSubscription(final String id, 251 final IMediaBrowserServiceCompatCallbacks callbacks) { 252 mHandler.post(new Runnable() { 253 @Override 254 public void run() { 255 final IBinder b = callbacks.asBinder(); 256 257 ConnectionRecord connection = mConnections.get(b); 258 if (connection == null) { 259 Log.w(TAG, "removeSubscription for callback that isn't registered id=" 260 + id); 261 return; 262 } 263 if (!connection.subscriptions.remove(id)) { 264 Log.w(TAG, "removeSubscription called for " + id 265 + " which is not subscribed"); 266 } 267 } 268 }); 269 } 270 271 @Override 272 public void getMediaItem(final String mediaId, final ResultReceiver receiver) { 273 if (TextUtils.isEmpty(mediaId) || receiver == null) { 274 return; 275 } 276 277 mHandler.post(new Runnable() { 278 @Override 279 public void run() { 280 performLoadItem(mediaId, receiver); 281 } 282 }); 283 } 284 } 285 286 @Override 287 public void onCreate() { 288 super.onCreate(); 289 mBinder = new ServiceBinder(); 290 } 291 292 @Override 293 public IBinder onBind(Intent intent) { 294 if (SERVICE_INTERFACE.equals(intent.getAction())) { 295 return mBinder; 296 } 297 return null; 298 } 299 300 @Override 301 public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { 302 } 303 304 /** 305 * Called to get the root information for browsing by a particular client. 306 * <p> 307 * The implementation should verify that the client package has permission 308 * to access browse media information before returning the root id; it 309 * should return null if the client is not allowed to access this 310 * information. 311 * </p> 312 * 313 * @param clientPackageName The package name of the application which is 314 * requesting access to browse media. 315 * @param clientUid The uid of the application which is requesting access to 316 * browse media. 317 * @param rootHints An optional bundle of service-specific arguments to send 318 * to the media browse service when connecting and retrieving the 319 * root id for browsing, or null if none. The contents of this 320 * bundle may affect the information returned when browsing. 321 * @return The {@link BrowserRoot} for accessing this app's content or null. 322 */ 323 public abstract @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, 324 int clientUid, @Nullable Bundle rootHints); 325 326 /** 327 * Called to get information about the children of a media item. 328 * <p> 329 * Implementations must call {@link Result#sendResult result.sendResult} 330 * with the list of children. If loading the children will be an expensive 331 * operation that should be performed on another thread, 332 * {@link Result#detach result.detach} may be called before returning from 333 * this function, and then {@link Result#sendResult result.sendResult} 334 * called when the loading is complete. 335 * 336 * @param parentId The id of the parent media item whose children are to be 337 * queried. 338 * @param result The Result to send the list of children to, or null if the 339 * id is invalid. 340 */ 341 public abstract void onLoadChildren(@NonNull String parentId, 342 @NonNull Result<List<MediaBrowserCompat.MediaItem>> result); 343 344 /** 345 * Called to get information about a specific media item. 346 * <p> 347 * Implementations must call {@link Result#sendResult result.sendResult}. If 348 * loading the item will be an expensive operation {@link Result#detach 349 * result.detach} may be called before returning from this function, and 350 * then {@link Result#sendResult result.sendResult} called when the item has 351 * been loaded. 352 * <p> 353 * The default implementation sends a null result. 354 * 355 * @param itemId The id for the specific 356 * {@link MediaBrowserCompat.MediaItem}. 357 * @param result The Result to send the item to, or null if the id is 358 * invalid. 359 */ 360 public void onLoadItem(String itemId, Result<MediaBrowserCompat.MediaItem> result) { 361 result.sendResult(null); 362 } 363 364 /** 365 * Call to set the media session. 366 * <p> 367 * This should be called as soon as possible during the service's startup. 368 * It may only be called once. 369 * 370 * @param token The token for the service's {@link MediaSessionCompat}. 371 */ 372 public void setSessionToken(final MediaSessionCompat.Token token) { 373 if (token == null) { 374 throw new IllegalArgumentException("Session token may not be null."); 375 } 376 if (mSession != null) { 377 throw new IllegalStateException("The session token has already been set."); 378 } 379 mSession = token; 380 mHandler.post(new Runnable() { 381 @Override 382 public void run() { 383 for (IBinder key : mConnections.keySet()) { 384 ConnectionRecord connection = mConnections.get(key); 385 try { 386 connection.callbacks.onConnect(connection.root.getRootId(), token, 387 connection.root.getExtras()); 388 } catch (RemoteException e) { 389 Log.w(TAG, "Connection for " + connection.pkg + " is no longer valid."); 390 mConnections.remove(key); 391 } 392 } 393 } 394 }); 395 } 396 397 /** 398 * Gets the session token, or null if it has not yet been created 399 * or if it has been destroyed. 400 */ 401 public @Nullable MediaSessionCompat.Token getSessionToken() { 402 return mSession; 403 } 404 405 /** 406 * Notifies all connected media browsers that the children of 407 * the specified parent id have changed in some way. 408 * This will cause browsers to fetch subscribed content again. 409 * 410 * @param parentId The id of the parent media item whose 411 * children changed. 412 */ 413 public void notifyChildrenChanged(@NonNull final String parentId) { 414 if (parentId == null) { 415 throw new IllegalArgumentException("parentId cannot be null in notifyChildrenChanged"); 416 } 417 mHandler.post(new Runnable() { 418 @Override 419 public void run() { 420 for (IBinder binder : mConnections.keySet()) { 421 ConnectionRecord connection = mConnections.get(binder); 422 if (connection.subscriptions.contains(parentId)) { 423 performLoadChildren(parentId, connection); 424 } 425 } 426 } 427 }); 428 } 429 430 /** 431 * Return whether the given package is one of the ones that is owned by the uid. 432 */ 433 private boolean isValidPackage(String pkg, int uid) { 434 if (pkg == null) { 435 return false; 436 } 437 final PackageManager pm = getPackageManager(); 438 final String[] packages = pm.getPackagesForUid(uid); 439 final int N = packages.length; 440 for (int i=0; i<N; i++) { 441 if (packages[i].equals(pkg)) { 442 return true; 443 } 444 } 445 return false; 446 } 447 448 /** 449 * Save the subscription and if it is a new subscription send the results. 450 */ 451 private void addSubscription(String id, ConnectionRecord connection) { 452 // Save the subscription 453 connection.subscriptions.add(id); 454 455 // send the results 456 performLoadChildren(id, connection); 457 } 458 459 /** 460 * Call onLoadChildren and then send the results back to the connection. 461 * <p> 462 * Callers must make sure that this connection is still connected. 463 */ 464 private void performLoadChildren(final String parentId, final ConnectionRecord connection) { 465 final Result<List<MediaBrowserCompat.MediaItem>> result 466 = new Result<List<MediaBrowserCompat.MediaItem>>(parentId) { 467 @Override 468 void onResultSent(List<MediaBrowserCompat.MediaItem> list) { 469 if (list == null) { 470 throw new IllegalStateException("onLoadChildren sent null list for id " 471 + parentId); 472 } 473 if (mConnections.get(connection.callbacks.asBinder()) != connection) { 474 if (DBG) { 475 Log.d(TAG, "Not sending onLoadChildren result for connection that has" 476 + " been disconnected. pkg=" + connection.pkg + " id=" + parentId); 477 } 478 return; 479 } 480 481 try { 482 connection.callbacks.onLoadChildren(parentId, list); 483 } catch (RemoteException ex) { 484 // The other side is in the process of crashing. 485 Log.w(TAG, "Calling onLoadChildren() failed for id=" + parentId 486 + " package=" + connection.pkg); 487 } 488 } 489 }; 490 491 onLoadChildren(parentId, result); 492 493 if (!result.isDone()) { 494 throw new IllegalStateException("onLoadChildren must call detach() or sendResult()" 495 + " before returning for package=" + connection.pkg + " id=" + parentId); 496 } 497 } 498 499 private void performLoadItem(String itemId, final ResultReceiver receiver) { 500 final Result<MediaBrowserCompat.MediaItem> result = 501 new Result<MediaBrowserCompat.MediaItem>(itemId) { 502 @Override 503 void onResultSent(MediaBrowserCompat.MediaItem item) { 504 Bundle bundle = new Bundle(); 505 bundle.putParcelable(KEY_MEDIA_ITEM, item); 506 receiver.send(0, bundle); 507 } 508 }; 509 510 MediaBrowserServiceCompat.this.onLoadItem(itemId, result); 511 512 if (!result.isDone()) { 513 throw new IllegalStateException("onLoadItem must call detach() or sendResult()" 514 + " before returning for id=" + itemId); 515 } 516 } 517 518 /** 519 * Contains information that the browser service needs to send to the client 520 * when first connected. 521 */ 522 public static final class BrowserRoot { 523 final private String mRootId; 524 final private Bundle mExtras; 525 526 /** 527 * Constructs a browser root. 528 * @param rootId The root id for browsing. 529 * @param extras Any extras about the browser service. 530 */ 531 public BrowserRoot(@NonNull String rootId, @Nullable Bundle extras) { 532 if (rootId == null) { 533 throw new IllegalArgumentException("The root id in BrowserRoot cannot be null. " + 534 "Use null for BrowserRoot instead."); 535 } 536 mRootId = rootId; 537 mExtras = extras; 538 } 539 540 /** 541 * Gets the root id for browsing. 542 */ 543 public String getRootId() { 544 return mRootId; 545 } 546 547 /** 548 * Gets any extras about the brwoser service. 549 */ 550 public Bundle getExtras() { 551 return mExtras; 552 } 553 } 554} 555