summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTomasz Wasilczyk <twasilczyk@google.com>2018-05-03 12:56:04 -0700
committerTomasz Wasilczyk <twasilczyk@google.com>2018-05-07 11:10:54 -0700
commitf11da2ad7168c9f76518bac3bb63f9da9698109b (patch)
tree671b18652a4ce43475841c8a678f5aae060bc164
parentd1e574cb3fb8cac41085cc6daf4be528f3d66466 (diff)
downloadsystemlibs-f11da2ad7168c9f76518bac3bb63f9da9698109b.tar.gz
Extract car-broadcastradio-support lib from the radio app.
Bug: 75970985 Test: full build, flash and run radio app Change-Id: I5e1e6a9c392235a8eb30d5207d10d6adb0c063ee Merged-In: I5e1e6a9c392235a8eb30d5207d10d6adb0c063ee
-rw-r--r--car-broadcastradio-support/Android.mk36
-rw-r--r--car-broadcastradio-support/AndroidManifest.xml21
-rw-r--r--car-broadcastradio-support/OWNERS3
-rw-r--r--car-broadcastradio-support/res/values/strings.xml28
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.aidl18
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.java113
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/media/BrowseTree.java446
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ImageResolver.java33
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramInfoExt.java173
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramSelectorExt.java457
-rw-r--r--car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/RadioMetadataExt.java60
11 files changed, 1388 insertions, 0 deletions
diff --git a/car-broadcastradio-support/Android.mk b/car-broadcastradio-support/Android.mk
new file mode 100644
index 0000000..3184f72
--- /dev/null
+++ b/car-broadcastradio-support/Android.mk
@@ -0,0 +1,36 @@
+#
+# 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.
+#
+
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := car-broadcastradio-support
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+LOCAL_AIDL_INCLUDES := $(LOCAL_PATH)/src
+LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
+
+LOCAL_MODULE_TAGS := optional
+LOCAL_PRIVILEGED_MODULE := true
+
+LOCAL_PROGUARD_ENABLED := disabled
+LOCAL_USE_AAPT2 := true
+
+LOCAL_STATIC_ANDROID_LIBRARIES := \
+ android-support-v4
+
+include $(BUILD_STATIC_JAVA_LIBRARY)
diff --git a/car-broadcastradio-support/AndroidManifest.xml b/car-broadcastradio-support/AndroidManifest.xml
new file mode 100644
index 0000000..e592ee1
--- /dev/null
+++ b/car-broadcastradio-support/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.broadcastradio.support">
+ <uses-sdk
+ android:minSdkVersion="28"
+ android:targetSdkVersion='28'/>
+</manifest>
diff --git a/car-broadcastradio-support/OWNERS b/car-broadcastradio-support/OWNERS
new file mode 100644
index 0000000..136b607
--- /dev/null
+++ b/car-broadcastradio-support/OWNERS
@@ -0,0 +1,3 @@
+# Automotive team
+egranata@google.com
+twasilczyk@google.com
diff --git a/car-broadcastradio-support/res/values/strings.xml b/car-broadcastradio-support/res/values/strings.xml
new file mode 100644
index 0000000..229d701
--- /dev/null
+++ b/car-broadcastradio-support/res/values/strings.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+ <!-- Text to denote the AM radio band. -->
+ <string name="radio_am_text">AM</string>
+
+ <!-- Text to denote the FM radio band. -->
+ <string name="radio_fm_text">FM</string>
+
+ <!-- Text to denote the list of programs (stations). -->
+ <string name="program_list_text">Stations</string>
+
+ <!-- Text to denote the list of favorite programs (stations). -->
+ <string name="favorites_list_text">Favorites</string>
+</resources>
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.aidl b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.aidl
new file mode 100644
index 0000000..99c9a93
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.aidl
@@ -0,0 +1,18 @@
+/**
+ * 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;
+
+parcelable Program;
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.java
new file mode 100644
index 0000000..4d1de73
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/Program.java
@@ -0,0 +1,113 @@
+/**
+ * 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;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.graphics.Bitmap;
+import android.hardware.radio.ProgramSelector;
+import android.hardware.radio.RadioManager.ProgramInfo;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.car.broadcastradio.support.platform.ProgramInfoExt;
+
+import java.util.Objects;
+
+/**
+ * Holds storable information about a Program.
+ *
+ * Contrary to {@link android.hardware.radio.RadioManager.ProgramInfo}, it doesn't hold runtime
+ * information, like artist or signal quality.
+ */
+public final class Program implements Parcelable {
+ private final @NonNull ProgramSelector mSelector;
+ private final @NonNull String mName;
+
+ public Program(@NonNull ProgramSelector selector, @NonNull String name) {
+ mSelector = Objects.requireNonNull(selector);
+ mName = Objects.requireNonNull(name);
+ }
+
+ public @NonNull ProgramSelector getSelector() {
+ return mSelector;
+ }
+
+ public @NonNull String getName() {
+ return mName;
+ }
+
+ /** @hide */
+ public @Nullable Bitmap getIcon() {
+ // TODO(b/75970985): implement saving icons
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "Program(\"" + mName + "\", " + mSelector + ")";
+ }
+
+ @Override
+ public int hashCode() {
+ return mSelector.hashCode();
+ }
+
+ /**
+ * Two programs are considered equal if their selectors are equal.
+ */
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof Program)) return false;
+ Program other = (Program) obj;
+ return mSelector.equals(other.mSelector);
+ }
+
+ /**
+ * Builds a new {@link Program} object from {@link ProgramInfo}.
+ */
+ public static @NonNull Program fromProgramInfo(@NonNull ProgramInfo info) {
+ return new Program(info.getSelector(), ProgramInfoExt.getProgramName(info, 0));
+ }
+
+ private Program(Parcel in) {
+ mSelector = Objects.requireNonNull(in.readTypedObject(ProgramSelector.CREATOR));
+ mName = Objects.requireNonNull(in.readString());
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeTypedObject(mSelector, 0);
+ dest.writeString(mName);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Parcelable.Creator<Program> CREATOR = new Parcelable.Creator<Program>() {
+ public Program createFromParcel(Parcel in) {
+ return new Program(in);
+ }
+
+ public Program[] newArray(int size) {
+ return new Program[size];
+ }
+ };
+}
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/media/BrowseTree.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/media/BrowseTree.java
new file mode 100644
index 0000000..ebfd8ba
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/media/BrowseTree.java
@@ -0,0 +1,446 @@
+/**
+ * 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.os.Bundle;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
+import android.support.v4.media.MediaBrowserServiceCompat;
+import android.support.v4.media.MediaBrowserServiceCompat.BrowserRoot;
+import android.support.v4.media.MediaBrowserServiceCompat.Result;
+import android.support.v4.media.MediaDescriptionCompat;
+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;
+
+ private static final String NODE_ROOT = "root_id";
+ private static final String NODE_PROGRAMS = "programs_id";
+ private static final String NODE_FAVORITES = "favorites_id";
+
+ private static final String NODEPREFIX_BAND = "band:";
+ 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 MediaBrowserServiceCompat mBrowserService;
+ private final @Nullable ImageResolver mImageResolver;
+
+ private List<MediaItem> mRootChildren;
+
+ private final AmFmChannelList mAmChannels = new AmFmChannelList(
+ NODEPREFIX_BAND + "am", R.string.radio_am_text);
+ private final AmFmChannelList mFmChannels = new AmFmChannelList(
+ NODEPREFIX_BAND + "fm", R.string.radio_fm_text);
+
+ 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 MediaBrowserServiceCompat browserService,
+ @Nullable ImageResolver imageResolver) {
+ mBrowserService = Objects.requireNonNull(browserService);
+ mImageResolver = imageResolver;
+ }
+
+ public BrowserRoot getRoot() {
+ return mRoot;
+ }
+
+ private static MediaItem createChild(MediaDescriptionCompat.Builder descBuilder,
+ String mediaId, String title, ProgramSelector sel, Bitmap icon) {
+ MediaDescriptionCompat desc = descBuilder
+ .setMediaId(mediaId)
+ .setMediaUri(ProgramSelectorExt.toUri(sel))
+ .setTitle(title)
+ .setIconBitmap(icon)
+ .build();
+ return new MediaItem(desc, MediaItem.FLAG_PLAYABLE);
+ }
+
+ private static MediaItem createFolder(MediaDescriptionCompat.Builder descBuilder,
+ String mediaId, String title, boolean isPlayable, long folderType) {
+ Bundle extras = new Bundle();
+ extras.putLong(EXTRA_BCRADIO_FOLDER_TYPE, folderType);
+
+ MediaDescriptionCompat desc = descBuilder
+ .setMediaId(mediaId).setTitle(title).setExtras(extras).build();
+
+ int 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);
+ }
+ }
+
+ 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<>();
+
+ MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.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);
+ }
+ }
+
+ /** @hide */
+ public boolean isFavorite(@NonNull ProgramSelector selector) {
+ synchronized (mLock) {
+ if (mFavorites == null) return false;
+ return mFavorites.contains(new Program(selector, ""));
+ }
+ }
+
+ private List<MediaItem> getFavorites() {
+ synchronized (mLock) {
+ if (mFavorites == null) return null;
+ if (mFavoritesCache != null) return mFavoritesCache;
+ mFavoritesCache = new ArrayList<>();
+
+ MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.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<>();
+
+ MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.Builder();
+ if (mProgramList != null) {
+ mRootChildren.add(createFolder(dbld, NODE_PROGRAMS,
+ mBrowserService.getString(R.string.program_list_text),
+ false, BCRADIO_FOLDER_TYPE_PROGRAMS));
+ }
+ if (mFavorites != null) {
+ mRootChildren.add(createFolder(dbld, NODE_FAVORITES,
+ mBrowserService.getString(R.string.favorites_list_text),
+ true, BCRADIO_FOLDER_TYPE_FAVORITES));
+ }
+
+ MediaItem amRoot = mAmChannels.getBandRoot();
+ if (amRoot != null) mRootChildren.add(amRoot);
+ MediaItem fmRoot = mFmChannels.getBandRoot();
+ if (fmRoot != null) mRootChildren.add(fmRoot);
+
+ return mRootChildren;
+ }
+ }
+
+ private class AmFmChannelList {
+ public final @NonNull String mMediaId;
+ private final @StringRes int mBandName;
+ private @Nullable List<BandDescriptor> mBands;
+ private @Nullable List<MediaItem> mChannels;
+
+ private AmFmChannelList(@NonNull String mediaId, @StringRes int bandName) {
+ mMediaId = Objects.requireNonNull(mediaId);
+ mBandName = bandName;
+ }
+
+ 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;
+ return createFolder(new MediaDescriptionCompat.Builder(), mMediaId,
+ mBrowserService.getString(mBandName), true, BCRADIO_FOLDER_TYPE_BAND);
+ }
+
+ public List<MediaItem> getChannels() {
+ synchronized (mLock) {
+ if (mChannels != null) return mChannels;
+ if (isEmpty()) return null;
+ mChannels = new ArrayList<>();
+
+ MediaDescriptionCompat.Builder dbld = new MediaDescriptionCompat.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);
+ }
+ return null;
+ }
+}
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ImageResolver.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ImageResolver.java
new file mode 100644
index 0000000..5538a58
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ImageResolver.java
@@ -0,0 +1,33 @@
+/**
+ * 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.platform;
+
+import android.annotation.Nullable;
+import android.graphics.Bitmap;
+
+/**
+ * Resolves metadata images.
+ */
+public interface ImageResolver {
+ /**
+ * Resolve a given metadata image global id to a bitmap.
+ *
+ * @param globalId metadata image id
+ * @return A bitmap, or null if it was not available or invalid
+ */
+ @Nullable Bitmap resolve(long globalId);
+}
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramInfoExt.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramInfoExt.java
new file mode 100644
index 0000000..ce3d014
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramInfoExt.java
@@ -0,0 +1,173 @@
+/**
+ * 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.platform;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.graphics.Bitmap;
+import android.hardware.radio.ProgramSelector;
+import android.hardware.radio.RadioManager.ProgramInfo;
+import android.hardware.radio.RadioMetadata;
+import android.media.MediaMetadata;
+import android.media.Rating;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Proposed extensions to android.hardware.radio.RadioManager.ProgramInfo.
+ *
+ * They might eventually get pushed to the framework.
+ */
+public class ProgramInfoExt {
+ private static final String TAG = "BcRadioApp.pinfoext";
+
+ /**
+ * If there is no suitable program name, return null instead of doing
+ * a fallback to channel display name.
+ */
+ public static final int NAME_NO_CHANNEL_FALLBACK = 1 << 16;
+
+ /**
+ * Flags to control how to fetch program name with {@link #getProgramName}.
+ *
+ * Lower 16 bits are reserved for {@link ProgramSelectorExt#NameFlag}.
+ */
+ @IntDef(prefix = { "NAME_" }, flag = true, value = {
+ ProgramSelectorExt.NAME_NO_MODULATION,
+ ProgramSelectorExt.NAME_MODULATION_ONLY,
+ NAME_NO_CHANNEL_FALLBACK,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface NameFlag {}
+
+ private static final char EN_DASH = '\u2013';
+ private static final String TITLE_SEPARATOR = " " + EN_DASH + " ";
+
+ private static final String[] PROGRAM_NAME_ORDER = new String[] {
+ RadioMetadata.METADATA_KEY_PROGRAM_NAME,
+ RadioMetadata.METADATA_KEY_DAB_COMPONENT_NAME,
+ RadioMetadata.METADATA_KEY_DAB_SERVICE_NAME,
+ RadioMetadata.METADATA_KEY_DAB_ENSEMBLE_NAME,
+ RadioMetadata.METADATA_KEY_RDS_PS,
+ };
+
+ /**
+ * Returns program name suitable to display.
+ *
+ * If there is no program name, it falls back to channel name. Flags related to
+ * the channel name display will be forwarded to the channel name generation method.
+ */
+ public static @NonNull String getProgramName(@NonNull ProgramInfo info, @NameFlag int flags) {
+ RadioMetadata meta = info.getMetadata();
+ if (meta != null) {
+ for (String key : PROGRAM_NAME_ORDER) {
+ String value = meta.getString(key);
+ if (value != null) return value;
+ }
+ }
+
+ if ((flags & NAME_NO_CHANNEL_FALLBACK) != 0) return "";
+
+ ProgramSelector sel = info.getSelector();
+
+ // if it's AM/FM program, prefer to display currently used AF frequency
+ if (ProgramSelectorExt.isAmFmProgram(sel)) {
+ ProgramSelector.Identifier phy = info.getPhysicallyTunedTo();
+ if (phy != null && phy.getType() == ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY) {
+ String chName = ProgramSelectorExt.formatAmFmFrequency(phy.getValue(), flags);
+ if (chName != null) return chName;
+ }
+ }
+
+ String selName = ProgramSelectorExt.getDisplayName(sel, flags);
+ if (selName != null) return selName;
+
+ Log.w(TAG, "ProgramInfo without a name nor channel name");
+ return "";
+ }
+
+ /**
+ * Proposed reimplementation of {@link RadioManager#ProgramInfo#getMetadata}.
+ *
+ * As opposed to the original implementation, it never returns null.
+ */
+ public static @NonNull RadioMetadata getMetadata(@NonNull ProgramInfo info) {
+ RadioMetadata meta = info.getMetadata();
+ if (meta != null) return meta;
+
+ /* Creating new Metadata object on each get won't be necessary after we
+ * push this code to the framework. */
+ return (new RadioMetadata.Builder()).build();
+ }
+
+ /**
+ * Converts {@ProgramInfo} to {@MediaMetadata}.
+ *
+ * This method is meant to be used for currently playing station in {@link MediaSession}.
+ *
+ * @param info {@link ProgramInfo} to convert
+ * @param isFavorite true, if a given program is a favorite
+ * @param imageResolver metadata images resolver/cache
+ * @return {@link MediaMetadata} object
+ */
+ public static @NonNull MediaMetadata toMediaMetadata(@NonNull ProgramInfo info,
+ boolean isFavorite, @Nullable ImageResolver imageResolver) {
+ MediaMetadata.Builder bld = new MediaMetadata.Builder();
+
+ bld.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, getProgramName(info, 0));
+
+ RadioMetadata meta = info.getMetadata();
+ if (meta != null) {
+ String title = meta.getString(RadioMetadata.METADATA_KEY_TITLE);
+ if (title != null) {
+ bld.putString(MediaMetadata.METADATA_KEY_TITLE, title);
+ }
+ String artist = meta.getString(RadioMetadata.METADATA_KEY_ARTIST);
+ if (artist != null) {
+ bld.putString(MediaMetadata.METADATA_KEY_ARTIST, artist);
+ }
+ String album = meta.getString(RadioMetadata.METADATA_KEY_ALBUM);
+ if (album != null) {
+ bld.putString(MediaMetadata.METADATA_KEY_ALBUM, album);
+ }
+ if (title != null || artist != null) {
+ String subtitle;
+ if (title == null) {
+ subtitle = artist;
+ } else if (artist == null) {
+ subtitle = title;
+ } else {
+ subtitle = title + TITLE_SEPARATOR + artist;
+ }
+ bld.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle);
+ }
+ long albumArtId = RadioMetadataExt.getGlobalBitmapId(meta,
+ RadioMetadata.METADATA_KEY_ART);
+ if (albumArtId != 0 && imageResolver != null) {
+ Bitmap bm = imageResolver.resolve(albumArtId);
+ if (bm != null) bld.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bm);
+ }
+ }
+
+ bld.putRating(MediaMetadata.METADATA_KEY_USER_RATING, Rating.newHeartRating(isFavorite));
+
+ return bld.build();
+ }
+}
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramSelectorExt.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramSelectorExt.java
new file mode 100644
index 0000000..6d07437
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/ProgramSelectorExt.java
@@ -0,0 +1,457 @@
+/**
+ * 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.platform;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.hardware.radio.ProgramSelector;
+import android.hardware.radio.ProgramSelector.Identifier;
+import android.hardware.radio.RadioManager;
+import android.net.Uri;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+
+/**
+ * Proposed extensions to android.hardware.radio.ProgramSelector.
+ *
+ * They might eventually get pushed to the framework.
+ */
+public class ProgramSelectorExt {
+ private static final String TAG = "BcRadioApp.pselext";
+
+ /**
+ * If this is AM/FM channel (or any other technology using different modulations),
+ * don't return modulation part.
+ */
+ public static final int NAME_NO_MODULATION = 1 << 0;
+
+ /**
+ * Return only modulation part of channel name.
+ *
+ * If this is not a radio technology using modulation, return nothing
+ * (unless combined with other _ONLY flags in the future).
+ *
+ * If this returns non-null string, it's guaranteed that {@link #NAME_NO_MODULATION}
+ * will return the complement of channel name.
+ */
+ public static final int NAME_MODULATION_ONLY = 1 << 1;
+
+ /**
+ * Flags to control how channel values are converted to string with {@link #getDisplayName}.
+ *
+ * Upper 16 bits are reserved for {@link ProgramInfoExt#NameFlag}.
+ */
+ @IntDef(prefix = { "NAME_" }, flag = true, value = {
+ NAME_NO_MODULATION,
+ NAME_MODULATION_ONLY,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface NameFlag {}
+
+ private static final String URI_SCHEME_BROADCASTRADIO = "broadcastradio";
+ private static final String URI_AUTHORITY_PROGRAM = "program";
+ private static final String URI_VENDOR_PREFIX = "VENDOR_";
+ private static final String URI_HEX_PREFIX = "0x";
+
+ private static final DecimalFormat FORMAT_FM = new DecimalFormat("###.#");
+
+ private static final Map<Integer, String> ID_TO_URI = new HashMap<>();
+ private static final Map<String, Integer> URI_TO_ID = new HashMap<>();
+
+ /**
+ * New proposed constructor for {@link ProgramSelector}.
+ *
+ * As opposed to the current platform API, this one matches more closely simplified HAL 2.0.
+ *
+ * @param primaryId primary program identifier.
+ * @param secondaryIds list of secondary program identifiers.
+ */
+ public static @NonNull ProgramSelector newProgramSelector(@NonNull Identifier primaryId,
+ @Nullable Identifier[] secondaryIds) {
+ return new ProgramSelector(
+ identifierToProgramType(primaryId),
+ primaryId, secondaryIds, null);
+ }
+
+ // when pushed to the framework, remove similar code from HAL 2.0 service
+ private static @ProgramSelector.ProgramType int identifierToProgramType(
+ @NonNull Identifier primaryId) {
+ int idType = primaryId.getType();
+ switch (idType) {
+ case ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY:
+ if (isAmFrequency(primaryId.getValue())) {
+ return ProgramSelector.PROGRAM_TYPE_AM;
+ } else {
+ return ProgramSelector.PROGRAM_TYPE_FM;
+ }
+ case ProgramSelector.IDENTIFIER_TYPE_RDS_PI:
+ return ProgramSelector.PROGRAM_TYPE_FM;
+ case ProgramSelector.IDENTIFIER_TYPE_HD_STATION_ID_EXT:
+ if (isAmFrequency(IdentifierExt.asHdPrimary(primaryId).getFrequency())) {
+ return ProgramSelector.PROGRAM_TYPE_AM_HD;
+ } else {
+ return ProgramSelector.PROGRAM_TYPE_FM_HD;
+ }
+ case ProgramSelector.IDENTIFIER_TYPE_DAB_SIDECC:
+ case ProgramSelector.IDENTIFIER_TYPE_DAB_ENSEMBLE:
+ case ProgramSelector.IDENTIFIER_TYPE_DAB_SCID:
+ case ProgramSelector.IDENTIFIER_TYPE_DAB_FREQUENCY:
+ return ProgramSelector.PROGRAM_TYPE_DAB;
+ case ProgramSelector.IDENTIFIER_TYPE_DRMO_SERVICE_ID:
+ case ProgramSelector.IDENTIFIER_TYPE_DRMO_FREQUENCY:
+ return ProgramSelector.PROGRAM_TYPE_DRMO;
+ case ProgramSelector.IDENTIFIER_TYPE_SXM_SERVICE_ID:
+ case ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL:
+ return ProgramSelector.PROGRAM_TYPE_SXM;
+ }
+ if (idType >= ProgramSelector.IDENTIFIER_TYPE_VENDOR_PRIMARY_START
+ && idType <= ProgramSelector.IDENTIFIER_TYPE_VENDOR_PRIMARY_END) {
+ return idType;
+ }
+ return ProgramSelector.PROGRAM_TYPE_INVALID;
+ }
+
+ /**
+ * Checks, if a given AM frequency is roughly valid and in correct unit.
+ *
+ * It does not check the range precisely: it may provide false positives, but not false
+ * negatives. In particular, it may be way off for certain regions.
+ * The main purpose is to avoid passing inproper units, ie. MHz instead of kHz.
+ * It also can be used to check if a given frequency is likely to be used
+ * with AM or FM modulation.
+ *
+ * @param frequencyKhz the frequency in kHz.
+ * @return true, if the frequency is rougly valid.
+ */
+ public static boolean isAmFrequency(long frequencyKhz) {
+ return frequencyKhz > 150 && frequencyKhz <= 30000;
+ }
+
+ /**
+ * Checks, if a given FM frequency is roughly valid and in correct unit.
+ *
+ * It does not check the range precisely: it may provide false positives, but not false
+ * negatives. In particular, it may be way off for certain regions.
+ * The main purpose is to avoid passing inproper units, ie. MHz instead of kHz.
+ * It also can be used to check if a given frequency is likely to be used
+ * with AM or FM modulation.
+ *
+ * @param frequencyKhz the frequency in kHz.
+ * @return true, if the frequency is rougly valid.
+ */
+ public static boolean isFmFrequency(long frequencyKhz) {
+ return frequencyKhz > 60000 && frequencyKhz < 110000;
+ }
+
+ /**
+ * Provides human-readable representation of AM/FM frequency.
+ *
+ * @param frequencyKhz the frequency in kHz.
+ * @param flags flags that affect display format
+ * @return human-readable formatted frequency
+ */
+ public static @Nullable String formatAmFmFrequency(long frequencyKhz, @NameFlag int flags) {
+ String channel;
+ String modulation;
+
+ if (isAmFrequency(frequencyKhz)) {
+ channel = Long.toString(frequencyKhz);
+ modulation = "AM";
+ } else if (isFmFrequency(frequencyKhz)) {
+ channel = FORMAT_FM.format(frequencyKhz / 1000f);
+ modulation = "FM";
+ } else {
+ Log.w(TAG, "AM/FM frequency out of range: " + frequencyKhz);
+ return null;
+ }
+
+ if ((flags & NAME_MODULATION_ONLY) != 0) return modulation;
+ if ((flags & NAME_NO_MODULATION) != 0) return channel;
+ return channel + ' ' + modulation;
+ }
+
+ /**
+ * Builds new ProgramSelector for AM/FM frequency.
+ *
+ * @param frequencyKhz the frequency in kHz.
+ * @return new ProgramSelector object representing given frequency.
+ * @throws IllegalArgumentException if provided frequency is out of bounds.
+ */
+ public static @NonNull ProgramSelector createAmFmSelector(int frequencyKhz) {
+ return ProgramSelector.createAmFmSelector(RadioManager.BAND_INVALID, frequencyKhz);
+ }
+
+ /**
+ * Checks, if {@link ProgramSelector} contains an id of a given type.
+ *
+ * @param sel selector being checked
+ * @param type identifier type to check for
+ * @return true, if sel contains any identifier of a given type
+ */
+ public static boolean hasId(@NonNull ProgramSelector sel,
+ @ProgramSelector.IdentifierType int type) {
+ try {
+ sel.getFirstId(type);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Checks, if {@link ProgramSelector} is a AM/FM program.
+ *
+ * @return true, if the primary identifier of a selector belongs to one of the following
+ * technologies:
+ * - Analogue AM/FM
+ * - FM RDS
+ * - HD Radio AM/FM
+ */
+ public static boolean isAmFmProgram(@NonNull ProgramSelector sel) {
+ int priType = sel.getPrimaryId().getType();
+ return priType == ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY
+ || priType == ProgramSelector.IDENTIFIER_TYPE_RDS_PI
+ || priType == ProgramSelector.IDENTIFIER_TYPE_HD_STATION_ID_EXT;
+ }
+
+ /**
+ * Returns a channel name that can be displayed to the user.
+ *
+ * It's implemented only for radio technologies where the channel is meant
+ * to be presented to the user.
+ *
+ * @param sel the program selector
+ * @return Channel name or null, if radio technology doesn't present channel names to the user.
+ */
+ public static @Nullable String getDisplayName(@NonNull ProgramSelector sel,
+ @NameFlag int flags) {
+ if (isAmFmProgram(sel)) {
+ if (!hasId(sel, ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY)) return null;
+ long freq = sel.getFirstId(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY);
+ return formatAmFmFrequency(freq, flags);
+ }
+
+ if ((flags & NAME_MODULATION_ONLY) != 0) return null;
+
+ if (sel.getPrimaryId().getType() == ProgramSelector.IDENTIFIER_TYPE_SXM_SERVICE_ID) {
+ if (!hasId(sel, ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL)) return null;
+ return Long.toString(sel.getFirstId(ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL));
+ }
+
+ return null;
+ }
+
+ static {
+ BiConsumer<Integer, String> add = (idType, name) -> {
+ ID_TO_URI.put(idType, name);
+ URI_TO_ID.put(name, idType);
+ };
+
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY, "AMFM_FREQUENCY");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_RDS_PI, "RDS_PI");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_HD_STATION_ID_EXT, "HD_STATION_ID_EXT");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_HD_STATION_NAME, "HD_STATION_NAME");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DAB_SID_EXT, "DAB_SID_EXT");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DAB_ENSEMBLE, "DAB_ENSEMBLE");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DAB_SCID, "DAB_SCID");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DAB_FREQUENCY, "DAB_FREQUENCY");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DRMO_SERVICE_ID, "DRMO_SERVICE_ID");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_DRMO_FREQUENCY, "DRMO_FREQUENCY");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_SXM_SERVICE_ID, "SXM_SERVICE_ID");
+ add.accept(ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL, "SXM_CHANNEL");
+ }
+
+ private static @Nullable String typeToUri(int identifierType) {
+ if (identifierType >= ProgramSelector.IDENTIFIER_TYPE_VENDOR_START
+ && identifierType <= ProgramSelector.IDENTIFIER_TYPE_VENDOR_END) {
+ int idx = identifierType - ProgramSelector.IDENTIFIER_TYPE_VENDOR_START;
+ return URI_VENDOR_PREFIX + idx;
+ }
+ return ID_TO_URI.get(identifierType);
+ }
+
+ private static int uriToType(@Nullable String typeUri) {
+ if (typeUri == null) return ProgramSelector.IDENTIFIER_TYPE_INVALID;
+ if (typeUri.startsWith(URI_VENDOR_PREFIX)) {
+ int idx;
+ try {
+ idx = Integer.parseInt(typeUri.substring(URI_VENDOR_PREFIX.length()));
+ } catch (NumberFormatException ex) {
+ return ProgramSelector.IDENTIFIER_TYPE_INVALID;
+ }
+ if (idx > ProgramSelector.IDENTIFIER_TYPE_VENDOR_END
+ - ProgramSelector.IDENTIFIER_TYPE_VENDOR_START) {
+ return ProgramSelector.IDENTIFIER_TYPE_INVALID;
+ }
+ return ProgramSelector.IDENTIFIER_TYPE_VENDOR_START + idx;
+ }
+ return URI_TO_ID.get(typeUri);
+ }
+
+ private static @NonNull String valueToUri(@NonNull Identifier id) {
+ long val = id.getValue();
+ switch (id.getType()) {
+ case ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY:
+ case ProgramSelector.IDENTIFIER_TYPE_DAB_FREQUENCY:
+ case ProgramSelector.IDENTIFIER_TYPE_DRMO_FREQUENCY:
+ case ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL:
+ return Long.toString(val);
+ default:
+ return URI_HEX_PREFIX + Long.toHexString(val);
+ }
+ }
+
+ private static @Nullable Long uriToValue(@Nullable String valUri) {
+ if (valUri == null) return null;
+ try {
+ if (valUri.startsWith(URI_HEX_PREFIX)) {
+ return Long.parseLong(valUri.substring(URI_HEX_PREFIX.length()), 16);
+ } else {
+ return Long.parseLong(valUri, 10);
+ }
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Serialize {@link ProgramSelector} to URI.
+ *
+ * @param sel selector to serialize
+ * @return serialized form of selector
+ */
+ public static @Nullable Uri toUri(@NonNull ProgramSelector sel) {
+ Identifier pri = sel.getPrimaryId();
+ String priType = typeToUri(pri.getType());
+ // unsupported primary identifier, might be from future HAL revision
+ if (priType == null) return null;
+
+ Uri.Builder uri = new Uri.Builder()
+ .scheme(URI_SCHEME_BROADCASTRADIO)
+ .authority(URI_AUTHORITY_PROGRAM)
+ .appendPath(priType)
+ .appendPath(valueToUri(pri));
+
+ for (Identifier sec : sel.getSecondaryIds()) {
+ String secType = typeToUri(sec.getType());
+ if (secType == null) continue; // skip unsupported secondary identifier
+ uri.appendQueryParameter(secType, valueToUri(sec));
+ }
+ return uri.build();
+ }
+
+ /**
+ * Parse serialized {@link ProgramSelector}.
+ *
+ * @param uri URI-zed form of ProgramSelector
+ * @return de-serialized object or null, if couldn't parse
+ */
+ public static @Nullable ProgramSelector fromUri(@Nullable Uri uri) {
+ if (uri == null) return null;
+
+ if (!URI_SCHEME_BROADCASTRADIO.equals(uri.getScheme())) return null;
+ if (!URI_AUTHORITY_PROGRAM.equals(uri.getAuthority())) {
+ Log.w(TAG, "Unknown URI authority part (might be a future, unsupported version): "
+ + uri.getAuthority());
+ return null;
+ }
+
+ BiFunction<String, String, Identifier> parseComponents = (typeStr, valueStr) -> {
+ int type = uriToType(typeStr);
+ Long value = uriToValue(valueStr);
+ if (type == ProgramSelector.IDENTIFIER_TYPE_INVALID || value == null) return null;
+ return new Identifier(type, value);
+ };
+
+ List<String> priUri = uri.getPathSegments();
+ if (priUri.size() != 2) return null;
+ Identifier pri = parseComponents.apply(priUri.get(0), priUri.get(1));
+ if (pri == null) return null;
+
+ String query = uri.getQuery();
+ List<Identifier> secIds = new ArrayList<>();
+ if (query != null) {
+ for (String secPair : query.split("&")) {
+ String[] secStr = secPair.split("=");
+ if (secStr.length != 2) continue;
+ Identifier sec = parseComponents.apply(secStr[0], secStr[1]);
+ if (sec != null) secIds.add(sec);
+ }
+ }
+
+ return newProgramSelector(pri, secIds.toArray(new Identifier[secIds.size()]));
+ }
+
+ /**
+ * Proposed extensions to android.hardware.radio.ProgramSelector.Identifier.
+ *
+ * They might eventually get pushed to the framework.
+ */
+ public static class IdentifierExt {
+ /**
+ * Decode {@link ProgramSelector#IDENTIFIER_TYPE_HD_STATION_ID_EXT} value.
+ *
+ * @param id identifier to decode
+ * @return value decoder
+ */
+ public static @Nullable HdPrimary asHdPrimary(@NonNull Identifier id) {
+ if (id.getType() == ProgramSelector.IDENTIFIER_TYPE_HD_STATION_ID_EXT) {
+ return new HdPrimary(id.getValue());
+ }
+ return null;
+ }
+
+ /**
+ * Decoder of {@link ProgramSelector#IDENTIFIER_TYPE_HD_STATION_ID_EXT} value.
+ *
+ * When pushed to the framework, it will be non-static class referring
+ * to the original value.
+ */
+ public static class HdPrimary {
+ /* For mValue format (bit shifts and bit masks), please refer to
+ * HD_STATION_ID_EXT from broadcastradio HAL 2.0.
+ */
+ private final long mValue;
+
+ private HdPrimary(long value) {
+ mValue = value;
+ }
+
+ public long getStationId() {
+ return mValue & 0xFFFFFFFF;
+ }
+
+ public int getSubchannel() {
+ return (int) ((mValue >>> 32) & 0xF);
+ }
+
+ public int getFrequency() {
+ return (int) ((mValue >>> (32 + 4)) & 0x3FFFF);
+ }
+ }
+ }
+}
diff --git a/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/RadioMetadataExt.java b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/RadioMetadataExt.java
new file mode 100644
index 0000000..e7b6f3b
--- /dev/null
+++ b/car-broadcastradio-support/src/com/android/car/broadcastradio/support/platform/RadioMetadataExt.java
@@ -0,0 +1,60 @@
+/**
+ * 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.platform;
+
+import android.annotation.NonNull;
+import android.hardware.radio.RadioMetadata;
+
+/**
+ * Proposed extensions to android.hardware.radio.RadioMetadata.
+ *
+ * They might eventually get pushed to the framework.
+ */
+public class RadioMetadataExt {
+ private static int sModuleId;
+
+ /**
+ * A hack to inject module ID for getGlobalBitmapId. When pushed to the framework,
+ * it will be set with RadioMetadata object creation or just separate int field.
+ * @hide
+ */
+ public static void setModuleId(int id) {
+ sModuleId = id;
+ }
+
+ /**
+ * Proposed redefinition of {@link RadioMetadata#getBitmapId}.
+ *
+ * {@link RadioMetadata#getBitmapId} isn't part of the system API yet, so we can skip
+ * deprecation here and jump straight to the correct solution.
+ */
+ public static long getGlobalBitmapId(@NonNull RadioMetadata meta, @NonNull String key) {
+ int localId = meta.getBitmapId(key);
+ if (localId == 0) return 0;
+
+ /* When generating global bitmap ID, we want them to remain stable between sessions
+ * (radio app might cache images to disk between sessions).
+ *
+ * Local IDs are already stable, but module ID is not guaranteed to be stable (i.e. some
+ * module might be not available at each boot, due to HW failure).
+ *
+ * When we push this to the framework, we will need persistence mechanism at the radio
+ * service to permanently match modules to their IDs.
+ */
+ return ((long) sModuleId << 32) | localId;
+ }
+}