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