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<Integer, Integer>, 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<Integer>, 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)
+}