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