/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.support.v7.app;
import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED;
import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTING;
import android.app.Dialog;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.v7.media.MediaRouteSelector;
import android.support.v7.media.MediaRouter;
import android.support.v7.mediarouter.R;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
/**
* This class implements the route chooser dialog for {@link MediaRouter}.
*
* This dialog allows the user to choose a route that matches a given selector.
*
*
* @see MediaRouteButton
* @see MediaRouteActionProvider
*/
public class MediaRouteChooserDialog extends Dialog {
private static final String TAG = "MediaRouteChooserDialog";
private final MediaRouter mRouter;
private final MediaRouterCallback mCallback;
private MediaRouteSelector mSelector = MediaRouteSelector.EMPTY;
private ArrayList mRoutes;
private RouteAdapter mAdapter;
private ListView mListView;
private boolean mAttachedToWindow;
private AsyncTask mRefreshRoutesTask;
private AsyncTask mOnItemClickTask;
public MediaRouteChooserDialog(Context context) {
this(context, 0);
}
public MediaRouteChooserDialog(Context context, int theme) {
super(MediaRouterThemeHelper.createThemedContext(context, theme), theme);
context = getContext();
mRouter = MediaRouter.getInstance(context);
mCallback = new MediaRouterCallback();
}
/**
* Gets the media route selector for filtering the routes that the user can select.
*
* @return The selector, never null.
*/
@NonNull
public MediaRouteSelector getRouteSelector() {
return mSelector;
}
/**
* Sets the media route selector for filtering the routes that the user can select.
*
* @param selector The selector, must not be null.
*/
public void setRouteSelector(@NonNull MediaRouteSelector selector) {
if (selector == null) {
throw new IllegalArgumentException("selector must not be null");
}
if (!mSelector.equals(selector)) {
mSelector = selector;
if (mAttachedToWindow) {
mRouter.removeCallback(mCallback);
mRouter.addCallback(selector, mCallback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
}
refreshRoutes();
}
}
/**
* Called to filter the set of routes that should be included in the list.
*
* The default implementation iterates over all routes in the provided list and
* removes those for which {@link #onFilterRoute} returns false.
*
*
* @param routes The list of routes to filter in-place, never null.
*/
public void onFilterRoutes(@NonNull List routes) {
for (int i = routes.size(); i-- > 0; ) {
if (!onFilterRoute(routes.get(i))) {
routes.remove(i);
}
}
}
/**
* Returns true if the route should be included in the list.
*
* The default implementation returns true for enabled non-default routes that
* match the selector. Subclasses can override this method to filter routes
* differently.
*
*
* @param route The route to consider, never null.
* @return True if the route should be included in the chooser dialog.
*/
public boolean onFilterRoute(@NonNull MediaRouter.RouteInfo route) {
return !route.isDefaultOrBluetooth() && route.isEnabled()
&& route.matchesSelector(mSelector);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.mr_chooser_dialog);
setTitle(R.string.mr_chooser_title);
mRoutes = new ArrayList<>();
mAdapter = new RouteAdapter(getContext(), mRoutes);
mListView = (ListView)findViewById(R.id.mr_chooser_list);
mListView.setAdapter(mAdapter);
mListView.setOnItemClickListener(mAdapter);
mListView.setEmptyView(findViewById(android.R.id.empty));
updateLayout();
}
/**
* Sets the width of the dialog. Also called when configuration changes.
*/
void updateLayout() {
getWindow().setLayout(MediaRouteDialogHelper.getDialogWidth(getContext()),
ViewGroup.LayoutParams.WRAP_CONTENT);
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
mAttachedToWindow = true;
mRouter.addCallback(mSelector, mCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
refreshRoutes();
}
@Override
public void onDetachedFromWindow() {
mAttachedToWindow = false;
mRouter.removeCallback(mCallback);
super.onDetachedFromWindow();
}
/**
* Refreshes the list of routes that are shown in the chooser dialog.
*/
public void refreshRoutes() {
if (mAttachedToWindow) {
if (mRefreshRoutesTask != null) {
mRefreshRoutesTask.cancel(true);
mRefreshRoutesTask = null;
}
mRefreshRoutesTask = new AsyncTask() {
private ArrayList mNewRoutes;
@Override
protected void onPreExecute() {
mNewRoutes = new ArrayList<>(mRouter.getRoutes());
onFilterRoutes(mNewRoutes);
}
@Override
protected Void doInBackground(Void... params) {
// In API 4 ~ 10, AsyncTasks are running in parallel. Needs synchronization.
synchronized (MediaRouteChooserDialog.this) {
if (!isCancelled()) {
RouteComparator.getInstance(getContext())
.loadRouteUsageScores(mNewRoutes);
}
}
return null;
}
@Override
protected void onPostExecute(Void params) {
mRoutes.clear();
mRoutes.addAll(mNewRoutes);
Collections.sort(mRoutes, RouteComparator.sInstance);
mAdapter.notifyDataSetChanged();
mRefreshRoutesTask = null;
}
}.execute();
}
}
private final class RouteAdapter extends ArrayAdapter
implements ListView.OnItemClickListener {
private final LayoutInflater mInflater;
private final Drawable mDefaultIcon;
private final Drawable mTvIcon;
private final Drawable mSpeakerIcon;
private final Drawable mSpeakerGroupIcon;
public RouteAdapter(Context context, List routes) {
super(context, 0, routes);
mInflater = LayoutInflater.from(context);
TypedArray styledAttributes = getContext().obtainStyledAttributes(new int[] {
R.attr.mediaRouteDefaultIconDrawable,
R.attr.mediaRouteTvIconDrawable,
R.attr.mediaRouteSpeakerIconDrawable,
R.attr.mediaRouteSpeakerGroupIconDrawable});
mDefaultIcon = styledAttributes.getDrawable(0);
mTvIcon = styledAttributes.getDrawable(1);
mSpeakerIcon = styledAttributes.getDrawable(2);
mSpeakerGroupIcon = styledAttributes.getDrawable(3);
styledAttributes.recycle();
}
@Override
public boolean areAllItemsEnabled() {
return false;
}
@Override
public boolean isEnabled(int position) {
return getItem(position).isEnabled();
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
View view = convertView;
if (view == null) {
view = mInflater.inflate(R.layout.mr_chooser_list_item, parent, false);
}
MediaRouter.RouteInfo route = getItem(position);
TextView text1 = (TextView) view.findViewById(R.id.mr_chooser_route_name);
TextView text2 = (TextView) view.findViewById(R.id.mr_chooser_route_desc);
text1.setText(route.getName());
String description = route.getDescription();
boolean isConnectedOrConnecting =
route.getConnectionState() == CONNECTION_STATE_CONNECTED
|| route.getConnectionState() == CONNECTION_STATE_CONNECTING;
if (isConnectedOrConnecting && !TextUtils.isEmpty(description)) {
text1.setGravity(Gravity.BOTTOM);
text2.setVisibility(View.VISIBLE);
text2.setText(description);
} else {
text1.setGravity(Gravity.CENTER_VERTICAL);
text2.setVisibility(View.GONE);
text2.setText("");
}
view.setEnabled(route.isEnabled());
ImageView iconView = (ImageView) view.findViewById(R.id.mr_chooser_route_icon);
if (iconView != null) {
iconView.setImageDrawable(getIconDrawable(route));
}
return view;
}
@Override
public void onItemClick(AdapterView> parent, View view, int position, long id) {
final MediaRouter.RouteInfo route = getItem(position);
if (route.isEnabled() && mOnItemClickTask == null) {
mOnItemClickTask = new AsyncTask() {
@Override
protected void onPreExecute() {
route.select();
}
@Override
protected Void doInBackground(Void... params) {
RouteComparator.getInstance(getContext())
.storeRouteUsageScores(route.getId());
return null;
}
@Override
protected void onPostExecute(Void params) {
dismiss();
mOnItemClickTask = null;
}
}.execute();
}
}
private Drawable getIconDrawable(MediaRouter.RouteInfo route) {
Uri iconUri = route.getIconUri();
if (iconUri != null) {
try {
InputStream is = getContext().getContentResolver().openInputStream(iconUri);
Drawable drawable = Drawable.createFromStream(is, null);
if (drawable != null) {
return drawable;
}
} catch (IOException e) {
Log.w(TAG, "Failed to load " + iconUri, e);
// Falls back.
}
}
return getDefaultIconDrawable(route);
}
private Drawable getDefaultIconDrawable(MediaRouter.RouteInfo route) {
// If the type of the receiver device is specified, use it.
switch (route.getDeviceType()) {
case MediaRouter.RouteInfo.DEVICE_TYPE_TV:
return mTvIcon;
case MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER:
return mSpeakerIcon;
}
// Otherwise, make the best guess based on other route information.
if (route instanceof MediaRouter.RouteGroup) {
// Only speakers can be grouped for now.
return mSpeakerGroupIcon;
}
return mDefaultIcon;
}
}
private final class MediaRouterCallback extends MediaRouter.Callback {
@Override
public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
refreshRoutes();
}
@Override
public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
refreshRoutes();
}
@Override
public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
refreshRoutes();
}
@Override
public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo route) {
dismiss();
}
}
private static final class RouteComparator implements Comparator {
private static final String PREF_ROUTE_IDS =
"android.support.v7.app.MediaRouteChooserDialog_route_ids";
private static final String PREF_USAGE_SCORE_PREFIX =
"android.support.v7.app.MediaRouteChooserDialog_route_usage_score_";
// Routes with the usage score less than MIN_USAGE_SCORE are decayed.
private static final float MIN_USAGE_SCORE = 0.1f;
private static final float USAGE_SCORE_DECAY_FACTOR = 0.95f;
private static RouteComparator sInstance;
private final HashMap mRouteUsageScoreMap;
private final SharedPreferences mPreferences;
public static RouteComparator getInstance(Context context) {
if (sInstance == null) {
sInstance = new RouteComparator(context);
}
return sInstance;
}
private RouteComparator(Context context) {
mRouteUsageScoreMap = new HashMap();
mPreferences = PreferenceManager.getDefaultSharedPreferences(context);
}
@Override
public int compare(MediaRouter.RouteInfo lhs, MediaRouter.RouteInfo rhs) {
if (lhs == null) {
return rhs == null ? 0 : -1;
} else if (rhs == null) {
return 1;
}
Float lhsUsageScore = mRouteUsageScoreMap.get(lhs.getId());
if (lhsUsageScore == null) {
lhsUsageScore = 0f;
}
Float rhsUsageScore = mRouteUsageScoreMap.get(rhs.getId());
if (rhsUsageScore == null) {
rhsUsageScore = 0f;
}
if (!lhsUsageScore.equals(rhsUsageScore)) {
return lhsUsageScore > rhsUsageScore ? -1 : 1;
}
return lhs.getName().compareTo(rhs.getName());
}
private void loadRouteUsageScores(List routes) {
for (MediaRouter.RouteInfo route : routes) {
if (mRouteUsageScoreMap.get(route.getId()) == null) {
mRouteUsageScoreMap.put(route.getId(),
mPreferences.getFloat(PREF_USAGE_SCORE_PREFIX + route.getId(), 0f));
}
}
}
private void storeRouteUsageScores(String selectedRouteId) {
SharedPreferences.Editor prefEditor = mPreferences.edit();
List routeIds = new ArrayList<>(
Arrays.asList(mPreferences.getString(PREF_ROUTE_IDS, "").split(",")));
if (!routeIds.contains(selectedRouteId)) {
routeIds.add(selectedRouteId);
}
StringBuilder routeIdsBuilder = new StringBuilder();
for (String routeId : routeIds) {
// The new route usage score is calculated as follows:
// 1) usageScore * USAGE_SCORE_DECAY_FACTOR + 1, if the route is selected,
// 2) 0, if usageScore * USAGE_SCORE_DECAY_FACTOR < MIN_USAGE_SCORE, or
// 3) usageScore * USAGE_SCORE_DECAY_FACTOR, otherwise,
String routeUsageScoreKey = PREF_USAGE_SCORE_PREFIX + routeId;
float newUsageScore = mPreferences.getFloat(routeUsageScoreKey, 0f)
* USAGE_SCORE_DECAY_FACTOR;
if (selectedRouteId.equals(routeId)) {
newUsageScore += 1f;
}
if (newUsageScore < MIN_USAGE_SCORE) {
mRouteUsageScoreMap.remove(routeId);
prefEditor.remove(routeId);
} else {
mRouteUsageScoreMap.put(routeId, newUsageScore);
prefEditor.putFloat(routeUsageScoreKey, newUsageScore);
if (routeIdsBuilder.length() > 0) {
routeIdsBuilder.append(',');
}
routeIdsBuilder.append(routeId);
}
}
prefEditor.putString(PREF_ROUTE_IDS, routeIdsBuilder.toString());
prefEditor.commit();
}
}
}