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 * &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 */
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