Introduce ProgramInfoCache
ProgramInfoCache will be used in a future CL by BroadcastRadioService to
perform the multiple AIDL client to single HAL fanout of ITunerSession's
"program list update" API.
This CL also addresses a few issues with some of the AIDL classes:
- RadioMetadata.equals() was not defined despite being used by RadioManager.ProgramInfo.equals().
- Added ProgramList.Identifier.isCategoryType() to provide a formal definition of how ProgramList.Filter.areCategoriesIncluded() works.
Bug: 121305828
Test: atest com.android.server.broadcastradio.hal2.ProgramInfoCacheTest
Change-Id: I50befe3b16125a50d494899ba97aa0f9b6d76913
diff --git a/api/system-current.txt b/api/system-current.txt
index 8ae718a..f7b6cce 100644
--- a/api/system-current.txt
+++ b/api/system-current.txt
@@ -2739,6 +2739,7 @@
method public int describeContents();
method @android.hardware.radio.ProgramSelector.IdentifierType public int getType();
method public long getValue();
+ method public boolean isCategoryType();
method public void writeToParcel(android.os.Parcel, int);
field @NonNull public static final android.os.Parcelable.Creator<android.hardware.radio.ProgramSelector.Identifier> CREATOR;
}
diff --git a/core/java/android/hardware/radio/ProgramList.java b/core/java/android/hardware/radio/ProgramList.java
index 69f9273..623d5ec 100644
--- a/core/java/android/hardware/radio/ProgramList.java
+++ b/core/java/android/hardware/radio/ProgramList.java
@@ -352,6 +352,8 @@
/**
* Checks, if non-tunable entries that define tree structure on the
* program list (i.e. DAB ensembles) should be included.
+ *
+ * @see {@link ProgramSelector.Identifier#isCategory()}
*/
public boolean areCategoriesIncluded() {
return mIncludeCategories;
diff --git a/core/java/android/hardware/radio/ProgramSelector.java b/core/java/android/hardware/radio/ProgramSelector.java
index 277a186..b321855 100644
--- a/core/java/android/hardware/radio/ProgramSelector.java
+++ b/core/java/android/hardware/radio/ProgramSelector.java
@@ -581,6 +581,19 @@
}
/**
+ * Returns whether this Identifier's type is considered a category when filtering
+ * ProgramLists for category entries.
+ *
+ * @see {@link ProgramList.Filter#areCategoriesIncluded()}
+ * @return False if this identifier's type is not tuneable (e.g. DAB ensemble or
+ * vendor-specified type). True otherwise.
+ */
+ public boolean isCategoryType() {
+ return (mType >= IDENTIFIER_TYPE_VENDOR_START && mType <= IDENTIFIER_TYPE_VENDOR_END)
+ || mType == IDENTIFIER_TYPE_DAB_ENSEMBLE;
+ }
+
+ /**
* Value of an identifier.
*
* Its meaning depends on identifier type, ie. for IDENTIFIER_TYPE_AMFM_FREQUENCY type,
diff --git a/core/java/android/hardware/radio/RadioMetadata.java b/core/java/android/hardware/radio/RadioMetadata.java
index 1cbb171..b60c136 100644
--- a/core/java/android/hardware/radio/RadioMetadata.java
+++ b/core/java/android/hardware/radio/RadioMetadata.java
@@ -257,6 +257,24 @@
private final Bundle mBundle;
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof RadioMetadata)) return false;
+ Bundle otherBundle = ((RadioMetadata) obj).mBundle;
+ if (!mBundle.keySet().equals(otherBundle.keySet())) {
+ return false;
+ }
+ for (String key : mBundle.keySet()) {
+ // This logic will return a false negative if we ever put Bundles into mBundle. As of
+ // 2019-04-09, we only put ints, Strings, and Parcelables in, so it's fine for now.
+ if (!mBundle.get(key).equals(otherBundle.get(key))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
RadioMetadata() {
mBundle = new Bundle();
}
diff --git a/core/tests/BroadcastRadioTests/Android.bp b/core/tests/BroadcastRadioTests/Android.bp
index 0aebaaa..b8efd19 100644
--- a/core/tests/BroadcastRadioTests/Android.bp
+++ b/core/tests/BroadcastRadioTests/Android.bp
@@ -23,6 +23,7 @@
"compatibility-device-util-axt",
"androidx.test.rules",
"testng",
+ "services.core",
],
libs: ["android.test.base"],
srcs: ["src/**/*.java"],
diff --git a/core/tests/BroadcastRadioTests/src/com/android/server/broadcastradio/hal2/ProgramInfoCacheTest.java b/core/tests/BroadcastRadioTests/src/com/android/server/broadcastradio/hal2/ProgramInfoCacheTest.java
new file mode 100644
index 0000000..b2254c5
--- /dev/null
+++ b/core/tests/BroadcastRadioTests/src/com/android/server/broadcastradio/hal2/ProgramInfoCacheTest.java
@@ -0,0 +1,302 @@
+/*
+ * Copyright (C) 2019 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.server.broadcastradio.hal2;
+
+import static org.junit.Assert.*;
+
+import android.hardware.broadcastradio.V2_0.ProgramInfo;
+import android.hardware.broadcastradio.V2_0.ProgramListChunk;
+import android.hardware.radio.ProgramList;
+import android.hardware.radio.ProgramSelector;
+import android.hardware.radio.RadioManager;
+import android.hardware.radio.RadioMetadata;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Unit tests for ProgramInfoCache
+ */
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class ProgramInfoCacheTest {
+ private static final String TAG = "BroadcastRadioTests.ProgramInfoCache";
+
+ private final ProgramSelector.Identifier mAmFmIdentifier =
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY, 88500);
+ private final RadioManager.ProgramInfo mAmFmInfo = makeProgramInfo(
+ ProgramSelector.PROGRAM_TYPE_FM, mAmFmIdentifier, 0);
+
+ private final ProgramSelector.Identifier mRdsIdentifier =
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_RDS_PI, 15019);
+ private final RadioManager.ProgramInfo mRdsInfo = makeProgramInfo(
+ ProgramSelector.PROGRAM_TYPE_FM, mRdsIdentifier, 0);
+
+ private final ProgramSelector.Identifier mDabEnsembleIdentifier =
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_DAB_ENSEMBLE, 1337);
+ private final RadioManager.ProgramInfo mDabEnsembleInfo = makeProgramInfo(
+ ProgramSelector.PROGRAM_TYPE_DAB, mDabEnsembleIdentifier, 0);
+
+ private final ProgramSelector.Identifier mVendorCustomIdentifier =
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_VENDOR_START, 9001);
+ private final RadioManager.ProgramInfo mVendorCustomInfo = makeProgramInfo(
+ ProgramSelector.PROGRAM_TYPE_VENDOR_START, mVendorCustomIdentifier, 0);
+
+ // HAL-side ProgramInfoCache containing all of the above ProgramInfos.
+ private final ProgramInfoCache mAllProgramInfos = new ProgramInfoCache(null, mAmFmInfo,
+ mRdsInfo, mDabEnsembleInfo, mVendorCustomInfo);
+
+ @Test
+ public void testUpdateFromHal() {
+ // First test a purging chunk.
+ ProgramInfoCache cache = new ProgramInfoCache(null, mAmFmInfo);
+ ProgramListChunk chunk = new ProgramListChunk();
+ chunk.purge = true;
+ chunk.modified.add(programInfoToHal(mRdsInfo));
+ chunk.modified.add(programInfoToHal(mDabEnsembleInfo));
+ cache.updateFromHalProgramListChunk(chunk);
+ chunk.complete = true;
+ assertTrue(cache.programInfosAreExactly(mRdsInfo, mDabEnsembleInfo));
+
+ // Then test a non-purging chunk.
+ chunk.purge = false;
+ chunk.modified.clear();
+ RadioManager.ProgramInfo updatedRdsInfo = makeProgramInfo(ProgramSelector.PROGRAM_TYPE_FM,
+ mRdsIdentifier, 1);
+ chunk.modified.add(programInfoToHal(updatedRdsInfo));
+ chunk.modified.add(programInfoToHal(mVendorCustomInfo));
+ chunk.removed.add(Convert.programIdentifierToHal(mDabEnsembleIdentifier));
+ cache.updateFromHalProgramListChunk(chunk);
+ assertTrue(cache.programInfosAreExactly(updatedRdsInfo, mVendorCustomInfo));
+ }
+
+ @Test
+ public void testNullFilter() {
+ ProgramInfoCache cache = new ProgramInfoCache(null);
+ cache.filterAndUpdateFrom(mAllProgramInfos, false);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, mRdsInfo, mDabEnsembleInfo,
+ mVendorCustomInfo));
+ }
+
+ @Test
+ public void testEmptyFilter() {
+ ProgramInfoCache cache = new ProgramInfoCache(new ProgramList.Filter(new HashSet<Integer>(),
+ new HashSet<ProgramSelector.Identifier>(), true, false));
+ cache.filterAndUpdateFrom(mAllProgramInfos, false);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, mRdsInfo, mDabEnsembleInfo,
+ mVendorCustomInfo));
+ }
+
+ @Test
+ public void testFilterByType() {
+ HashSet<Integer> filterTypes = new HashSet<>();
+ filterTypes.add(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY);
+ filterTypes.add(ProgramSelector.IDENTIFIER_TYPE_DAB_ENSEMBLE);
+ ProgramInfoCache cache = new ProgramInfoCache(new ProgramList.Filter(filterTypes,
+ new HashSet<ProgramSelector.Identifier>(), true, false));
+ cache.filterAndUpdateFrom(mAllProgramInfos, false);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, mDabEnsembleInfo));
+ }
+
+ @Test
+ public void testFilterByIdentifier() {
+ HashSet<ProgramSelector.Identifier> filterIds = new HashSet<>();
+ filterIds.add(mRdsIdentifier);
+ filterIds.add(mVendorCustomIdentifier);
+ ProgramInfoCache cache = new ProgramInfoCache(new ProgramList.Filter(new HashSet<Integer>(),
+ filterIds, true, false));
+ cache.filterAndUpdateFrom(mAllProgramInfos, false);
+ assertTrue(cache.programInfosAreExactly(mRdsInfo, mVendorCustomInfo));
+ }
+
+ @Test
+ public void testFilterExcludeCategories() {
+ ProgramInfoCache cache = new ProgramInfoCache(new ProgramList.Filter(new HashSet<Integer>(),
+ new HashSet<ProgramSelector.Identifier>(), false, false));
+ cache.filterAndUpdateFrom(mAllProgramInfos, false);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, mRdsInfo));
+ }
+
+ @Test
+ public void testPurgeUpdateChunks() {
+ ProgramInfoCache cache = new ProgramInfoCache(null, mAmFmInfo);
+ List<ProgramList.Chunk> chunks =
+ cache.filterAndUpdateFromInternal(mAllProgramInfos, true, 3, 3);
+ assertEquals(2, chunks.size());
+ verifyChunkListFlags(chunks, true);
+ verifyChunkListModified(chunks, 3, mAmFmInfo, mRdsInfo, mDabEnsembleInfo,
+ mVendorCustomInfo);
+ verifyChunkListRemoved(chunks, 0);
+ }
+
+ @Test
+ public void testDeltaUpdateChunksModificationsIncluded() {
+ // Create a cache with a filter that allows modifications, and set its contents to
+ // mAmFmInfo, mRdsInfo, mDabEnsembleInfo, and mVendorCustomInfo.
+ ProgramInfoCache cache = new ProgramInfoCache(null, mAmFmInfo, mRdsInfo, mDabEnsembleInfo,
+ mVendorCustomInfo);
+
+ // Create a HAL cache that:
+ // - Retains mAmFmInfo.
+ // - Replaces mRdsInfo with updatedRdsInfo.
+ // - Drops mDabEnsembleInfo and mVendorCustomInfo.
+ // - Introduces a new SXM info.
+ RadioManager.ProgramInfo updatedRdsInfo = makeProgramInfo(ProgramSelector.PROGRAM_TYPE_FM,
+ mRdsIdentifier, 1);
+ RadioManager.ProgramInfo newSxmInfo = makeProgramInfo(ProgramSelector.PROGRAM_TYPE_SXM,
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL, 12345),
+ 0);
+ ProgramInfoCache halCache = new ProgramInfoCache(null, mAmFmInfo, updatedRdsInfo,
+ newSxmInfo);
+
+ // Update the cache and verify:
+ // - mAmFmInfo is retained and not reported in the chunks.
+ // - updatedRdsInfo should appear as an update to mRdsInfo.
+ // - newSxmInfo should appear as a new entry.
+ // - mDabEnsembleInfo and mVendorCustomInfo should be reported as removed.
+ List<ProgramList.Chunk> chunks = cache.filterAndUpdateFromInternal(halCache, false, 5, 1);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, updatedRdsInfo, newSxmInfo));
+ assertEquals(2, chunks.size());
+ verifyChunkListFlags(chunks, false);
+ verifyChunkListModified(chunks, 5, updatedRdsInfo, newSxmInfo);
+ verifyChunkListRemoved(chunks, 1, mDabEnsembleIdentifier, mVendorCustomIdentifier);
+ }
+
+ @Test
+ public void testDeltaUpdateChunksModificationsExcluded() {
+ // Create a cache with a filter that excludes modifications, and set its contents to
+ // mAmFmInfo, mRdsInfo, mDabEnsembleInfo, and mVendorCustomInfo.
+ ProgramInfoCache cache = new ProgramInfoCache(new ProgramList.Filter(new HashSet<Integer>(),
+ new HashSet<ProgramSelector.Identifier>(), true, true), mAmFmInfo, mRdsInfo,
+ mDabEnsembleInfo, mVendorCustomInfo);
+
+ // Create a HAL cache that:
+ // - Retains mAmFmInfo.
+ // - Replaces mRdsInfo with updatedRdsInfo.
+ // - Drops mDabEnsembleInfo and mVendorCustomInfo.
+ // - Introduces a new SXM info.
+ RadioManager.ProgramInfo updatedRdsInfo = makeProgramInfo(ProgramSelector.PROGRAM_TYPE_FM,
+ mRdsIdentifier, 1);
+ RadioManager.ProgramInfo newSxmInfo = makeProgramInfo(ProgramSelector.PROGRAM_TYPE_SXM,
+ new ProgramSelector.Identifier(ProgramSelector.IDENTIFIER_TYPE_SXM_CHANNEL, 12345),
+ 0);
+ ProgramInfoCache halCache = new ProgramInfoCache(null, mAmFmInfo, updatedRdsInfo,
+ newSxmInfo);
+
+ // Update the cache and verify:
+ // - mAmFmInfo and mRdsInfo are retained and not reported in the chunks.
+ // - newSxmInfo should appear as a new entry.
+ // - mDabEnsembleInfo and mVendorCustomInfo should be reported as removed.
+ List<ProgramList.Chunk> chunks = cache.filterAndUpdateFromInternal(halCache, false, 5, 1);
+ assertTrue(cache.programInfosAreExactly(mAmFmInfo, mRdsInfo, newSxmInfo));
+ assertEquals(2, chunks.size());
+ verifyChunkListFlags(chunks, false);
+ verifyChunkListModified(chunks, 5, newSxmInfo);
+ verifyChunkListRemoved(chunks, 1, mDabEnsembleIdentifier, mVendorCustomIdentifier);
+ }
+
+ private static RadioManager.ProgramInfo makeProgramInfo(int programType,
+ ProgramSelector.Identifier identifier, int signalQuality) {
+ // Note: If you set new fields, check if programInfoToHal() needs to be updated as well.
+ return new RadioManager.ProgramInfo(new ProgramSelector(programType, identifier, null,
+ null), null, null, null, 0, signalQuality, new RadioMetadata.Builder().build(),
+ new HashMap<String, String>());
+ }
+
+ private static ProgramInfo programInfoToHal(RadioManager.ProgramInfo info) {
+ // Note that because Convert does not by design provide functions for all conversions, this
+ // function only copies fields that are set by makeProgramInfo().
+ ProgramInfo hwInfo = new ProgramInfo();
+ hwInfo.selector = Convert.programSelectorToHal(info.getSelector());
+ hwInfo.signalQuality = info.getSignalStrength();
+ return hwInfo;
+ }
+
+ // Verifies that:
+ // - The first chunk's purge flag matches expectPurge.
+ // - The last chunk's complete flag is set.
+ // - All other flags are false.
+ private static void verifyChunkListFlags(List<ProgramList.Chunk> chunks, boolean expectPurge) {
+ if (chunks.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < chunks.size(); i++) {
+ ProgramList.Chunk chunk = chunks.get(i);
+ assertEquals(i == 0 && expectPurge, chunk.isPurge());
+ assertEquals(i == chunks.size() - 1, chunk.isComplete());
+ }
+ }
+
+ // Verifies that:
+ // - Each chunk's modified array has at most maxModifiedPerChunk elements.
+ // - Each chunk's modified array has a similar number of elements.
+ // - Each element of expectedProgramInfos appears in a chunk.
+ private static void verifyChunkListModified(List<ProgramList.Chunk> chunks,
+ int maxModifiedPerChunk, RadioManager.ProgramInfo... expectedProgramInfos) {
+ if (chunks.isEmpty()) {
+ assertEquals(0, expectedProgramInfos.length);
+ return;
+ }
+ HashSet<RadioManager.ProgramInfo> expectedSet = new HashSet<>();
+ for (RadioManager.ProgramInfo programInfo : expectedProgramInfos) {
+ expectedSet.add(programInfo);
+ }
+
+ HashSet<RadioManager.ProgramInfo> actualSet = new HashSet<>();
+ int chunk0NumModified = chunks.get(0).getModified().size();
+ for (ProgramList.Chunk chunk : chunks) {
+ Set<RadioManager.ProgramInfo> chunkModified = chunk.getModified();
+ assertTrue(chunkModified.size() <= maxModifiedPerChunk);
+ assertTrue(Math.abs(chunkModified.size() - chunk0NumModified) <= 1);
+ actualSet.addAll(chunkModified);
+ }
+ assertEquals(expectedSet, actualSet);
+ }
+
+ // Verifies that:
+ // - Each chunk's removed array has at most maxRemovedPerChunk elements.
+ // - Each chunk's removed array has a similar number of elements.
+ // - Each element of expectedIdentifiers appears in a chunk.
+ private static void verifyChunkListRemoved(List<ProgramList.Chunk> chunks,
+ int maxRemovedPerChunk, ProgramSelector.Identifier... expectedIdentifiers) {
+ if (chunks.isEmpty()) {
+ assertEquals(0, expectedIdentifiers.length);
+ return;
+ }
+ HashSet<ProgramSelector.Identifier> expectedSet = new HashSet<>();
+ for (ProgramSelector.Identifier identifier : expectedIdentifiers) {
+ expectedSet.add(identifier);
+ }
+
+ HashSet<ProgramSelector.Identifier> actualSet = new HashSet<>();
+ int chunk0NumRemoved = chunks.get(0).getRemoved().size();
+ for (ProgramList.Chunk chunk : chunks) {
+ Set<ProgramSelector.Identifier> chunkRemoved = chunk.getRemoved();
+ assertTrue(chunkRemoved.size() <= maxRemovedPerChunk);
+ assertTrue(Math.abs(chunkRemoved.size() - chunk0NumRemoved) <= 1);
+ actualSet.addAll(chunkRemoved);
+ }
+ assertEquals(expectedSet, actualSet);
+ }
+}
diff --git a/services/core/java/com/android/server/broadcastradio/hal2/ProgramInfoCache.java b/services/core/java/com/android/server/broadcastradio/hal2/ProgramInfoCache.java
new file mode 100644
index 0000000..b1bd395
--- /dev/null
+++ b/services/core/java/com/android/server/broadcastradio/hal2/ProgramInfoCache.java
@@ -0,0 +1,213 @@
+/**
+ * Copyright (C) 2019 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.server.broadcastradio.hal2;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.hardware.radio.ProgramList;
+import android.hardware.radio.ProgramSelector;
+import android.hardware.radio.RadioManager;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+class ProgramInfoCache {
+ // Maximum number of RadioManager.ProgramInfo elements that will be put into a
+ // ProgramList.Chunk.mModified array. Used to try to ensure a single ProgramList.Chunk stays
+ // within the AIDL data size limit.
+ private static final int MAX_NUM_MODIFIED_PER_CHUNK = 100;
+
+ // Maximum number of ProgramSelector.Identifier elements that will be put into a
+ // ProgramList.Chunk.mRemoved array. Used to try to ensure a single ProgramList.Chunk stays
+ // within the AIDL data size limit.
+ private static final int MAX_NUM_REMOVED_PER_CHUNK = 500;
+
+ // Map from primary identifier to corresponding ProgramInfo.
+ private final Map<ProgramSelector.Identifier, RadioManager.ProgramInfo> mProgramInfoMap =
+ new HashMap<>();
+
+ // Optional filter used in filterAndUpdateFrom(). Usually this field is null for a HAL-side
+ // cache and non-null for an AIDL-side cache.
+ private final ProgramList.Filter mFilter;
+
+ ProgramInfoCache(@Nullable ProgramList.Filter filter) {
+ mFilter = filter;
+ }
+
+ // Constructor for testing.
+ @VisibleForTesting
+ ProgramInfoCache(@Nullable ProgramList.Filter filter,
+ RadioManager.ProgramInfo... programInfos) {
+ mFilter = filter;
+ for (RadioManager.ProgramInfo programInfo : programInfos) {
+ mProgramInfoMap.put(programInfo.getSelector().getPrimaryId(), programInfo);
+ }
+ }
+
+ @VisibleForTesting
+ boolean programInfosAreExactly(RadioManager.ProgramInfo... programInfos) {
+ Map<ProgramSelector.Identifier, RadioManager.ProgramInfo> expectedMap = new HashMap<>();
+ for (RadioManager.ProgramInfo programInfo : programInfos) {
+ expectedMap.put(programInfo.getSelector().getPrimaryId(), programInfo);
+ }
+ return expectedMap.equals(mProgramInfoMap);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("ProgramInfoCache(");
+ mProgramInfoMap.forEach((id, programInfo) -> {
+ sb.append("\n");
+ sb.append(programInfo.toString());
+ });
+ sb.append(")");
+ return sb.toString();
+ }
+
+ public @Nullable ProgramList.Filter getFilter() {
+ return mFilter;
+ }
+
+ void updateFromHalProgramListChunk(
+ @NonNull android.hardware.broadcastradio.V2_0.ProgramListChunk chunk) {
+ if (chunk.purge) {
+ mProgramInfoMap.clear();
+ }
+ for (android.hardware.broadcastradio.V2_0.ProgramInfo halProgramInfo : chunk.modified) {
+ RadioManager.ProgramInfo programInfo = Convert.programInfoFromHal(halProgramInfo);
+ mProgramInfoMap.put(programInfo.getSelector().getPrimaryId(), programInfo);
+ }
+ for (android.hardware.broadcastradio.V2_0.ProgramIdentifier halProgramId : chunk.removed) {
+ mProgramInfoMap.remove(Convert.programIdentifierFromHal(halProgramId));
+ }
+ }
+
+ @NonNull List<ProgramList.Chunk> filterAndUpdateFrom(@NonNull ProgramInfoCache other,
+ boolean purge) {
+ return filterAndUpdateFromInternal(other, purge, MAX_NUM_MODIFIED_PER_CHUNK,
+ MAX_NUM_REMOVED_PER_CHUNK);
+ }
+
+ @VisibleForTesting
+ @NonNull List<ProgramList.Chunk> filterAndUpdateFromInternal(@NonNull ProgramInfoCache other,
+ boolean purge, int maxNumModifiedPerChunk, int maxNumRemovedPerChunk) {
+ if (purge) {
+ mProgramInfoMap.clear();
+ }
+ // If mProgramInfoMap is empty, we treat this update as a purge because this might be the
+ // first update to an AIDL client that changed its filter.
+ if (mProgramInfoMap.isEmpty()) {
+ purge = true;
+ }
+
+ Set<Integer> idTypes = mFilter != null ? mFilter.getIdentifierTypes() : null;
+ Set<ProgramSelector.Identifier> ids = mFilter != null ? mFilter.getIdentifiers() : null;
+ boolean includeCategories = mFilter != null ? mFilter.areCategoriesIncluded() : true;
+ boolean includeModifications = mFilter != null ? !mFilter.areModificationsExcluded() : true;
+
+ Set<RadioManager.ProgramInfo> modified = new HashSet<>();
+ Set<ProgramSelector.Identifier> removed = new HashSet<>(mProgramInfoMap.keySet());
+ for (Map.Entry<ProgramSelector.Identifier, RadioManager.ProgramInfo> entry
+ : other.mProgramInfoMap.entrySet()) {
+ ProgramSelector.Identifier id = entry.getKey();
+ if ((idTypes != null && !idTypes.isEmpty() && !idTypes.contains(id.getType()))
+ || (ids != null && !ids.isEmpty() && !ids.contains(id))
+ || (!includeCategories && id.isCategoryType())) {
+ continue;
+ }
+
+ removed.remove(id);
+ RadioManager.ProgramInfo oldInfo = mProgramInfoMap.get(id);
+ RadioManager.ProgramInfo newInfo = entry.getValue();
+ if (oldInfo != null && (!includeModifications || oldInfo.equals(newInfo))) {
+ continue;
+ }
+ mProgramInfoMap.put(id, newInfo);
+ modified.add(newInfo);
+ }
+ for (ProgramSelector.Identifier rem : removed) {
+ mProgramInfoMap.remove(rem);
+ }
+ return buildChunks(purge, modified, maxNumModifiedPerChunk, removed, maxNumRemovedPerChunk);
+ }
+
+ private static int roundUpFraction(int numerator, int denominator) {
+ return (numerator / denominator) + (numerator % denominator > 0 ? 1 : 0);
+ }
+
+ private @NonNull List<ProgramList.Chunk> buildChunks(boolean purge,
+ @Nullable Collection<RadioManager.ProgramInfo> modified, int maxNumModifiedPerChunk,
+ @Nullable Collection<ProgramSelector.Identifier> removed, int maxNumRemovedPerChunk) {
+ // Communication protocol requires that if purge is set, removed is empty.
+ if (purge) {
+ removed = null;
+ }
+
+ // Determine number of chunks we need to send.
+ int numChunks = 0;
+ if (modified != null) {
+ numChunks = roundUpFraction(modified.size(), maxNumModifiedPerChunk);
+ }
+ if (removed != null) {
+ numChunks = Math.max(numChunks, roundUpFraction(removed.size(), maxNumRemovedPerChunk));
+ }
+ if (numChunks == 0) {
+ return new ArrayList<ProgramList.Chunk>();
+ }
+
+ // Try to make similarly-sized chunks by evenly distributing elements from modified and
+ // removed among them.
+ int modifiedPerChunk = 0;
+ int removedPerChunk = 0;
+ Iterator<RadioManager.ProgramInfo> modifiedIter = null;
+ Iterator<ProgramSelector.Identifier> removedIter = null;
+ if (modified != null) {
+ modifiedPerChunk = roundUpFraction(modified.size(), numChunks);
+ modifiedIter = modified.iterator();
+ }
+ if (removed != null) {
+ removedPerChunk = roundUpFraction(removed.size(), numChunks);
+ removedIter = removed.iterator();
+ }
+ List<ProgramList.Chunk> chunks = new ArrayList<ProgramList.Chunk>(numChunks);
+ for (int i = 0; i < numChunks; i++) {
+ HashSet<RadioManager.ProgramInfo> modifiedChunk = new HashSet<>();
+ HashSet<ProgramSelector.Identifier> removedChunk = new HashSet<>();
+ if (modifiedIter != null) {
+ for (int j = 0; j < modifiedPerChunk && modifiedIter.hasNext(); j++) {
+ modifiedChunk.add(modifiedIter.next());
+ }
+ }
+ if (removedIter != null) {
+ for (int j = 0; j < removedPerChunk && removedIter.hasNext(); j++) {
+ removedChunk.add(removedIter.next());
+ }
+ }
+ chunks.add(new ProgramList.Chunk(purge && i == 0, i == numChunks - 1, modifiedChunk,
+ removedChunk));
+ }
+ return chunks;
+ }
+}