| /** |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.car.broadcastradio.support.media; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.StringRes; |
| import android.graphics.Bitmap; |
| import android.hardware.radio.ProgramList; |
| import android.hardware.radio.ProgramSelector; |
| import android.hardware.radio.RadioManager; |
| import android.hardware.radio.RadioManager.BandDescriptor; |
| import android.hardware.radio.RadioMetadata; |
| import android.media.MediaDescription; |
| import android.media.browse.MediaBrowser.MediaItem; |
| import android.os.Bundle; |
| import android.service.media.MediaBrowserService; |
| import android.service.media.MediaBrowserService.BrowserRoot; |
| import android.service.media.MediaBrowserService.Result; |
| import android.util.Log; |
| |
| import com.android.car.broadcastradio.support.Program; |
| import com.android.car.broadcastradio.support.R; |
| import com.android.car.broadcastradio.support.platform.ImageResolver; |
| import com.android.car.broadcastradio.support.platform.ProgramInfoExt; |
| import com.android.car.broadcastradio.support.platform.ProgramSelectorExt; |
| import com.android.car.broadcastradio.support.platform.RadioMetadataExt; |
| |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Set; |
| |
| /** |
| * Implementation of MediaBrowserService logic regarding browser tree. |
| */ |
| public class BrowseTree { |
| private static final String TAG = "BcRadioApp.BrowseTree"; |
| |
| /** |
| * Used as a long extra field to indicate the Broadcast Radio folder type of the media item. |
| * The value should be one of the following: |
| * <ul> |
| * <li>{@link #BCRADIO_FOLDER_TYPE_PROGRAMS}</li> |
| * <li>{@link #BCRADIO_FOLDER_TYPE_FAVORITES}</li> |
| * <li>{@link #BCRADIO_FOLDER_TYPE_BAND}</li> |
| * </ul> |
| * |
| * @see android.media.MediaDescription#getExtras() |
| */ |
| public static final String EXTRA_BCRADIO_FOLDER_TYPE = |
| "android.media.extra.EXTRA_BCRADIO_FOLDER_TYPE"; |
| |
| /** |
| * The type of folder that contains a list of Broadcast Radio programs available |
| * to tune at the moment. |
| */ |
| public static final long BCRADIO_FOLDER_TYPE_PROGRAMS = 1; |
| |
| /** |
| * The type of folder that contains a list of Broadcast Radio programs added |
| * to favorites (not necessarily available to tune at the moment). |
| * |
| * If this folder has {@link android.media.browse.MediaBrowser.MediaItem#FLAG_PLAYABLE} flag |
| * set, it can be used to play some program from the favorite list (selection depends on the |
| * radio app implementation). |
| */ |
| public static final long BCRADIO_FOLDER_TYPE_FAVORITES = 2; |
| |
| /** |
| * The type of folder that contains the list of all Broadcast Radio channels |
| * (frequency values valid in the current region) for a given band. |
| * Each band (like AM, FM) has its own, separate folder. |
| * These lists include all channels, whether or not some program is tunable through it. |
| * |
| * If this folder has {@link android.media.browse.MediaBrowser.MediaItem#FLAG_PLAYABLE} flag |
| * set, it can be used to tune to some channel within a given band (selection depends on the |
| * radio app implementation). |
| */ |
| public static final long BCRADIO_FOLDER_TYPE_BAND = 3; |
| |
| /** |
| * Non-localized name of the band. |
| * |
| * For now, it can only take one of the following values: |
| * - AM; |
| * - FM; |
| * - DAB; |
| * - SXM. |
| * |
| * However, in future releases the list might get extended. |
| */ |
| public static final String EXTRA_BCRADIO_BAND_NAME_EN = |
| "android.media.extra.EXTRA_BCRADIO_BAND_NAME_EN"; |
| |
| /** |
| * General play intent action. |
| * |
| * MediaBrowserService of the radio app must handle this command to perform general |
| * "play" command. It usually means starting playback of recently tuned station. |
| */ |
| public static final String ACTION_PLAY_BROADCASTRADIO = |
| "android.car.intent.action.PLAY_BROADCASTRADIO"; |
| |
| private static final String NODE_ROOT = "root_id"; |
| public static final String NODE_PROGRAMS = "programs_id"; |
| public static final String NODE_FAVORITES = "favorites_id"; |
| |
| private static final String NODEPREFIX_BAND = "band:"; |
| public static final String NODE_BAND_AM = NODEPREFIX_BAND + "am"; |
| public static final String NODE_BAND_FM = NODEPREFIX_BAND + "fm"; |
| public static final String NODE_BAND_DAB = NODEPREFIX_BAND + "dab"; |
| |
| private static final String NODEPREFIX_AMFMCHANNEL = "amfm:"; |
| private static final String NODEPREFIX_PROGRAM = "program:"; |
| |
| private final BrowserRoot mRoot = new BrowserRoot(NODE_ROOT, null); |
| |
| private final Object mLock = new Object(); |
| private final @NonNull MediaBrowserService mBrowserService; |
| private final @Nullable ImageResolver mImageResolver; |
| |
| private List<MediaItem> mRootChildren; |
| |
| private final AmFmChannelList mAmChannels = new AmFmChannelList( |
| NODE_BAND_AM, R.string.radio_am_text, "AM"); |
| private final AmFmChannelList mFmChannels = new AmFmChannelList( |
| NODE_BAND_FM, R.string.radio_fm_text, "FM"); |
| private boolean mDABEnabled; |
| |
| private final ProgramList.OnCompleteListener mProgramListCompleteListener = |
| this::onProgramListUpdated; |
| @Nullable private ProgramList mProgramList; |
| @Nullable private List<RadioManager.ProgramInfo> mProgramListSnapshot; |
| @Nullable private List<MediaItem> mProgramListCache; |
| private final List<Runnable> mProgramListTasks = new ArrayList<>(); |
| private final Map<String, ProgramSelector> mProgramSelectors = new HashMap<>(); |
| |
| @Nullable Set<Program> mFavorites; |
| @Nullable private List<MediaItem> mFavoritesCache; |
| |
| public BrowseTree(@NonNull MediaBrowserService browserService, |
| @Nullable ImageResolver imageResolver) { |
| mBrowserService = Objects.requireNonNull(browserService); |
| mImageResolver = imageResolver; |
| } |
| |
| public BrowserRoot getRoot() { |
| return mRoot; |
| } |
| |
| private static MediaItem createChild(MediaDescription.Builder descBuilder, |
| String mediaId, String title, ProgramSelector sel, Bitmap icon) { |
| MediaDescription desc = descBuilder |
| .setMediaId(mediaId) |
| .setMediaUri(ProgramSelectorExt.toUri(sel)) |
| .setTitle(title) |
| .setIconBitmap(icon) |
| .build(); |
| return new MediaItem(desc, MediaItem.FLAG_PLAYABLE); |
| } |
| |
| private static MediaItem createFolder(MediaDescription.Builder descBuilder, String mediaId, |
| String title, boolean isBrowseable, boolean isPlayable, long folderType, |
| Bundle extras) { |
| if (extras == null) extras = new Bundle(); |
| extras.putLong(EXTRA_BCRADIO_FOLDER_TYPE, folderType); |
| |
| MediaDescription desc = descBuilder |
| .setMediaId(mediaId).setTitle(title).setExtras(extras).build(); |
| |
| int flags = 0; |
| if (isBrowseable) flags |= MediaItem.FLAG_BROWSABLE; |
| if (isPlayable) flags |= MediaItem.FLAG_PLAYABLE; |
| return new MediaItem(desc, flags); |
| } |
| |
| /** |
| * Sets AM/FM region configuration. |
| * |
| * This method is meant to be called shortly after initialization, if AM/FM is supported. |
| */ |
| public void setAmFmRegionConfig(@Nullable List<BandDescriptor> amFmBands) { |
| List<BandDescriptor> amBands = new ArrayList<>(); |
| List<BandDescriptor> fmBands = new ArrayList<>(); |
| |
| if (amFmBands != null) { |
| for (BandDescriptor band : amFmBands) { |
| final int freq = band.getLowerLimit(); |
| if (ProgramSelectorExt.isAmFrequency(freq)) { |
| amBands.add(band); |
| } else if (ProgramSelectorExt.isFmFrequency(freq)) { |
| fmBands.add(band); |
| } |
| } |
| } |
| |
| synchronized (mLock) { |
| mAmChannels.setBands(amBands); |
| mFmChannels.setBands(fmBands); |
| mRootChildren = null; |
| mBrowserService.notifyChildrenChanged(NODE_ROOT); |
| } |
| } |
| |
| /** |
| * Configures the BrowseTree to include a DAB node or not |
| */ |
| public void setDABEnabled(boolean enabled) { |
| synchronized (mLock) { |
| if (mDABEnabled != enabled) { |
| mDABEnabled = enabled; |
| mRootChildren = null; |
| mBrowserService.notifyChildrenChanged(NODE_ROOT); |
| } |
| } |
| } |
| |
| private void onProgramListUpdated() { |
| synchronized (mLock) { |
| mProgramListSnapshot = mProgramList.toList(); |
| mProgramListCache = null; |
| mBrowserService.notifyChildrenChanged(NODE_PROGRAMS); |
| |
| for (Runnable task : mProgramListTasks) { |
| task.run(); |
| } |
| mProgramListTasks.clear(); |
| } |
| } |
| |
| /** |
| * Binds program list. |
| * |
| * This method is meant to be called shortly after opening a new tuner session. |
| */ |
| public void setProgramList(@Nullable ProgramList programList) { |
| synchronized (mLock) { |
| if (mProgramList != null) { |
| mProgramList.removeOnCompleteListener(mProgramListCompleteListener); |
| } |
| mProgramList = programList; |
| if (programList != null) { |
| mProgramList.addOnCompleteListener(mProgramListCompleteListener); |
| } |
| mBrowserService.notifyChildrenChanged(NODE_ROOT); |
| } |
| } |
| |
| private List<MediaItem> getPrograms() { |
| synchronized (mLock) { |
| if (mProgramListSnapshot == null) { |
| Log.w(TAG, "There is no snapshot of the program list"); |
| return null; |
| } |
| |
| if (mProgramListCache != null) return mProgramListCache; |
| mProgramListCache = new ArrayList<>(); |
| |
| MediaDescription.Builder dbld = new MediaDescription.Builder(); |
| |
| for (RadioManager.ProgramInfo program : mProgramListSnapshot) { |
| ProgramSelector sel = program.getSelector(); |
| String mediaId = selectorToMediaId(sel); |
| mProgramSelectors.put(mediaId, sel); |
| |
| Bitmap icon = null; |
| RadioMetadata meta = program.getMetadata(); |
| if (meta != null && mImageResolver != null) { |
| long id = RadioMetadataExt.getGlobalBitmapId(meta, |
| RadioMetadata.METADATA_KEY_ICON); |
| if (id != 0) icon = mImageResolver.resolve(id); |
| } |
| |
| mProgramListCache.add(createChild(dbld, mediaId, |
| ProgramInfoExt.getProgramName(program, 0), program.getSelector(), icon)); |
| } |
| |
| if (mProgramListCache.size() == 0) { |
| Log.v(TAG, "Program list is empty"); |
| } |
| return mProgramListCache; |
| } |
| } |
| |
| private void sendPrograms(final Result<List<MediaItem>> result) { |
| synchronized (mLock) { |
| if (mProgramListSnapshot != null) { |
| result.sendResult(getPrograms()); |
| } else { |
| Log.d(TAG, "Program list is not ready yet"); |
| result.detach(); |
| mProgramListTasks.add(() -> result.sendResult(getPrograms())); |
| } |
| } |
| } |
| |
| /** |
| * Updates favorites list. |
| */ |
| public void setFavorites(@Nullable Set<Program> favorites) { |
| synchronized (mLock) { |
| boolean rootChanged = (mFavorites == null) != (favorites == null); |
| mFavorites = favorites; |
| mFavoritesCache = null; |
| mBrowserService.notifyChildrenChanged(NODE_FAVORITES); |
| if (rootChanged) mBrowserService.notifyChildrenChanged(NODE_ROOT); |
| } |
| } |
| |
| private List<MediaItem> getFavorites() { |
| synchronized (mLock) { |
| if (mFavorites == null) return null; |
| if (mFavoritesCache != null) return mFavoritesCache; |
| mFavoritesCache = new ArrayList<>(); |
| |
| MediaDescription.Builder dbld = new MediaDescription.Builder(); |
| |
| for (Program fav : mFavorites) { |
| ProgramSelector sel = fav.getSelector(); |
| String mediaId = selectorToMediaId(sel); |
| mProgramSelectors.putIfAbsent(mediaId, sel); // prefer program list entries |
| mFavoritesCache.add(createChild(dbld, mediaId, fav.getName(), sel, fav.getIcon())); |
| } |
| |
| return mFavoritesCache; |
| } |
| } |
| |
| private List<MediaItem> getRootChildren() { |
| synchronized (mLock) { |
| if (mRootChildren != null) return mRootChildren; |
| mRootChildren = new ArrayList<>(); |
| |
| MediaDescription.Builder dbld = new MediaDescription.Builder(); |
| if (mProgramList != null) { |
| mRootChildren.add(createFolder(dbld, NODE_PROGRAMS, |
| mBrowserService.getString(R.string.program_list_text), |
| true, false, BCRADIO_FOLDER_TYPE_PROGRAMS, null)); |
| } |
| if (mFavorites != null) { |
| mRootChildren.add(createFolder(dbld, NODE_FAVORITES, |
| mBrowserService.getString(R.string.favorites_list_text), |
| true, true, BCRADIO_FOLDER_TYPE_FAVORITES, null)); |
| } |
| |
| MediaItem amRoot = mAmChannels.getBandRoot(); |
| if (amRoot != null) mRootChildren.add(amRoot); |
| MediaItem fmRoot = mFmChannels.getBandRoot(); |
| if (fmRoot != null) mRootChildren.add(fmRoot); |
| |
| if (mDABEnabled) { |
| mRootChildren.add(createFolder(dbld, NODE_BAND_DAB, |
| mBrowserService.getString(R.string.radio_dab_text), |
| false, true, BCRADIO_FOLDER_TYPE_BAND, null)); |
| } |
| |
| return mRootChildren; |
| } |
| } |
| |
| private class AmFmChannelList { |
| public final @NonNull String mMediaId; |
| private final @StringRes int mBandName; |
| private final @NonNull String mBandNameEn; |
| private @Nullable List<BandDescriptor> mBands; |
| private @Nullable List<MediaItem> mChannels; |
| |
| private AmFmChannelList(@NonNull String mediaId, @StringRes int bandName, |
| @NonNull String bandNameEn) { |
| mMediaId = Objects.requireNonNull(mediaId); |
| mBandName = bandName; |
| mBandNameEn = Objects.requireNonNull(bandNameEn); |
| } |
| |
| public void setBands(List<BandDescriptor> bands) { |
| synchronized (mLock) { |
| mBands = bands; |
| mChannels = null; |
| mBrowserService.notifyChildrenChanged(mMediaId); |
| } |
| } |
| |
| private boolean isEmpty() { |
| if (mBands == null) { |
| Log.w(TAG, "AM/FM configuration not set"); |
| return true; |
| } |
| return mBands.isEmpty(); |
| } |
| |
| public @Nullable MediaItem getBandRoot() { |
| if (isEmpty()) return null; |
| Bundle extras = new Bundle(); |
| extras.putString(EXTRA_BCRADIO_BAND_NAME_EN, mBandNameEn); |
| return createFolder(new MediaDescription.Builder(), mMediaId, |
| mBrowserService.getString(mBandName), true, true, BCRADIO_FOLDER_TYPE_BAND, |
| extras); |
| } |
| |
| public List<MediaItem> getChannels() { |
| synchronized (mLock) { |
| if (mChannels != null) return mChannels; |
| if (isEmpty()) return null; |
| mChannels = new ArrayList<>(); |
| |
| MediaDescription.Builder dbld = new MediaDescription.Builder(); |
| |
| for (BandDescriptor band : mBands) { |
| final int lowerLimit = band.getLowerLimit(); |
| final int upperLimit = band.getUpperLimit(); |
| final int spacing = band.getSpacing(); |
| for (int ch = lowerLimit; ch <= upperLimit; ch += spacing) { |
| ProgramSelector sel = ProgramSelectorExt.createAmFmSelector(ch); |
| mChannels.add(createChild(dbld, NODEPREFIX_AMFMCHANNEL + ch, |
| ProgramSelectorExt.getDisplayName(sel, 0), sel, null)); |
| } |
| } |
| |
| return mChannels; |
| } |
| } |
| } |
| |
| /** |
| * Loads subtree children. |
| * |
| * This method is meant to be used in MediaBrowserService's onLoadChildren callback. |
| */ |
| public void loadChildren(final String parentMediaId, final Result<List<MediaItem>> result) { |
| if (parentMediaId == null || result == null) return; |
| |
| if (NODE_ROOT.equals(parentMediaId)) { |
| result.sendResult(getRootChildren()); |
| } else if (NODE_PROGRAMS.equals(parentMediaId)) { |
| sendPrograms(result); |
| } else if (NODE_FAVORITES.equals(parentMediaId)) { |
| result.sendResult(getFavorites()); |
| } else if (parentMediaId.equals(mAmChannels.mMediaId)) { |
| result.sendResult(mAmChannels.getChannels()); |
| } else if (parentMediaId.equals(mFmChannels.mMediaId)) { |
| result.sendResult(mFmChannels.getChannels()); |
| } else { |
| Log.w(TAG, "Invalid parent media ID: " + parentMediaId); |
| result.sendResult(null); |
| } |
| } |
| |
| private static @NonNull String selectorToMediaId(@NonNull ProgramSelector sel) { |
| ProgramSelector.Identifier id = sel.getPrimaryId(); |
| return NODEPREFIX_PROGRAM + id.getType() + '/' + id.getValue(); |
| } |
| |
| /** |
| * Resolves mediaId to a tunable {@link ProgramSelector}. |
| * |
| * This method is meant to be used in MediaSession's onPlayFromMediaId callback. |
| */ |
| public @Nullable ProgramSelector parseMediaId(@Nullable String mediaId) { |
| if (mediaId == null) return null; |
| |
| if (mediaId.startsWith(NODEPREFIX_AMFMCHANNEL)) { |
| String freqStr = mediaId.substring(NODEPREFIX_AMFMCHANNEL.length()); |
| int freqInt; |
| try { |
| freqInt = Integer.parseInt(freqStr); |
| } catch (NumberFormatException ex) { |
| Log.e(TAG, "Invalid frequency", ex); |
| return null; |
| } |
| return ProgramSelectorExt.createAmFmSelector(freqInt); |
| } else if (mediaId.startsWith(NODEPREFIX_PROGRAM)) { |
| return mProgramSelectors.get(mediaId); |
| } else if (mediaId.equals(NODE_FAVORITES)) { |
| if (mFavorites == null || mFavorites.isEmpty()) return null; |
| return mFavorites.iterator().next().getSelector(); |
| } else if (mediaId.equals(NODE_PROGRAMS)) { |
| if (mProgramListSnapshot == null || mProgramListSnapshot.isEmpty()) return null; |
| return mProgramListSnapshot.get(0).getSelector(); |
| } else if (mediaId.equals(NODE_BAND_AM)) { |
| if (mAmChannels.mBands == null || mAmChannels.mBands.isEmpty()) return null; |
| return ProgramSelectorExt.createAmFmSelector(mAmChannels.mBands.get(0).getLowerLimit()); |
| } else if (mediaId.equals(NODE_BAND_FM)) { |
| if (mFmChannels.mBands == null || mFmChannels.mBands.isEmpty()) return null; |
| return ProgramSelectorExt.createAmFmSelector(mFmChannels.mBands.get(0).getLowerLimit()); |
| } |
| return null; |
| } |
| } |