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;
+    }
+}