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.IntDef;
20import android.annotation.NonNull;
21import android.annotation.Nullable;
22import android.annotation.SdkConstant;
23import android.annotation.SdkConstant.SdkConstantType;
24import android.app.Service;
25import android.content.Intent;
26import android.content.pm.PackageManager;
27import android.content.pm.ParceledListSlice;
28import android.media.browse.MediaBrowser;
29import android.media.browse.MediaBrowserUtils;
30import android.media.session.MediaSession;
31import android.os.Binder;
32import android.os.Bundle;
33import android.os.Handler;
34import android.os.IBinder;
35import android.os.RemoteException;
36import android.os.ResultReceiver;
37import android.service.media.IMediaBrowserService;
38import android.service.media.IMediaBrowserServiceCallbacks;
39import android.text.TextUtils;
40import android.util.ArrayMap;
41import android.util.Log;
42import android.util.Pair;
43
44import java.io.FileDescriptor;
45import java.io.PrintWriter;
46import java.lang.annotation.Retention;
47import java.lang.annotation.RetentionPolicy;
48import java.util.ArrayList;
49import java.util.Collections;
50import java.util.HashMap;
51import java.util.List;
52
53/**
54 * Base class for media browse services.
55 * <p>
56 * Media browse services enable applications to browse media content provided by an application
57 * and ask the application to start playing it. They may also be used to control content that
58 * is already playing by way of a {@link MediaSession}.
59 * </p>
60 *
61 * To extend this class, you must declare the service in your manifest file with
62 * an intent filter with the {@link #SERVICE_INTERFACE} action.
63 *
64 * For example:
65 * </p><pre>
66 * &lt;service android:name=".MyMediaBrowserService"
67 *          android:label="&#64;string/service_name" >
68 *     &lt;intent-filter>
69 *         &lt;action android:name="android.media.browse.MediaBrowserService" />
70 *     &lt;/intent-filter>
71 * &lt;/service>
72 * </pre>
73 *
74 */
75public abstract class MediaBrowserService extends Service {
76    private static final String TAG = "MediaBrowserService";
77    private static final boolean DBG = false;
78
79    /**
80     * The {@link Intent} that must be declared as handled by the service.
81     */
82    @SdkConstant(SdkConstantType.SERVICE_ACTION)
83    public static final String SERVICE_INTERFACE = "android.media.browse.MediaBrowserService";
84
85    /**
86     * A key for passing the MediaItem to the ResultReceiver in getItem.
87     *
88     * @hide
89     */
90    public static final String KEY_MEDIA_ITEM = "media_item";
91
92    private static final int RESULT_FLAG_OPTION_NOT_HANDLED = 0x00000001;
93
94    /** @hide */
95    @Retention(RetentionPolicy.SOURCE)
96    @IntDef(flag=true, value = { RESULT_FLAG_OPTION_NOT_HANDLED })
97    private @interface ResultFlags { }
98
99    private final ArrayMap<IBinder, ConnectionRecord> mConnections = new ArrayMap<>();
100    private ConnectionRecord mCurConnection;
101    private final Handler mHandler = new Handler();
102    private ServiceBinder mBinder;
103    MediaSession.Token mSession;
104
105    /**
106     * All the info about a connection.
107     */
108    private class ConnectionRecord {
109        String pkg;
110        Bundle rootHints;
111        IMediaBrowserServiceCallbacks callbacks;
112        BrowserRoot root;
113        HashMap<String, List<Pair<IBinder, Bundle>>> subscriptions = new HashMap<>();
114    }
115
116    /**
117     * Completion handler for asynchronous callback methods in {@link MediaBrowserService}.
118     * <p>
119     * Each of the methods that takes one of these to send the result must call
120     * {@link #sendResult} to respond to the caller with the given results. If those
121     * functions return without calling {@link #sendResult}, they must instead call
122     * {@link #detach} before returning, and then may call {@link #sendResult} when
123     * they are done. If more than one of those methods is called, an exception will
124     * be thrown.
125     *
126     * @see #onLoadChildren
127     * @see #onLoadItem
128     */
129    public class Result<T> {
130        private Object mDebug;
131        private boolean mDetachCalled;
132        private boolean mSendResultCalled;
133        private int mFlags;
134
135        Result(Object debug) {
136            mDebug = debug;
137        }
138
139        /**
140         * Send the result back to the caller.
141         */
142        public void sendResult(T result) {
143            if (mSendResultCalled) {
144                throw new IllegalStateException("sendResult() called twice for: " + mDebug);
145            }
146            mSendResultCalled = true;
147            onResultSent(result, mFlags);
148        }
149
150        /**
151         * Detach this message from the current thread and allow the {@link #sendResult}
152         * call to happen later.
153         */
154        public void detach() {
155            if (mDetachCalled) {
156                throw new IllegalStateException("detach() called when detach() had already"
157                        + " been called for: " + mDebug);
158            }
159            if (mSendResultCalled) {
160                throw new IllegalStateException("detach() called when sendResult() had already"
161                        + " been called for: " + mDebug);
162            }
163            mDetachCalled = true;
164        }
165
166        boolean isDone() {
167            return mDetachCalled || mSendResultCalled;
168        }
169
170        void setFlags(@ResultFlags int flags) {
171            mFlags = flags;
172        }
173
174        /**
175         * Called when the result is sent, after assertions about not being called twice
176         * have happened.
177         */
178        void onResultSent(T result, @ResultFlags int flags) {
179        }
180    }
181
182    private class ServiceBinder extends IMediaBrowserService.Stub {
183        @Override
184        public void connect(final String pkg, final Bundle rootHints,
185                final IMediaBrowserServiceCallbacks callbacks) {
186
187            final int uid = Binder.getCallingUid();
188            if (!isValidPackage(pkg, uid)) {
189                throw new IllegalArgumentException("Package/uid mismatch: uid=" + uid
190                        + " package=" + pkg);
191            }
192
193            mHandler.post(new Runnable() {
194                    @Override
195                    public void run() {
196                        final IBinder b = callbacks.asBinder();
197
198                        // Clear out the old subscriptions. We are getting new ones.
199                        mConnections.remove(b);
200
201                        final ConnectionRecord connection = new ConnectionRecord();
202                        connection.pkg = pkg;
203                        connection.rootHints = rootHints;
204                        connection.callbacks = callbacks;
205
206                        connection.root = MediaBrowserService.this.onGetRoot(pkg, uid, rootHints);
207
208                        // If they didn't return something, don't allow this client.
209                        if (connection.root == null) {
210                            Log.i(TAG, "No root for client " + pkg + " from service "
211                                    + getClass().getName());
212                            try {
213                                callbacks.onConnectFailed();
214                            } catch (RemoteException ex) {
215                                Log.w(TAG, "Calling onConnectFailed() failed. Ignoring. "
216                                        + "pkg=" + pkg);
217                            }
218                        } else {
219                            try {
220                                mConnections.put(b, connection);
221                                if (mSession != null) {
222                                    callbacks.onConnect(connection.root.getRootId(),
223                                            mSession, connection.root.getExtras());
224                                }
225                            } catch (RemoteException ex) {
226                                Log.w(TAG, "Calling onConnect() failed. Dropping client. "
227                                        + "pkg=" + pkg);
228                                mConnections.remove(b);
229                            }
230                        }
231                    }
232                });
233        }
234
235        @Override
236        public void disconnect(final IMediaBrowserServiceCallbacks callbacks) {
237            mHandler.post(new Runnable() {
238                    @Override
239                    public void run() {
240                        final IBinder b = callbacks.asBinder();
241
242                        // Clear out the old subscriptions. We are getting new ones.
243                        final ConnectionRecord old = mConnections.remove(b);
244                        if (old != null) {
245                            // TODO
246                        }
247                    }
248                });
249        }
250
251        @Override
252        public void addSubscriptionDeprecated(String id, IMediaBrowserServiceCallbacks callbacks) {
253            // do-nothing
254        }
255
256        @Override
257        public void addSubscription(final String id, final IBinder token, final Bundle options,
258                final IMediaBrowserServiceCallbacks callbacks) {
259            mHandler.post(new Runnable() {
260                    @Override
261                    public void run() {
262                        final IBinder b = callbacks.asBinder();
263
264                        // Get the record for the connection
265                        final ConnectionRecord connection = mConnections.get(b);
266                        if (connection == null) {
267                            Log.w(TAG, "addSubscription for callback that isn't registered id="
268                                + id);
269                            return;
270                        }
271
272                        MediaBrowserService.this.addSubscription(id, connection, token, options);
273                    }
274                });
275        }
276
277        @Override
278        public void removeSubscriptionDeprecated(String id, IMediaBrowserServiceCallbacks callbacks) {
279            // do-nothing
280        }
281
282        @Override
283        public void removeSubscription(final String id, final IBinder token,
284                final IMediaBrowserServiceCallbacks callbacks) {
285            mHandler.post(new Runnable() {
286                @Override
287                public void run() {
288                    final IBinder b = callbacks.asBinder();
289
290                    ConnectionRecord connection = mConnections.get(b);
291                    if (connection == null) {
292                        Log.w(TAG, "removeSubscription for callback that isn't registered id="
293                                + id);
294                        return;
295                    }
296                    if (!MediaBrowserService.this.removeSubscription(id, connection, token)) {
297                        Log.w(TAG, "removeSubscription called for " + id
298                                + " which is not subscribed");
299                    }
300                }
301            });
302        }
303
304        @Override
305        public void getMediaItem(final String mediaId, final ResultReceiver receiver,
306                final IMediaBrowserServiceCallbacks callbacks) {
307            if (TextUtils.isEmpty(mediaId) || receiver == null) {
308                return;
309            }
310
311            mHandler.post(new Runnable() {
312                @Override
313                public void run() {
314                    final IBinder b = callbacks.asBinder();
315                    ConnectionRecord connection = mConnections.get(b);
316                    if (connection == null) {
317                        Log.w(TAG, "getMediaItem for callback that isn't registered id=" + mediaId);
318                        return;
319                    }
320                    performLoadItem(mediaId, connection, receiver);
321                }
322            });
323        }
324    }
325
326    @Override
327    public void onCreate() {
328        super.onCreate();
329        mBinder = new ServiceBinder();
330    }
331
332    @Override
333    public IBinder onBind(Intent intent) {
334        if (SERVICE_INTERFACE.equals(intent.getAction())) {
335            return mBinder;
336        }
337        return null;
338    }
339
340    @Override
341    public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
342    }
343
344    /**
345     * Called to get the root information for browsing by a particular client.
346     * <p>
347     * The implementation should verify that the client package has permission
348     * to access browse media information before returning the root id; it
349     * should return null if the client is not allowed to access this
350     * information.
351     * </p>
352     *
353     * @param clientPackageName The package name of the application which is
354     *            requesting access to browse media.
355     * @param clientUid The uid of the application which is requesting access to
356     *            browse media.
357     * @param rootHints An optional bundle of service-specific arguments to send
358     *            to the media browse service when connecting and retrieving the
359     *            root id for browsing, or null if none. The contents of this
360     *            bundle may affect the information returned when browsing.
361     * @return The {@link BrowserRoot} for accessing this app's content or null.
362     * @see BrowserRoot#EXTRA_RECENT
363     * @see BrowserRoot#EXTRA_OFFLINE
364     * @see BrowserRoot#EXTRA_SUGGESTED
365     */
366    public abstract @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName,
367            int clientUid, @Nullable Bundle rootHints);
368
369    /**
370     * Called to get information about the children of a media item.
371     * <p>
372     * Implementations must call {@link Result#sendResult result.sendResult}
373     * with the list of children. If loading the children will be an expensive
374     * operation that should be performed on another thread,
375     * {@link Result#detach result.detach} may be called before returning from
376     * this function, and then {@link Result#sendResult result.sendResult}
377     * called when the loading is complete.
378     * </p><p>
379     * In case the media item does not have any children, call {@link Result#sendResult}
380     * with an empty list. When the given {@code parentId} is invalid, implementations must
381     * call {@link Result#sendResult result.sendResult} with {@code null}, which will invoke
382     * {@link MediaBrowser.SubscriptionCallback#onError}.
383     * </p>
384     *
385     * @param parentId The id of the parent media item whose children are to be
386     *            queried.
387     * @param result The Result to send the list of children to.
388     */
389    public abstract void onLoadChildren(@NonNull String parentId,
390            @NonNull Result<List<MediaBrowser.MediaItem>> result);
391
392    /**
393     * Called to get information about the children of a media item.
394     * <p>
395     * Implementations must call {@link Result#sendResult result.sendResult}
396     * with the list of children. If loading the children will be an expensive
397     * operation that should be performed on another thread,
398     * {@link Result#detach result.detach} may be called before returning from
399     * this function, and then {@link Result#sendResult result.sendResult}
400     * called when the loading is complete.
401     * </p><p>
402     * In case the media item does not have any children, call {@link Result#sendResult}
403     * with an empty list. When the given {@code parentId} is invalid, implementations must
404     * call {@link Result#sendResult result.sendResult} with {@code null}, which will invoke
405     * {@link MediaBrowser.SubscriptionCallback#onError}.
406     * </p>
407     *
408     * @param parentId The id of the parent media item whose children are to be
409     *            queried.
410     * @param result The Result to send the list of children to.
411     * @param options A bundle of service-specific arguments sent from the media
412     *            browse. The information returned through the result should be
413     *            affected by the contents of this bundle.
414     */
415    public void onLoadChildren(@NonNull String parentId,
416            @NonNull Result<List<MediaBrowser.MediaItem>> result, @NonNull Bundle options) {
417        // To support backward compatibility, when the implementation of MediaBrowserService doesn't
418        // override onLoadChildren() with options, onLoadChildren() without options will be used
419        // instead, and the options will be applied in the implementation of result.onResultSent().
420        result.setFlags(RESULT_FLAG_OPTION_NOT_HANDLED);
421        onLoadChildren(parentId, result);
422    }
423
424    /**
425     * Called to get information about a specific media item.
426     * <p>
427     * Implementations must call {@link Result#sendResult result.sendResult}. If
428     * loading the item will be an expensive operation {@link Result#detach
429     * result.detach} may be called before returning from this function, and
430     * then {@link Result#sendResult result.sendResult} called when the item has
431     * been loaded.
432     * </p><p>
433     * When the given {@code itemId} is invalid, implementations must call
434     * {@link Result#sendResult result.sendResult} with {@code null}, which will
435     * invoke {@link MediaBrowser.ItemCallback#onError}.
436     * </p><p>
437     * The default implementation calls {@link Result#sendResult result.sendResult}
438     * with {@code null}.
439     * </p>
440     *
441     * @param itemId The id for the specific
442     *            {@link android.media.browse.MediaBrowser.MediaItem}.
443     * @param result The Result to send the item to.
444     */
445    public void onLoadItem(String itemId, Result<MediaBrowser.MediaItem> result) {
446        result.sendResult(null);
447    }
448
449    /**
450     * Call to set the media session.
451     * <p>
452     * This should be called as soon as possible during the service's startup.
453     * It may only be called once.
454     *
455     * @param token The token for the service's {@link MediaSession}.
456     */
457    public void setSessionToken(final MediaSession.Token token) {
458        if (token == null) {
459            throw new IllegalArgumentException("Session token may not be null.");
460        }
461        if (mSession != null) {
462            throw new IllegalStateException("The session token has already been set.");
463        }
464        mSession = token;
465        mHandler.post(new Runnable() {
466            @Override
467            public void run() {
468                for (IBinder key : mConnections.keySet()) {
469                    ConnectionRecord connection = mConnections.get(key);
470                    try {
471                        connection.callbacks.onConnect(connection.root.getRootId(), token,
472                                connection.root.getExtras());
473                    } catch (RemoteException e) {
474                        Log.w(TAG, "Connection for " + connection.pkg + " is no longer valid.");
475                        mConnections.remove(key);
476                    }
477                }
478            }
479        });
480    }
481
482    /**
483     * Gets the session token, or null if it has not yet been created
484     * or if it has been destroyed.
485     */
486    public @Nullable MediaSession.Token getSessionToken() {
487        return mSession;
488    }
489
490    /**
491     * Gets the root hints sent from the currently connected {@link MediaBrowser}.
492     * The root hints are service-specific arguments included in an optional bundle sent to the
493     * media browser service when connecting and retrieving the root id for browsing, or null if
494     * none. The contents of this bundle may affect the information returned when browsing.
495     *
496     * @throws IllegalStateException If this method is called outside of {@link #onLoadChildren}
497     *             or {@link #onLoadItem}
498     * @see MediaBrowserService.BrowserRoot#EXTRA_RECENT
499     * @see MediaBrowserService.BrowserRoot#EXTRA_OFFLINE
500     * @see MediaBrowserService.BrowserRoot#EXTRA_SUGGESTED
501     */
502    public final Bundle getBrowserRootHints() {
503        if (mCurConnection == null) {
504            throw new IllegalStateException("This should be called inside of onLoadChildren or"
505                    + " onLoadItem methods");
506        }
507        return mCurConnection.rootHints == null ? null : new Bundle(mCurConnection.rootHints);
508    }
509
510    /**
511     * Notifies all connected media browsers that the children of
512     * the specified parent id have changed in some way.
513     * This will cause browsers to fetch subscribed content again.
514     *
515     * @param parentId The id of the parent media item whose
516     * children changed.
517     */
518    public void notifyChildrenChanged(@NonNull String parentId) {
519        notifyChildrenChangedInternal(parentId, null);
520    }
521
522    /**
523     * Notifies all connected media browsers that the children of
524     * the specified parent id have changed in some way.
525     * This will cause browsers to fetch subscribed content again.
526     *
527     * @param parentId The id of the parent media item whose
528     *            children changed.
529     * @param options A bundle of service-specific arguments to send
530     *            to the media browse. The contents of this bundle may
531     *            contain the information about the change.
532     */
533    public void notifyChildrenChanged(@NonNull String parentId, @NonNull Bundle options) {
534        if (options == null) {
535            throw new IllegalArgumentException("options cannot be null in notifyChildrenChanged");
536        }
537        notifyChildrenChangedInternal(parentId, options);
538    }
539
540    private void notifyChildrenChangedInternal(final String parentId, final Bundle options) {
541        if (parentId == null) {
542            throw new IllegalArgumentException("parentId cannot be null in notifyChildrenChanged");
543        }
544        mHandler.post(new Runnable() {
545            @Override
546            public void run() {
547                for (IBinder binder : mConnections.keySet()) {
548                    ConnectionRecord connection = mConnections.get(binder);
549                    List<Pair<IBinder, Bundle>> callbackList =
550                            connection.subscriptions.get(parentId);
551                    if (callbackList != null) {
552                        for (Pair<IBinder, Bundle> callback : callbackList) {
553                            if (MediaBrowserUtils.hasDuplicatedItems(options, callback.second)) {
554                                performLoadChildren(parentId, connection, callback.second);
555                            }
556                        }
557                    }
558                }
559            }
560        });
561    }
562
563    /**
564     * Return whether the given package is one of the ones that is owned by the uid.
565     */
566    private boolean isValidPackage(String pkg, int uid) {
567        if (pkg == null) {
568            return false;
569        }
570        final PackageManager pm = getPackageManager();
571        final String[] packages = pm.getPackagesForUid(uid);
572        final int N = packages.length;
573        for (int i=0; i<N; i++) {
574            if (packages[i].equals(pkg)) {
575                return true;
576            }
577        }
578        return false;
579    }
580
581    /**
582     * Save the subscription and if it is a new subscription send the results.
583     */
584    private void addSubscription(String id, ConnectionRecord connection, IBinder token,
585            Bundle options) {
586        // Save the subscription
587        List<Pair<IBinder, Bundle>> callbackList = connection.subscriptions.get(id);
588        if (callbackList == null) {
589            callbackList = new ArrayList<>();
590        }
591        for (Pair<IBinder, Bundle> callback : callbackList) {
592            if (token == callback.first
593                    && MediaBrowserUtils.areSameOptions(options, callback.second)) {
594                return;
595            }
596        }
597        callbackList.add(new Pair<>(token, options));
598        connection.subscriptions.put(id, callbackList);
599        // send the results
600        performLoadChildren(id, connection, options);
601    }
602
603    /**
604     * Remove the subscription.
605     */
606    private boolean removeSubscription(String id, ConnectionRecord connection, IBinder token) {
607        if (token == null) {
608            return connection.subscriptions.remove(id) != null;
609        }
610        boolean removed = false;
611        List<Pair<IBinder, Bundle>> callbackList = connection.subscriptions.get(id);
612        if (callbackList != null) {
613            for (Pair<IBinder, Bundle> callback : callbackList) {
614                if (token == callback.first) {
615                    removed = true;
616                    callbackList.remove(callback);
617                }
618            }
619            if (callbackList.size() == 0) {
620                connection.subscriptions.remove(id);
621            }
622        }
623        return removed;
624    }
625
626    /**
627     * Call onLoadChildren and then send the results back to the connection.
628     * <p>
629     * Callers must make sure that this connection is still connected.
630     */
631    private void performLoadChildren(final String parentId, final ConnectionRecord connection,
632            final Bundle options) {
633        final Result<List<MediaBrowser.MediaItem>> result
634                = new Result<List<MediaBrowser.MediaItem>>(parentId) {
635            @Override
636            void onResultSent(List<MediaBrowser.MediaItem> list, @ResultFlags int flag) {
637                if (mConnections.get(connection.callbacks.asBinder()) != connection) {
638                    if (DBG) {
639                        Log.d(TAG, "Not sending onLoadChildren result for connection that has"
640                                + " been disconnected. pkg=" + connection.pkg + " id=" + parentId);
641                    }
642                    return;
643                }
644
645                List<MediaBrowser.MediaItem> filteredList =
646                        (flag & RESULT_FLAG_OPTION_NOT_HANDLED) != 0
647                        ? applyOptions(list, options) : list;
648                final ParceledListSlice<MediaBrowser.MediaItem> pls =
649                        filteredList == null ? null : new ParceledListSlice<>(filteredList);
650                try {
651                    connection.callbacks.onLoadChildrenWithOptions(parentId, pls, options);
652                } catch (RemoteException ex) {
653                    // The other side is in the process of crashing.
654                    Log.w(TAG, "Calling onLoadChildren() failed for id=" + parentId
655                            + " package=" + connection.pkg);
656                }
657            }
658        };
659
660        mCurConnection = connection;
661        if (options == null) {
662            onLoadChildren(parentId, result);
663        } else {
664            onLoadChildren(parentId, result, options);
665        }
666        mCurConnection = null;
667
668        if (!result.isDone()) {
669            throw new IllegalStateException("onLoadChildren must call detach() or sendResult()"
670                    + " before returning for package=" + connection.pkg + " id=" + parentId);
671        }
672    }
673
674    private List<MediaBrowser.MediaItem> applyOptions(List<MediaBrowser.MediaItem> list,
675            final Bundle options) {
676        if (list == null) {
677            return null;
678        }
679        int page = options.getInt(MediaBrowser.EXTRA_PAGE, -1);
680        int pageSize = options.getInt(MediaBrowser.EXTRA_PAGE_SIZE, -1);
681        if (page == -1 && pageSize == -1) {
682            return list;
683        }
684        int fromIndex = pageSize * page;
685        int toIndex = fromIndex + pageSize;
686        if (page < 0 || pageSize < 1 || fromIndex >= list.size()) {
687            return Collections.EMPTY_LIST;
688        }
689        if (toIndex > list.size()) {
690            toIndex = list.size();
691        }
692        return list.subList(fromIndex, toIndex);
693    }
694
695    private void performLoadItem(String itemId, final ConnectionRecord connection,
696            final ResultReceiver receiver) {
697        final Result<MediaBrowser.MediaItem> result =
698                new Result<MediaBrowser.MediaItem>(itemId) {
699            @Override
700            void onResultSent(MediaBrowser.MediaItem item, @ResultFlags int flag) {
701                Bundle bundle = new Bundle();
702                bundle.putParcelable(KEY_MEDIA_ITEM, item);
703                receiver.send(0, bundle);
704            }
705        };
706
707        mCurConnection = connection;
708        onLoadItem(itemId, result);
709        mCurConnection = null;
710
711        if (!result.isDone()) {
712            throw new IllegalStateException("onLoadItem must call detach() or sendResult()"
713                    + " before returning for id=" + itemId);
714        }
715    }
716
717    /**
718     * Contains information that the browser service needs to send to the client
719     * when first connected.
720     */
721    public static final class BrowserRoot {
722        /**
723         * The lookup key for a boolean that indicates whether the browser service should return a
724         * browser root for recently played media items.
725         *
726         * <p>When creating a media browser for a given media browser service, this key can be
727         * supplied as a root hint for retrieving media items that are recently played.
728         * If the media browser service can provide such media items, the implementation must return
729         * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back.
730         *
731         * <p>The root hint may contain multiple keys.
732         *
733         * @see #EXTRA_OFFLINE
734         * @see #EXTRA_SUGGESTED
735         */
736        public static final String EXTRA_RECENT = "android.service.media.extra.RECENT";
737
738        /**
739         * The lookup key for a boolean that indicates whether the browser service should return a
740         * browser root for offline media items.
741         *
742         * <p>When creating a media browser for a given media browser service, this key can be
743         * supplied as a root hint for retrieving media items that are can be played without an
744         * internet connection.
745         * If the media browser service can provide such media items, the implementation must return
746         * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back.
747         *
748         * <p>The root hint may contain multiple keys.
749         *
750         * @see #EXTRA_RECENT
751         * @see #EXTRA_SUGGESTED
752         */
753        public static final String EXTRA_OFFLINE = "android.service.media.extra.OFFLINE";
754
755        /**
756         * The lookup key for a boolean that indicates whether the browser service should return a
757         * browser root for suggested media items.
758         *
759         * <p>When creating a media browser for a given media browser service, this key can be
760         * supplied as a root hint for retrieving the media items suggested by the media browser
761         * service. The list of media items passed in {@link android.media.browse.MediaBrowser.SubscriptionCallback#onChildrenLoaded(String, List)}
762         * is considered ordered by relevance, first being the top suggestion.
763         * If the media browser service can provide such media items, the implementation must return
764         * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back.
765         *
766         * <p>The root hint may contain multiple keys.
767         *
768         * @see #EXTRA_RECENT
769         * @see #EXTRA_OFFLINE
770         */
771        public static final String EXTRA_SUGGESTED = "android.service.media.extra.SUGGESTED";
772
773        final private String mRootId;
774        final private Bundle mExtras;
775
776        /**
777         * Constructs a browser root.
778         * @param rootId The root id for browsing.
779         * @param extras Any extras about the browser service.
780         */
781        public BrowserRoot(@NonNull String rootId, @Nullable Bundle extras) {
782            if (rootId == null) {
783                throw new IllegalArgumentException("The root id in BrowserRoot cannot be null. " +
784                        "Use null for BrowserRoot instead.");
785            }
786            mRootId = rootId;
787            mExtras = extras;
788        }
789
790        /**
791         * Gets the root id for browsing.
792         */
793        public String getRootId() {
794            return mRootId;
795        }
796
797        /**
798         * Gets any extras about the browser service.
799         */
800        public Bundle getExtras() {
801            return mExtras;
802        }
803    }
804}
805