Home | History | Annotate | Download | only in app
      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 
    116     public MediaRouteButton(Context context) {
    117         this(context, null);
    118     }
    119 
    120     public MediaRouteButton(Context context, AttributeSet attrs) {
    121         this(context, attrs, R.attr.mediaRouteButtonStyle);
    122     }
    123 
    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
    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      */
    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
    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      */
    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      */
    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 
    268     private FragmentManager getFragmentManager() {
    269         Activity activity = getActivity();
    270         if (activity instanceof FragmentActivity) {
    271             return ((FragmentActivity)activity).getSupportFragmentManager();
    272         }
    273         return null;
    274     }
    275 
    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      */
    292     void setCheatSheetEnabled(boolean enable) {
    293         TooltipCompat.setTooltipText(this,
    294                 enable ? getContext().getString(R.string.mr_button_content_description) : null);
    295     }
    296 
    297     @Override
    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
    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
    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      */
    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
    375     protected boolean verifyDrawable(Drawable who) {
    376         return super.verifyDrawable(who) || who == mRemoteIndicator;
    377     }
    378 
    379     @Override
    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 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
    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     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 
    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 {
    540         MediaRouterCallback() {
    541         }
    542 
    543         @Override
    544         public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
    545             refreshRoute();
    546         }
    547 
    548         @Override
    549         public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
    550             refreshRoute();
    551         }
    552 
    553         @Override
    554         public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
    555             refreshRoute();
    556         }
    557 
    558         @Override
    559         public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
    560             refreshRoute();
    561         }
    562 
    563         @Override
    564         public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
    565             refreshRoute();
    566         }
    567 
    568         @Override
    569         public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) {
    570             refreshRoute();
    571         }
    572 
    573         @Override
    574         public void onProviderRemoved(MediaRouter router, MediaRouter.ProviderInfo provider) {
    575             refreshRoute();
    576         }
    577 
    578         @Override
    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 
    587         RemoteIndicatorLoader(int resId) {
    588             mResId = resId;
    589         }
    590 
    591         @Override
    592         protected Drawable doInBackground(Void... params) {
    593             return getContext().getResources().getDrawable(mResId);
    594         }
    595 
    596         @Override
    597         protected void onPostExecute(Drawable remoteIndicator) {
    598             cacheAndReset(remoteIndicator);
    599             setRemoteIndicatorDrawable(remoteIndicator);
    600         }
    601 
    602         @Override
    603         protected void onCancelled(Drawable remoteIndicator) {
    604             cacheAndReset(remoteIndicator);
    605         }
    606 
    607         private void cacheAndReset(Drawable remoteIndicator) {
    608             if (remoteIndicator != null) {
    609                 sRemoteIndicatorCache.put(mResId, remoteIndicator.getConstantState());
    610             }
    611             mRemoteIndicatorLoader = null;
    612         }
    613     }
    614 }
    615