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