Home | History | Annotate | Download | only in media
      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 
     17 package android.service.media;
     18 
     19 import android.annotation.NonNull;
     20 import android.annotation.Nullable;
     21 import android.annotation.SdkConstant;
     22 import android.annotation.SdkConstant.SdkConstantType;
     23 import android.app.Service;
     24 import android.content.Intent;
     25 import android.content.pm.PackageManager;
     26 import android.content.pm.ParceledListSlice;
     27 import android.media.browse.MediaBrowser;
     28 import android.media.session.MediaSession;
     29 import android.os.Binder;
     30 import android.os.Bundle;
     31 import android.os.IBinder;
     32 import android.os.Handler;
     33 import android.os.RemoteException;
     34 import android.os.ResultReceiver;
     35 import android.service.media.IMediaBrowserService;
     36 import android.service.media.IMediaBrowserServiceCallbacks;
     37 import android.text.TextUtils;
     38 import android.util.ArrayMap;
     39 import android.util.Log;
     40 
     41 import java.io.FileDescriptor;
     42 import java.io.PrintWriter;
     43 import java.util.HashSet;
     44 import 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  * &lt;service android:name=".MyMediaBrowserService"
     60  *          android:label="&#64;string/service_name" >
     61  *     &lt;intent-filter>
     62  *         &lt;action android:name="android.media.browse.MediaBrowserService" />
     63  *     &lt;/intent-filter>
     64  * &lt;/service>
     65  * </pre>
     66  *
     67  */
     68 public 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