1/*
2 * Copyright 2018 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 com.android.support.mediarouter.app;
18
19import android.annotation.NonNull;
20import android.app.Activity;
21import android.app.FragmentManager;
22import android.content.Context;
23import android.content.ContextWrapper;
24import android.content.res.ColorStateList;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.graphics.Canvas;
28import android.graphics.drawable.AnimationDrawable;
29import android.graphics.drawable.Drawable;
30import android.os.AsyncTask;
31import android.support.v4.graphics.drawable.DrawableCompat;
32import android.support.v7.widget.TooltipCompat;
33import android.util.AttributeSet;
34import android.util.Log;
35import android.util.SparseArray;
36import android.view.SoundEffectConstants;
37import android.view.View;
38
39import com.android.media.update.ApiHelper;
40import com.android.media.update.R;
41import com.android.support.mediarouter.media.MediaRouteSelector;
42import com.android.support.mediarouter.media.MediaRouter;
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 int mRouteCallbackFlags;
93    private MediaRouteDialogFactory mDialogFactory = MediaRouteDialogFactory.getDefault();
94
95    private boolean mAttachedToWindow;
96
97    private static final SparseArray<Drawable.ConstantState> sRemoteIndicatorCache =
98            new SparseArray<>(2);
99    private RemoteIndicatorLoader mRemoteIndicatorLoader;
100    private Drawable mRemoteIndicator;
101    private boolean mRemoteActive;
102    private boolean mIsConnecting;
103
104    private ColorStateList mButtonTint;
105    private int mMinWidth;
106    private int mMinHeight;
107
108    // The checked state is used when connected to a remote route.
109    private static final int[] CHECKED_STATE_SET = {
110        android.R.attr.state_checked
111    };
112
113    // The checkable state is used while connecting to a remote route.
114    private static final int[] CHECKABLE_STATE_SET = {
115        android.R.attr.state_checkable
116    };
117
118    public MediaRouteButton(Context context) {
119        this(context, null);
120    }
121
122    public MediaRouteButton(Context context, AttributeSet attrs) {
123        this(context, attrs, R.attr.mediaRouteButtonStyle);
124    }
125
126    public MediaRouteButton(Context context, AttributeSet attrs, int defStyleAttr) {
127        super(MediaRouterThemeHelper.createThemedButtonContext(context), attrs, defStyleAttr);
128        context = getContext();
129
130        mRouter = MediaRouter.getInstance(context);
131        mCallback = new MediaRouterCallback();
132
133        Resources.Theme theme = ApiHelper.getLibResources(context).newTheme();
134        theme.applyStyle(MediaRouterThemeHelper.getRouterThemeId(context), true);
135        TypedArray a = theme.obtainStyledAttributes(attrs,
136                R.styleable.MediaRouteButton, defStyleAttr, 0);
137
138        mButtonTint = a.getColorStateList(R.styleable.MediaRouteButton_mediaRouteButtonTint);
139        mMinWidth = a.getDimensionPixelSize(
140                R.styleable.MediaRouteButton_android_minWidth, 0);
141        mMinHeight = a.getDimensionPixelSize(
142                R.styleable.MediaRouteButton_android_minHeight, 0);
143        int remoteIndicatorResId = a.getResourceId(
144                R.styleable.MediaRouteButton_externalRouteEnabledDrawable, 0);
145        a.recycle();
146
147        if (remoteIndicatorResId != 0) {
148            Drawable.ConstantState remoteIndicatorState =
149                    sRemoteIndicatorCache.get(remoteIndicatorResId);
150            if (remoteIndicatorState != null) {
151                setRemoteIndicatorDrawable(remoteIndicatorState.newDrawable());
152            } else {
153                mRemoteIndicatorLoader = new RemoteIndicatorLoader(remoteIndicatorResId);
154                mRemoteIndicatorLoader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
155            }
156        }
157
158        updateContentDescription();
159        setClickable(true);
160    }
161
162    /**
163     * Gets the media route selector for filtering the routes that the user can
164     * select using the media route chooser dialog.
165     *
166     * @return The selector, never null.
167     */
168    @NonNull
169    public MediaRouteSelector getRouteSelector() {
170        return mSelector;
171    }
172
173    /**
174     * Sets the media route selector for filtering the routes that the user can
175     * select using the media route chooser dialog.
176     *
177     * @param selector The selector.
178     */
179    public void setRouteSelector(MediaRouteSelector selector) {
180        setRouteSelector(selector, 0);
181    }
182
183    /**
184     * Sets the media route selector for filtering the routes that the user can
185     * select using the media route chooser dialog.
186     *
187     * @param selector The selector.
188     * @param flags Flags to control the behavior of the callback. May be zero or a combination of
189     *              {@link #MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN} and
190     *              {@link #MediaRouter.CALLBACK_FLAG_UNFILTERED_EVENTS}.
191     */
192    public void setRouteSelector(MediaRouteSelector selector, int flags) {
193        if (mSelector.equals(selector) && mRouteCallbackFlags == flags) {
194            return;
195        }
196        if (!mSelector.isEmpty()) {
197            mRouter.removeCallback(mCallback);
198        }
199        if (selector == null || selector.isEmpty()) {
200            mSelector = MediaRouteSelector.EMPTY;
201            return;
202        }
203
204        mSelector = selector;
205        mRouteCallbackFlags = flags;
206
207        if (mAttachedToWindow) {
208            mRouter.addCallback(selector, mCallback, flags);
209            refreshRoute();
210        }
211    }
212
213    /**
214     * Gets the media route dialog factory to use when showing the route chooser
215     * or controller dialog.
216     *
217     * @return The dialog factory, never null.
218     */
219    @NonNull
220    public MediaRouteDialogFactory getDialogFactory() {
221        return mDialogFactory;
222    }
223
224    /**
225     * Sets the media route dialog factory to use when showing the route chooser
226     * or controller dialog.
227     *
228     * @param factory The dialog factory, must not be null.
229     */
230    public void setDialogFactory(@NonNull MediaRouteDialogFactory factory) {
231        if (factory == null) {
232            throw new IllegalArgumentException("factory must not be null");
233        }
234
235        mDialogFactory = factory;
236    }
237
238    /**
239     * Show the route chooser or controller dialog.
240     * <p>
241     * If the default route is selected or if the currently selected route does
242     * not match the {@link #getRouteSelector selector}, then shows the route chooser dialog.
243     * Otherwise, shows the route controller dialog to offer the user
244     * a choice to disconnect from the route or perform other control actions
245     * such as setting the route's volume.
246     * </p><p>
247     * The application can customize the dialogs by calling {@link #setDialogFactory}
248     * to provide a customized dialog factory.
249     * </p>
250     *
251     * @return True if the dialog was actually shown.
252     *
253     * @throws IllegalStateException if the activity is not a subclass of
254     * {@link FragmentActivity}.
255     */
256    public boolean showDialog() {
257        if (!mAttachedToWindow) {
258            return false;
259        }
260
261        final FragmentManager fm = getActivity().getFragmentManager();
262        if (fm == null) {
263            throw new IllegalStateException("The activity must be a subclass of FragmentActivity");
264        }
265
266        MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
267        if (route.isDefaultOrBluetooth() || !route.matchesSelector(mSelector)) {
268            if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) {
269                Log.w(TAG, "showDialog(): Route chooser dialog already showing!");
270                return false;
271            }
272            MediaRouteChooserDialogFragment f =
273                    mDialogFactory.onCreateChooserDialogFragment();
274            f.setRouteSelector(mSelector);
275            f.show(fm, CHOOSER_FRAGMENT_TAG);
276        } else {
277            if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) {
278                Log.w(TAG, "showDialog(): Route controller dialog already showing!");
279                return false;
280            }
281            MediaRouteControllerDialogFragment f =
282                    mDialogFactory.onCreateControllerDialogFragment();
283            f.show(fm, CONTROLLER_FRAGMENT_TAG);
284        }
285        return true;
286    }
287
288
289    private Activity getActivity() {
290        // Gross way of unwrapping the Activity so we can get the FragmentManager
291        Context context = getContext();
292        while (context instanceof ContextWrapper) {
293            if (context instanceof Activity) {
294                return (Activity)context;
295            }
296            context = ((ContextWrapper)context).getBaseContext();
297        }
298        return null;
299    }
300
301    /**
302     * Sets whether to enable showing a toast with the content descriptor of the
303     * button when the button is long pressed.
304     */
305    void setCheatSheetEnabled(boolean enable) {
306        TooltipCompat.setTooltipText(this, enable
307                ? ApiHelper.getLibResources(getContext())
308                    .getString(R.string.mr_button_content_description)
309                : null);
310    }
311
312    @Override
313    public boolean performClick() {
314        // Send the appropriate accessibility events and call listeners
315        boolean handled = super.performClick();
316        if (!handled) {
317            playSoundEffect(SoundEffectConstants.CLICK);
318        }
319        return showDialog() || handled;
320    }
321
322    @Override
323    protected int[] onCreateDrawableState(int extraSpace) {
324        final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
325
326        // Technically we should be handling this more completely, but these
327        // are implementation details here. Checkable is used to express the connecting
328        // drawable state and it's mutually exclusive with check for the purposes
329        // of state selection here.
330        if (mIsConnecting) {
331            mergeDrawableStates(drawableState, CHECKABLE_STATE_SET);
332        } else if (mRemoteActive) {
333            mergeDrawableStates(drawableState, CHECKED_STATE_SET);
334        }
335        return drawableState;
336    }
337
338    @Override
339    protected void drawableStateChanged() {
340        super.drawableStateChanged();
341
342        if (mRemoteIndicator != null) {
343            int[] myDrawableState = getDrawableState();
344            mRemoteIndicator.setState(myDrawableState);
345            invalidate();
346        }
347    }
348
349    /**
350     * Sets a drawable to use as the remote route indicator.
351     */
352    public void setRemoteIndicatorDrawable(Drawable d) {
353        if (mRemoteIndicatorLoader != null) {
354            mRemoteIndicatorLoader.cancel(false);
355        }
356
357        if (mRemoteIndicator != null) {
358            mRemoteIndicator.setCallback(null);
359            unscheduleDrawable(mRemoteIndicator);
360        }
361        if (d != null) {
362            if (mButtonTint != null) {
363                d = DrawableCompat.wrap(d.mutate());
364                DrawableCompat.setTintList(d, mButtonTint);
365            }
366            d.setCallback(this);
367            d.setState(getDrawableState());
368            d.setVisible(getVisibility() == VISIBLE, false);
369        }
370        mRemoteIndicator = d;
371
372        refreshDrawableState();
373        if (mAttachedToWindow && mRemoteIndicator != null
374                && mRemoteIndicator.getCurrent() instanceof AnimationDrawable) {
375            AnimationDrawable curDrawable = (AnimationDrawable) mRemoteIndicator.getCurrent();
376            if (mIsConnecting) {
377                if (!curDrawable.isRunning()) {
378                    curDrawable.start();
379                }
380            } else if (mRemoteActive) {
381                if (curDrawable.isRunning()) {
382                    curDrawable.stop();
383                }
384                curDrawable.selectDrawable(curDrawable.getNumberOfFrames() - 1);
385            }
386        }
387    }
388
389    @Override
390    protected boolean verifyDrawable(Drawable who) {
391        return super.verifyDrawable(who) || who == mRemoteIndicator;
392    }
393
394    @Override
395    public void jumpDrawablesToCurrentState() {
396        // We can't call super to handle the background so we do it ourselves.
397        //super.jumpDrawablesToCurrentState();
398        if (getBackground() != null) {
399            DrawableCompat.jumpToCurrentState(getBackground());
400        }
401
402        // Handle our own remote indicator.
403        if (mRemoteIndicator != null) {
404            DrawableCompat.jumpToCurrentState(mRemoteIndicator);
405        }
406    }
407
408    @Override
409    public void setVisibility(int visibility) {
410        super.setVisibility(visibility);
411
412        if (mRemoteIndicator != null) {
413            mRemoteIndicator.setVisible(getVisibility() == VISIBLE, false);
414        }
415    }
416
417    @Override
418    public void onAttachedToWindow() {
419        super.onAttachedToWindow();
420
421        mAttachedToWindow = true;
422        if (!mSelector.isEmpty()) {
423            mRouter.addCallback(mSelector, mCallback, mRouteCallbackFlags);
424        }
425        refreshRoute();
426    }
427
428    @Override
429    public void onDetachedFromWindow() {
430        mAttachedToWindow = false;
431        if (!mSelector.isEmpty()) {
432            mRouter.removeCallback(mCallback);
433        }
434
435        super.onDetachedFromWindow();
436    }
437
438    @Override
439    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
440        final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
441        final int heightSize = MeasureSpec.getSize(heightMeasureSpec);
442        final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
443        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
444
445        final int width = Math.max(mMinWidth, mRemoteIndicator != null ?
446                mRemoteIndicator.getIntrinsicWidth() + getPaddingLeft() + getPaddingRight() : 0);
447        final int height = Math.max(mMinHeight, mRemoteIndicator != null ?
448                mRemoteIndicator.getIntrinsicHeight() + getPaddingTop() + getPaddingBottom() : 0);
449
450        int measuredWidth;
451        switch (widthMode) {
452            case MeasureSpec.EXACTLY:
453                measuredWidth = widthSize;
454                break;
455            case MeasureSpec.AT_MOST:
456                measuredWidth = Math.min(widthSize, width);
457                break;
458            default:
459            case MeasureSpec.UNSPECIFIED:
460                measuredWidth = width;
461                break;
462        }
463
464        int measuredHeight;
465        switch (heightMode) {
466            case MeasureSpec.EXACTLY:
467                measuredHeight = heightSize;
468                break;
469            case MeasureSpec.AT_MOST:
470                measuredHeight = Math.min(heightSize, height);
471                break;
472            default:
473            case MeasureSpec.UNSPECIFIED:
474                measuredHeight = height;
475                break;
476        }
477
478        setMeasuredDimension(measuredWidth, measuredHeight);
479    }
480
481    @Override
482    protected void onDraw(Canvas canvas) {
483        super.onDraw(canvas);
484
485        if (mRemoteIndicator != null) {
486            final int left = getPaddingLeft();
487            final int right = getWidth() - getPaddingRight();
488            final int top = getPaddingTop();
489            final int bottom = getHeight() - getPaddingBottom();
490
491            final int drawWidth = mRemoteIndicator.getIntrinsicWidth();
492            final int drawHeight = mRemoteIndicator.getIntrinsicHeight();
493            final int drawLeft = left + (right - left - drawWidth) / 2;
494            final int drawTop = top + (bottom - top - drawHeight) / 2;
495
496            mRemoteIndicator.setBounds(drawLeft, drawTop,
497                    drawLeft + drawWidth, drawTop + drawHeight);
498            mRemoteIndicator.draw(canvas);
499        }
500    }
501
502    void refreshRoute() {
503        final MediaRouter.RouteInfo route = mRouter.getSelectedRoute();
504        final boolean isRemote = !route.isDefaultOrBluetooth() && route.matchesSelector(mSelector);
505        final boolean isConnecting = isRemote && route.isConnecting();
506        boolean needsRefresh = false;
507        if (mRemoteActive != isRemote) {
508            mRemoteActive = isRemote;
509            needsRefresh = true;
510        }
511        if (mIsConnecting != isConnecting) {
512            mIsConnecting = isConnecting;
513            needsRefresh = true;
514        }
515
516        if (needsRefresh) {
517            updateContentDescription();
518            refreshDrawableState();
519        }
520        if (mAttachedToWindow) {
521            setEnabled(mRouter.isRouteAvailable(mSelector,
522                    MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE));
523        }
524        if (mRemoteIndicator != null
525                && mRemoteIndicator.getCurrent() instanceof AnimationDrawable) {
526            AnimationDrawable curDrawable = (AnimationDrawable) mRemoteIndicator.getCurrent();
527            if (mAttachedToWindow) {
528                if ((needsRefresh || isConnecting) && !curDrawable.isRunning()) {
529                    curDrawable.start();
530                }
531            } else if (isRemote && !isConnecting) {
532                // When the route is already connected before the view is attached, show the last
533                // frame of the connected animation immediately.
534                if (curDrawable.isRunning()) {
535                    curDrawable.stop();
536                }
537                curDrawable.selectDrawable(curDrawable.getNumberOfFrames() - 1);
538            }
539        }
540    }
541
542    private void updateContentDescription() {
543        int resId;
544        if (mIsConnecting) {
545            resId = R.string.mr_cast_button_connecting;
546        } else if (mRemoteActive) {
547            resId = R.string.mr_cast_button_connected;
548        } else {
549            resId = R.string.mr_cast_button_disconnected;
550        }
551        setContentDescription(ApiHelper.getLibResources(getContext()).getString(resId));
552    }
553
554    private final class MediaRouterCallback extends MediaRouter.Callback {
555        MediaRouterCallback() {
556        }
557
558        @Override
559        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
560            refreshRoute();
561        }
562
563        @Override
564        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
565            refreshRoute();
566        }
567
568        @Override
569        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
570            refreshRoute();
571        }
572
573        @Override
574        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
575            refreshRoute();
576        }
577
578        @Override
579        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
580            refreshRoute();
581        }
582
583        @Override
584        public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) {
585            refreshRoute();
586        }
587
588        @Override
589        public void onProviderRemoved(MediaRouter router, MediaRouter.ProviderInfo provider) {
590            refreshRoute();
591        }
592
593        @Override
594        public void onProviderChanged(MediaRouter router, MediaRouter.ProviderInfo provider) {
595            refreshRoute();
596        }
597    }
598
599    private final class RemoteIndicatorLoader extends AsyncTask<Void, Void, Drawable> {
600        private final int mResId;
601
602        RemoteIndicatorLoader(int resId) {
603            mResId = resId;
604        }
605
606        @Override
607        protected Drawable doInBackground(Void... params) {
608            return ApiHelper.getLibResources(getContext()).getDrawable(mResId);
609        }
610
611        @Override
612        protected void onPostExecute(Drawable remoteIndicator) {
613            cacheAndReset(remoteIndicator);
614            setRemoteIndicatorDrawable(remoteIndicator);
615        }
616
617        @Override
618        protected void onCancelled(Drawable remoteIndicator) {
619            cacheAndReset(remoteIndicator);
620        }
621
622        private void cacheAndReset(Drawable remoteIndicator) {
623            if (remoteIndicator != null) {
624                sRemoteIndicatorCache.put(mResId, remoteIndicator.getConstantState());
625            }
626            mRemoteIndicatorLoader = null;
627        }
628    }
629}
630