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