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