1 /* 2 * Copyright (C) 2013 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 android.support.v7.app; 18 19 import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED; 20 import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTING; 21 22 import android.app.Dialog; 23 import android.content.Context; 24 import android.content.SharedPreferences; 25 import android.content.res.TypedArray; 26 import android.graphics.drawable.Drawable; 27 import android.net.Uri; 28 import android.os.AsyncTask; 29 import android.os.Bundle; 30 import android.preference.PreferenceManager; 31 import android.support.annotation.NonNull; 32 import android.support.v7.media.MediaRouteSelector; 33 import android.support.v7.media.MediaRouter; 34 import android.support.v7.mediarouter.R; 35 import android.text.TextUtils; 36 import android.util.Log; 37 import android.view.Gravity; 38 import android.view.LayoutInflater; 39 import android.view.View; 40 import android.view.ViewGroup; 41 import android.widget.AdapterView; 42 import android.widget.ArrayAdapter; 43 import android.widget.ImageView; 44 import android.widget.ListView; 45 import android.widget.TextView; 46 47 import java.io.IOException; 48 import java.io.InputStream; 49 import java.util.ArrayList; 50 import java.util.Arrays; 51 import java.util.Collections; 52 import java.util.Comparator; 53 import java.util.HashMap; 54 import java.util.List; 55 56 /** 57 * This class implements the route chooser dialog for {@link MediaRouter}. 58 * <p> 59 * This dialog allows the user to choose a route that matches a given selector. 60 * </p> 61 * 62 * @see MediaRouteButton 63 * @see MediaRouteActionProvider 64 */ 65 public class MediaRouteChooserDialog extends Dialog { 66 private static final String TAG = "MediaRouteChooserDialog"; 67 68 private final MediaRouter mRouter; 69 private final MediaRouterCallback mCallback; 70 71 private MediaRouteSelector mSelector = MediaRouteSelector.EMPTY; 72 private ArrayList<MediaRouter.RouteInfo> mRoutes; 73 private RouteAdapter mAdapter; 74 private ListView mListView; 75 private boolean mAttachedToWindow; 76 private AsyncTask<Void, Void, Void> mRefreshRoutesTask; 77 private AsyncTask<Void, Void, Void> mOnItemClickTask; 78 MediaRouteChooserDialog(Context context)79 public MediaRouteChooserDialog(Context context) { 80 this(context, 0); 81 } 82 MediaRouteChooserDialog(Context context, int theme)83 public MediaRouteChooserDialog(Context context, int theme) { 84 super(MediaRouterThemeHelper.createThemedContext(context, theme), theme); 85 context = getContext(); 86 87 mRouter = MediaRouter.getInstance(context); 88 mCallback = new MediaRouterCallback(); 89 } 90 91 /** 92 * Gets the media route selector for filtering the routes that the user can select. 93 * 94 * @return The selector, never null. 95 */ 96 @NonNull getRouteSelector()97 public MediaRouteSelector getRouteSelector() { 98 return mSelector; 99 } 100 101 /** 102 * Sets the media route selector for filtering the routes that the user can select. 103 * 104 * @param selector The selector, must not be null. 105 */ setRouteSelector(@onNull MediaRouteSelector selector)106 public void setRouteSelector(@NonNull MediaRouteSelector selector) { 107 if (selector == null) { 108 throw new IllegalArgumentException("selector must not be null"); 109 } 110 111 if (!mSelector.equals(selector)) { 112 mSelector = selector; 113 114 if (mAttachedToWindow) { 115 mRouter.removeCallback(mCallback); 116 mRouter.addCallback(selector, mCallback, 117 MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); 118 } 119 120 refreshRoutes(); 121 } 122 } 123 124 /** 125 * Called to filter the set of routes that should be included in the list. 126 * <p> 127 * The default implementation iterates over all routes in the provided list and 128 * removes those for which {@link #onFilterRoute} returns false. 129 * </p> 130 * 131 * @param routes The list of routes to filter in-place, never null. 132 */ onFilterRoutes(@onNull List<MediaRouter.RouteInfo> routes)133 public void onFilterRoutes(@NonNull List<MediaRouter.RouteInfo> routes) { 134 for (int i = routes.size(); i-- > 0; ) { 135 if (!onFilterRoute(routes.get(i))) { 136 routes.remove(i); 137 } 138 } 139 } 140 141 /** 142 * Returns true if the route should be included in the list. 143 * <p> 144 * The default implementation returns true for enabled non-default routes that 145 * match the selector. Subclasses can override this method to filter routes 146 * differently. 147 * </p> 148 * 149 * @param route The route to consider, never null. 150 * @return True if the route should be included in the chooser dialog. 151 */ onFilterRoute(@onNull MediaRouter.RouteInfo route)152 public boolean onFilterRoute(@NonNull MediaRouter.RouteInfo route) { 153 return !route.isDefaultOrBluetooth() && route.isEnabled() 154 && route.matchesSelector(mSelector); 155 } 156 157 @Override onCreate(Bundle savedInstanceState)158 protected void onCreate(Bundle savedInstanceState) { 159 super.onCreate(savedInstanceState); 160 161 setContentView(R.layout.mr_chooser_dialog); 162 setTitle(R.string.mr_chooser_title); 163 164 mRoutes = new ArrayList<>(); 165 mAdapter = new RouteAdapter(getContext(), mRoutes); 166 mListView = (ListView)findViewById(R.id.mr_chooser_list); 167 mListView.setAdapter(mAdapter); 168 mListView.setOnItemClickListener(mAdapter); 169 mListView.setEmptyView(findViewById(android.R.id.empty)); 170 171 updateLayout(); 172 } 173 174 /** 175 * Sets the width of the dialog. Also called when configuration changes. 176 */ updateLayout()177 void updateLayout() { 178 getWindow().setLayout(MediaRouteDialogHelper.getDialogWidth(getContext()), 179 ViewGroup.LayoutParams.WRAP_CONTENT); 180 } 181 182 @Override onAttachedToWindow()183 public void onAttachedToWindow() { 184 super.onAttachedToWindow(); 185 186 mAttachedToWindow = true; 187 mRouter.addCallback(mSelector, mCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); 188 refreshRoutes(); 189 } 190 191 @Override onDetachedFromWindow()192 public void onDetachedFromWindow() { 193 mAttachedToWindow = false; 194 mRouter.removeCallback(mCallback); 195 196 super.onDetachedFromWindow(); 197 } 198 199 /** 200 * Refreshes the list of routes that are shown in the chooser dialog. 201 */ refreshRoutes()202 public void refreshRoutes() { 203 if (mAttachedToWindow) { 204 if (mRefreshRoutesTask != null) { 205 mRefreshRoutesTask.cancel(true); 206 mRefreshRoutesTask = null; 207 } 208 mRefreshRoutesTask = new AsyncTask<Void, Void, Void>() { 209 private ArrayList<MediaRouter.RouteInfo> mNewRoutes; 210 211 @Override 212 protected void onPreExecute() { 213 mNewRoutes = new ArrayList<>(mRouter.getRoutes()); 214 onFilterRoutes(mNewRoutes); 215 } 216 217 @Override 218 protected Void doInBackground(Void... params) { 219 // In API 4 ~ 10, AsyncTasks are running in parallel. Needs synchronization. 220 synchronized (MediaRouteChooserDialog.this) { 221 if (!isCancelled()) { 222 RouteComparator.getInstance(getContext()) 223 .loadRouteUsageScores(mNewRoutes); 224 } 225 } 226 return null; 227 } 228 229 @Override 230 protected void onPostExecute(Void params) { 231 mRoutes.clear(); 232 mRoutes.addAll(mNewRoutes); 233 Collections.sort(mRoutes, RouteComparator.sInstance); 234 mAdapter.notifyDataSetChanged(); 235 mRefreshRoutesTask = null; 236 } 237 }.execute(); 238 } 239 } 240 241 private final class RouteAdapter extends ArrayAdapter<MediaRouter.RouteInfo> 242 implements ListView.OnItemClickListener { 243 private final LayoutInflater mInflater; 244 private final Drawable mDefaultIcon; 245 private final Drawable mTvIcon; 246 private final Drawable mSpeakerIcon; 247 private final Drawable mSpeakerGroupIcon; 248 RouteAdapter(Context context, List<MediaRouter.RouteInfo> routes)249 public RouteAdapter(Context context, List<MediaRouter.RouteInfo> routes) { 250 super(context, 0, routes); 251 mInflater = LayoutInflater.from(context); 252 TypedArray styledAttributes = getContext().obtainStyledAttributes(new int[] { 253 R.attr.mediaRouteDefaultIconDrawable, 254 R.attr.mediaRouteTvIconDrawable, 255 R.attr.mediaRouteSpeakerIconDrawable, 256 R.attr.mediaRouteSpeakerGroupIconDrawable}); 257 mDefaultIcon = styledAttributes.getDrawable(0); 258 mTvIcon = styledAttributes.getDrawable(1); 259 mSpeakerIcon = styledAttributes.getDrawable(2); 260 mSpeakerGroupIcon = styledAttributes.getDrawable(3); 261 styledAttributes.recycle(); 262 } 263 264 @Override areAllItemsEnabled()265 public boolean areAllItemsEnabled() { 266 return false; 267 } 268 269 @Override isEnabled(int position)270 public boolean isEnabled(int position) { 271 return getItem(position).isEnabled(); 272 } 273 274 @Override getView(int position, View convertView, ViewGroup parent)275 public View getView(int position, View convertView, ViewGroup parent) { 276 View view = convertView; 277 if (view == null) { 278 view = mInflater.inflate(R.layout.mr_chooser_list_item, parent, false); 279 } 280 281 MediaRouter.RouteInfo route = getItem(position); 282 TextView text1 = (TextView) view.findViewById(R.id.mr_chooser_route_name); 283 TextView text2 = (TextView) view.findViewById(R.id.mr_chooser_route_desc); 284 text1.setText(route.getName()); 285 String description = route.getDescription(); 286 boolean isConnectedOrConnecting = 287 route.getConnectionState() == CONNECTION_STATE_CONNECTED 288 || route.getConnectionState() == CONNECTION_STATE_CONNECTING; 289 if (isConnectedOrConnecting && !TextUtils.isEmpty(description)) { 290 text1.setGravity(Gravity.BOTTOM); 291 text2.setVisibility(View.VISIBLE); 292 text2.setText(description); 293 } else { 294 text1.setGravity(Gravity.CENTER_VERTICAL); 295 text2.setVisibility(View.GONE); 296 text2.setText(""); 297 } 298 view.setEnabled(route.isEnabled()); 299 300 ImageView iconView = (ImageView) view.findViewById(R.id.mr_chooser_route_icon); 301 if (iconView != null) { 302 iconView.setImageDrawable(getIconDrawable(route)); 303 } 304 return view; 305 } 306 307 @Override onItemClick(AdapterView<?> parent, View view, int position, long id)308 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 309 final MediaRouter.RouteInfo route = getItem(position); 310 if (route.isEnabled() && mOnItemClickTask == null) { 311 mOnItemClickTask = new AsyncTask<Void, Void, Void>() { 312 @Override 313 protected void onPreExecute() { 314 route.select(); 315 } 316 317 @Override 318 protected Void doInBackground(Void... params) { 319 RouteComparator.getInstance(getContext()) 320 .storeRouteUsageScores(route.getId()); 321 return null; 322 } 323 324 @Override 325 protected void onPostExecute(Void params) { 326 dismiss(); 327 mOnItemClickTask = null; 328 } 329 }.execute(); 330 } 331 } 332 getIconDrawable(MediaRouter.RouteInfo route)333 private Drawable getIconDrawable(MediaRouter.RouteInfo route) { 334 Uri iconUri = route.getIconUri(); 335 if (iconUri != null) { 336 try { 337 InputStream is = getContext().getContentResolver().openInputStream(iconUri); 338 Drawable drawable = Drawable.createFromStream(is, null); 339 if (drawable != null) { 340 return drawable; 341 } 342 } catch (IOException e) { 343 Log.w(TAG, "Failed to load " + iconUri, e); 344 // Falls back. 345 } 346 } 347 return getDefaultIconDrawable(route); 348 } 349 getDefaultIconDrawable(MediaRouter.RouteInfo route)350 private Drawable getDefaultIconDrawable(MediaRouter.RouteInfo route) { 351 // If the type of the receiver device is specified, use it. 352 switch (route.getDeviceType()) { 353 case MediaRouter.RouteInfo.DEVICE_TYPE_TV: 354 return mTvIcon; 355 case MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER: 356 return mSpeakerIcon; 357 } 358 359 // Otherwise, make the best guess based on other route information. 360 if (route instanceof MediaRouter.RouteGroup) { 361 // Only speakers can be grouped for now. 362 return mSpeakerGroupIcon; 363 } 364 return mDefaultIcon; 365 } 366 } 367 368 private final class MediaRouterCallback extends MediaRouter.Callback { 369 @Override onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info)370 public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) { 371 refreshRoutes(); 372 } 373 374 @Override onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info)375 public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) { 376 refreshRoutes(); 377 } 378 379 @Override onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info)380 public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) { 381 refreshRoutes(); 382 } 383 384 @Override onRouteSelected(MediaRouter router, MediaRouter.RouteInfo route)385 public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo route) { 386 dismiss(); 387 } 388 } 389 390 private static final class RouteComparator implements Comparator<MediaRouter.RouteInfo> { 391 private static final String PREF_ROUTE_IDS = 392 "android.support.v7.app.MediaRouteChooserDialog_route_ids"; 393 private static final String PREF_USAGE_SCORE_PREFIX = 394 "android.support.v7.app.MediaRouteChooserDialog_route_usage_score_"; 395 // Routes with the usage score less than MIN_USAGE_SCORE are decayed. 396 private static final float MIN_USAGE_SCORE = 0.1f; 397 private static final float USAGE_SCORE_DECAY_FACTOR = 0.95f; 398 399 private static RouteComparator sInstance; 400 private final HashMap<String, Float> mRouteUsageScoreMap; 401 private final SharedPreferences mPreferences; 402 getInstance(Context context)403 public static RouteComparator getInstance(Context context) { 404 if (sInstance == null) { 405 sInstance = new RouteComparator(context); 406 } 407 return sInstance; 408 } 409 RouteComparator(Context context)410 private RouteComparator(Context context) { 411 mRouteUsageScoreMap = new HashMap(); 412 mPreferences = PreferenceManager.getDefaultSharedPreferences(context); 413 } 414 415 @Override compare(MediaRouter.RouteInfo lhs, MediaRouter.RouteInfo rhs)416 public int compare(MediaRouter.RouteInfo lhs, MediaRouter.RouteInfo rhs) { 417 if (lhs == null) { 418 return rhs == null ? 0 : -1; 419 } else if (rhs == null) { 420 return 1; 421 } 422 Float lhsUsageScore = mRouteUsageScoreMap.get(lhs.getId()); 423 if (lhsUsageScore == null) { 424 lhsUsageScore = 0f; 425 } 426 Float rhsUsageScore = mRouteUsageScoreMap.get(rhs.getId()); 427 if (rhsUsageScore == null) { 428 rhsUsageScore = 0f; 429 } 430 if (!lhsUsageScore.equals(rhsUsageScore)) { 431 return lhsUsageScore > rhsUsageScore ? -1 : 1; 432 } 433 return lhs.getName().compareTo(rhs.getName()); 434 } 435 loadRouteUsageScores(List<MediaRouter.RouteInfo> routes)436 private void loadRouteUsageScores(List<MediaRouter.RouteInfo> routes) { 437 for (MediaRouter.RouteInfo route : routes) { 438 if (mRouteUsageScoreMap.get(route.getId()) == null) { 439 mRouteUsageScoreMap.put(route.getId(), 440 mPreferences.getFloat(PREF_USAGE_SCORE_PREFIX + route.getId(), 0f)); 441 } 442 } 443 } 444 storeRouteUsageScores(String selectedRouteId)445 private void storeRouteUsageScores(String selectedRouteId) { 446 SharedPreferences.Editor prefEditor = mPreferences.edit(); 447 List<String> routeIds = new ArrayList<>( 448 Arrays.asList(mPreferences.getString(PREF_ROUTE_IDS, "").split(","))); 449 if (!routeIds.contains(selectedRouteId)) { 450 routeIds.add(selectedRouteId); 451 } 452 StringBuilder routeIdsBuilder = new StringBuilder(); 453 for (String routeId : routeIds) { 454 // The new route usage score is calculated as follows: 455 // 1) usageScore * USAGE_SCORE_DECAY_FACTOR + 1, if the route is selected, 456 // 2) 0, if usageScore * USAGE_SCORE_DECAY_FACTOR < MIN_USAGE_SCORE, or 457 // 3) usageScore * USAGE_SCORE_DECAY_FACTOR, otherwise, 458 String routeUsageScoreKey = PREF_USAGE_SCORE_PREFIX + routeId; 459 float newUsageScore = mPreferences.getFloat(routeUsageScoreKey, 0f) 460 * USAGE_SCORE_DECAY_FACTOR; 461 if (selectedRouteId.equals(routeId)) { 462 newUsageScore += 1f; 463 } 464 if (newUsageScore < MIN_USAGE_SCORE) { 465 mRouteUsageScoreMap.remove(routeId); 466 prefEditor.remove(routeId); 467 } else { 468 mRouteUsageScoreMap.put(routeId, newUsageScore); 469 prefEditor.putFloat(routeUsageScoreKey, newUsageScore); 470 if (routeIdsBuilder.length() > 0) { 471 routeIdsBuilder.append(','); 472 } 473 routeIdsBuilder.append(routeId); 474 } 475 } 476 prefEditor.putString(PREF_ROUTE_IDS, routeIdsBuilder.toString()); 477 prefEditor.commit(); 478 } 479 } 480 } 481