MediaBrowser.java revision 17d47989ee53c9e54f250d29a343ba949edf0ff9
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.media.browse;
18
19import android.annotation.NonNull;
20import android.content.ComponentName;
21import android.content.Context;
22import android.content.Intent;
23import android.content.ServiceConnection;
24import android.content.pm.ParceledListSlice;
25import android.graphics.Bitmap;
26import android.media.session.MediaSession;
27import android.net.Uri;
28import android.os.Bundle;
29import android.os.Handler;
30import android.os.IBinder;
31import android.os.RemoteException;
32import android.util.ArrayMap;
33import android.util.Log;
34
35import java.lang.ref.WeakReference;
36import java.util.ArrayList;
37import java.util.Collections;
38import java.util.HashMap;
39import java.util.List;
40
41/**
42 * Browses media content offered by a link MediaBrowserService.
43 * <p>
44 * This object is not thread-safe. All calls should happen on the thread on which the browser
45 * was constructed.
46 * </p>
47 */
48public final class MediaBrowser {
49    private static final String TAG = "MediaBrowser";
50    private static final boolean DBG = false;
51
52    private static final int CONNECT_STATE_DISCONNECTED = 0;
53    private static final int CONNECT_STATE_CONNECTING = 1;
54    private static final int CONNECT_STATE_CONNECTED = 2;
55    private static final int CONNECT_STATE_SUSPENDED = 3;
56
57    private final Context mContext;
58    private final ComponentName mServiceComponent;
59    private final ConnectionCallback mCallback;
60    private final Bundle mRootHints;
61    private final Handler mHandler = new Handler();
62    private final ArrayMap<Uri,Subscription> mSubscriptions =
63            new ArrayMap<Uri, MediaBrowser.Subscription>();
64
65    private int mState = CONNECT_STATE_DISCONNECTED;
66    private MediaServiceConnection mServiceConnection;
67    private IMediaBrowserService mServiceBinder;
68    private IMediaBrowserServiceCallbacks mServiceCallbacks;
69    private Uri mRootUri;
70    private MediaSession.Token mMediaSessionToken;
71
72    /**
73     * Creates a media browser for the specified media browse service.
74     *
75     * @param context The context.
76     * @param serviceComponent The component name of the media browse service.
77     * @param callback The connection callback.
78     * @param rootHints An optional bundle of service-specific arguments to send
79     * to the media browse service when connecting and retrieving the root uri
80     * for browsing, or null if none.  The contents of this bundle may affect
81     * the information returned when browsing.
82     */
83    public MediaBrowser(Context context, ComponentName serviceComponent,
84            ConnectionCallback callback, Bundle rootHints) {
85        if (context == null) {
86            throw new IllegalArgumentException("context must not be null");
87        }
88        if (serviceComponent == null) {
89            throw new IllegalArgumentException("service component must not be null");
90        }
91        if (callback == null) {
92            throw new IllegalArgumentException("connection callback must not be null");
93        }
94        mContext = context;
95        mServiceComponent = serviceComponent;
96        mCallback = callback;
97        mRootHints = rootHints;
98    }
99
100    /**
101     * Connects to the media browse service.
102     * <p>
103     * The connection callback specified in the constructor will be invoked
104     * when the connection completes or fails.
105     * </p>
106     */
107    public void connect() {
108        if (mState != CONNECT_STATE_DISCONNECTED) {
109            throw new IllegalStateException("connect() called while not disconnected (state="
110                    + getStateLabel(mState) + ")");
111        }
112        // TODO: remove this extra check.
113        if (DBG) {
114            if (mServiceConnection != null) {
115                throw new RuntimeException("mServiceConnection should be null. Instead it is "
116                        + mServiceConnection);
117            }
118        }
119        if (mServiceBinder != null) {
120            throw new RuntimeException("mServiceBinder should be null. Instead it is "
121                    + mServiceBinder);
122        }
123        if (mServiceCallbacks != null) {
124            throw new RuntimeException("mServiceCallbacks should be null. Instead it is "
125                    + mServiceCallbacks);
126        }
127
128        mState = CONNECT_STATE_CONNECTING;
129
130        final Intent intent = new Intent(MediaBrowserService.SERVICE_ACTION);
131        intent.setComponent(mServiceComponent);
132
133        final ServiceConnection thisConnection = mServiceConnection = new MediaServiceConnection();
134
135        try {
136            mContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
137        } catch (Exception ex) {
138            Log.e(TAG, "Failed binding to service " + mServiceComponent);
139
140            // Tell them that it didn't work.  We are already on the main thread,
141            // but we don't want to do callbacks inside of connect().  So post it,
142            // and then check that we are on the same ServiceConnection.  We know
143            // we won't also get an onServiceConnected or onServiceDisconnected,
144            // so we won't be doing double callbacks.
145            mHandler.post(new Runnable() {
146                    @Override
147                    public void run() {
148                        // Ensure that nobody else came in or tried to connect again.
149                        if (thisConnection == mServiceConnection) {
150                            forceCloseConnection();
151                            mCallback.onConnectionFailed();
152                        }
153                    }
154                });
155        }
156
157        if (DBG) {
158            Log.d(TAG, "connect...");
159            dump();
160        }
161    }
162
163    /**
164     * Disconnects from the media browse service.
165     * @more
166     * After this, no more callbacks will be received.
167     */
168    public void disconnect() {
169        // It's ok to call this any state, because allowing this lets apps not have
170        // to check isConnected() unnecessarily.  They won't appreciate the extra
171        // assertions for this.  We do everything we can here to go back to a sane state.
172        if (mServiceCallbacks != null) {
173            try {
174                mServiceBinder.disconnect(mServiceCallbacks);
175            } catch (RemoteException ex) {
176                // We are disconnecting anyway.  Log, just for posterity but it's not
177                // a big problem.
178                Log.w(TAG, "RemoteException during connect for " + mServiceComponent);
179            }
180        }
181        forceCloseConnection();
182
183        if (DBG) {
184            Log.d(TAG, "disconnect...");
185            dump();
186        }
187    }
188
189    /**
190     * Null out the variables and unbind from the service.  This doesn't include
191     * calling disconnect on the service, because we only try to do that in the
192     * clean shutdown cases.
193     * <p>
194     * Everywhere that calls this EXCEPT for disconnect() should follow it with
195     * a call to mCallback.onConnectionFailed().  Disconnect doesn't do that callback
196     * for a clean shutdown, but everywhere else is a dirty shutdown and should
197     * notify the app.
198     */
199    private void forceCloseConnection() {
200        if (mServiceConnection != null) {
201            mContext.unbindService(mServiceConnection);
202        }
203        mState = CONNECT_STATE_DISCONNECTED;
204        mServiceConnection = null;
205        mServiceBinder = null;
206        mServiceCallbacks = null;
207        mRootUri = null;
208        mMediaSessionToken = null;
209    }
210
211    /**
212     * Returns whether the browser is connected to the service.
213     */
214    public boolean isConnected() {
215        return mState == CONNECT_STATE_CONNECTED;
216    }
217
218    /**
219     * Gets the root Uri.
220     * <p>
221     * Note that the root uri may become invalid or change when when the
222     * browser is disconnected.
223     * </p>
224     *
225     * @throws IllegalStateException if not connected.
226      */
227    public @NonNull Uri getRoot() {
228        if (mState != CONNECT_STATE_CONNECTED) {
229            throw new IllegalStateException("getSessionToken() called while not connected (state="
230                    + getStateLabel(mState) + ")");
231        }
232        return mRootUri;
233    }
234
235    /**
236     * Gets the media session token associated with the media browser.
237     * <p>
238     * Note that the session token may become invalid or change when when the
239     * browser is disconnected.
240     * </p>
241     *
242     * @return The session token for the browser, never null.
243     *
244     * @throws IllegalStateException if not connected.
245     */
246     public @NonNull MediaSession.Token getSessionToken() {
247        if (mState != CONNECT_STATE_CONNECTED) {
248            throw new IllegalStateException("getSessionToken() called while not connected (state="
249                    + mState + ")");
250        }
251        return mMediaSessionToken;
252    }
253
254    /**
255     * Queries for information about the media items that are contained within
256     * the specified Uri and subscribes to receive updates when they change.
257     * <p>
258     * The list of subscriptions is maintained even when not connected and is
259     * restored after reconnection.  It is ok to subscribe while not connected
260     * but the results will not be returned until the connection completes.
261     * </p><p>
262     * If the uri is already subscribed with a different callback then the new
263     * callback will replace the previous one.
264     * </p>
265     *
266     * @param parentUri The uri of the parent media item whose list of children
267     * will be subscribed.
268     * @param callback The callback to receive the list of children.
269     */
270    public void subscribe(@NonNull Uri parentUri, @NonNull SubscriptionCallback callback) {
271        // Check arguments.
272        if (parentUri == null) {
273            throw new IllegalArgumentException("parentUri is null");
274        }
275        if (callback == null) {
276            throw new IllegalArgumentException("callback is null");
277        }
278
279        // Update or create the subscription.
280        Subscription sub = mSubscriptions.get(parentUri);
281        boolean newSubscription = sub == null;
282        if (newSubscription) {
283            sub = new Subscription(parentUri);
284            mSubscriptions.put(parentUri, sub);
285        }
286        sub.callback = callback;
287
288        // If we are connected, tell the service that we are watching.  If we aren't
289        // connected, the service will be told when we connect.
290        if (mState == CONNECT_STATE_CONNECTED && newSubscription) {
291            try {
292                mServiceBinder.addSubscription(parentUri, mServiceCallbacks);
293            } catch (RemoteException ex) {
294                // Process is crashing.  We will disconnect, and upon reconnect we will
295                // automatically reregister. So nothing to do here.
296                Log.d(TAG, "addSubscription failed with RemoteException parentUri=" + parentUri);
297            }
298        }
299    }
300
301    /**
302     * Unsubscribes for changes to the children of the specified Uri.
303     * <p>
304     * The query callback will no longer be invoked for results associated with
305     * this Uri once this method returns.
306     * </p>
307     *
308     * @param parentUri The uri of the parent media item whose list of children
309     * will be unsubscribed.
310     */
311    public void unsubscribe(@NonNull Uri parentUri) {
312        // Check arguments.
313        if (parentUri == null) {
314            throw new IllegalArgumentException("parentUri is null");
315        }
316
317        // Remove from our list.
318        final Subscription sub = mSubscriptions.remove(parentUri);
319
320        // Tell the service if necessary.
321        if (mState == CONNECT_STATE_CONNECTED && sub != null) {
322            try {
323                mServiceBinder.removeSubscription(parentUri, mServiceCallbacks);
324            } catch (RemoteException ex) {
325                // Process is crashing.  We will disconnect, and upon reconnect we will
326                // automatically reregister. So nothing to do here.
327                Log.d(TAG, "removeSubscription failed with RemoteException parentUri=" + parentUri);
328            }
329        }
330    }
331
332    /**
333     * Loads the thumbnail of a media item.
334     *
335     * @param uri The uri of the media item.
336     * @param width The preferred width of the icon in dp.
337     * @param height The preferred width of the icon in dp.
338     * @param density The preferred density of the icon. Must be one of the android
339     *      density buckets.
340     * @param callback The callback to receive the thumbnail.
341     *
342     * @throws IllegalStateException if not connected. TODO: Is this restriction necessary?
343     */
344    public void loadThumbnail(@NonNull Uri uri, int width, int height, int density,
345            @NonNull ThumbnailCallback callback) {
346        throw new RuntimeException("implement me");
347    }
348
349    /**
350     * For debugging.
351     */
352    private static String getStateLabel(int state) {
353        switch (state) {
354            case CONNECT_STATE_DISCONNECTED:
355                return "CONNECT_STATE_DISCONNECTED";
356            case CONNECT_STATE_CONNECTING:
357                return "CONNECT_STATE_CONNECTING";
358            case CONNECT_STATE_CONNECTED:
359                return "CONNECT_STATE_CONNECTED";
360            case CONNECT_STATE_SUSPENDED:
361                return "CONNECT_STATE_SUSPENDED";
362            default:
363                return "UNKNOWN/" + state;
364        }
365    }
366
367    private final void onServiceConnected(final IMediaBrowserServiceCallbacks callback,
368            final Uri root, final MediaSession.Token session) {
369        mHandler.post(new Runnable() {
370            @Override
371            public void run() {
372                // Check to make sure there hasn't been a disconnect or a different
373                // ServiceConnection.
374                if (!isCurrent(callback, "onConnect")) {
375                    return;
376                }
377                // Don't allow them to call us twice.
378                if (mState != CONNECT_STATE_CONNECTING) {
379                    Log.w(TAG, "onConnect from service while mState="
380                            + getStateLabel(mState) + "... ignoring");
381                    return;
382                }
383                mRootUri = root;
384                mMediaSessionToken = session;
385                mState = CONNECT_STATE_CONNECTED;
386
387                if (DBG) {
388                    Log.d(TAG, "ServiceCallbacks.onConnect...");
389                    dump();
390                }
391                mCallback.onConnected();
392
393                // we may receive some subscriptions before we are connected, so re-subscribe
394                // everything now
395                for (Uri uri : mSubscriptions.keySet()) {
396                    try {
397                        mServiceBinder.addSubscription(uri, mServiceCallbacks);
398                    } catch (RemoteException ex) {
399                        // Process is crashing.  We will disconnect, and upon reconnect we will
400                        // automatically reregister. So nothing to do here.
401                        Log.d(TAG, "addSubscription failed with RemoteException parentUri=" + uri);
402                    }
403                }
404
405            }
406        });
407    }
408
409    private final void onConnectionFailed(final IMediaBrowserServiceCallbacks callback) {
410        mHandler.post(new Runnable() {
411            @Override
412            public void run() {
413                Log.e(TAG, "onConnectFailed for " + mServiceComponent);
414
415                // Check to make sure there hasn't been a disconnect or a different
416                // ServiceConnection.
417                if (!isCurrent(callback, "onConnectFailed")) {
418                    return;
419                }
420                // Don't allow them to call us twice.
421                if (mState != CONNECT_STATE_CONNECTING) {
422                    Log.w(TAG, "onConnect from service while mState="
423                            + getStateLabel(mState) + "... ignoring");
424                    return;
425                }
426
427                // Clean up
428                forceCloseConnection();
429
430                // Tell the app.
431                mCallback.onConnectionFailed();
432            }
433        });
434    }
435
436    private final void onLoadChildren(final IMediaBrowserServiceCallbacks callback, final Uri uri,
437            final ParceledListSlice list) {
438        mHandler.post(new Runnable() {
439            @Override
440            public void run() {
441                // Check that there hasn't been a disconnect or a different
442                // ServiceConnection.
443                if (!isCurrent(callback, "onLoadChildren")) {
444                    return;
445                }
446
447                List<MediaBrowserItem> data = list.getList();
448                if (DBG) {
449                    Log.d(TAG, "onLoadChildren for " + mServiceComponent + " uri=" + uri);
450                }
451                if (data == null) {
452                    data = Collections.emptyList();
453                }
454
455                // Check that the subscription is still subscribed.
456                final Subscription subscription = mSubscriptions.get(uri);
457                if (subscription == null) {
458                    if (DBG) {
459                        Log.d(TAG, "onLoadChildren for uri that isn't subscribed uri="
460                                + uri);
461                    }
462                    return;
463                }
464
465                // Tell the app.
466                subscription.callback.onChildrenLoaded(uri, data);
467            }
468        });
469    }
470
471
472    /**
473     * Return true if {@code callback} is the current ServiceCallbacks.  Also logs if it's not.
474     */
475    private boolean isCurrent(IMediaBrowserServiceCallbacks callback, String funcName) {
476        if (mServiceCallbacks != callback) {
477            if (mState != CONNECT_STATE_DISCONNECTED) {
478                Log.i(TAG, funcName + " for " + mServiceComponent + " with mServiceConnection="
479                        + mServiceCallbacks + " this=" + this);
480            }
481            return false;
482        }
483        return true;
484    }
485
486    private ServiceCallbacks getNewServiceCallbacks() {
487        return new ServiceCallbacks(this);
488    }
489
490    /**
491     * Log internal state.
492     * @hide
493     */
494    void dump() {
495        Log.d(TAG, "MediaBrowser...");
496        Log.d(TAG, "  mServiceComponent=" + mServiceComponent);
497        Log.d(TAG, "  mCallback=" + mCallback);
498        Log.d(TAG, "  mRootHints=" + mRootHints);
499        Log.d(TAG, "  mState=" + getStateLabel(mState));
500        Log.d(TAG, "  mServiceConnection=" + mServiceConnection);
501        Log.d(TAG, "  mServiceBinder=" + mServiceBinder);
502        Log.d(TAG, "  mServiceCallbacks=" + mServiceCallbacks);
503        Log.d(TAG, "  mRootUri=" + mRootUri);
504        Log.d(TAG, "  mMediaSessionToken=" + mMediaSessionToken);
505    }
506
507
508    /**
509     * Callbacks for connection related events.
510     */
511    public static class ConnectionCallback {
512        /**
513         * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed.
514         */
515        public void onConnected() {
516        }
517
518        /**
519         * Invoked when the client is disconnected from the media browser.
520         */
521        public void onConnectionSuspended() {
522        }
523
524        /**
525         * Invoked when the connection to the media browser failed.
526         */
527        public void onConnectionFailed() {
528        }
529    }
530
531    /**
532     * Callbacks for subscription related events.
533     */
534    public static abstract class SubscriptionCallback {
535        /**
536         * Called when the list of children is loaded or updated.
537         */
538        public void onChildrenLoaded(@NonNull Uri parentUri,
539                                     @NonNull List<MediaBrowserItem> children) {
540        }
541
542        /**
543         * Called when the Uri doesn't exist or other errors in subscribing.
544         * <p>
545         * If this is called, the subscription remains until {@link MediaBrowser#unsubscribe}
546         * called, because some errors may heal themselves.
547         * </p>
548         */
549        public void onError(@NonNull Uri uri) {
550        }
551    }
552
553    /**
554     * Callbacks for thumbnail loading.
555     */
556    public static abstract class ThumbnailCallback {
557        /**
558         * Called when the thumbnail is loaded.
559         */
560        public void onThumbnailLoaded(@NonNull Uri uri, @NonNull Bitmap bitmap) {
561        }
562
563        /**
564         * Called when the Uri doesn’t exist or the bitmap cannot be loaded.
565         */
566        public void onError(@NonNull Uri uri) {
567        }
568    }
569
570    /**
571     * ServiceConnection to the other app.
572     */
573    private class MediaServiceConnection implements ServiceConnection {
574        @Override
575        public void onServiceConnected(ComponentName name, IBinder binder) {
576            if (DBG) {
577                Log.d(TAG, "MediaServiceConnection.onServiceConnected name=" + name
578                        + " binder=" + binder);
579                dump();
580            }
581
582            // Make sure we are still the current connection, and that they haven't called
583            // disconnect().
584            if (!isCurrent("onServiceConnected")) {
585                return;
586            }
587
588            // Save their binder
589            mServiceBinder = IMediaBrowserService.Stub.asInterface(binder);
590
591            // We make a new mServiceCallbacks each time we connect so that we can drop
592            // responses from previous connections.
593            mServiceCallbacks = getNewServiceCallbacks();
594
595            // Call connect, which is async. When we get a response from that we will
596            // say that we're connected.
597            try {
598                if (DBG) {
599                    Log.d(TAG, "ServiceCallbacks.onConnect...");
600                    dump();
601                }
602                mServiceBinder.connect(mContext.getPackageName(), mRootHints, mServiceCallbacks);
603            } catch (RemoteException ex) {
604                // Connect failed, which isn't good. But the auto-reconnect on the service
605                // will take over and we will come back.  We will also get the
606                // onServiceDisconnected, which has all the cleanup code.  So let that do it.
607                Log.w(TAG, "RemoteException during connect for " + mServiceComponent);
608                if (DBG) {
609                    Log.d(TAG, "ServiceCallbacks.onConnect...");
610                    dump();
611                }
612            }
613        }
614
615        @Override
616        public void onServiceDisconnected(ComponentName name) {
617            if (DBG) {
618                Log.d(TAG, "MediaServiceConnection.onServiceDisconnected name=" + name
619                        + " this=" + this + " mServiceConnection=" + mServiceConnection);
620                dump();
621            }
622
623            // Make sure we are still the current connection, and that they haven't called
624            // disconnect().
625            if (!isCurrent("onServiceDisconnected")) {
626                return;
627            }
628
629            // Clear out what we set in onServiceConnected
630            mServiceBinder = null;
631            mServiceCallbacks = null;
632
633            // And tell the app that it's suspended.
634            mState = CONNECT_STATE_SUSPENDED;
635            mCallback.onConnectionSuspended();
636        }
637
638        /**
639         * Return true if this is the current ServiceConnection.  Also logs if it's not.
640         */
641        private boolean isCurrent(String funcName) {
642            if (mServiceConnection != this) {
643                if (mState != CONNECT_STATE_DISCONNECTED) {
644                    // Check mState, because otherwise this log is noisy.
645                    Log.i(TAG, funcName + " for " + mServiceComponent + " with mServiceConnection="
646                            + mServiceConnection + " this=" + this);
647                }
648                return false;
649            }
650            return true;
651        }
652    };
653
654    /**
655     * Callbacks from the service.
656     */
657    private static class ServiceCallbacks extends IMediaBrowserServiceCallbacks.Stub {
658        private WeakReference<MediaBrowser> mMediaBrowser;
659
660        public ServiceCallbacks(MediaBrowser mediaBrowser) {
661            mMediaBrowser = new WeakReference<MediaBrowser>(mediaBrowser);
662        }
663
664        /**
665         * The other side has acknowledged our connection.  The parameters to this function
666         * are the initial data as requested.
667         */
668        @Override
669        public void onConnect(final Uri root, final MediaSession.Token session) {
670            MediaBrowser mediaBrowser = mMediaBrowser.get();
671            if (mediaBrowser != null) {
672                mediaBrowser.onServiceConnected(this, root, session);
673            }
674        }
675
676        /**
677         * The other side does not like us.  Tell the app via onConnectionFailed.
678         */
679        @Override
680        public void onConnectFailed() {
681            MediaBrowser mediaBrowser = mMediaBrowser.get();
682            if (mediaBrowser != null) {
683                mediaBrowser.onConnectionFailed(this);
684            }
685        }
686
687        @Override
688        public void onLoadChildren(final Uri uri, final ParceledListSlice list) {
689            MediaBrowser mediaBrowser = mMediaBrowser.get();
690            if (mediaBrowser != null) {
691                mediaBrowser.onLoadChildren(this, uri, list);
692            }
693        }
694    }
695
696    private static class Subscription {
697        final Uri uri;
698        SubscriptionCallback callback;
699
700        Subscription(Uri u) {
701            this.uri = u;
702        }
703    }
704}
705