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 com.android.support.mediarouter.app;
     18 
     19 import android.annotation.NonNull;
     20 import android.app.Activity;
     21 import android.app.FragmentManager;
     22 import android.content.Context;
     23 import android.content.ContextWrapper;
     24 import android.content.res.ColorStateList;
     25 import android.content.res.Resources;
     26 import android.content.res.TypedArray;
     27 import android.graphics.Canvas;
     28 import android.graphics.drawable.AnimationDrawable;
     29 import android.graphics.drawable.Drawable;
     30 import android.os.AsyncTask;
     31 import android.support.v4.graphics.drawable.DrawableCompat;
     32 import android.support.v7.widget.TooltipCompat;
     33 import android.util.AttributeSet;
     34 import android.util.Log;
     35 import android.util.SparseArray;
     36 import android.view.SoundEffectConstants;
     37 import android.view.View;
     38 
     39 import com.android.media.update.ApiHelper;
     40 import com.android.media.update.R;
     41 import com.android.support.mediarouter.media.MediaRouteSelector;
     42 import 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  */
     80 public 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