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