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 
17 package com.android.car.broadcastradio.support.media;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.StringRes;
22 import android.graphics.Bitmap;
23 import android.hardware.radio.ProgramList;
24 import android.hardware.radio.ProgramSelector;
25 import android.hardware.radio.RadioManager;
26 import android.hardware.radio.RadioManager.BandDescriptor;
27 import android.hardware.radio.RadioMetadata;
28 import android.media.MediaDescription;
29 import android.media.browse.MediaBrowser.MediaItem;
30 import android.os.Bundle;
31 import android.service.media.MediaBrowserService;
32 import android.service.media.MediaBrowserService.BrowserRoot;
33 import android.service.media.MediaBrowserService.Result;
34 import android.util.Log;
35 
36 import com.android.car.broadcastradio.support.Program;
37 import com.android.car.broadcastradio.support.R;
38 import com.android.car.broadcastradio.support.platform.ImageResolver;
39 import com.android.car.broadcastradio.support.platform.ProgramInfoExt;
40 import com.android.car.broadcastradio.support.platform.ProgramSelectorExt;
41 import com.android.car.broadcastradio.support.platform.RadioMetadataExt;
42 
43 import java.util.ArrayList;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.Objects;
48 import java.util.Set;
49 
50 /**
51  * Implementation of MediaBrowserService logic regarding browser tree.
52  */
53 public 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     /**
113      * General play intent action.
114      *
115      * MediaBrowserService of the radio app must handle this command to perform general
116      * "play" command. It usually means starting playback of recently tuned station.
117      */
118     public static final String ACTION_PLAY_BROADCASTRADIO =
119             "android.car.intent.action.PLAY_BROADCASTRADIO";
120 
121     private static final String NODE_ROOT = "root_id";
122     public static final String NODE_PROGRAMS = "programs_id";
123     public static final String NODE_FAVORITES = "favorites_id";
124 
125     private static final String NODEPREFIX_BAND = "band:";
126     public static final String NODE_BAND_AM = NODEPREFIX_BAND + "am";
127     public static final String NODE_BAND_FM = NODEPREFIX_BAND + "fm";
128     public static final String NODE_BAND_DAB = NODEPREFIX_BAND + "dab";
129 
130     private static final String NODEPREFIX_AMFMCHANNEL = "amfm:";
131     private static final String NODEPREFIX_PROGRAM = "program:";
132 
133     private final BrowserRoot mRoot = new BrowserRoot(NODE_ROOT, null);
134 
135     private final Object mLock = new Object();
136     private final @NonNull MediaBrowserService mBrowserService;
137     private final @Nullable ImageResolver mImageResolver;
138 
139     private List<MediaItem> mRootChildren;
140 
141     private final AmFmChannelList mAmChannels = new AmFmChannelList(
142             NODE_BAND_AM, R.string.radio_am_text, "AM");
143     private final AmFmChannelList mFmChannels = new AmFmChannelList(
144             NODE_BAND_FM, R.string.radio_fm_text, "FM");
145     private boolean mDABEnabled;
146 
147     private final ProgramList.OnCompleteListener mProgramListCompleteListener =
148             this::onProgramListUpdated;
149     @Nullable private ProgramList mProgramList;
150     @Nullable private List<RadioManager.ProgramInfo> mProgramListSnapshot;
151     @Nullable private List<MediaItem> mProgramListCache;
152     private final List<Runnable> mProgramListTasks = new ArrayList<>();
153     private final Map<String, ProgramSelector> mProgramSelectors = new HashMap<>();
154 
155     @Nullable Set<Program> mFavorites;
156     @Nullable private List<MediaItem> mFavoritesCache;
157 
BrowseTree(@onNull MediaBrowserService browserService, @Nullable ImageResolver imageResolver)158     public BrowseTree(@NonNull MediaBrowserService browserService,
159             @Nullable ImageResolver imageResolver) {
160         mBrowserService = Objects.requireNonNull(browserService);
161         mImageResolver = imageResolver;
162     }
163 
getRoot()164     public BrowserRoot getRoot() {
165         return mRoot;
166     }
167 
createChild(MediaDescription.Builder descBuilder, String mediaId, String title, ProgramSelector sel, Bitmap icon)168     private static MediaItem createChild(MediaDescription.Builder descBuilder,
169             String mediaId, String title, ProgramSelector sel, Bitmap icon) {
170         MediaDescription desc = descBuilder
171                 .setMediaId(mediaId)
172                 .setMediaUri(ProgramSelectorExt.toUri(sel))
173                 .setTitle(title)
174                 .setIconBitmap(icon)
175                 .build();
176         return new MediaItem(desc, MediaItem.FLAG_PLAYABLE);
177     }
178 
createFolder(MediaDescription.Builder descBuilder, String mediaId, String title, boolean isBrowseable, boolean isPlayable, long folderType, Bundle extras)179     private static MediaItem createFolder(MediaDescription.Builder descBuilder, String mediaId,
180             String title, boolean isBrowseable, boolean isPlayable, long folderType,
181             Bundle extras) {
182         if (extras == null) extras = new Bundle();
183         extras.putLong(EXTRA_BCRADIO_FOLDER_TYPE, folderType);
184 
185         MediaDescription desc = descBuilder
186                 .setMediaId(mediaId).setTitle(title).setExtras(extras).build();
187 
188         int flags = 0;
189         if (isBrowseable) flags |= MediaItem.FLAG_BROWSABLE;
190         if (isPlayable) flags |= MediaItem.FLAG_PLAYABLE;
191         return new MediaItem(desc, flags);
192     }
193 
194     /**
195      * Sets AM/FM region configuration.
196      *
197      * This method is meant to be called shortly after initialization, if AM/FM is supported.
198      */
setAmFmRegionConfig(@ullable List<BandDescriptor> amFmBands)199     public void setAmFmRegionConfig(@Nullable List<BandDescriptor> amFmBands) {
200         List<BandDescriptor> amBands = new ArrayList<>();
201         List<BandDescriptor> fmBands = new ArrayList<>();
202 
203         if (amFmBands != null) {
204             for (BandDescriptor band : amFmBands) {
205                 final int freq = band.getLowerLimit();
206                 if (ProgramSelectorExt.isAmFrequency(freq)) {
207                     amBands.add(band);
208                 } else if (ProgramSelectorExt.isFmFrequency(freq)) {
209                     fmBands.add(band);
210                 }
211             }
212         }
213 
214         synchronized (mLock) {
215             mAmChannels.setBands(amBands);
216             mFmChannels.setBands(fmBands);
217             mRootChildren = null;
218             mBrowserService.notifyChildrenChanged(NODE_ROOT);
219         }
220     }
221 
222     /**
223      * Configures the BrowseTree to include a DAB node or not
224      */
setDABEnabled(boolean enabled)225     public void setDABEnabled(boolean enabled) {
226         synchronized (mLock) {
227             if (mDABEnabled != enabled) {
228                 mDABEnabled = enabled;
229                 mRootChildren = null;
230                 mBrowserService.notifyChildrenChanged(NODE_ROOT);
231             }
232         }
233     }
234 
onProgramListUpdated()235     private void onProgramListUpdated() {
236         synchronized (mLock) {
237             mProgramListSnapshot = mProgramList.toList();
238             mProgramListCache = null;
239             mBrowserService.notifyChildrenChanged(NODE_PROGRAMS);
240 
241             for (Runnable task : mProgramListTasks) {
242                 task.run();
243             }
244             mProgramListTasks.clear();
245         }
246     }
247 
248     /**
249      * Binds program list.
250      *
251      * This method is meant to be called shortly after opening a new tuner session.
252      */
setProgramList(@ullable ProgramList programList)253     public void setProgramList(@Nullable ProgramList programList) {
254         synchronized (mLock) {
255             if (mProgramList != null) {
256                 mProgramList.removeOnCompleteListener(mProgramListCompleteListener);
257             }
258             mProgramList = programList;
259             if (programList != null) {
260                 mProgramList.addOnCompleteListener(mProgramListCompleteListener);
261             }
262             mBrowserService.notifyChildrenChanged(NODE_ROOT);
263         }
264     }
265 
getPrograms()266     private List<MediaItem> getPrograms() {
267         synchronized (mLock) {
268             if (mProgramListSnapshot == null) {
269                 Log.w(TAG, "There is no snapshot of the program list");
270                 return null;
271             }
272 
273             if (mProgramListCache != null) return mProgramListCache;
274             mProgramListCache = new ArrayList<>();
275 
276             MediaDescription.Builder dbld = new MediaDescription.Builder();
277 
278             for (RadioManager.ProgramInfo program : mProgramListSnapshot) {
279                 ProgramSelector sel = program.getSelector();
280                 String mediaId = selectorToMediaId(sel);
281                 mProgramSelectors.put(mediaId, sel);
282 
283                 Bitmap icon = null;
284                 RadioMetadata meta = program.getMetadata();
285                 if (meta != null && mImageResolver != null) {
286                     long id = RadioMetadataExt.getGlobalBitmapId(meta,
287                             RadioMetadata.METADATA_KEY_ICON);
288                     if (id != 0) icon = mImageResolver.resolve(id);
289                 }
290 
291                 mProgramListCache.add(createChild(dbld, mediaId,
292                         ProgramInfoExt.getProgramName(program, 0), program.getSelector(), icon));
293             }
294 
295             if (mProgramListCache.size() == 0) {
296                 Log.v(TAG, "Program list is empty");
297             }
298             return mProgramListCache;
299         }
300     }
301 
sendPrograms(final Result<List<MediaItem>> result)302     private void sendPrograms(final Result<List<MediaItem>> result) {
303         synchronized (mLock) {
304             if (mProgramListSnapshot != null) {
305                 result.sendResult(getPrograms());
306             } else {
307                 Log.d(TAG, "Program list is not ready yet");
308                 result.detach();
309                 mProgramListTasks.add(() -> result.sendResult(getPrograms()));
310             }
311         }
312     }
313 
314     /**
315      * Updates favorites list.
316      */
setFavorites(@ullable Set<Program> favorites)317     public void setFavorites(@Nullable Set<Program> favorites) {
318         synchronized (mLock) {
319             boolean rootChanged = (mFavorites == null) != (favorites == null);
320             mFavorites = favorites;
321             mFavoritesCache = null;
322             mBrowserService.notifyChildrenChanged(NODE_FAVORITES);
323             if (rootChanged) mBrowserService.notifyChildrenChanged(NODE_ROOT);
324         }
325     }
326 
getFavorites()327     private List<MediaItem> getFavorites() {
328         synchronized (mLock) {
329             if (mFavorites == null) return null;
330             if (mFavoritesCache != null) return mFavoritesCache;
331             mFavoritesCache = new ArrayList<>();
332 
333             MediaDescription.Builder dbld = new MediaDescription.Builder();
334 
335             for (Program fav : mFavorites) {
336                 ProgramSelector sel = fav.getSelector();
337                 String mediaId = selectorToMediaId(sel);
338                 mProgramSelectors.putIfAbsent(mediaId, sel);  // prefer program list entries
339                 mFavoritesCache.add(createChild(dbld, mediaId, fav.getName(), sel, fav.getIcon()));
340             }
341 
342             return mFavoritesCache;
343         }
344     }
345 
getRootChildren()346     private List<MediaItem> getRootChildren() {
347         synchronized (mLock) {
348             if (mRootChildren != null) return mRootChildren;
349             mRootChildren = new ArrayList<>();
350 
351             MediaDescription.Builder dbld = new MediaDescription.Builder();
352             if (mProgramList != null) {
353                 mRootChildren.add(createFolder(dbld, NODE_PROGRAMS,
354                         mBrowserService.getString(R.string.program_list_text),
355                         true, false, BCRADIO_FOLDER_TYPE_PROGRAMS, null));
356             }
357             if (mFavorites != null) {
358                 mRootChildren.add(createFolder(dbld, NODE_FAVORITES,
359                         mBrowserService.getString(R.string.favorites_list_text),
360                         true, true, BCRADIO_FOLDER_TYPE_FAVORITES, null));
361             }
362 
363             MediaItem amRoot = mAmChannels.getBandRoot();
364             if (amRoot != null) mRootChildren.add(amRoot);
365             MediaItem fmRoot = mFmChannels.getBandRoot();
366             if (fmRoot != null) mRootChildren.add(fmRoot);
367 
368             if (mDABEnabled) {
369                 mRootChildren.add(createFolder(dbld, NODE_BAND_DAB,
370                         mBrowserService.getString(R.string.radio_dab_text),
371                         false, true, BCRADIO_FOLDER_TYPE_BAND, null));
372             }
373 
374             return mRootChildren;
375         }
376     }
377 
378     private class AmFmChannelList {
379         public final @NonNull String mMediaId;
380         private final @StringRes int mBandName;
381         private final @NonNull String mBandNameEn;
382         private @Nullable List<BandDescriptor> mBands;
383         private @Nullable List<MediaItem> mChannels;
384 
AmFmChannelList(@onNull String mediaId, @StringRes int bandName, @NonNull String bandNameEn)385         private AmFmChannelList(@NonNull String mediaId, @StringRes int bandName,
386                 @NonNull String bandNameEn) {
387             mMediaId = Objects.requireNonNull(mediaId);
388             mBandName = bandName;
389             mBandNameEn = Objects.requireNonNull(bandNameEn);
390         }
391 
setBands(List<BandDescriptor> bands)392         public void setBands(List<BandDescriptor> bands) {
393             synchronized (mLock) {
394                 mBands = bands;
395                 mChannels = null;
396                 mBrowserService.notifyChildrenChanged(mMediaId);
397             }
398         }
399 
isEmpty()400         private boolean isEmpty() {
401             if (mBands == null) {
402                 Log.w(TAG, "AM/FM configuration not set");
403                 return true;
404             }
405             return mBands.isEmpty();
406         }
407 
getBandRoot()408         public @Nullable MediaItem getBandRoot() {
409             if (isEmpty()) return null;
410             Bundle extras = new Bundle();
411             extras.putString(EXTRA_BCRADIO_BAND_NAME_EN, mBandNameEn);
412             return createFolder(new MediaDescription.Builder(), mMediaId,
413                     mBrowserService.getString(mBandName), true, true, BCRADIO_FOLDER_TYPE_BAND,
414                     extras);
415         }
416 
getChannels()417         public List<MediaItem> getChannels() {
418             synchronized (mLock) {
419                 if (mChannels != null) return mChannels;
420                 if (isEmpty()) return null;
421                 mChannels = new ArrayList<>();
422 
423                 MediaDescription.Builder dbld = new MediaDescription.Builder();
424 
425                 for (BandDescriptor band : mBands) {
426                     final int lowerLimit = band.getLowerLimit();
427                     final int upperLimit = band.getUpperLimit();
428                     final int spacing = band.getSpacing();
429                     for (int ch = lowerLimit; ch <= upperLimit; ch += spacing) {
430                         ProgramSelector sel = ProgramSelectorExt.createAmFmSelector(ch);
431                         mChannels.add(createChild(dbld, NODEPREFIX_AMFMCHANNEL + ch,
432                                 ProgramSelectorExt.getDisplayName(sel, 0), sel, null));
433                     }
434                 }
435 
436                 return mChannels;
437             }
438         }
439     }
440 
441     /**
442      * Loads subtree children.
443      *
444      * This method is meant to be used in MediaBrowserService's onLoadChildren callback.
445      */
loadChildren(final String parentMediaId, final Result<List<MediaItem>> result)446     public void loadChildren(final String parentMediaId, final Result<List<MediaItem>> result) {
447         if (parentMediaId == null || result == null) return;
448 
449         if (NODE_ROOT.equals(parentMediaId)) {
450             result.sendResult(getRootChildren());
451         } else if (NODE_PROGRAMS.equals(parentMediaId)) {
452             sendPrograms(result);
453         } else if (NODE_FAVORITES.equals(parentMediaId)) {
454             result.sendResult(getFavorites());
455         } else if (parentMediaId.equals(mAmChannels.mMediaId)) {
456             result.sendResult(mAmChannels.getChannels());
457         } else if (parentMediaId.equals(mFmChannels.mMediaId)) {
458             result.sendResult(mFmChannels.getChannels());
459         } else {
460             Log.w(TAG, "Invalid parent media ID: " + parentMediaId);
461             result.sendResult(null);
462         }
463     }
464 
selectorToMediaId(@onNull ProgramSelector sel)465     private static @NonNull String selectorToMediaId(@NonNull ProgramSelector sel) {
466         ProgramSelector.Identifier id = sel.getPrimaryId();
467         return NODEPREFIX_PROGRAM + id.getType() + '/' + id.getValue();
468     }
469 
470     /**
471      * Resolves mediaId to a tunable {@link ProgramSelector}.
472      *
473      * This method is meant to be used in MediaSession's onPlayFromMediaId callback.
474      */
parseMediaId(@ullable String mediaId)475     public @Nullable ProgramSelector parseMediaId(@Nullable String mediaId) {
476         if (mediaId == null) return null;
477 
478         if (mediaId.startsWith(NODEPREFIX_AMFMCHANNEL)) {
479             String freqStr = mediaId.substring(NODEPREFIX_AMFMCHANNEL.length());
480             int freqInt;
481             try {
482                 freqInt = Integer.parseInt(freqStr);
483             } catch (NumberFormatException ex) {
484                 Log.e(TAG, "Invalid frequency", ex);
485                 return null;
486             }
487             return ProgramSelectorExt.createAmFmSelector(freqInt);
488         } else if (mediaId.startsWith(NODEPREFIX_PROGRAM)) {
489             return mProgramSelectors.get(mediaId);
490         } else if (mediaId.equals(NODE_FAVORITES)) {
491             if (mFavorites == null || mFavorites.isEmpty()) return null;
492             return mFavorites.iterator().next().getSelector();
493         } else if (mediaId.equals(NODE_PROGRAMS)) {
494             if (mProgramListSnapshot == null || mProgramListSnapshot.isEmpty()) return null;
495             return mProgramListSnapshot.get(0).getSelector();
496         } else if (mediaId.equals(NODE_BAND_AM)) {
497             if (mAmChannels.mBands == null || mAmChannels.mBands.isEmpty()) return null;
498             return ProgramSelectorExt.createAmFmSelector(mAmChannels.mBands.get(0).getLowerLimit());
499         } else if (mediaId.equals(NODE_BAND_FM)) {
500             if (mFmChannels.mBands == null || mFmChannels.mBands.isEmpty()) return null;
501             return ProgramSelectorExt.createAmFmSelector(mFmChannels.mBands.get(0).getLowerLimit());
502         }
503         return null;
504     }
505 }
506