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