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