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