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 static com.android.support.mediarouter.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED;
20import static com.android.support.mediarouter.media.MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTING;
21
22import android.annotation.NonNull;
23import android.app.Dialog;
24import android.content.Context;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.graphics.drawable.Drawable;
28import android.net.Uri;
29import android.os.Bundle;
30import android.os.Handler;
31import android.os.Message;
32import android.os.SystemClock;
33import android.support.v7.app.AppCompatDialog;
34import android.text.TextUtils;
35import android.util.Log;
36import android.view.ContextThemeWrapper;
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 com.android.media.update.ApiHelper;
48import com.android.media.update.R;
49import com.android.support.mediarouter.media.MediaRouteSelector;
50import com.android.support.mediarouter.media.MediaRouter;
51
52import java.io.IOException;
53import java.io.InputStream;
54import java.util.ArrayList;
55import java.util.Collections;
56import java.util.Comparator;
57import java.util.List;
58
59/**
60 * This class implements the route chooser dialog for {@link MediaRouter}.
61 * <p>
62 * This dialog allows the user to choose a route that matches a given selector.
63 * </p>
64 *
65 * @see MediaRouteButton
66 * @see MediaRouteActionProvider
67 */
68public class MediaRouteChooserDialog extends Dialog {
69    static final String TAG = "MediaRouteChooserDialog";
70
71    // Do not update the route list immediately to avoid unnatural dialog change.
72    private static final long UPDATE_ROUTES_DELAY_MS = 300L;
73    static final int MSG_UPDATE_ROUTES = 1;
74
75    private final MediaRouter mRouter;
76    private final MediaRouterCallback mCallback;
77
78    private TextView mTitleView;
79    private MediaRouteSelector mSelector = MediaRouteSelector.EMPTY;
80    private ArrayList<MediaRouter.RouteInfo> mRoutes;
81    private RouteAdapter mAdapter;
82    private ListView mListView;
83    private boolean mAttachedToWindow;
84    private long mLastUpdateTime;
85    private final Handler mHandler = new Handler() {
86        @Override
87        public void handleMessage(Message message) {
88            switch (message.what) {
89                case MSG_UPDATE_ROUTES:
90                    updateRoutes((List<MediaRouter.RouteInfo>) message.obj);
91                    break;
92            }
93        }
94    };
95
96    public MediaRouteChooserDialog(Context context) {
97        this(context, 0);
98    }
99
100    public MediaRouteChooserDialog(Context context, int theme) {
101        // TODO (b/72975976): Avoid to use ContextThemeWrapper with app context and lib theme.
102        super(new ContextThemeWrapper(context, ApiHelper.getLibTheme(context,
103                MediaRouterThemeHelper.getRouterThemeId(context))), theme);
104        context = getContext();
105
106        mRouter = MediaRouter.getInstance(context);
107        mCallback = new MediaRouterCallback();
108    }
109
110    /**
111     * Gets the media route selector for filtering the routes that the user can select.
112     *
113     * @return The selector, never null.
114     */
115    @NonNull
116    public MediaRouteSelector getRouteSelector() {
117        return mSelector;
118    }
119
120    /**
121     * Sets the media route selector for filtering the routes that the user can select.
122     *
123     * @param selector The selector, must not be null.
124     */
125    public void setRouteSelector(@NonNull MediaRouteSelector selector) {
126        if (selector == null) {
127            throw new IllegalArgumentException("selector must not be null");
128        }
129
130        if (!mSelector.equals(selector)) {
131            mSelector = selector;
132
133            if (mAttachedToWindow) {
134                mRouter.removeCallback(mCallback);
135                mRouter.addCallback(selector, mCallback,
136                        MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
137            }
138
139            refreshRoutes();
140        }
141    }
142
143    /**
144     * Called to filter the set of routes that should be included in the list.
145     * <p>
146     * The default implementation iterates over all routes in the provided list and
147     * removes those for which {@link #onFilterRoute} returns false.
148     * </p>
149     *
150     * @param routes The list of routes to filter in-place, never null.
151     */
152    public void onFilterRoutes(@NonNull List<MediaRouter.RouteInfo> routes) {
153        for (int i = routes.size(); i-- > 0; ) {
154            if (!onFilterRoute(routes.get(i))) {
155                routes.remove(i);
156            }
157        }
158    }
159
160    /**
161     * Returns true if the route should be included in the list.
162     * <p>
163     * The default implementation returns true for enabled non-default routes that
164     * match the selector.  Subclasses can override this method to filter routes
165     * differently.
166     * </p>
167     *
168     * @param route The route to consider, never null.
169     * @return True if the route should be included in the chooser dialog.
170     */
171    public boolean onFilterRoute(@NonNull MediaRouter.RouteInfo route) {
172        return !route.isDefaultOrBluetooth() && route.isEnabled()
173                && route.matchesSelector(mSelector);
174    }
175
176    @Override
177    public void setTitle(CharSequence title) {
178        mTitleView.setText(title);
179    }
180
181    @Override
182    public void setTitle(int titleId) {
183        mTitleView.setText(titleId);
184    }
185
186    @Override
187    protected void onCreate(Bundle savedInstanceState) {
188        super.onCreate(savedInstanceState);
189
190        setContentView(ApiHelper.inflateLibLayout(getContext(), ApiHelper.getLibTheme(getContext(),
191                MediaRouterThemeHelper.getRouterThemeId(getContext())),
192                R.layout.mr_chooser_dialog));
193
194        mRoutes = new ArrayList<>();
195        mAdapter = new RouteAdapter(getContext(), mRoutes);
196        mListView = (ListView)findViewById(R.id.mr_chooser_list);
197        mListView.setAdapter(mAdapter);
198        mListView.setOnItemClickListener(mAdapter);
199        mListView.setEmptyView(findViewById(android.R.id.empty));
200        mTitleView = findViewById(R.id.mr_chooser_title);
201
202        updateLayout();
203    }
204
205    /**
206     * Sets the width of the dialog. Also called when configuration changes.
207     */
208    void updateLayout() {
209        getWindow().setLayout(MediaRouteDialogHelper.getDialogWidth(getContext()),
210                ViewGroup.LayoutParams.WRAP_CONTENT);
211    }
212
213    @Override
214    public void onAttachedToWindow() {
215        super.onAttachedToWindow();
216
217        mAttachedToWindow = true;
218        mRouter.addCallback(mSelector, mCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
219        refreshRoutes();
220    }
221
222    @Override
223    public void onDetachedFromWindow() {
224        mAttachedToWindow = false;
225        mRouter.removeCallback(mCallback);
226        mHandler.removeMessages(MSG_UPDATE_ROUTES);
227
228        super.onDetachedFromWindow();
229    }
230
231    /**
232     * Refreshes the list of routes that are shown in the chooser dialog.
233     */
234    public void refreshRoutes() {
235        if (mAttachedToWindow) {
236            ArrayList<MediaRouter.RouteInfo> routes = new ArrayList<>(mRouter.getRoutes());
237            onFilterRoutes(routes);
238            Collections.sort(routes, RouteComparator.sInstance);
239            if (SystemClock.uptimeMillis() - mLastUpdateTime >= UPDATE_ROUTES_DELAY_MS) {
240                updateRoutes(routes);
241            } else {
242                mHandler.removeMessages(MSG_UPDATE_ROUTES);
243                mHandler.sendMessageAtTime(mHandler.obtainMessage(MSG_UPDATE_ROUTES, routes),
244                        mLastUpdateTime + UPDATE_ROUTES_DELAY_MS);
245            }
246        }
247    }
248
249    void updateRoutes(List<MediaRouter.RouteInfo> routes) {
250        mLastUpdateTime = SystemClock.uptimeMillis();
251        mRoutes.clear();
252        mRoutes.addAll(routes);
253        mAdapter.notifyDataSetChanged();
254    }
255
256    private final class RouteAdapter extends ArrayAdapter<MediaRouter.RouteInfo>
257            implements ListView.OnItemClickListener {
258        private final Drawable mDefaultIcon;
259        private final Drawable mTvIcon;
260        private final Drawable mSpeakerIcon;
261        private final Drawable mSpeakerGroupIcon;
262
263        public RouteAdapter(Context context, List<MediaRouter.RouteInfo> routes) {
264            super(context, 0, routes);
265
266            TypedArray styledAttributes = ApiHelper.getLibTheme(context,
267                    MediaRouterThemeHelper.getRouterThemeId(context)).obtainStyledAttributes(
268                            new int[] {
269                                R.attr.mediaRouteDefaultIconDrawable,
270                                R.attr.mediaRouteTvIconDrawable,
271                                R.attr.mediaRouteSpeakerIconDrawable,
272                                R.attr.mediaRouteSpeakerGroupIconDrawable
273                            });
274
275            mDefaultIcon = styledAttributes.getDrawable(0);
276            mTvIcon = styledAttributes.getDrawable(1);
277            mSpeakerIcon = styledAttributes.getDrawable(2);
278            mSpeakerGroupIcon = styledAttributes.getDrawable(3);
279            styledAttributes.recycle();
280        }
281
282        @Override
283        public boolean areAllItemsEnabled() {
284            return false;
285        }
286
287        @Override
288        public boolean isEnabled(int position) {
289            return getItem(position).isEnabled();
290        }
291
292        @Override
293        public View getView(int position, View convertView, ViewGroup parent) {
294            View view = convertView;
295            if (view == null) {
296                view = ApiHelper.inflateLibLayout(getContext(),
297                        ApiHelper.getLibTheme(getContext(),
298                                MediaRouterThemeHelper.getRouterThemeId(getContext())),
299                        R.layout.mr_chooser_list_item, parent, false);
300            }
301
302            MediaRouter.RouteInfo route = getItem(position);
303            TextView text1 = (TextView) view.findViewById(R.id.mr_chooser_route_name);
304            TextView text2 = (TextView) view.findViewById(R.id.mr_chooser_route_desc);
305            text1.setText(route.getName());
306            String description = route.getDescription();
307            boolean isConnectedOrConnecting =
308                    route.getConnectionState() == CONNECTION_STATE_CONNECTED
309                            || route.getConnectionState() == CONNECTION_STATE_CONNECTING;
310            if (isConnectedOrConnecting && !TextUtils.isEmpty(description)) {
311                text1.setGravity(Gravity.BOTTOM);
312                text2.setVisibility(View.VISIBLE);
313                text2.setText(description);
314            } else {
315                text1.setGravity(Gravity.CENTER_VERTICAL);
316                text2.setVisibility(View.GONE);
317                text2.setText("");
318            }
319            view.setEnabled(route.isEnabled());
320
321            ImageView iconView = (ImageView) view.findViewById(R.id.mr_chooser_route_icon);
322            if (iconView != null) {
323                iconView.setImageDrawable(getIconDrawable(route));
324            }
325            return view;
326        }
327
328        @Override
329        public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
330            MediaRouter.RouteInfo route = getItem(position);
331            if (route.isEnabled()) {
332                route.select();
333                dismiss();
334            }
335        }
336
337        private Drawable getIconDrawable(MediaRouter.RouteInfo route) {
338            Uri iconUri = route.getIconUri();
339            if (iconUri != null) {
340                try {
341                    InputStream is = getContext().getContentResolver().openInputStream(iconUri);
342                    Drawable drawable = Drawable.createFromStream(is, null);
343                    if (drawable != null) {
344                        return drawable;
345                    }
346                } catch (IOException e) {
347                    Log.w(TAG, "Failed to load " + iconUri, e);
348                    // Falls back.
349                }
350            }
351            return getDefaultIconDrawable(route);
352        }
353
354        private Drawable getDefaultIconDrawable(MediaRouter.RouteInfo route) {
355            // If the type of the receiver device is specified, use it.
356            switch (route.getDeviceType()) {
357                case  MediaRouter.RouteInfo.DEVICE_TYPE_TV:
358                    return mTvIcon;
359                case MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER:
360                    return mSpeakerIcon;
361            }
362
363            // Otherwise, make the best guess based on other route information.
364            if (route instanceof MediaRouter.RouteGroup) {
365                // Only speakers can be grouped for now.
366                return mSpeakerGroupIcon;
367            }
368            return mDefaultIcon;
369        }
370    }
371
372    private final class MediaRouterCallback extends MediaRouter.Callback {
373        MediaRouterCallback() {
374        }
375
376        @Override
377        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
378            refreshRoutes();
379        }
380
381        @Override
382        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
383            refreshRoutes();
384        }
385
386        @Override
387        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
388            refreshRoutes();
389        }
390
391        @Override
392        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo route) {
393            dismiss();
394        }
395    }
396
397    static final class RouteComparator implements Comparator<MediaRouter.RouteInfo> {
398        public static final RouteComparator sInstance = new RouteComparator();
399
400        @Override
401        public int compare(MediaRouter.RouteInfo lhs, MediaRouter.RouteInfo rhs) {
402            return lhs.getName().compareToIgnoreCase(rhs.getName());
403        }
404    }
405}
406