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