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
17package android.support.v7.app;
18
19import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED;
20import static android.support.v7.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTING;
21
22import android.app.Dialog;
23import android.content.Context;
24import android.content.SharedPreferences;
25import android.content.res.TypedArray;
26import android.graphics.drawable.Drawable;
27import android.net.Uri;
28import android.os.AsyncTask;
29import android.os.Bundle;
30import android.preference.PreferenceManager;
31import android.support.annotation.NonNull;
32import android.support.v7.media.MediaRouteSelector;
33import android.support.v7.media.MediaRouter;
34import android.support.v7.mediarouter.R;
35import android.text.TextUtils;
36import android.util.Log;
37import android.view.Gravity;
38import android.view.LayoutInflater;
39import android.view.View;
40import android.view.ViewGroup;
41import android.widget.AdapterView;
42import android.widget.ArrayAdapter;
43import android.widget.ImageView;
44import android.widget.ListView;
45import android.widget.TextView;
46
47import java.io.IOException;
48import java.io.InputStream;
49import java.util.ArrayList;
50import java.util.Arrays;
51import java.util.Collections;
52import java.util.Comparator;
53import java.util.HashMap;
54import 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 */
65public 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
79    public MediaRouteChooserDialog(Context context) {
80        this(context, 0);
81    }
82
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
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     */
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     */
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     */
152    public boolean onFilterRoute(@NonNull MediaRouter.RouteInfo route) {
153        return !route.isDefaultOrBluetooth() && route.isEnabled()
154                && route.matchesSelector(mSelector);
155    }
156
157    @Override
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     */
177    void updateLayout() {
178        getWindow().setLayout(MediaRouteDialogHelper.getDialogWidth(getContext()),
179                ViewGroup.LayoutParams.WRAP_CONTENT);
180    }
181
182    @Override
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
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     */
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
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
265        public boolean areAllItemsEnabled() {
266            return false;
267        }
268
269        @Override
270        public boolean isEnabled(int position) {
271            return getItem(position).isEnabled();
272        }
273
274        @Override
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
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
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
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
370        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
371            refreshRoutes();
372        }
373
374        @Override
375        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
376            refreshRoutes();
377        }
378
379        @Override
380        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
381            refreshRoutes();
382        }
383
384        @Override
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
403        public static RouteComparator getInstance(Context context) {
404            if (sInstance == null) {
405                sInstance = new RouteComparator(context);
406            }
407            return sInstance;
408        }
409
410        private RouteComparator(Context context) {
411            mRouteUsageScoreMap = new HashMap();
412            mPreferences = PreferenceManager.getDefaultSharedPreferences(context);
413        }
414
415        @Override
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
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
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