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