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 */
16
17package android.support.v7.app;
18
19import android.app.Activity;
20import android.content.Context;
21import android.content.ContextWrapper;
22import android.content.res.TypedArray;
23import android.graphics.Canvas;
24import android.graphics.Rect;
25import android.graphics.drawable.AnimationDrawable;
26import android.graphics.drawable.Drawable;
27import android.support.annotation.NonNull;
28import android.support.v4.app.FragmentActivity;
29import android.support.v4.app.FragmentManager;
30import android.support.v4.graphics.drawable.DrawableCompat;
31import android.support.v4.view.GravityCompat;
32import android.support.v7.media.MediaRouter;
33import android.support.v7.media.MediaRouteSelector;
34import android.support.v7.mediarouter.R;
35import android.text.TextUtils;
36import android.util.AttributeSet;
37import android.util.Log;
38import android.view.Gravity;
39import android.view.HapticFeedbackConstants;
40import android.view.SoundEffectConstants;
41import android.view.View;
42import android.widget.Toast;
43
44/**
45 * The media route button allows the user to select routes and to control the
46 * currently selected route.
47 * <p>
48 * The application must specify the kinds of routes that the user should be allowed
49 * to select by specifying a {@link MediaRouteSelector selector} with the
50 * {@link #setRouteSelector} method.
51 * </p><p>
52 * When the default route is selected or when the currently selected route does not
53 * match the {@link #getRouteSelector() selector}, the button will appear in
54 * an inactive state indicating that the application is not connected to a
55 * route of the kind that it wants to use.  Clicking on the button opens
56 * a {@link MediaRouteChooserDialog} to allow the user to select a route.
57 * If no non-default routes match the selector and it is not possible for an active
58 * scan to discover any matching routes, then the button is disabled and cannot
59 * be clicked.
60 * </p><p>
61 * When a non-default route is selected that matches the selector, the button will
62 * appear in an active state indicating that the application is connected
63 * to a route of the kind that it wants to use.  The button may also appear
64 * in an intermediary connecting state if the route is in the process of connecting
65 * to the destination but has not yet completed doing so.  In either case, clicking
66 * on the button opens a {@link MediaRouteControllerDialog} to allow the user
67 * to control or disconnect from the current route.
68 * </p>
69 *
70 * <h3>Prerequisites</h3>
71 * <p>
72 * To use the media route button, the activity must be a subclass of
73 * {@link FragmentActivity} from the <code>android.support.v4</code>
74 * support library.  Refer to support library documentation for details.
75 * </p>
76 *
77 * @see MediaRouteActionProvider
78 * @see #setRouteSelector
79 */
80public class MediaRouteButton extends View {
81    private static final String TAG = "MediaRouteButton";
82
83    private static final String CHOOSER_FRAGMENT_TAG =
84            "android.support.v7.mediarouter:MediaRouteChooserDialogFragment";
85    private static final String CONTROLLER_FRAGMENT_TAG =
86            "android.support.v7.mediarouter:MediaRouteControllerDialogFragment";
87
88    private final MediaRouter mRouter;
89    private final MediaRouterCallback mCallback;
90
91    private MediaRouteSelector mSelector = MediaRouteSelector.EMPTY;
92    private MediaRouteDialogFactory mDialogFactory = MediaRouteDialogFactory.getDefault();
93
94    private boolean mAttachedToWindow;
95
96    private Drawable mRemoteIndicator;
97    private boolean mRemoteActive;
98    private boolean mCheatSheetEnabled;
99    private boolean mIsConnecting;
100
101    private int mMinWidth;
102    private int mMinHeight;
103
104    // The checked state is used when connected to a remote route.
105    private static final int[] CHECKED_STATE_SET = {
106        android.R.attr.state_checked
107    };
108
109    // The checkable state is used while connecting to a remote route.
110    private static final int[] CHECKABLE_STATE_SET = {
111        android.R.attr.state_checkable
112    };
113
114    public MediaRouteButton(Context context) {
115        this(context, null);
116    }
117
118    public MediaRouteButton(Context context, AttributeSet attrs) {
119        this(context, attrs, R.attr.mediaRouteButtonStyle);
120    }
121
122    public MediaRouteButton(Context context, AttributeSet attrs, int defStyleAttr) {
123        super(MediaRouterThemeHelper.createThemedContext(context, defStyleAttr), attrs,
124                defStyleAttr);
125        context = getContext();
126
127        mRouter = MediaRouter.getInstance(context);
128        mCallback = new MediaRouterCallback();
129
130        TypedArray a = context.obtainStyledAttributes(attrs,
131                R.styleable.MediaRouteButton, defStyleAttr, 0);
132        setRemoteIndicatorDrawable(a.getDrawable(
133                R.styleable.MediaRouteButton_externalRouteEnabledDrawable));
134        mMinWidth = a.getDimensionPixelSize(
135                R.styleable.MediaRouteButton_android_minWidth, 0);
136        mMinHeight = a.getDimensionPixelSize(
137                R.styleable.MediaRouteButton_android_minHeight, 0);
138        a.recycle();
139
140        setClickable(true);
141        setLongClickable(true);
142    }
143
144    /**
145     * Gets the media route selector for filtering the routes that the user can
146     * select using the media route chooser dialog.
147     *
148     * @return The selector, never null.
149     */
150    @NonNull
151    public MediaRouteSelector getRouteSelector() {
152        return mSelector;
153    }
154
155    /**
156     * Sets the media route selector for filtering the routes that the user can
157     * select using the media route chooser dialog.
158     *
159     * @param selector The selector, must not be null.
160     */
161    public void setRouteSelector(MediaRouteSelector selector) {
162        if (selector == null) {
163            throw new IllegalArgumentException("selector must not be null");
164        }
165
166        if (!mSelector.equals(selector)) {
167            if (mAttachedToWindow) {
168                if (!mSelector.isEmpty()) {
169                    mRouter.removeCallback(mCallback);
170                }
171                if (!selector.isEmpty()) {
172                    mRouter.addCallback(selector, mCallback);
173                }
174            }
175            mSelector = selector;
176            refreshRoute();
177        }
178    }
179
180    /**
181     * Gets the media route dialog factory to use when showing the route chooser
182     * or controller dialog.
183     *
184     * @return The dialog factory, never null.
185     */
186    @NonNull
187    public MediaRouteDialogFactory getDialogFactory() {
188        return mDialogFactory;
189    }
190
191    /**
192     * Sets the media route dialog factory to use when showing the route chooser
193     * or controller dialog.
194     *
195     * @param factory The dialog factory, must not be null.
196     */
197    public void setDialogFactory(@NonNull MediaRouteDialogFactory factory) {
198        if (factory == null) {
199            throw new IllegalArgumentException("factory must not be null");
200        }
201
202        mDialogFactory = factory;
203    }
204
205    /**
206     * Show the route chooser or controller dialog.
207     * <p>
208     * If the default route is selected or if the currently selected route does
209     * not match the {@link #getRouteSelector selector}, then shows the route chooser dialog.
210     * Otherwise, shows the route controller dialog to offer the user
211     * a choice to disconnect from the route or perform other control actions
212     * such as setting the route's volume.
213     * </p><p>
214     * The application can customize the dialogs by calling {@link #setDialogFactory}
215     * to provide a customized dialog factory.
216     * </p>
217     *
218     * @return True if the dialog was actually shown.
219     *
220     * @throws IllegalStateException if the activity is not a subclass of
221     * {@link FragmentActivity}.
222     */
223    public boolean showDialog() {
224        if (!mAttachedToWindow) {
225            return false;
226        }
227
228        final FragmentManager fm = getFragmentManager();
229        if (fm == null) {
230            throw new IllegalStateException("The activity must be a subclass of FragmentActivity");
231        }
232
233        MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
234        if (route.isDefaultOrBluetooth() || !route.matchesSelector(mSelector)) {
235            if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) {
236                Log.w(TAG, "showDialog(): Route chooser dialog already showing!");
237                return false;
238            }
239            MediaRouteChooserDialogFragment f =
240                    mDialogFactory.onCreateChooserDialogFragment();
241            f.setRouteSelector(mSelector);
242            f.show(fm, CHOOSER_FRAGMENT_TAG);
243        } else {
244            if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) {
245                Log.w(TAG, "showDialog(): Route controller dialog already showing!");
246                return false;
247            }
248            MediaRouteControllerDialogFragment f =
249                    mDialogFactory.onCreateControllerDialogFragment();
250            f.show(fm, CONTROLLER_FRAGMENT_TAG);
251        }
252        return true;
253    }
254
255    private FragmentManager getFragmentManager() {
256        Activity activity = getActivity();
257        if (activity instanceof FragmentActivity) {
258            return ((FragmentActivity)activity).getSupportFragmentManager();
259        }
260        return null;
261    }
262
263    private Activity getActivity() {
264        // Gross way of unwrapping the Activity so we can get the FragmentManager
265        Context context = getContext();
266        while (context instanceof ContextWrapper) {
267            if (context instanceof Activity) {
268                return (Activity)context;
269            }
270            context = ((ContextWrapper)context).getBaseContext();
271        }
272        return null;
273    }
274
275    /**
276     * Sets whether to enable showing a toast with the content descriptor of the
277     * button when the button is long pressed.
278     */
279    void setCheatSheetEnabled(boolean enable) {
280        mCheatSheetEnabled = enable;
281    }
282
283    @Override
284    public boolean performClick() {
285        // Send the appropriate accessibility events and call listeners
286        boolean handled = super.performClick();
287        if (!handled) {
288            playSoundEffect(SoundEffectConstants.CLICK);
289        }
290        return showDialog() || handled;
291    }
292
293    @Override
294    public boolean performLongClick() {
295        if (super.performLongClick()) {
296            return true;
297        }
298
299        if (!mCheatSheetEnabled) {
300            return false;
301        }
302
303        final CharSequence contentDesc = getContentDescription();
304        if (TextUtils.isEmpty(contentDesc)) {
305            // Don't show the cheat sheet if we have no description
306            return false;
307        }
308
309        final int[] screenPos = new int[2];
310        final Rect displayFrame = new Rect();
311        getLocationOnScreen(screenPos);
312        getWindowVisibleDisplayFrame(displayFrame);
313
314        final Context context = getContext();
315        final int width = getWidth();
316        final int height = getHeight();
317        final int midy = screenPos[1] + height / 2;
318        final int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
319
320        Toast cheatSheet = Toast.makeText(context, contentDesc, Toast.LENGTH_SHORT);
321        if (midy < displayFrame.height()) {
322            // Show along the top; follow action buttons
323            cheatSheet.setGravity(Gravity.TOP | GravityCompat.END,
324                    screenWidth - screenPos[0] - width / 2, height);
325        } else {
326            // Show along the bottom center
327            cheatSheet.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, height);
328        }
329        cheatSheet.show();
330        performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
331        return true;
332    }
333
334    @Override
335    protected int[] onCreateDrawableState(int extraSpace) {
336        final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
337
338        // Technically we should be handling this more completely, but these
339        // are implementation details here. Checkable is used to express the connecting
340        // drawable state and it's mutually exclusive with check for the purposes
341        // of state selection here.
342        if (mIsConnecting) {
343            mergeDrawableStates(drawableState, CHECKABLE_STATE_SET);
344        } else if (mRemoteActive) {
345            mergeDrawableStates(drawableState, CHECKED_STATE_SET);
346        }
347        return drawableState;
348    }
349
350    @Override
351    protected void drawableStateChanged() {
352        super.drawableStateChanged();
353
354        if (mRemoteIndicator != null) {
355            int[] myDrawableState = getDrawableState();
356            mRemoteIndicator.setState(myDrawableState);
357            invalidate();
358        }
359    }
360
361    /**
362     * Sets a drawable to use as the remote route indicator.
363     */
364    public void setRemoteIndicatorDrawable(Drawable d) {
365        if (mRemoteIndicator != null) {
366            mRemoteIndicator.setCallback(null);
367            unscheduleDrawable(mRemoteIndicator);
368        }
369        mRemoteIndicator = d;
370        if (d != null) {
371            d.setCallback(this);
372            d.setState(getDrawableState());
373            d.setVisible(getVisibility() == VISIBLE, false);
374        }
375
376        refreshDrawableState();
377    }
378
379    @Override
380    protected boolean verifyDrawable(Drawable who) {
381        return super.verifyDrawable(who) || who == mRemoteIndicator;
382    }
383
384    //@Override defined in v11
385    public void jumpDrawablesToCurrentState() {
386        // We can't call super to handle the background so we do it ourselves.
387        //super.jumpDrawablesToCurrentState();
388        if (getBackground() != null) {
389            DrawableCompat.jumpToCurrentState(getBackground());
390        }
391
392        // Handle our own remote indicator.
393        if (mRemoteIndicator != null) {
394            DrawableCompat.jumpToCurrentState(mRemoteIndicator);
395        }
396    }
397
398    @Override
399    public void setVisibility(int visibility) {
400        super.setVisibility(visibility);
401
402        if (mRemoteIndicator != null) {
403            mRemoteIndicator.setVisible(getVisibility() == VISIBLE, false);
404        }
405    }
406
407    @Override
408    public void onAttachedToWindow() {
409        super.onAttachedToWindow();
410
411        mAttachedToWindow = true;
412        if (!mSelector.isEmpty()) {
413            mRouter.addCallback(mSelector, mCallback);
414        }
415        refreshRoute();
416    }
417
418    @Override
419    public void onDetachedFromWindow() {
420        mAttachedToWindow = false;
421        if (!mSelector.isEmpty()) {
422            mRouter.removeCallback(mCallback);
423        }
424
425        super.onDetachedFromWindow();
426    }
427
428    @Override
429    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
430        final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
431        final int heightSize = MeasureSpec.getSize(heightMeasureSpec);
432        final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
433        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
434
435        final int width = Math.max(mMinWidth, mRemoteIndicator != null ?
436                mRemoteIndicator.getIntrinsicWidth() + getPaddingLeft() + getPaddingRight() : 0);
437        final int height = Math.max(mMinHeight, mRemoteIndicator != null ?
438                mRemoteIndicator.getIntrinsicHeight() + getPaddingTop() + getPaddingBottom() : 0);
439
440        int measuredWidth;
441        switch (widthMode) {
442            case MeasureSpec.EXACTLY:
443                measuredWidth = widthSize;
444                break;
445            case MeasureSpec.AT_MOST:
446                measuredWidth = Math.min(widthSize, width);
447                break;
448            default:
449            case MeasureSpec.UNSPECIFIED:
450                measuredWidth = width;
451                break;
452        }
453
454        int measuredHeight;
455        switch (heightMode) {
456            case MeasureSpec.EXACTLY:
457                measuredHeight = heightSize;
458                break;
459            case MeasureSpec.AT_MOST:
460                measuredHeight = Math.min(heightSize, height);
461                break;
462            default:
463            case MeasureSpec.UNSPECIFIED:
464                measuredHeight = height;
465                break;
466        }
467
468        setMeasuredDimension(measuredWidth, measuredHeight);
469    }
470
471    @Override
472    protected void onDraw(Canvas canvas) {
473        super.onDraw(canvas);
474
475        if (mRemoteIndicator != null) {
476            final int left = getPaddingLeft();
477            final int right = getWidth() - getPaddingRight();
478            final int top = getPaddingTop();
479            final int bottom = getHeight() - getPaddingBottom();
480
481            final int drawWidth = mRemoteIndicator.getIntrinsicWidth();
482            final int drawHeight = mRemoteIndicator.getIntrinsicHeight();
483            final int drawLeft = left + (right - left - drawWidth) / 2;
484            final int drawTop = top + (bottom - top - drawHeight) / 2;
485
486            mRemoteIndicator.setBounds(drawLeft, drawTop,
487                    drawLeft + drawWidth, drawTop + drawHeight);
488            mRemoteIndicator.draw(canvas);
489        }
490    }
491
492    private void refreshRoute() {
493        if (mAttachedToWindow) {
494            final MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
495            final boolean isRemote = !route.isDefaultOrBluetooth()
496                    && route.matchesSelector(mSelector);
497            final boolean isConnecting = isRemote && route.isConnecting();
498
499            boolean needsRefresh = false;
500            if (mRemoteActive != isRemote) {
501                mRemoteActive = isRemote;
502                needsRefresh = true;
503            }
504            if (mIsConnecting != isConnecting) {
505                mIsConnecting = isConnecting;
506                needsRefresh = true;
507            }
508
509            if (needsRefresh) {
510                refreshDrawableState();
511                if (mRemoteIndicator.getCurrent() instanceof AnimationDrawable) {
512                    AnimationDrawable curDrawable =
513                            (AnimationDrawable) mRemoteIndicator.getCurrent();
514                    if (!curDrawable.isRunning()) {
515                        curDrawable.start();
516                    }
517                }
518            }
519        }
520    }
521
522    private final class MediaRouterCallback extends MediaRouter.Callback {
523        @Override
524        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
525            refreshRoute();
526        }
527
528        @Override
529        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
530            refreshRoute();
531        }
532
533        @Override
534        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
535            refreshRoute();
536        }
537
538        @Override
539        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
540            refreshRoute();
541        }
542
543        @Override
544        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
545            refreshRoute();
546        }
547
548        @Override
549        public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) {
550            refreshRoute();
551        }
552
553        @Override
554        public void onProviderRemoved(MediaRouter router, MediaRouter.ProviderInfo provider) {
555            refreshRoute();
556        }
557
558        @Override
559        public void onProviderChanged(MediaRouter router, MediaRouter.ProviderInfo provider) {
560            refreshRoute();
561        }
562    }
563}
564