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        updateContentDescription();
141        setClickable(true);
142        setLongClickable(true);
143    }
144
145    /**
146     * Gets the media route selector for filtering the routes that the user can
147     * select using the media route chooser dialog.
148     *
149     * @return The selector, never null.
150     */
151    @NonNull
152    public MediaRouteSelector getRouteSelector() {
153        return mSelector;
154    }
155
156    /**
157     * Sets the media route selector for filtering the routes that the user can
158     * select using the media route chooser dialog.
159     *
160     * @param selector The selector, must not be null.
161     */
162    public void setRouteSelector(MediaRouteSelector selector) {
163        if (selector == null) {
164            throw new IllegalArgumentException("selector must not be null");
165        }
166
167        if (!mSelector.equals(selector)) {
168            if (mAttachedToWindow) {
169                if (!mSelector.isEmpty()) {
170                    mRouter.removeCallback(mCallback);
171                }
172                if (!selector.isEmpty()) {
173                    mRouter.addCallback(selector, mCallback);
174                }
175            }
176            mSelector = selector;
177            refreshRoute();
178        }
179    }
180
181    /**
182     * Gets the media route dialog factory to use when showing the route chooser
183     * or controller dialog.
184     *
185     * @return The dialog factory, never null.
186     */
187    @NonNull
188    public MediaRouteDialogFactory getDialogFactory() {
189        return mDialogFactory;
190    }
191
192    /**
193     * Sets the media route dialog factory to use when showing the route chooser
194     * or controller dialog.
195     *
196     * @param factory The dialog factory, must not be null.
197     */
198    public void setDialogFactory(@NonNull MediaRouteDialogFactory factory) {
199        if (factory == null) {
200            throw new IllegalArgumentException("factory must not be null");
201        }
202
203        mDialogFactory = factory;
204    }
205
206    /**
207     * Show the route chooser or controller dialog.
208     * <p>
209     * If the default route is selected or if the currently selected route does
210     * not match the {@link #getRouteSelector selector}, then shows the route chooser dialog.
211     * Otherwise, shows the route controller dialog to offer the user
212     * a choice to disconnect from the route or perform other control actions
213     * such as setting the route's volume.
214     * </p><p>
215     * The application can customize the dialogs by calling {@link #setDialogFactory}
216     * to provide a customized dialog factory.
217     * </p>
218     *
219     * @return True if the dialog was actually shown.
220     *
221     * @throws IllegalStateException if the activity is not a subclass of
222     * {@link FragmentActivity}.
223     */
224    public boolean showDialog() {
225        if (!mAttachedToWindow) {
226            return false;
227        }
228
229        final FragmentManager fm = getFragmentManager();
230        if (fm == null) {
231            throw new IllegalStateException("The activity must be a subclass of FragmentActivity");
232        }
233
234        MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
235        if (route.isDefaultOrBluetooth() || !route.matchesSelector(mSelector)) {
236            if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) {
237                Log.w(TAG, "showDialog(): Route chooser dialog already showing!");
238                return false;
239            }
240            MediaRouteChooserDialogFragment f =
241                    mDialogFactory.onCreateChooserDialogFragment();
242            f.setRouteSelector(mSelector);
243            f.show(fm, CHOOSER_FRAGMENT_TAG);
244        } else {
245            if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) {
246                Log.w(TAG, "showDialog(): Route controller dialog already showing!");
247                return false;
248            }
249            MediaRouteControllerDialogFragment f =
250                    mDialogFactory.onCreateControllerDialogFragment();
251            f.show(fm, CONTROLLER_FRAGMENT_TAG);
252        }
253        return true;
254    }
255
256    private FragmentManager getFragmentManager() {
257        Activity activity = getActivity();
258        if (activity instanceof FragmentActivity) {
259            return ((FragmentActivity)activity).getSupportFragmentManager();
260        }
261        return null;
262    }
263
264    private Activity getActivity() {
265        // Gross way of unwrapping the Activity so we can get the FragmentManager
266        Context context = getContext();
267        while (context instanceof ContextWrapper) {
268            if (context instanceof Activity) {
269                return (Activity)context;
270            }
271            context = ((ContextWrapper)context).getBaseContext();
272        }
273        return null;
274    }
275
276    /**
277     * Sets whether to enable showing a toast with the content descriptor of the
278     * button when the button is long pressed.
279     */
280    void setCheatSheetEnabled(boolean enable) {
281        mCheatSheetEnabled = enable;
282    }
283
284    @Override
285    public boolean performClick() {
286        // Send the appropriate accessibility events and call listeners
287        boolean handled = super.performClick();
288        if (!handled) {
289            playSoundEffect(SoundEffectConstants.CLICK);
290        }
291        return showDialog() || handled;
292    }
293
294    @Override
295    public boolean performLongClick() {
296        if (super.performLongClick()) {
297            return true;
298        }
299
300        if (!mCheatSheetEnabled) {
301            return false;
302        }
303
304        final int[] screenPos = new int[2];
305        final Rect displayFrame = new Rect();
306        getLocationOnScreen(screenPos);
307        getWindowVisibleDisplayFrame(displayFrame);
308
309        final Context context = getContext();
310        final int width = getWidth();
311        final int height = getHeight();
312        final int midy = screenPos[1] + height / 2;
313        final int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
314
315        Toast cheatSheet = Toast.makeText(context, R.string.mr_button_content_description,
316                Toast.LENGTH_SHORT);
317        if (midy < displayFrame.height()) {
318            // Show along the top; follow action buttons
319            cheatSheet.setGravity(Gravity.TOP | GravityCompat.END,
320                    screenWidth - screenPos[0] - width / 2, height);
321        } else {
322            // Show along the bottom center
323            cheatSheet.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, height);
324        }
325        cheatSheet.show();
326        performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
327        return true;
328    }
329
330    @Override
331    protected int[] onCreateDrawableState(int extraSpace) {
332        final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
333
334        // Technically we should be handling this more completely, but these
335        // are implementation details here. Checkable is used to express the connecting
336        // drawable state and it's mutually exclusive with check for the purposes
337        // of state selection here.
338        if (mIsConnecting) {
339            mergeDrawableStates(drawableState, CHECKABLE_STATE_SET);
340        } else if (mRemoteActive) {
341            mergeDrawableStates(drawableState, CHECKED_STATE_SET);
342        }
343        return drawableState;
344    }
345
346    @Override
347    protected void drawableStateChanged() {
348        super.drawableStateChanged();
349
350        if (mRemoteIndicator != null) {
351            int[] myDrawableState = getDrawableState();
352            mRemoteIndicator.setState(myDrawableState);
353            invalidate();
354        }
355    }
356
357    /**
358     * Sets a drawable to use as the remote route indicator.
359     */
360    public void setRemoteIndicatorDrawable(Drawable d) {
361        if (mRemoteIndicator != null) {
362            mRemoteIndicator.setCallback(null);
363            unscheduleDrawable(mRemoteIndicator);
364        }
365        mRemoteIndicator = d;
366        if (d != null) {
367            d.setCallback(this);
368            d.setState(getDrawableState());
369            d.setVisible(getVisibility() == VISIBLE, false);
370        }
371
372        refreshDrawableState();
373    }
374
375    @Override
376    protected boolean verifyDrawable(Drawable who) {
377        return super.verifyDrawable(who) || who == mRemoteIndicator;
378    }
379
380    //@Override defined in v11
381    public void jumpDrawablesToCurrentState() {
382        // We can't call super to handle the background so we do it ourselves.
383        //super.jumpDrawablesToCurrentState();
384        if (getBackground() != null) {
385            DrawableCompat.jumpToCurrentState(getBackground());
386        }
387
388        // Handle our own remote indicator.
389        if (mRemoteIndicator != null) {
390            DrawableCompat.jumpToCurrentState(mRemoteIndicator);
391        }
392    }
393
394    @Override
395    public void setVisibility(int visibility) {
396        super.setVisibility(visibility);
397
398        if (mRemoteIndicator != null) {
399            mRemoteIndicator.setVisible(getVisibility() == VISIBLE, false);
400        }
401    }
402
403    @Override
404    public void onAttachedToWindow() {
405        super.onAttachedToWindow();
406
407        mAttachedToWindow = true;
408        if (!mSelector.isEmpty()) {
409            mRouter.addCallback(mSelector, mCallback);
410        }
411        refreshRoute();
412    }
413
414    @Override
415    public void onDetachedFromWindow() {
416        mAttachedToWindow = false;
417        if (!mSelector.isEmpty()) {
418            mRouter.removeCallback(mCallback);
419        }
420
421        super.onDetachedFromWindow();
422    }
423
424    @Override
425    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
426        final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
427        final int heightSize = MeasureSpec.getSize(heightMeasureSpec);
428        final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
429        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
430
431        final int width = Math.max(mMinWidth, mRemoteIndicator != null ?
432                mRemoteIndicator.getIntrinsicWidth() + getPaddingLeft() + getPaddingRight() : 0);
433        final int height = Math.max(mMinHeight, mRemoteIndicator != null ?
434                mRemoteIndicator.getIntrinsicHeight() + getPaddingTop() + getPaddingBottom() : 0);
435
436        int measuredWidth;
437        switch (widthMode) {
438            case MeasureSpec.EXACTLY:
439                measuredWidth = widthSize;
440                break;
441            case MeasureSpec.AT_MOST:
442                measuredWidth = Math.min(widthSize, width);
443                break;
444            default:
445            case MeasureSpec.UNSPECIFIED:
446                measuredWidth = width;
447                break;
448        }
449
450        int measuredHeight;
451        switch (heightMode) {
452            case MeasureSpec.EXACTLY:
453                measuredHeight = heightSize;
454                break;
455            case MeasureSpec.AT_MOST:
456                measuredHeight = Math.min(heightSize, height);
457                break;
458            default:
459            case MeasureSpec.UNSPECIFIED:
460                measuredHeight = height;
461                break;
462        }
463
464        setMeasuredDimension(measuredWidth, measuredHeight);
465    }
466
467    @Override
468    protected void onDraw(Canvas canvas) {
469        super.onDraw(canvas);
470
471        if (mRemoteIndicator != null) {
472            final int left = getPaddingLeft();
473            final int right = getWidth() - getPaddingRight();
474            final int top = getPaddingTop();
475            final int bottom = getHeight() - getPaddingBottom();
476
477            final int drawWidth = mRemoteIndicator.getIntrinsicWidth();
478            final int drawHeight = mRemoteIndicator.getIntrinsicHeight();
479            final int drawLeft = left + (right - left - drawWidth) / 2;
480            final int drawTop = top + (bottom - top - drawHeight) / 2;
481
482            mRemoteIndicator.setBounds(drawLeft, drawTop,
483                    drawLeft + drawWidth, drawTop + drawHeight);
484            mRemoteIndicator.draw(canvas);
485        }
486    }
487
488    void refreshRoute() {
489        if (mAttachedToWindow) {
490            final MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
491            final boolean isRemote = !route.isDefaultOrBluetooth()
492                    && route.matchesSelector(mSelector);
493            final boolean isConnecting = isRemote && route.isConnecting();
494
495            boolean needsRefresh = false;
496            if (mRemoteActive != isRemote) {
497                mRemoteActive = isRemote;
498                needsRefresh = true;
499            }
500            if (mIsConnecting != isConnecting) {
501                mIsConnecting = isConnecting;
502                needsRefresh = true;
503            }
504
505            if (needsRefresh) {
506                updateContentDescription();
507                refreshDrawableState();
508                if (mRemoteIndicator.getCurrent() instanceof AnimationDrawable) {
509                    AnimationDrawable curDrawable =
510                            (AnimationDrawable) mRemoteIndicator.getCurrent();
511                    if (!curDrawable.isRunning()) {
512                        curDrawable.start();
513                    }
514                }
515            }
516
517            setEnabled(mRouter.isRouteAvailable(mSelector,
518                    MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE));
519        }
520    }
521
522    private void updateContentDescription() {
523        int resId;
524        if (mIsConnecting) {
525            resId = R.string.mr_cast_button_connecting;
526        } else if (mRemoteActive) {
527            resId = R.string.mr_cast_button_connected;
528        } else {
529            resId = R.string.mr_cast_button_disconnected;
530        }
531        setContentDescription(getContext().getString(resId));
532    }
533
534    private final class MediaRouterCallback extends MediaRouter.Callback {
535        MediaRouterCallback() {
536        }
537
538        @Override
539        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
540            refreshRoute();
541        }
542
543        @Override
544        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
545            refreshRoute();
546        }
547
548        @Override
549        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
550            refreshRoute();
551        }
552
553        @Override
554        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
555            refreshRoute();
556        }
557
558        @Override
559        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
560            refreshRoute();
561        }
562
563        @Override
564        public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) {
565            refreshRoute();
566        }
567
568        @Override
569        public void onProviderRemoved(MediaRouter router, MediaRouter.ProviderInfo provider) {
570            refreshRoute();
571        }
572
573        @Override
574        public void onProviderChanged(MediaRouter router, MediaRouter.ProviderInfo provider) {
575            refreshRoute();
576        }
577    }
578}
579