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