BrowseTree.java revision 049d3d3231757b863fbb6ed92ee5cf479b60bd5e
1/**
2 * Copyright (C) 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.car.broadcastradio.support.media;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.annotation.StringRes;
22import android.graphics.Bitmap;
23import android.hardware.radio.ProgramList;
24import android.hardware.radio.ProgramSelector;
25import android.hardware.radio.RadioManager;
26import android.hardware.radio.RadioManager.BandDescriptor;
27import android.hardware.radio.RadioMetadata;
28import android.os.Bundle;
29import android.support.v4.media.MediaBrowserCompat.MediaItem;
30import android.support.v4.media.MediaBrowserServiceCompat;
31import android.support.v4.media.MediaBrowserServiceCompat.BrowserRoot;
32import android.support.v4.media.MediaBrowserServiceCompat.Result;
33import android.support.v4.media.MediaDescriptionCompat;
34import android.util.Log;
35
36import com.android.car.broadcastradio.support.Program;
37import com.android.car.broadcastradio.support.R;
38import com.android.car.broadcastradio.support.platform.ImageResolver;
39import com.android.car.broadcastradio.support.platform.ProgramInfoExt;
40import com.android.car.broadcastradio.support.platform.ProgramSelectorExt;
41import com.android.car.broadcastradio.support.platform.RadioMetadataExt;
42
43import java.util.ArrayList;
44import java.util.HashMap;
45import java.util.List;
46import java.util.Map;
47import java.util.Objects;
48import java.util.Set;
49
50/**
51 * Implementation of MediaBrowserService logic regarding browser tree.
52 */
53public class BrowseTree {
54    private static final String TAG = "BcRadioApp.BrowseTree";
55
56    /**
57     * Used as a long extra field to indicate the Broadcast Radio folder type of the media item.
58     * The value should be one of the following:
59     * <ul>
60     * <li>{@link #BCRADIO_FOLDER_TYPE_PROGRAMS}</li>
61     * <li>{@link #BCRADIO_FOLDER_TYPE_FAVORITES}</li>
62     * <li>{@link #BCRADIO_FOLDER_TYPE_BAND}</li>
63     * </ul>
64     *
65     * @see android.media.MediaDescription#getExtras()
66     */
67    public static final String EXTRA_BCRADIO_FOLDER_TYPE =
68            "android.media.extra.EXTRA_BCRADIO_FOLDER_TYPE";
69
70    /**
71     * The type of folder that contains a list of Broadcast Radio programs available
72     * to tune at the moment.
73     */
74    public static final long BCRADIO_FOLDER_TYPE_PROGRAMS = 1;
75
76    /**
77     * The type of folder that contains a list of Broadcast Radio programs added
78     * to favorites (not necessarily available to tune at the moment).
79     *
80     * If this folder has {@link android.media.browse.MediaBrowser.MediaItem#FLAG_PLAYABLE} flag
81     * set, it can be used to play some program from the favorite list (selection depends on the
82     * radio app implementation).
83     */
84    public static final long BCRADIO_FOLDER_TYPE_FAVORITES = 2;
85
86    /**
87     * The type of folder that contains the list of all Broadcast Radio channels
88     * (frequency values valid in the current region) for a given band.
89     * Each band (like AM, FM) has its own, separate folder.
90     * These lists include all channels, whether or not some program is tunable through it.
91     *
92     * If this folder has {@link android.media.browse.MediaBrowser.MediaItem#FLAG_PLAYABLE} flag
93     * set, it can be used to tune to some channel within a given band (selection depends on the
94     * radio app implementation).
95     */
96    public static final long BCRADIO_FOLDER_TYPE_BAND = 3;
97
98    /**
99     * Non-localized name of the band.
100     *
101     * For now, it can only take one of the following values:
102     *  - AM;
103     *  - FM;
104     *  - DAB;
105     *  - SXM.
106     *
107     * However, in future releases the list might get extended.
108     */
109    public static final String EXTRA_BCRADIO_BAND_NAME_EN =
110            "android.media.extra.EXTRA_BCRADIO_BAND_NAME_EN";
111
112    private static final String NODE_ROOT = "root_id";
113    private static final String NODE_PROGRAMS = "programs_id";
114    private static final String NODE_FAVORITES = "favorites_id";
115
116    private static final String NODEPREFIX_BAND = "band:";
117    private static final String NODEPREFIX_AMFMCHANNEL = "amfm:";
118    private static final String NODEPREFIX_PROGRAM = "program:";
119
120    private final BrowserRoot mRoot = new BrowserRoot(NODE_ROOT, null);
121
122    private final Object mLock = new Object();
123    private final @NonNull MediaBrowserServiceCompat mBrowserService;
124    private final @Nullable ImageResolver mImageResolver;
125
126    private List<MediaItem> mRootChildren;
127
128    private final AmFmChannelList mAmChannels = new AmFmChannelList(
129            NODEPREFIX_BAND + "am", R.string.radio_am_text, "AM");
130    private final AmFmChannelList mFmChannels = new AmFmChannelList(
131            NODEPREFIX_BAND + "fm", R.string.radio_fm_text, "FM");
132
133    private final ProgramList.OnCompleteListener mProgramListCompleteListener =
134            this::onProgramListUpdated;
135    @Nullable private ProgramList mProgramList;
136    @Nullable private List<RadioManager.ProgramInfo> mProgramListSnapshot;
137    @Nullable private List<MediaItem> mProgramListCache;
138    private final List<Runnable> mProgramListTasks = new ArrayList<>();
139    private final Map<String, ProgramSelector> mProgramSelectors = new HashMap<>();
140
141    @Nullable Set<Program> mFavorites;
142    @Nullable private List<MediaItem> mFavoritesCache;
143
144    public BrowseTree(@NonNull MediaBrowserServiceCompat browserService,
145            @Nullable ImageResolver imageResolver) {
146        mBrowserService = Objects.requireNonNull(browserService);
147        mImageResolver = imageResolver;
148    }
149
150    public BrowserRoot getRoot() {
151        return mRoot;
152    }
153
154    private static MediaItem createChild(MediaDescriptionCompat.Builder descBuilder,
155            String mediaId, String title, ProgramSelector sel, Bitmap icon) {
156        MediaDescriptionCompat desc = descBuilder
157                .setMediaId(mediaId)
158                .setMediaUri(ProgramSelectorExt.toUri(sel))
159                .setTitle(title)
160                .setIconBitmap(icon)
161                .build();
162        return new MediaItem(desc, MediaItem.FLAG_PLAYABLE);
163    }
164
165    private static MediaItem createFolder(MediaDescriptionCompat.Builder descBuilder,
166            String mediaId, String title, boolean isPlayable, long folderType, Bundle extras) {
167        if (extras == null) extras = new Bundle();
168        extras.putLong(EXTRA_BCRADIO_FOLDER_TYPE, folderType);
169
170        MediaDescriptionCompat desc = descBuilder
171                .setMediaId(mediaId).setTitle(title).setExtras(extras).build();
172
173        int flags = MediaItem.FLAG_BROWSABLE;
174        if (isPlayable) flags |= MediaItem.FLAG_PLAYABLE;
175        return new MediaItem(desc, flags);
176    }
177
178    /**
179     * Sets AM/FM region configuration.
180     *
181     * This method is meant to be called shortly after initialization, if AM/FM is supported.
182     */
183    public void setAmFmRegionConfig(@Nullable List<BandDescriptor> amFmBands) {
184        List<BandDescriptor> amBands = new ArrayList<>();
185        List<BandDescriptor> fmBands = new ArrayList<>();
186
187        if (amFmBands != null) {
188            for (BandDescriptor band : amFmBands) {
189                final int freq = band.getLowerLimit();
190                if (ProgramSelectorExt.isAmFrequency(freq)) {
191                    amBands.add(band);
192                } else if (ProgramSelectorExt.isFmFrequency(freq)) {
193                    fmBands.add(band);
194                }
195            }
196        }
197
198        synchronized (mLock) {
199            mAmChannels.setBands(amBands);
200            mFmChannels.setBands(fmBands);
201            mRootChildren = null;
202            mBrowserService.notifyChildrenChanged(NODE_ROOT);
203        }
204    }
205
206    private void onProgramListUpdated() {
207        synchronized (mLock) {
208            mProgramListSnapshot = mProgramList.toList();
209            mProgramListCache = null;
210            mBrowserService.notifyChildrenChanged(NODE_PROGRAMS);
211
212            for (Runnable task : mProgramListTasks) {
213                task.run();
214            }
215            mProgramListTasks.clear();
216        }
217    }
218
219    /**
220     * Binds program list.
221     *
222     * This method is meant to be called shortly after opening a new tuner session.
223     */
224    public void setProgramList(@Nullable ProgramList programList) {
225        synchronized (mLock) {
226            if (mProgramList != null) {
227                mProgramList.removeOnCompleteListener(mProgramListCompleteListener);
228            }
229            mProgramList = programList;
230            if (programList != null) {
231                mProgramList.addOnCompleteListener(mProgramListCompleteListener);
232            }
233            mBrowserService.notifyChildrenChanged(NODE_ROOT);
234        }
235    }
236
237    private List<MediaItem> getPrograms() {
238        synchronized (mLock) {
239            if (mProgramListSnapshot == null) {
240                Log.w(TAG, "There is no snapshot of the program list");
241                return null;
242            }
243
244            if (mProgramListCache != null) return mProgramListCache;
245            mProgramListCache = new ArrayList<>();
246
247            MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.Builder();
248
249            for (RadioManager.ProgramInfo program : mProgramListSnapshot) {
250                ProgramSelector sel = program.getSelector();
251                String mediaId = selectorToMediaId(sel);
252                mProgramSelectors.put(mediaId, sel);
253
254                Bitmap icon = null;
255                RadioMetadata meta = program.getMetadata();
256                if (meta != null && mImageResolver != null) {
257                    long id = RadioMetadataExt.getGlobalBitmapId(meta,
258                            RadioMetadata.METADATA_KEY_ICON);
259                    if (id != 0) icon = mImageResolver.resolve(id);
260                }
261
262                mProgramListCache.add(createChild(dbld, mediaId,
263                        ProgramInfoExt.getProgramName(program, 0), program.getSelector(), icon));
264            }
265
266            if (mProgramListCache.size() == 0) {
267                Log.v(TAG, "Program list is empty");
268            }
269            return mProgramListCache;
270        }
271    }
272
273    private void sendPrograms(final Result<List<MediaItem>> result) {
274        synchronized (mLock) {
275            if (mProgramListSnapshot != null) {
276                result.sendResult(getPrograms());
277            } else {
278                Log.d(TAG, "Program list is not ready yet");
279                result.detach();
280                mProgramListTasks.add(() -> result.sendResult(getPrograms()));
281            }
282        }
283    }
284
285    /**
286     * Updates favorites list.
287     */
288    public void setFavorites(@Nullable Set<Program> favorites) {
289        synchronized (mLock) {
290            boolean rootChanged = (mFavorites == null) != (favorites == null);
291            mFavorites = favorites;
292            mFavoritesCache = null;
293            mBrowserService.notifyChildrenChanged(NODE_FAVORITES);
294            if (rootChanged) mBrowserService.notifyChildrenChanged(NODE_ROOT);
295        }
296    }
297
298    /** @hide */
299    public boolean isFavorite(@NonNull ProgramSelector selector) {
300        synchronized (mLock) {
301            if (mFavorites == null) return false;
302            return mFavorites.contains(new Program(selector, ""));
303        }
304    }
305
306    private List<MediaItem> getFavorites() {
307        synchronized (mLock) {
308            if (mFavorites == null) return null;
309            if (mFavoritesCache != null) return mFavoritesCache;
310            mFavoritesCache = new ArrayList<>();
311
312            MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.Builder();
313
314            for (Program fav : mFavorites) {
315                ProgramSelector sel = fav.getSelector();
316                String mediaId = selectorToMediaId(sel);
317                mProgramSelectors.putIfAbsent(mediaId, sel);  // prefer program list entries
318                mFavoritesCache.add(createChild(dbld, mediaId, fav.getName(), sel, fav.getIcon()));
319            }
320
321            return mFavoritesCache;
322        }
323    }
324
325    private List<MediaItem> getRootChildren() {
326        synchronized (mLock) {
327            if (mRootChildren != null) return mRootChildren;
328            mRootChildren = new ArrayList<>();
329
330            MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.Builder();
331            if (mProgramList != null) {
332                mRootChildren.add(createFolder(dbld, NODE_PROGRAMS,
333                        mBrowserService.getString(R.string.program_list_text),
334                        false, BCRADIO_FOLDER_TYPE_PROGRAMS, null));
335            }
336            if (mFavorites != null) {
337                mRootChildren.add(createFolder(dbld, NODE_FAVORITES,
338                        mBrowserService.getString(R.string.favorites_list_text),
339                        true, BCRADIO_FOLDER_TYPE_FAVORITES, null));
340            }
341
342            MediaItem amRoot = mAmChannels.getBandRoot();
343            if (amRoot != null) mRootChildren.add(amRoot);
344            MediaItem fmRoot = mFmChannels.getBandRoot();
345            if (fmRoot != null) mRootChildren.add(fmRoot);
346
347            return mRootChildren;
348        }
349    }
350
351    private class AmFmChannelList {
352        public final @NonNull String mMediaId;
353        private final @StringRes int mBandName;
354        private final @NonNull String mBandNameEn;
355        private @Nullable List<BandDescriptor> mBands;
356        private @Nullable List<MediaItem> mChannels;
357
358        private AmFmChannelList(@NonNull String mediaId, @StringRes int bandName,
359                @NonNull String bandNameEn) {
360            mMediaId = Objects.requireNonNull(mediaId);
361            mBandName = bandName;
362            mBandNameEn = Objects.requireNonNull(bandNameEn);
363        }
364
365        public void setBands(List<BandDescriptor> bands) {
366            synchronized (mLock) {
367                mBands = bands;
368                mChannels = null;
369                mBrowserService.notifyChildrenChanged(mMediaId);
370            }
371        }
372
373        private boolean isEmpty() {
374            if (mBands == null) {
375                Log.w(TAG, "AM/FM configuration not set");
376                return true;
377            }
378            return mBands.isEmpty();
379        }
380
381        public @Nullable MediaItem getBandRoot() {
382            if (isEmpty()) return null;
383            Bundle extras = new Bundle();
384            extras.putString(EXTRA_BCRADIO_BAND_NAME_EN, mBandNameEn);
385            return createFolder(new MediaDescriptionCompat.Builder(), mMediaId,
386                    mBrowserService.getString(mBandName), true, BCRADIO_FOLDER_TYPE_BAND, extras);
387        }
388
389        public List<MediaItem> getChannels() {
390            synchronized (mLock) {
391                if (mChannels != null) return mChannels;
392                if (isEmpty()) return null;
393                mChannels = new ArrayList<>();
394
395                MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.Builder();
396
397                for (BandDescriptor band : mBands) {
398                    final int lowerLimit = band.getLowerLimit();
399                    final int upperLimit = band.getUpperLimit();
400                    final int spacing = band.getSpacing();
401                    for (int ch = lowerLimit; ch <= upperLimit; ch += spacing) {
402                        ProgramSelector sel = ProgramSelectorExt.createAmFmSelector(ch);
403                        mChannels.add(createChild(dbld, NODEPREFIX_AMFMCHANNEL + ch,
404                                ProgramSelectorExt.getDisplayName(sel, 0), sel, null));
405                    }
406                }
407
408                return mChannels;
409            }
410        }
411    }
412
413    /**
414     * Loads subtree children.
415     *
416     * This method is meant to be used in MediaBrowserService's onLoadChildren callback.
417     */
418    public void loadChildren(final String parentMediaId, final Result<List<MediaItem>> result) {
419        if (parentMediaId == null || result == null) return;
420
421        if (NODE_ROOT.equals(parentMediaId)) {
422            result.sendResult(getRootChildren());
423        } else if (NODE_PROGRAMS.equals(parentMediaId)) {
424            sendPrograms(result);
425        } else if (NODE_FAVORITES.equals(parentMediaId)) {
426            result.sendResult(getFavorites());
427        } else if (parentMediaId.equals(mAmChannels.mMediaId)) {
428            result.sendResult(mAmChannels.getChannels());
429        } else if (parentMediaId.equals(mFmChannels.mMediaId)) {
430            result.sendResult(mFmChannels.getChannels());
431        } else {
432            Log.w(TAG, "Invalid parent media ID: " + parentMediaId);
433            result.sendResult(null);
434        }
435    }
436
437    private static @NonNull String selectorToMediaId(@NonNull ProgramSelector sel) {
438        ProgramSelector.Identifier id = sel.getPrimaryId();
439        return NODEPREFIX_PROGRAM + id.getType() + '/' + id.getValue();
440    }
441
442    /**
443     * Resolves mediaId to a tunable {@link ProgramSelector}.
444     *
445     * This method is meant to be used in MediaSession's onPlayFromMediaId callback.
446     */
447    public @Nullable ProgramSelector parseMediaId(@Nullable String mediaId) {
448        if (mediaId == null) return null;
449
450        if (mediaId.startsWith(NODEPREFIX_AMFMCHANNEL)) {
451            String freqStr = mediaId.substring(NODEPREFIX_AMFMCHANNEL.length());
452            int freqInt;
453            try {
454                freqInt = Integer.parseInt(freqStr);
455            } catch (NumberFormatException ex) {
456                Log.e(TAG, "Invalid frequency", ex);
457                return null;
458            }
459            return ProgramSelectorExt.createAmFmSelector(freqInt);
460        } else if (mediaId.startsWith(NODEPREFIX_PROGRAM)) {
461            return mProgramSelectors.get(mediaId);
462        }
463        return null;
464    }
465}
466