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