1/*
2 * Copyright (C) 2013 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 */
16package android.support.v7.media;
17
18import static android.support.v4.utils.ObjectUtils.objectEquals;
19
20import android.app.PendingIntent;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.Intent;
24import android.content.IntentFilter;
25import android.net.Uri;
26import android.os.Bundle;
27import android.util.Log;
28
29/**
30 * A helper class for playing media on remote routes using the remote playback protocol
31 * defined by {@link MediaControlIntent}.
32 * <p>
33 * The client maintains session state and offers a simplified interface for issuing
34 * remote playback media control intents to a single route.
35 * </p>
36 */
37public class RemotePlaybackClient {
38    static final String TAG = "RemotePlaybackClient";
39    static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
40
41    private final Context mContext;
42    private final MediaRouter.RouteInfo mRoute;
43    private final ActionReceiver mActionReceiver;
44    private final PendingIntent mItemStatusPendingIntent;
45    private final PendingIntent mSessionStatusPendingIntent;
46    private final PendingIntent mMessagePendingIntent;
47
48    private boolean mRouteSupportsRemotePlayback;
49    private boolean mRouteSupportsQueuing;
50    private boolean mRouteSupportsSessionManagement;
51    private boolean mRouteSupportsMessaging;
52
53    String mSessionId;
54    StatusCallback mStatusCallback;
55    OnMessageReceivedListener mOnMessageReceivedListener;
56
57    /**
58     * Creates a remote playback client for a route.
59     *
60     * @param route The media route.
61     */
62    public RemotePlaybackClient(Context context, MediaRouter.RouteInfo route) {
63        if (context == null) {
64            throw new IllegalArgumentException("context must not be null");
65        }
66        if (route == null) {
67            throw new IllegalArgumentException("route must not be null");
68        }
69
70        mContext = context;
71        mRoute = route;
72
73        IntentFilter actionFilter = new IntentFilter();
74        actionFilter.addAction(ActionReceiver.ACTION_ITEM_STATUS_CHANGED);
75        actionFilter.addAction(ActionReceiver.ACTION_SESSION_STATUS_CHANGED);
76        actionFilter.addAction(ActionReceiver.ACTION_MESSAGE_RECEIVED);
77        mActionReceiver = new ActionReceiver();
78        context.registerReceiver(mActionReceiver, actionFilter);
79
80        Intent itemStatusIntent = new Intent(ActionReceiver.ACTION_ITEM_STATUS_CHANGED);
81        itemStatusIntent.setPackage(context.getPackageName());
82        mItemStatusPendingIntent = PendingIntent.getBroadcast(
83                context, 0, itemStatusIntent, 0);
84
85        Intent sessionStatusIntent = new Intent(ActionReceiver.ACTION_SESSION_STATUS_CHANGED);
86        sessionStatusIntent.setPackage(context.getPackageName());
87        mSessionStatusPendingIntent = PendingIntent.getBroadcast(
88                context, 0, sessionStatusIntent, 0);
89
90        Intent messageIntent = new Intent(ActionReceiver.ACTION_MESSAGE_RECEIVED);
91        messageIntent.setPackage(context.getPackageName());
92        mMessagePendingIntent = PendingIntent.getBroadcast(
93                context, 0, messageIntent, 0);
94        detectFeatures();
95    }
96
97    /**
98     * Releases resources owned by the client.
99     */
100    public void release() {
101        mContext.unregisterReceiver(mActionReceiver);
102    }
103
104    /**
105     * Returns true if the route supports remote playback.
106     * <p>
107     * If the route does not support remote playback, then none of the functionality
108     * offered by the client will be available.
109     * </p><p>
110     * This method returns true if the route supports all of the following
111     * actions: {@link MediaControlIntent#ACTION_PLAY play},
112     * {@link MediaControlIntent#ACTION_SEEK seek},
113     * {@link MediaControlIntent#ACTION_GET_STATUS get status},
114     * {@link MediaControlIntent#ACTION_PAUSE pause},
115     * {@link MediaControlIntent#ACTION_RESUME resume},
116     * {@link MediaControlIntent#ACTION_STOP stop}.
117     * </p>
118     *
119     * @return True if remote playback is supported.
120     */
121    public boolean isRemotePlaybackSupported() {
122        return mRouteSupportsRemotePlayback;
123    }
124
125    /**
126     * Returns true if the route supports queuing features.
127     * <p>
128     * If the route does not support queuing, then at most one media item can be played
129     * at a time and the {@link #enqueue} method will not be available.
130     * </p><p>
131     * This method returns true if the route supports all of the basic remote playback
132     * actions and all of the following actions:
133     * {@link MediaControlIntent#ACTION_ENQUEUE enqueue},
134     * {@link MediaControlIntent#ACTION_REMOVE remove}.
135     * </p>
136     *
137     * @return True if queuing is supported.  Implies {@link #isRemotePlaybackSupported}
138     * is also true.
139     *
140     * @see #isRemotePlaybackSupported
141     */
142    public boolean isQueuingSupported() {
143        return mRouteSupportsQueuing;
144    }
145
146    /**
147     * Returns true if the route supports session management features.
148     * <p>
149     * If the route does not support session management, then the session will
150     * not be created until the first media item is played.
151     * </p><p>
152     * This method returns true if the route supports all of the basic remote playback
153     * actions and all of the following actions:
154     * {@link MediaControlIntent#ACTION_START_SESSION start session},
155     * {@link MediaControlIntent#ACTION_GET_SESSION_STATUS get session status},
156     * {@link MediaControlIntent#ACTION_END_SESSION end session}.
157     * </p>
158     *
159     * @return True if session management is supported.
160     * Implies {@link #isRemotePlaybackSupported} is also true.
161     *
162     * @see #isRemotePlaybackSupported
163     */
164    public boolean isSessionManagementSupported() {
165        return mRouteSupportsSessionManagement;
166    }
167
168    /**
169     * Returns true if the route supports messages.
170     * <p>
171     * This method returns true if the route supports all of the basic remote playback
172     * actions and all of the following actions:
173     * {@link MediaControlIntent#ACTION_START_SESSION start session},
174     * {@link MediaControlIntent#ACTION_SEND_MESSAGE send message},
175     * {@link MediaControlIntent#ACTION_END_SESSION end session}.
176     * </p>
177     *
178     * @return True if session management is supported.
179     * Implies {@link #isRemotePlaybackSupported} is also true.
180     *
181     * @see #isRemotePlaybackSupported
182     */
183    public boolean isMessagingSupported() {
184        return mRouteSupportsMessaging;
185    }
186
187    /**
188     * Gets the current session id if there is one.
189     *
190     * @return The current session id, or null if none.
191     */
192    public String getSessionId() {
193        return mSessionId;
194    }
195
196    /**
197     * Sets the current session id.
198     * <p>
199     * It is usually not necessary to set the session id explicitly since
200     * it is created as a side-effect of other requests such as
201     * {@link #play}, {@link #enqueue}, and {@link #startSession}.
202     * </p>
203     *
204     * @param sessionId The new session id, or null if none.
205     */
206    public void setSessionId(String sessionId) {
207        if (!objectEquals(mSessionId, sessionId)) {
208            if (DEBUG) {
209                Log.d(TAG, "Session id is now: " + sessionId);
210            }
211            mSessionId = sessionId;
212            if (mStatusCallback != null) {
213                mStatusCallback.onSessionChanged(sessionId);
214            }
215        }
216    }
217
218    /**
219     * Returns true if the client currently has a session.
220     * <p>
221     * Equivalent to checking whether {@link #getSessionId} returns a non-null result.
222     * </p>
223     *
224     * @return True if there is a current session.
225     */
226    public boolean hasSession() {
227        return mSessionId != null;
228    }
229
230    /**
231     * Sets a callback that should receive status updates when the state of
232     * media sessions or media items created by this instance of the remote
233     * playback client changes.
234     * <p>
235     * The callback should be set before the session is created or any play
236     * commands are issued.
237     * </p>
238     *
239     * @param callback The callback to set.  May be null to remove the previous callback.
240     */
241    public void setStatusCallback(StatusCallback callback) {
242        mStatusCallback = callback;
243    }
244
245    /**
246     * Sets a callback that should receive messages when a message is sent from
247     * media sessions created by this instance of the remote playback client changes.
248     * <p>
249     * The callback should be set before the session is created.
250     * </p>
251     *
252     * @param listener The callback to set.  May be null to remove the previous callback.
253     */
254    public void setOnMessageReceivedListener(OnMessageReceivedListener listener) {
255        mOnMessageReceivedListener = listener;
256    }
257
258    /**
259     * Sends a request to play a media item.
260     * <p>
261     * Clears the queue and starts playing the new item immediately.  If the queue
262     * was previously paused, then it is resumed as a side-effect of this request.
263     * </p><p>
264     * The request is issued in the current session.  If no session is available, then
265     * one is created implicitly.
266     * </p><p>
267     * Please refer to {@link MediaControlIntent#ACTION_PLAY ACTION_PLAY} for
268     * more information about the semantics of this request.
269     * </p>
270     *
271     * @param contentUri The content Uri to play.
272     * @param mimeType The mime type of the content, or null if unknown.
273     * @param positionMillis The initial content position for the item in milliseconds,
274     * or <code>0</code> to start at the beginning.
275     * @param metadata The media item metadata bundle, or null if none.
276     * @param extras A bundle of extra arguments to be added to the
277     * {@link MediaControlIntent#ACTION_PLAY} intent, or null if none.
278     * @param callback A callback to invoke when the request has been
279     * processed, or null if none.
280     *
281     * @throws UnsupportedOperationException if the route does not support remote playback.
282     *
283     * @see MediaControlIntent#ACTION_PLAY
284     * @see #isRemotePlaybackSupported
285     */
286    public void play(Uri contentUri, String mimeType, Bundle metadata,
287            long positionMillis, Bundle extras, ItemActionCallback callback) {
288        playOrEnqueue(contentUri, mimeType, metadata, positionMillis,
289                extras, callback, MediaControlIntent.ACTION_PLAY);
290    }
291
292    /**
293     * Sends a request to enqueue a media item.
294     * <p>
295     * Enqueues a new item to play.  If the queue was previously paused, then will
296     * remain paused.
297     * </p><p>
298     * The request is issued in the current session.  If no session is available, then
299     * one is created implicitly.
300     * </p><p>
301     * Please refer to {@link MediaControlIntent#ACTION_ENQUEUE ACTION_ENQUEUE} for
302     * more information about the semantics of this request.
303     * </p>
304     *
305     * @param contentUri The content Uri to enqueue.
306     * @param mimeType The mime type of the content, or null if unknown.
307     * @param positionMillis The initial content position for the item in milliseconds,
308     * or <code>0</code> to start at the beginning.
309     * @param metadata The media item metadata bundle, or null if none.
310     * @param extras A bundle of extra arguments to be added to the
311     * {@link MediaControlIntent#ACTION_ENQUEUE} intent, or null if none.
312     * @param callback A callback to invoke when the request has been
313     * processed, or null if none.
314     *
315     * @throws UnsupportedOperationException if the route does not support queuing.
316     *
317     * @see MediaControlIntent#ACTION_ENQUEUE
318     * @see #isRemotePlaybackSupported
319     * @see #isQueuingSupported
320     */
321    public void enqueue(Uri contentUri, String mimeType, Bundle metadata,
322            long positionMillis, Bundle extras, ItemActionCallback callback) {
323        playOrEnqueue(contentUri, mimeType, metadata, positionMillis,
324                extras, callback, MediaControlIntent.ACTION_ENQUEUE);
325    }
326
327    private void playOrEnqueue(Uri contentUri, String mimeType, Bundle metadata,
328            long positionMillis, Bundle extras,
329            final ItemActionCallback callback, String action) {
330        if (contentUri == null) {
331            throw new IllegalArgumentException("contentUri must not be null");
332        }
333        throwIfRemotePlaybackNotSupported();
334        if (action.equals(MediaControlIntent.ACTION_ENQUEUE)) {
335            throwIfQueuingNotSupported();
336        }
337
338        Intent intent = new Intent(action);
339        intent.setDataAndType(contentUri, mimeType);
340        intent.putExtra(MediaControlIntent.EXTRA_ITEM_STATUS_UPDATE_RECEIVER,
341                mItemStatusPendingIntent);
342        if (metadata != null) {
343            intent.putExtra(MediaControlIntent.EXTRA_ITEM_METADATA, metadata);
344        }
345        if (positionMillis != 0) {
346            intent.putExtra(MediaControlIntent.EXTRA_ITEM_CONTENT_POSITION, positionMillis);
347        }
348        performItemAction(intent, mSessionId, null, extras, callback);
349    }
350
351    /**
352     * Sends a request to seek to a new position in a media item.
353     * <p>
354     * Seeks to a new position.  If the queue was previously paused then it
355     * remains paused but the item's new position is still remembered.
356     * </p><p>
357     * The request is issued in the current session.
358     * </p><p>
359     * Please refer to {@link MediaControlIntent#ACTION_SEEK ACTION_SEEK} for
360     * more information about the semantics of this request.
361     * </p>
362     *
363     * @param itemId The item id.
364     * @param positionMillis The new content position for the item in milliseconds,
365     * or <code>0</code> to start at the beginning.
366     * @param extras A bundle of extra arguments to be added to the
367     * {@link MediaControlIntent#ACTION_SEEK} intent, or null if none.
368     * @param callback A callback to invoke when the request has been
369     * processed, or null if none.
370     *
371     * @throws IllegalStateException if there is no current session.
372     *
373     * @see MediaControlIntent#ACTION_SEEK
374     * @see #isRemotePlaybackSupported
375     */
376    public void seek(String itemId, long positionMillis, Bundle extras,
377            ItemActionCallback callback) {
378        if (itemId == null) {
379            throw new IllegalArgumentException("itemId must not be null");
380        }
381        throwIfNoCurrentSession();
382
383        Intent intent = new Intent(MediaControlIntent.ACTION_SEEK);
384        intent.putExtra(MediaControlIntent.EXTRA_ITEM_CONTENT_POSITION, positionMillis);
385        performItemAction(intent, mSessionId, itemId, extras, callback);
386    }
387
388    /**
389     * Sends a request to get the status of a media item.
390     * <p>
391     * The request is issued in the current session.
392     * </p><p>
393     * Please refer to {@link MediaControlIntent#ACTION_GET_STATUS ACTION_GET_STATUS} for
394     * more information about the semantics of this request.
395     * </p>
396     *
397     * @param itemId The item id.
398     * @param extras A bundle of extra arguments to be added to the
399     * {@link MediaControlIntent#ACTION_GET_STATUS} intent, or null if none.
400     * @param callback A callback to invoke when the request has been
401     * processed, or null if none.
402     *
403     * @throws IllegalStateException if there is no current session.
404     *
405     * @see MediaControlIntent#ACTION_GET_STATUS
406     * @see #isRemotePlaybackSupported
407     */
408    public void getStatus(String itemId, Bundle extras, ItemActionCallback callback) {
409        if (itemId == null) {
410            throw new IllegalArgumentException("itemId must not be null");
411        }
412        throwIfNoCurrentSession();
413
414        Intent intent = new Intent(MediaControlIntent.ACTION_GET_STATUS);
415        performItemAction(intent, mSessionId, itemId, extras, callback);
416    }
417
418    /**
419     * Sends a request to remove a media item from the queue.
420     * <p>
421     * The request is issued in the current session.
422     * </p><p>
423     * Please refer to {@link MediaControlIntent#ACTION_REMOVE ACTION_REMOVE} for
424     * more information about the semantics of this request.
425     * </p>
426     *
427     * @param itemId The item id.
428     * @param extras A bundle of extra arguments to be added to the
429     * {@link MediaControlIntent#ACTION_REMOVE} intent, or null if none.
430     * @param callback A callback to invoke when the request has been
431     * processed, or null if none.
432     *
433     * @throws IllegalStateException if there is no current session.
434     * @throws UnsupportedOperationException if the route does not support queuing.
435     *
436     * @see MediaControlIntent#ACTION_REMOVE
437     * @see #isRemotePlaybackSupported
438     * @see #isQueuingSupported
439     */
440    public void remove(String itemId, Bundle extras, ItemActionCallback callback) {
441        if (itemId == null) {
442            throw new IllegalArgumentException("itemId must not be null");
443        }
444        throwIfQueuingNotSupported();
445        throwIfNoCurrentSession();
446
447        Intent intent = new Intent(MediaControlIntent.ACTION_REMOVE);
448        performItemAction(intent, mSessionId, itemId, extras, callback);
449    }
450
451    /**
452     * Sends a request to pause media playback.
453     * <p>
454     * The request is issued in the current session.  If playback is already paused
455     * then the request has no effect.
456     * </p><p>
457     * Please refer to {@link MediaControlIntent#ACTION_PAUSE ACTION_PAUSE} for
458     * more information about the semantics of this request.
459     * </p>
460     *
461     * @param extras A bundle of extra arguments to be added to the
462     * {@link MediaControlIntent#ACTION_PAUSE} intent, or null if none.
463     * @param callback A callback to invoke when the request has been
464     * processed, or null if none.
465     *
466     * @throws IllegalStateException if there is no current session.
467     *
468     * @see MediaControlIntent#ACTION_PAUSE
469     * @see #isRemotePlaybackSupported
470     */
471    public void pause(Bundle extras, SessionActionCallback callback) {
472        throwIfNoCurrentSession();
473
474        Intent intent = new Intent(MediaControlIntent.ACTION_PAUSE);
475        performSessionAction(intent, mSessionId, extras, callback);
476    }
477
478    /**
479     * Sends a request to resume (unpause) media playback.
480     * <p>
481     * The request is issued in the current session.  If playback is not paused
482     * then the request has no effect.
483     * </p><p>
484     * Please refer to {@link MediaControlIntent#ACTION_RESUME ACTION_RESUME} for
485     * more information about the semantics of this request.
486     * </p>
487     *
488     * @param extras A bundle of extra arguments to be added to the
489     * {@link MediaControlIntent#ACTION_RESUME} intent, or null if none.
490     * @param callback A callback to invoke when the request has been
491     * processed, or null if none.
492     *
493     * @throws IllegalStateException if there is no current session.
494     *
495     * @see MediaControlIntent#ACTION_RESUME
496     * @see #isRemotePlaybackSupported
497     */
498    public void resume(Bundle extras, SessionActionCallback callback) {
499        throwIfNoCurrentSession();
500
501        Intent intent = new Intent(MediaControlIntent.ACTION_RESUME);
502        performSessionAction(intent, mSessionId, extras, callback);
503    }
504
505    /**
506     * Sends a request to stop media playback and clear the media playback queue.
507     * <p>
508     * The request is issued in the current session.  If the queue is already
509     * empty then the request has no effect.
510     * </p><p>
511     * Please refer to {@link MediaControlIntent#ACTION_STOP ACTION_STOP} for
512     * more information about the semantics of this request.
513     * </p>
514     *
515     * @param extras A bundle of extra arguments to be added to the
516     * {@link MediaControlIntent#ACTION_STOP} intent, or null if none.
517     * @param callback A callback to invoke when the request has been
518     * processed, or null if none.
519     *
520     * @throws IllegalStateException if there is no current session.
521     *
522     * @see MediaControlIntent#ACTION_STOP
523     * @see #isRemotePlaybackSupported
524     */
525    public void stop(Bundle extras, SessionActionCallback callback) {
526        throwIfNoCurrentSession();
527
528        Intent intent = new Intent(MediaControlIntent.ACTION_STOP);
529        performSessionAction(intent, mSessionId, extras, callback);
530    }
531
532    /**
533     * Sends a request to start a new media playback session.
534     * <p>
535     * The application must wait for the callback to indicate that this request
536     * is complete before issuing other requests that affect the session.  If this
537     * request is successful then the previous session will be invalidated.
538     * </p><p>
539     * Please refer to {@link MediaControlIntent#ACTION_START_SESSION ACTION_START_SESSION}
540     * for more information about the semantics of this request.
541     * </p>
542     *
543     * @param extras A bundle of extra arguments to be added to the
544     * {@link MediaControlIntent#ACTION_START_SESSION} intent, or null if none.
545     * @param callback A callback to invoke when the request has been
546     * processed, or null if none.
547     *
548     * @throws UnsupportedOperationException if the route does not support session management.
549     *
550     * @see MediaControlIntent#ACTION_START_SESSION
551     * @see #isRemotePlaybackSupported
552     * @see #isSessionManagementSupported
553     */
554    public void startSession(Bundle extras, SessionActionCallback callback) {
555        throwIfSessionManagementNotSupported();
556
557        Intent intent = new Intent(MediaControlIntent.ACTION_START_SESSION);
558        intent.putExtra(MediaControlIntent.EXTRA_SESSION_STATUS_UPDATE_RECEIVER,
559                mSessionStatusPendingIntent);
560        if (mRouteSupportsMessaging) {
561            intent.putExtra(MediaControlIntent.EXTRA_MESSAGE_RECEIVER, mMessagePendingIntent);
562        }
563        performSessionAction(intent, null, extras, callback);
564    }
565
566    /**
567     * Sends a message.
568     * <p>
569     * The request is issued in the current session.
570     * </p><p>
571     * Please refer to {@link MediaControlIntent#ACTION_SEND_MESSAGE} for
572     * more information about the semantics of this request.
573     * </p>
574     *
575     * @param message A bundle message denoting {@link MediaControlIntent#EXTRA_MESSAGE}.
576     * @param callback A callback to invoke when the request has been processed, or null if none.
577     *
578     * @throws IllegalStateException if there is no current session.
579     * @throws UnsupportedOperationException if the route does not support messages.
580     *
581     * @see MediaControlIntent#ACTION_SEND_MESSAGE
582     * @see #isMessagingSupported
583     */
584    public void sendMessage(Bundle message, SessionActionCallback callback) {
585        throwIfNoCurrentSession();
586        throwIfMessageNotSupported();
587
588        Intent intent = new Intent(MediaControlIntent.ACTION_SEND_MESSAGE);
589        performSessionAction(intent, mSessionId, message, callback);
590    }
591
592    /**
593     * Sends a request to get the status of the media playback session.
594     * <p>
595     * The request is issued in the current session.
596     * </p><p>
597     * Please refer to {@link MediaControlIntent#ACTION_GET_SESSION_STATUS
598     * ACTION_GET_SESSION_STATUS} for more information about the semantics of this request.
599     * </p>
600     *
601     * @param extras A bundle of extra arguments to be added to the
602     * {@link MediaControlIntent#ACTION_GET_SESSION_STATUS} intent, or null if none.
603     * @param callback A callback to invoke when the request has been
604     * processed, or null if none.
605     *
606     * @throws IllegalStateException if there is no current session.
607     * @throws UnsupportedOperationException if the route does not support session management.
608     *
609     * @see MediaControlIntent#ACTION_GET_SESSION_STATUS
610     * @see #isRemotePlaybackSupported
611     * @see #isSessionManagementSupported
612     */
613    public void getSessionStatus(Bundle extras, SessionActionCallback callback) {
614        throwIfSessionManagementNotSupported();
615        throwIfNoCurrentSession();
616
617        Intent intent = new Intent(MediaControlIntent.ACTION_GET_SESSION_STATUS);
618        performSessionAction(intent, mSessionId, extras, callback);
619    }
620
621    /**
622     * Sends a request to end the media playback session.
623     * <p>
624     * The request is issued in the current session.  If this request is successful,
625     * the {@link #getSessionId session id property} will be set to null after
626     * the callback is invoked.
627     * </p><p>
628     * Please refer to {@link MediaControlIntent#ACTION_END_SESSION ACTION_END_SESSION}
629     * for more information about the semantics of this request.
630     * </p>
631     *
632     * @param extras A bundle of extra arguments to be added to the
633     * {@link MediaControlIntent#ACTION_END_SESSION} intent, or null if none.
634     * @param callback A callback to invoke when the request has been
635     * processed, or null if none.
636     *
637     * @throws IllegalStateException if there is no current session.
638     * @throws UnsupportedOperationException if the route does not support session management.
639     *
640     * @see MediaControlIntent#ACTION_END_SESSION
641     * @see #isRemotePlaybackSupported
642     * @see #isSessionManagementSupported
643     */
644    public void endSession(Bundle extras, SessionActionCallback callback) {
645        throwIfSessionManagementNotSupported();
646        throwIfNoCurrentSession();
647
648        Intent intent = new Intent(MediaControlIntent.ACTION_END_SESSION);
649        performSessionAction(intent, mSessionId, extras, callback);
650    }
651
652    private void performItemAction(final Intent intent,
653            final String sessionId, final String itemId,
654            Bundle extras, final ItemActionCallback callback) {
655        intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK);
656        if (sessionId != null) {
657            intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, sessionId);
658        }
659        if (itemId != null) {
660            intent.putExtra(MediaControlIntent.EXTRA_ITEM_ID, itemId);
661        }
662        if (extras != null) {
663            intent.putExtras(extras);
664        }
665        logRequest(intent);
666        mRoute.sendControlRequest(intent, new MediaRouter.ControlRequestCallback() {
667            @Override
668            public void onResult(Bundle data) {
669                if (data != null) {
670                    String sessionIdResult = inferMissingResult(sessionId,
671                            data.getString(MediaControlIntent.EXTRA_SESSION_ID));
672                    MediaSessionStatus sessionStatus = MediaSessionStatus.fromBundle(
673                            data.getBundle(MediaControlIntent.EXTRA_SESSION_STATUS));
674                    String itemIdResult = inferMissingResult(itemId,
675                            data.getString(MediaControlIntent.EXTRA_ITEM_ID));
676                    MediaItemStatus itemStatus = MediaItemStatus.fromBundle(
677                            data.getBundle(MediaControlIntent.EXTRA_ITEM_STATUS));
678                    adoptSession(sessionIdResult);
679                    if (sessionIdResult != null && itemIdResult != null && itemStatus != null) {
680                        if (DEBUG) {
681                            Log.d(TAG, "Received result from " + intent.getAction()
682                                    + ": data=" + bundleToString(data)
683                                    + ", sessionId=" + sessionIdResult
684                                    + ", sessionStatus=" + sessionStatus
685                                    + ", itemId=" + itemIdResult
686                                    + ", itemStatus=" + itemStatus);
687                        }
688                        callback.onResult(data, sessionIdResult, sessionStatus,
689                                itemIdResult, itemStatus);
690                        return;
691                    }
692                }
693                handleInvalidResult(intent, callback, data);
694            }
695
696            @Override
697            public void onError(String error, Bundle data) {
698                handleError(intent, callback, error, data);
699            }
700        });
701    }
702
703    private void performSessionAction(final Intent intent, final String sessionId,
704            Bundle extras, final SessionActionCallback callback) {
705        intent.addCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK);
706        if (sessionId != null) {
707            intent.putExtra(MediaControlIntent.EXTRA_SESSION_ID, sessionId);
708        }
709        if (extras != null) {
710            intent.putExtras(extras);
711        }
712        logRequest(intent);
713        mRoute.sendControlRequest(intent, new MediaRouter.ControlRequestCallback() {
714            @Override
715            public void onResult(Bundle data) {
716                if (data != null) {
717                    String sessionIdResult = inferMissingResult(sessionId,
718                            data.getString(MediaControlIntent.EXTRA_SESSION_ID));
719                    MediaSessionStatus sessionStatus = MediaSessionStatus.fromBundle(
720                            data.getBundle(MediaControlIntent.EXTRA_SESSION_STATUS));
721                    adoptSession(sessionIdResult);
722                    if (sessionIdResult != null) {
723                        if (DEBUG) {
724                            Log.d(TAG, "Received result from " + intent.getAction()
725                                    + ": data=" + bundleToString(data)
726                                    + ", sessionId=" + sessionIdResult
727                                    + ", sessionStatus=" + sessionStatus);
728                        }
729                        try {
730                            callback.onResult(data, sessionIdResult, sessionStatus);
731                        } finally {
732                            if (intent.getAction().equals(MediaControlIntent.ACTION_END_SESSION)
733                                    && sessionIdResult.equals(mSessionId)) {
734                                setSessionId(null);
735                            }
736                        }
737                        return;
738                    }
739                }
740                handleInvalidResult(intent, callback, data);
741            }
742
743            @Override
744            public void onError(String error, Bundle data) {
745                handleError(intent, callback, error, data);
746            }
747        });
748    }
749
750    void adoptSession(String sessionId) {
751        if (sessionId != null) {
752            setSessionId(sessionId);
753        }
754    }
755
756    void handleInvalidResult(Intent intent, ActionCallback callback,
757            Bundle data) {
758        Log.w(TAG, "Received invalid result data from " + intent.getAction()
759                + ": data=" + bundleToString(data));
760        callback.onError(null, MediaControlIntent.ERROR_UNKNOWN, data);
761    }
762
763    void handleError(Intent intent, ActionCallback callback,
764            String error, Bundle data) {
765        final int code;
766        if (data != null) {
767            code = data.getInt(MediaControlIntent.EXTRA_ERROR_CODE,
768                    MediaControlIntent.ERROR_UNKNOWN);
769        } else {
770            code = MediaControlIntent.ERROR_UNKNOWN;
771        }
772        if (DEBUG) {
773            Log.w(TAG, "Received error from " + intent.getAction()
774                    + ": error=" + error
775                    + ", code=" + code
776                    + ", data=" + bundleToString(data));
777        }
778        callback.onError(error, code, data);
779    }
780
781    private void detectFeatures() {
782        mRouteSupportsRemotePlayback = routeSupportsAction(MediaControlIntent.ACTION_PLAY)
783                && routeSupportsAction(MediaControlIntent.ACTION_SEEK)
784                && routeSupportsAction(MediaControlIntent.ACTION_GET_STATUS)
785                && routeSupportsAction(MediaControlIntent.ACTION_PAUSE)
786                && routeSupportsAction(MediaControlIntent.ACTION_RESUME)
787                && routeSupportsAction(MediaControlIntent.ACTION_STOP);
788        mRouteSupportsQueuing = mRouteSupportsRemotePlayback
789                && routeSupportsAction(MediaControlIntent.ACTION_ENQUEUE)
790                && routeSupportsAction(MediaControlIntent.ACTION_REMOVE);
791        mRouteSupportsSessionManagement = mRouteSupportsRemotePlayback
792                && routeSupportsAction(MediaControlIntent.ACTION_START_SESSION)
793                && routeSupportsAction(MediaControlIntent.ACTION_GET_SESSION_STATUS)
794                && routeSupportsAction(MediaControlIntent.ACTION_END_SESSION);
795        mRouteSupportsMessaging = doesRouteSupportMessaging();
796    }
797
798    private boolean routeSupportsAction(String action) {
799        return mRoute.supportsControlAction(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK, action);
800    }
801
802    private boolean doesRouteSupportMessaging() {
803        for (IntentFilter filter : mRoute.getControlFilters()) {
804            if (filter.hasAction(MediaControlIntent.ACTION_SEND_MESSAGE)) {
805                return true;
806            }
807        }
808        return false;
809    }
810
811    private void throwIfRemotePlaybackNotSupported() {
812        if (!mRouteSupportsRemotePlayback) {
813            throw new UnsupportedOperationException("The route does not support remote playback.");
814        }
815    }
816
817    private void throwIfQueuingNotSupported() {
818        if (!mRouteSupportsQueuing) {
819            throw new UnsupportedOperationException("The route does not support queuing.");
820        }
821    }
822
823    private void throwIfSessionManagementNotSupported() {
824        if (!mRouteSupportsSessionManagement) {
825            throw new UnsupportedOperationException("The route does not support "
826                    + "session management.");
827        }
828    }
829
830    private void throwIfMessageNotSupported() {
831        if (!mRouteSupportsMessaging) {
832            throw new UnsupportedOperationException("The route does not support message.");
833        }
834    }
835
836    private void throwIfNoCurrentSession() {
837        if (mSessionId == null) {
838            throw new IllegalStateException("There is no current session.");
839        }
840    }
841
842    static String inferMissingResult(String request, String result) {
843        if (result == null) {
844            // Result is missing.
845            return request;
846        }
847        if (request == null || request.equals(result)) {
848            // Request didn't specify a value or result matches request.
849            return result;
850        }
851        // Result conflicts with request.
852        return null;
853    }
854
855    private static void logRequest(Intent intent) {
856        if (DEBUG) {
857            Log.d(TAG, "Sending request: " + intent);
858        }
859    }
860
861    static String bundleToString(Bundle bundle) {
862        if (bundle != null) {
863            bundle.size(); // force bundle to be unparcelled
864            return bundle.toString();
865        }
866        return "null";
867    }
868
869    private final class ActionReceiver extends BroadcastReceiver {
870        public static final String ACTION_ITEM_STATUS_CHANGED =
871                "android.support.v7.media.actions.ACTION_ITEM_STATUS_CHANGED";
872        public static final String ACTION_SESSION_STATUS_CHANGED =
873                "android.support.v7.media.actions.ACTION_SESSION_STATUS_CHANGED";
874        public static final String ACTION_MESSAGE_RECEIVED =
875                "android.support.v7.media.actions.ACTION_MESSAGE_RECEIVED";
876
877        ActionReceiver() {
878        }
879
880        @Override
881        public void onReceive(Context context, Intent intent) {
882            String sessionId = intent.getStringExtra(MediaControlIntent.EXTRA_SESSION_ID);
883            if (sessionId == null || !sessionId.equals(mSessionId)) {
884                Log.w(TAG, "Discarding spurious status callback "
885                        + "with missing or invalid session id: sessionId=" + sessionId);
886                return;
887            }
888
889            MediaSessionStatus sessionStatus = MediaSessionStatus.fromBundle(
890                    intent.getBundleExtra(MediaControlIntent.EXTRA_SESSION_STATUS));
891            String action = intent.getAction();
892            if (action.equals(ACTION_ITEM_STATUS_CHANGED)) {
893                String itemId = intent.getStringExtra(MediaControlIntent.EXTRA_ITEM_ID);
894                if (itemId == null) {
895                    Log.w(TAG, "Discarding spurious status callback with missing item id.");
896                    return;
897                }
898
899                MediaItemStatus itemStatus = MediaItemStatus.fromBundle(
900                        intent.getBundleExtra(MediaControlIntent.EXTRA_ITEM_STATUS));
901                if (itemStatus == null) {
902                    Log.w(TAG, "Discarding spurious status callback with missing item status.");
903                    return;
904                }
905
906                if (DEBUG) {
907                    Log.d(TAG, "Received item status callback: sessionId=" + sessionId
908                            + ", sessionStatus=" + sessionStatus
909                            + ", itemId=" + itemId
910                            + ", itemStatus=" + itemStatus);
911                }
912
913                if (mStatusCallback != null) {
914                    mStatusCallback.onItemStatusChanged(intent.getExtras(),
915                            sessionId, sessionStatus, itemId, itemStatus);
916                }
917            } else if (action.equals(ACTION_SESSION_STATUS_CHANGED)) {
918                if (sessionStatus == null) {
919                    Log.w(TAG, "Discarding spurious media status callback with "
920                            +"missing session status.");
921                    return;
922                }
923
924                if (DEBUG) {
925                    Log.d(TAG, "Received session status callback: sessionId=" + sessionId
926                            + ", sessionStatus=" + sessionStatus);
927                }
928
929                if (mStatusCallback != null) {
930                    mStatusCallback.onSessionStatusChanged(intent.getExtras(),
931                            sessionId, sessionStatus);
932                }
933            } else if (action.equals(ACTION_MESSAGE_RECEIVED)) {
934                if (DEBUG) {
935                    Log.d(TAG, "Received message callback: sessionId=" + sessionId);
936                }
937
938                if (mOnMessageReceivedListener != null) {
939                    mOnMessageReceivedListener.onMessageReceived(sessionId,
940                            intent.getBundleExtra(MediaControlIntent.EXTRA_MESSAGE));
941                }
942            }
943        }
944    }
945
946    /**
947     * A callback that will receive media status updates.
948     */
949    public static abstract class StatusCallback {
950        /**
951         * Called when the status of a media item changes.
952         *
953         * @param data The result data bundle.
954         * @param sessionId The session id.
955         * @param sessionStatus The session status, or null if unknown.
956         * @param itemId The item id.
957         * @param itemStatus The item status.
958         */
959        public void onItemStatusChanged(Bundle data,
960                String sessionId, MediaSessionStatus sessionStatus,
961                String itemId, MediaItemStatus itemStatus) {
962        }
963
964        /**
965         * Called when the status of a media session changes.
966         *
967         * @param data The result data bundle.
968         * @param sessionId The session id.
969         * @param sessionStatus The session status, or null if unknown.
970         */
971        public void onSessionStatusChanged(Bundle data,
972                String sessionId, MediaSessionStatus sessionStatus) {
973        }
974
975        /**
976         * Called when the session of the remote playback client changes.
977         *
978         * @param sessionId The new session id.
979         */
980        public void onSessionChanged(String sessionId) {
981        }
982    }
983
984    /**
985     * Base callback type for remote playback requests.
986     */
987    public static abstract class ActionCallback {
988        /**
989         * Called when a media control request fails.
990         *
991         * @param error A localized error message which may be shown to the user, or null
992         * if the cause of the error is unclear.
993         * @param code The error code, or {@link MediaControlIntent#ERROR_UNKNOWN} if unknown.
994         * @param data The error data bundle, or null if none.
995         */
996        public void onError(String error, int code, Bundle data) {
997        }
998    }
999
1000    /**
1001     * Callback for remote playback requests that operate on items.
1002     */
1003    public static abstract class ItemActionCallback extends ActionCallback {
1004        /**
1005         * Called when the request succeeds.
1006         *
1007         * @param data The result data bundle.
1008         * @param sessionId The session id.
1009         * @param sessionStatus The session status, or null if unknown.
1010         * @param itemId The item id.
1011         * @param itemStatus The item status.
1012         */
1013        public void onResult(Bundle data, String sessionId, MediaSessionStatus sessionStatus,
1014                String itemId, MediaItemStatus itemStatus) {
1015        }
1016    }
1017
1018    /**
1019     * Callback for remote playback requests that operate on sessions.
1020     */
1021    public static abstract class SessionActionCallback extends ActionCallback {
1022        /**
1023         * Called when the request succeeds.
1024         *
1025         * @param data The result data bundle.
1026         * @param sessionId The session id.
1027         * @param sessionStatus The session status, or null if unknown.
1028         */
1029        public void onResult(Bundle data, String sessionId, MediaSessionStatus sessionStatus) {
1030        }
1031    }
1032
1033    /**
1034     * A callback that will receive messages from media sessions.
1035     */
1036    public interface OnMessageReceivedListener {
1037        /**
1038         * Called when a message received.
1039         *
1040         * @param sessionId The session id.
1041         * @param message A bundle message denoting {@link MediaControlIntent#EXTRA_MESSAGE}.
1042         */
1043        void onMessageReceived(String sessionId, Bundle message);
1044    }
1045}
1046