Add a CallbackQueue class

The class will be used to build memory-efficient objects (int arrays)
used to queue ConnectivityService callbacks while an app is frozen.

This is very similar to an ArrayList<Pair<Short, Short>>, but avoids
object boxing which would use a lot more memory.

This is intended to be used by ConnectivityService both as a builder,
and as a utility to read lists of callbacks that it stores. Long-lived
references to the CallbackQueue would not be kept to minimize memory
usage; only the array returned by toArray needs to be stored.

Test: atest
Bug: 327038794
Bug: 279392981
Change-Id: I187ea15d14dfd160e577e9e9e87b1aded27b1938
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 5e41dd9..a6a967b 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -4489,6 +4489,7 @@
     public static final int CALLBACK_BLK_CHANGED                = 11;
     /** @hide */
     public static final int CALLBACK_LOCAL_NETWORK_INFO_CHANGED = 12;
+    // When adding new IDs, note CallbackQueue assumes callback IDs are at most 16 bits.
 
 
     /** @hide */
diff --git a/service/src/com/android/server/CallbackQueue.java b/service/src/com/android/server/CallbackQueue.java
new file mode 100644
index 0000000..060a984
--- /dev/null
+++ b/service/src/com/android/server/CallbackQueue.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2024 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;
+
+import android.annotation.NonNull;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+
+import com.android.net.module.util.GrowingIntArray;
+
+/**
+ * A utility class to add/remove {@link NetworkCallback}s from a queue.
+ *
+ * <p>This is intended to be used as a temporary builder to create/modify callbacks stored in an int
+ * array for memory efficiency.
+ *
+ * <p>Intended usage:
+ * <pre>
+ *     final CallbackQueue queue = new CallbackQueue(storedCallbacks);
+ *     queue.forEach(netId, callbackId -> { [...] });
+ *     queue.addCallback(netId, callbackId);
+ *     [...]
+ *     queue.shrinkToLength();
+ *     storedCallbacks = queue.getBackingArray();
+ * </pre>
+ *
+ * <p>This class is not thread-safe.
+ */
+public class CallbackQueue extends GrowingIntArray {
+    public CallbackQueue(int[] initialCallbacks) {
+        super(initialCallbacks);
+    }
+
+    /**
+     * Get a callback int from netId and callbackId.
+     *
+     * <p>The first 16 bits of each int is the netId; the last 16 bits are the callback index.
+     */
+    private static int getCallbackInt(int netId, int callbackId) {
+        return (netId << 16) | (callbackId & 0xffff);
+    }
+
+    private static int getNetId(int callbackInt) {
+        return callbackInt >>> 16;
+    }
+
+    private static int getCallbackId(int callbackInt) {
+        return callbackInt & 0xffff;
+    }
+
+    /**
+     * A consumer interface for {@link #forEach(CallbackConsumer)}.
+     *
+     * <p>This is similar to a BiConsumer&lt;Integer, Integer&gt;, but avoids the boxing cost.
+     */
+    public interface CallbackConsumer {
+        /**
+         * Method called on each callback in the queue.
+         */
+        void accept(int netId, int callbackId);
+    }
+
+    /**
+     * Iterate over all callbacks in the queue.
+     */
+    public void forEach(@NonNull CallbackConsumer consumer) {
+        forEach(value -> {
+            final int netId = getNetId(value);
+            final int callbackId = getCallbackId(value);
+            consumer.accept(netId, callbackId);
+        });
+    }
+
+    /**
+     * Indicates whether the queue contains a callback for the given (netId, callbackId).
+     */
+    public boolean hasCallback(int netId, int callbackId) {
+        return contains(getCallbackInt(netId, callbackId));
+    }
+
+    /**
+     * Remove all callbacks for the given netId.
+     *
+     * @return true if at least one callback was removed.
+     */
+    public boolean removeCallbacksForNetId(int netId) {
+        return removeValues(cb -> getNetId(cb) == netId);
+    }
+
+    /**
+     * Remove all callbacks for the given netId and callbackId.
+     * @return true if at least one callback was removed.
+     */
+    public boolean removeCallbacks(int netId, int callbackId) {
+        final int cbInt = getCallbackInt(netId, callbackId);
+        return removeValues(cb -> cb == cbInt);
+    }
+
+    /**
+     * Add a callback at the end of the queue.
+     */
+    public void addCallback(int netId, int callbackId) {
+        add(getCallbackInt(netId, callbackId));
+    }
+
+    @Override
+    protected String valueToString(int item) {
+        final int callbackId = getCallbackId(item);
+        final int netId = getNetId(item);
+        return ConnectivityManager.getCallbackName(callbackId) + "(" + netId + ")";
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/GrowingIntArray.java b/staticlibs/device/com/android/net/module/util/GrowingIntArray.java
new file mode 100644
index 0000000..4a81c10
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/GrowingIntArray.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2024 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.net.module.util;
+
+import android.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+import java.util.StringJoiner;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/**
+ * A growing array of primitive ints.
+ *
+ * <p>This is similar to ArrayList&lt;Integer&gt;, but avoids the cost of boxing (each Integer costs
+ * 16 bytes) and creation / garbage collection of individual Integer objects.
+ *
+ * <p>This class does not use any heuristic for growing capacity, so every call to
+ * {@link #add(int)} may reallocate the backing array. Callers should use
+ * {@link #ensureHasCapacity(int)} to minimize this behavior when they plan to add several values.
+ */
+public class GrowingIntArray {
+    private int[] mValues;
+    private int mLength;
+
+    /**
+     * Create an empty GrowingIntArray with the given capacity.
+     */
+    public GrowingIntArray(int initialCapacity) {
+        mValues = new int[initialCapacity];
+        mLength = 0;
+    }
+
+    /**
+     * Create a GrowingIntArray with an initial array of values.
+     *
+     * <p>The array will be used as-is and may be modified, so callers must stop using it after
+     * calling this constructor.
+     */
+    protected GrowingIntArray(int[] initialValues) {
+        mValues = initialValues;
+        mLength = initialValues.length;
+    }
+
+    /**
+     * Add a value to the array.
+     */
+    public void add(int value) {
+        ensureHasCapacity(1);
+        mValues[mLength] = value;
+        mLength++;
+    }
+
+    /**
+     * Get the current number of values in the array.
+     */
+    public int length() {
+        return mLength;
+    }
+
+    /**
+     * Get the value at a given index.
+     *
+     * @throws ArrayIndexOutOfBoundsException if the index is out of bounds.
+     */
+    public int get(int index) {
+        if (index < 0 || index >= mLength) {
+            throw new ArrayIndexOutOfBoundsException(index);
+        }
+        return mValues[index];
+    }
+
+    /**
+     * Iterate over all values in the array.
+     */
+    public void forEach(@NonNull IntConsumer consumer) {
+        for (int i = 0; i < mLength; i++) {
+            consumer.accept(mValues[i]);
+        }
+    }
+
+    /**
+     * Remove all values matching a predicate.
+     *
+     * @return true if at least one value was removed.
+     */
+    public boolean removeValues(@NonNull IntPredicate predicate) {
+        int newQueueLength = 0;
+        for (int i = 0; i < mLength; i++) {
+            final int cb = mValues[i];
+            if (!predicate.test(cb)) {
+                mValues[newQueueLength] = cb;
+                newQueueLength++;
+            }
+        }
+        if (mLength != newQueueLength) {
+            mLength = newQueueLength;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Indicates whether the array contains the given value.
+     */
+    public boolean contains(int value) {
+        for (int i = 0; i < mLength; i++) {
+            if (mValues[i] == value) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Remove all values from the array.
+     */
+    public void clear() {
+        mLength = 0;
+    }
+
+    /**
+     * Ensure at least the given number of values can be added to the array without reallocating.
+     *
+     * @param capacity The minimum number of additional values the array must be able to hold.
+     */
+    public void ensureHasCapacity(int capacity) {
+        if (mValues.length >= mLength + capacity) {
+            return;
+        }
+        mValues = Arrays.copyOf(mValues, mLength + capacity);
+    }
+
+    @VisibleForTesting
+    int getBackingArrayLength() {
+        return mValues.length;
+    }
+
+    /**
+     * Shrink the array backing this class to the minimum required length.
+     */
+    public void shrinkToLength() {
+        if (mValues.length != mLength) {
+            mValues = Arrays.copyOf(mValues, mLength);
+        }
+    }
+
+    /**
+     * Get values as array by shrinking the internal array to length and returning it.
+     *
+     * <p>This avoids reallocations if the array is already the correct length, but callers should
+     * stop using this instance of {@link GrowingIntArray} if they use the array returned by this
+     * method.
+     */
+    public int[] getShrinkedBackingArray() {
+        shrinkToLength();
+        return mValues;
+    }
+
+    /**
+     * Get the String representation of an item in the array, for use by {@link #toString()}.
+     */
+    protected String valueToString(int item) {
+        return String.valueOf(item);
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        final StringJoiner joiner = new StringJoiner(",", "[", "]");
+        forEach(item -> joiner.add(valueToString(item)));
+        return joiner.toString();
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt
new file mode 100644
index 0000000..bdcb8c0
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 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.net.module.util
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class GrowingIntArrayTest {
+    @Test
+    fun testAddAndGet() {
+        val array = GrowingIntArray(1)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        assertEquals(-1, array.get(0))
+        assertEquals(0, array.get(1))
+        assertEquals(2, array.get(2))
+        assertEquals(3, array.length())
+    }
+
+    @Test
+    fun testForEach() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        val actual = mutableListOf<Int>()
+        array.forEach { actual.add(it) }
+
+        val expected = listOf(-1, 0, 2)
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun testForEach_EmptyArray() {
+        val array = GrowingIntArray(10)
+        array.forEach {
+            fail("This should not be called")
+        }
+    }
+
+    @Test
+    fun testRemoveValues() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        array.removeValues { it <= 0 }
+        assertEquals(1, array.length())
+        assertEquals(2, array.get(0))
+    }
+
+    @Test
+    fun testContains() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+
+        assertTrue(array.contains(-1))
+        assertTrue(array.contains(2))
+
+        assertFalse(array.contains(0))
+        assertFalse(array.contains(3))
+    }
+
+    @Test
+    fun testClear() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+        array.clear()
+
+        assertEquals(0, array.length())
+    }
+
+    @Test
+    fun testEnsureHasCapacity() {
+        val array = GrowingIntArray(0)
+        array.add(42)
+        array.ensureHasCapacity(2)
+
+        assertEquals(3, array.backingArrayLength)
+    }
+
+    @Test
+    fun testGetShrinkedBackingArray() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+
+        assertContentEquals(intArrayOf(-1, 2), array.shrinkedBackingArray)
+    }
+
+    @Test
+    fun testToString() {
+        assertEquals("[]", GrowingIntArray(10).toString())
+        assertEquals("[1,2,3]", GrowingIntArray(3).apply {
+            add(1)
+            add(2)
+            add(3)
+        }.toString())
+    }
+}
diff --git a/tests/unit/java/com/android/server/CallbackQueueTest.kt b/tests/unit/java/com/android/server/CallbackQueueTest.kt
new file mode 100644
index 0000000..a6dd5c3
--- /dev/null
+++ b/tests/unit/java/com/android/server/CallbackQueueTest.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2024 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
+
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.CALLBACK_AVAILABLE
+import android.net.ConnectivityManager.CALLBACK_CAP_CHANGED
+import android.net.ConnectivityManager.CALLBACK_IP_CHANGED
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import java.lang.reflect.Modifier
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TEST_NETID_1 = 123
+
+// Maximum 16 bits unsigned value
+private const val TEST_NETID_2 = 0xffff
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class CallbackQueueTest {
+    @Test
+    fun testAddCallback() {
+        val cbs = listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_2 to CALLBACK_AVAILABLE,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED
+        )
+        val queue = CallbackQueue(intArrayOf()).apply {
+            cbs.forEach { addCallback(it.first, it.second) }
+        }
+
+        assertQueueEquals(cbs, queue)
+    }
+
+    @Test
+    fun testHasCallback() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+        }
+
+        assertTrue(queue.hasCallback(TEST_NETID_1, CALLBACK_AVAILABLE))
+        assertTrue(queue.hasCallback(TEST_NETID_2, CALLBACK_AVAILABLE))
+        assertTrue(queue.hasCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED))
+
+        assertFalse(queue.hasCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED))
+        assertFalse(queue.hasCallback(1234, CALLBACK_AVAILABLE))
+        assertFalse(queue.hasCallback(TEST_NETID_1, 5678))
+        assertFalse(queue.hasCallback(1234, 5678))
+    }
+
+    @Test
+    fun testRemoveCallbacks() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            assertFalse(removeCallbacks(TEST_NETID_1, CALLBACK_AVAILABLE))
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            assertTrue(removeCallbacks(TEST_NETID_1, CALLBACK_AVAILABLE))
+        }
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_2 to CALLBACK_AVAILABLE
+        ), queue)
+    }
+
+    @Test
+    fun testRemoveCallbacksForNetId() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            assertFalse(removeCallbacksForNetId(TEST_NETID_2))
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            assertFalse(removeCallbacksForNetId(TEST_NETID_1))
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_IP_CHANGED)
+            assertTrue(removeCallbacksForNetId(TEST_NETID_2))
+        }
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+        ), queue)
+    }
+
+    @Test
+    fun testConstructorFromExistingArray() {
+        val queue1 = CallbackQueue(intArrayOf()).apply {
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+        }
+        val queue2 = CallbackQueue(queue1.shrinkedBackingArray)
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_2 to CALLBACK_AVAILABLE
+        ), queue2)
+    }
+
+    @Test
+    fun testToString() {
+        assertEquals("[]", CallbackQueue(intArrayOf()).toString())
+        assertEquals(
+            "[CALLBACK_AVAILABLE($TEST_NETID_1)]",
+            CallbackQueue(intArrayOf()).apply {
+                addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            }.toString()
+        )
+        assertEquals(
+            "[CALLBACK_AVAILABLE($TEST_NETID_1),CALLBACK_CAP_CHANGED($TEST_NETID_2)]",
+            CallbackQueue(intArrayOf()).apply {
+                addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+                addCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED)
+            }.toString()
+        )
+    }
+
+    @Test
+    fun testMaxNetId() {
+        // CallbackQueue assumes netIds are at most 16 bits
+        assertTrue(NetIdManager.MAX_NET_ID <= 0xffff)
+    }
+
+    @Test
+    fun testMaxCallbackId() {
+        // CallbackQueue assumes callback IDs are at most 16 bits.
+        val constants = ConnectivityManager::class.java.declaredFields.filter {
+            Modifier.isStatic(it.modifiers) && Modifier.isFinal(it.modifiers) &&
+                    it.name.startsWith("CALLBACK_")
+        }
+        constants.forEach {
+            it.isAccessible = true
+            assertTrue(it.get(null) as Int <= 0xffff)
+        }
+    }
+}
+
+private fun assertQueueEquals(expected: List<Pair<Int, Int>>, actual: CallbackQueue) {
+    assertEquals(
+        expected.size,
+        actual.length(),
+        "Size mismatch between expected: $expected and actual: $actual"
+    )
+
+    var nextIndex = 0
+    actual.forEach { netId, cbId ->
+        val (expNetId, expCbId) = expected[nextIndex]
+        val msg = "$actual does not match $expected at index $nextIndex"
+        assertEquals(expNetId, netId, msg)
+        assertEquals(expCbId, cbId, msg)
+        nextIndex++
+    }
+    // Ensure forEach iterations and size are consistent
+    assertEquals(expected.size, nextIndex)
+}