Merge changes I0afdda02,I1c47f616 into main am: 5f730c6ab5

Original change: https://android-review.googlesource.com/c/platform/packages/modules/Connectivity/+/2460069

Change-Id: I7f6aedf1424751e067e4396016bc525393381487
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index 8e219a6..abda1fa 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -260,6 +260,19 @@
     private int mEnterpriseId;
 
     /**
+     * Gets the enterprise IDs as an int. Internal callers only.
+     *
+     * DO NOT USE THIS if not immediately collapsing back into a scalar. Instead,
+     * prefer getEnterpriseIds/hasEnterpriseId.
+     *
+     * @return the internal, version-dependent int representing enterprise ids
+     * @hide
+     */
+    public int getEnterpriseIdsInternal() {
+        return mEnterpriseId;
+    }
+
+    /**
      * Get enteprise identifiers set.
      *
      * Get all the enterprise capabilities identifier set on this {@code NetworkCapability}
@@ -741,8 +754,10 @@
 
     /**
      * Capabilities that are managed by ConnectivityService.
+     * @hide
      */
-    private static final long CONNECTIVITY_MANAGED_CAPABILITIES =
+    @VisibleForTesting
+    public static final long CONNECTIVITY_MANAGED_CAPABILITIES =
             BitUtils.packBitList(
                     NET_CAPABILITY_VALIDATED,
                     NET_CAPABILITY_CAPTIVE_PORTAL,
@@ -859,6 +874,19 @@
     }
 
     /**
+     * Gets the capabilities as an int. Internal callers only.
+     *
+     * DO NOT USE THIS if not immediately collapsing back into a scalar. Instead,
+     * prefer getCapabilities/hasCapability.
+     *
+     * @return an internal, version-dependent int representing the capabilities
+     * @hide
+     */
+    public long getCapabilitiesInternal() {
+        return mNetworkCapabilities;
+    }
+
+    /**
      * Gets all the capabilities set on this {@code NetworkCapability} instance.
      *
      * @return an array of capability values for this instance.
diff --git a/service/src/com/android/metrics/ConnectivitySampleMetricsHelper.java b/service/src/com/android/metrics/ConnectivitySampleMetricsHelper.java
new file mode 100644
index 0000000..93d1d5d
--- /dev/null
+++ b/service/src/com/android/metrics/ConnectivitySampleMetricsHelper.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2023 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.metrics;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+import android.util.StatsEvent;
+
+import com.android.modules.utils.HandlerExecutor;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * A class to register, sample and send connectivity state metrics.
+ */
+public class ConnectivitySampleMetricsHelper implements StatsManager.StatsPullAtomCallback {
+    private static final String TAG = ConnectivitySampleMetricsHelper.class.getSimpleName();
+
+    final Supplier<StatsEvent> mDelegate;
+
+    /**
+     * Start collecting metrics.
+     * @param context some context to get services
+     * @param connectivityServiceHandler the connectivity service handler
+     * @param atomTag the tag to collect metrics from
+     * @param delegate a method returning data when called on the handler thread
+     */
+    // Unfortunately it seems essentially impossible to unit test this method. The only thing
+    // to test is that there is a call to setPullAtomCallback, but StatsManager is final and
+    // can't be mocked without mockito-extended. Using mockito-extended in FrameworksNetTests
+    // would have a very large impact on performance, while splitting the unit test for this
+    // class in a separate target would make testing very hard to manage. Therefore, there
+    // can unfortunately be no unit tests for this method, but at least it is very simple.
+    public static void start(@NonNull final Context context,
+            @NonNull final Handler connectivityServiceHandler,
+            final int atomTag,
+            @NonNull final Supplier<StatsEvent> delegate) {
+        final ConnectivitySampleMetricsHelper metrics =
+                new ConnectivitySampleMetricsHelper(delegate);
+        final StatsManager mgr = context.getSystemService(StatsManager.class);
+        if (null == mgr) return; // No metrics for you
+        mgr.setPullAtomCallback(atomTag, null /* metadata */,
+                new HandlerExecutor(connectivityServiceHandler), metrics);
+    }
+
+    public ConnectivitySampleMetricsHelper(@NonNull final Supplier<StatsEvent> delegate) {
+        mDelegate = delegate;
+    }
+
+    @Override
+    public int onPullAtom(final int atomTag, final List<StatsEvent> data) {
+        Log.d(TAG, "Sampling data for atom : " + atomTag);
+        data.add(mDelegate.get());
+        return StatsManager.PULL_SUCCESS;
+    }
+}
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 1fc7e9a..aa82559 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -77,6 +77,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_OEM_PAID;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_OEM_PRIVATE;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED;
 import static android.net.NetworkCapabilities.NET_ENTERPRISE_ID_1;
 import static android.net.NetworkCapabilities.NET_ENTERPRISE_ID_5;
@@ -102,6 +103,7 @@
 import static com.android.net.module.util.PermissionUtils.enforceAnyPermissionOf;
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermission;
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermissionOr;
+import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
 
 import static java.util.Map.Entry;
 
@@ -236,6 +238,9 @@
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.provider.Settings;
+import android.stats.connectivity.MeteredState;
+import android.stats.connectivity.RequestType;
+import android.stats.connectivity.ValidatedState;
 import android.sysprop.NetworkProperties;
 import android.system.ErrnoException;
 import android.telephony.TelephonyManager;
@@ -248,6 +253,7 @@
 import android.util.Range;
 import android.util.SparseArray;
 import android.util.SparseIntArray;
+import android.util.StatsEvent;
 
 import androidx.annotation.RequiresApi;
 
@@ -256,6 +262,16 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.MessageUtils;
+import com.android.metrics.ConnectionDurationForTransports;
+import com.android.metrics.ConnectionDurationPerTransports;
+import com.android.metrics.ConnectivitySampleMetricsHelper;
+import com.android.metrics.ConnectivityStateSample;
+import com.android.metrics.NetworkCountForTransports;
+import com.android.metrics.NetworkCountPerTransports;
+import com.android.metrics.NetworkDescription;
+import com.android.metrics.NetworkList;
+import com.android.metrics.NetworkRequestCount;
+import com.android.metrics.RequestCountForType;
 import com.android.modules.utils.BasicShellCommandHandler;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
@@ -338,6 +354,7 @@
 import java.util.SortedSet;
 import java.util.StringJoiner;
 import java.util.TreeSet;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -2341,6 +2358,134 @@
         return out;
     }
 
+    // Because StatsEvent is not usable in tests (everything inside it is hidden), this
+    // method is used to convert a ConnectivityStateSample into a StatsEvent, so that tests
+    // can call sampleConnectivityState and make the checks on it.
+    @NonNull
+    private StatsEvent sampleConnectivityStateToStatsEvent() {
+        final ConnectivityStateSample sample = sampleConnectivityState();
+        return ConnectivityStatsLog.buildStatsEvent(
+                ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE,
+                sample.getNetworkCountPerTransports().toByteArray(),
+                sample.getConnectionDurationPerTransports().toByteArray(),
+                sample.getNetworkRequestCount().toByteArray(),
+                sample.getNetworks().toByteArray());
+    }
+
+    /**
+     * Gather and return a snapshot of the current connectivity state, to be used as a sample.
+     *
+     * This is used for metrics. These snapshots will be sampled and constitute a base for
+     * statistics about connectivity state of devices.
+     */
+    @VisibleForTesting
+    @NonNull
+    public ConnectivityStateSample sampleConnectivityState() {
+        ensureRunningOnConnectivityServiceThread();
+        final ConnectivityStateSample.Builder builder = ConnectivityStateSample.newBuilder();
+        builder.setNetworkCountPerTransports(sampleNetworkCount(mNetworkAgentInfos));
+        builder.setConnectionDurationPerTransports(sampleConnectionDuration(mNetworkAgentInfos));
+        builder.setNetworkRequestCount(sampleNetworkRequestCount(mNetworkRequests.values()));
+        builder.setNetworks(sampleNetworks(mNetworkAgentInfos));
+        return builder.build();
+    }
+
+    private static NetworkCountPerTransports sampleNetworkCount(
+            @NonNull final ArraySet<NetworkAgentInfo> nais) {
+        final SparseIntArray countPerTransports = new SparseIntArray();
+        for (final NetworkAgentInfo nai : nais) {
+            int transports = (int) nai.networkCapabilities.getTransportTypesInternal();
+            countPerTransports.put(transports, 1 + countPerTransports.get(transports, 0));
+        }
+        final NetworkCountPerTransports.Builder builder = NetworkCountPerTransports.newBuilder();
+        for (int i = countPerTransports.size() - 1; i >= 0; --i) {
+            final NetworkCountForTransports.Builder c = NetworkCountForTransports.newBuilder();
+            c.setTransportTypes(countPerTransports.keyAt(i));
+            c.setNetworkCount(countPerTransports.valueAt(i));
+            builder.addNetworkCountForTransports(c);
+        }
+        return builder.build();
+    }
+
+    private static ConnectionDurationPerTransports sampleConnectionDuration(
+            @NonNull final ArraySet<NetworkAgentInfo> nais) {
+        final ConnectionDurationPerTransports.Builder builder =
+                ConnectionDurationPerTransports.newBuilder();
+        for (final NetworkAgentInfo nai : nais) {
+            final ConnectionDurationForTransports.Builder c =
+                    ConnectionDurationForTransports.newBuilder();
+            c.setTransportTypes((int) nai.networkCapabilities.getTransportTypesInternal());
+            final long durationMillis = SystemClock.elapsedRealtime() - nai.getConnectedTime();
+            final long millisPerSecond = TimeUnit.SECONDS.toMillis(1);
+            // Add millisPerSecond/2 to round up or down to the nearest value
+            c.setDurationSec((int) ((durationMillis + millisPerSecond / 2) / millisPerSecond));
+            builder.addConnectionDurationForTransports(c);
+        }
+        return builder.build();
+    }
+
+    private static NetworkRequestCount sampleNetworkRequestCount(
+            @NonNull final Collection<NetworkRequestInfo> nris) {
+        final NetworkRequestCount.Builder builder = NetworkRequestCount.newBuilder();
+        final SparseIntArray countPerType = new SparseIntArray();
+        for (final NetworkRequestInfo nri : nris) {
+            final int type;
+            if (Process.SYSTEM_UID == nri.mAsUid) {
+                // The request is filed "as" the system, so it's the system on its own behalf.
+                type = RequestType.RT_SYSTEM.getNumber();
+            } else if (Process.SYSTEM_UID == nri.mUid) {
+                // The request is filed by the system as some other app, so it's the system on
+                // behalf of an app.
+                type = RequestType.RT_SYSTEM_ON_BEHALF_OF_APP.getNumber();
+            } else {
+                // Not the system, so it's an app requesting on its own behalf.
+                type = RequestType.RT_APP.getNumber();
+            }
+            countPerType.put(type, countPerType.get(type, 0));
+        }
+        for (int i = countPerType.size() - 1; i >= 0; --i) {
+            final RequestCountForType.Builder r = RequestCountForType.newBuilder();
+            r.setRequestType(RequestType.forNumber(countPerType.keyAt(i)));
+            r.setRequestCount(countPerType.valueAt(i));
+            builder.addRequestCountForType(r);
+        }
+        return builder.build();
+    }
+
+    private static NetworkList sampleNetworks(@NonNull final ArraySet<NetworkAgentInfo> nais) {
+        final NetworkList.Builder builder = NetworkList.newBuilder();
+        for (final NetworkAgentInfo nai : nais) {
+            final NetworkCapabilities nc = nai.networkCapabilities;
+            final NetworkDescription.Builder d = NetworkDescription.newBuilder();
+            d.setTransportTypes((int) nc.getTransportTypesInternal());
+            final MeteredState meteredState;
+            if (nc.hasCapability(NET_CAPABILITY_TEMPORARILY_NOT_METERED)) {
+                meteredState = MeteredState.METERED_TEMPORARILY_UNMETERED;
+            } else if (nc.hasCapability(NET_CAPABILITY_NOT_METERED)) {
+                meteredState = MeteredState.METERED_NO;
+            } else {
+                meteredState = MeteredState.METERED_YES;
+            }
+            d.setMeteredState(meteredState);
+            final ValidatedState validatedState;
+            if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) {
+                validatedState = ValidatedState.VS_PORTAL;
+            } else if (nc.hasCapability(NET_CAPABILITY_PARTIAL_CONNECTIVITY)) {
+                validatedState = ValidatedState.VS_PARTIAL;
+            } else if (nc.hasCapability(NET_CAPABILITY_VALIDATED)) {
+                validatedState = ValidatedState.VS_VALID;
+            } else {
+                validatedState = ValidatedState.VS_INVALID;
+            }
+            d.setValidatedState(validatedState);
+            d.setScorePolicies(nai.getScore().getPoliciesInternal());
+            d.setCapabilities(nc.getCapabilitiesInternal());
+            d.setEnterpriseId(nc.getEnterpriseIdsInternal());
+            builder.addNetworkDescription(d);
+        }
+        return builder.build();
+    }
+
     @Override
     public boolean isNetworkSupported(int networkType) {
         enforceAccessPermission();
@@ -3456,6 +3601,8 @@
         if (mDeps.isAtLeastT()) {
             mBpfNetMaps.setPullAtomCallback(mContext);
         }
+        ConnectivitySampleMetricsHelper.start(mContext, mHandler,
+                CONNECTIVITY_STATE_SAMPLE, this::sampleConnectivityStateToStatsEvent);
         // Wait PermissionMonitor to finish the permission update. Then MultipathPolicyTracker won't
         // have permission problem. While CV#block() is unbounded in time and can in principle block
         // forever, this replaces a synchronous call to PermissionMonitor#startMonitoring, which
diff --git a/service/src/com/android/server/connectivity/FullScore.java b/service/src/com/android/server/connectivity/FullScore.java
index 87ae0c9..648f3bf 100644
--- a/service/src/com/android/server/connectivity/FullScore.java
+++ b/service/src/com/android/server/connectivity/FullScore.java
@@ -124,7 +124,7 @@
             new Class[]{FullScore.class, NetworkScore.class}, new String[]{"POLICY_"});
 
     @VisibleForTesting
-    static @NonNull String policyNameOf(final int policy) {
+    public static @NonNull String policyNameOf(final int policy) {
         final String name = sMessageNames.get(policy);
         if (name == null) {
             // Don't throw here because name might be null due to proguard stripping out the
@@ -304,6 +304,18 @@
     }
 
     /**
+     * Gets the policies as an long. Internal callers only.
+     *
+     * DO NOT USE if not immediately collapsing back into a scalar. Instead, use
+     * {@link #hasPolicy}.
+     * @return the internal, version-dependent int representing the policies.
+     * @hide
+     */
+    public long getPoliciesInternal() {
+        return mPolicies;
+    }
+
+    /**
      * @return whether this score has a particular policy.
      */
     @VisibleForTesting
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 845c04c..bdd841f 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -1105,6 +1105,11 @@
      *         already present.
      */
     public boolean addRequest(NetworkRequest networkRequest) {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on ConnectivityService thread: "
+                            + Thread.currentThread().getName());
+        }
         NetworkRequest existing = mNetworkRequests.get(networkRequest.requestId);
         if (existing == networkRequest) return false;
         if (existing != null) {
@@ -1123,6 +1128,11 @@
      * Remove the specified request from this network.
      */
     public void removeRequest(int requestId) {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on ConnectivityService thread: "
+                            + Thread.currentThread().getName());
+        }
         NetworkRequest existing = mNetworkRequests.get(requestId);
         if (existing == null) return;
         updateRequestCounts(REMOVE, existing);
@@ -1144,6 +1154,11 @@
      * network.
      */
     public NetworkRequest requestAt(int index) {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on ConnectivityService thread: "
+                            + Thread.currentThread().getName());
+        }
         return mNetworkRequests.valueAt(index);
     }
 
@@ -1174,6 +1189,11 @@
      * Returns the number of requests of any type currently satisfied by this network.
      */
     public int numNetworkRequests() {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on ConnectivityService thread: "
+                            + Thread.currentThread().getName());
+        }
         return mNetworkRequests.size();
     }
 
diff --git a/tests/integration/util/com/android/server/NetworkAgentWrapper.java b/tests/integration/util/com/android/server/NetworkAgentWrapper.java
index 28edcb2..edd201d 100644
--- a/tests/integration/util/com/android/server/NetworkAgentWrapper.java
+++ b/tests/integration/util/com/android/server/NetworkAgentWrapper.java
@@ -64,6 +64,9 @@
 import java.util.function.Consumer;
 
 public class NetworkAgentWrapper implements TestableNetworkCallback.HasNetwork {
+    // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+    // please add it in CSAgentWrapper and use subclasses of CSTest instead of adding more
+    // tools in ConnectivityServiceTest.
     private final NetworkCapabilities mNetworkCapabilities;
     private final HandlerThread mHandlerThread;
     private final Context mContext;
@@ -468,4 +471,8 @@
     public boolean isBypassableVpn() {
         return mNetworkAgentConfig.isBypassableVpn();
     }
+
+    // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+    // please add it in CSAgentWrapper and use subclasses of CSTest instead of adding more
+    // tools in ConnectivityServiceTest.
 }
diff --git a/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
new file mode 100644
index 0000000..3043d50
--- /dev/null
+++ b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
@@ -0,0 +1,173 @@
+package com.android.metrics
+
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.CONNECTIVITY_MANAGED_CAPABILITIES
+import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
+import android.net.NetworkCapabilities.NET_CAPABILITY_ENTERPRISE
+import android.net.NetworkCapabilities.NET_CAPABILITY_IMS
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY
+import android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.NET_ENTERPRISE_ID_1
+import android.net.NetworkCapabilities.NET_ENTERPRISE_ID_3
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkScore
+import android.net.NetworkScore.POLICY_EXITING
+import android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY
+import android.os.Build
+import android.os.Handler
+import android.stats.connectivity.MeteredState
+import android.stats.connectivity.ValidatedState
+import androidx.test.filters.SmallTest
+import com.android.net.module.util.BitUtils
+import com.android.server.CSTest
+import com.android.server.FromS
+import com.android.server.connectivity.FullScore
+import com.android.server.connectivity.FullScore.POLICY_IS_UNMETERED
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CompletableFuture
+import kotlin.test.assertEquals
+import kotlin.test.fail
+
+private fun <T> Handler.onHandler(f: () -> T): T {
+    val future = CompletableFuture<T>()
+    post { future.complete(f()) }
+    return future.get()
+}
+
+private fun flags(vararg flags: Int) = flags.fold(0L) { acc, it -> acc or (1L shl it) }
+
+private fun Number.toTransportsString() = StringBuilder().also { sb ->
+    BitUtils.appendStringRepresentationOfBitMaskToStringBuilder(sb, this.toLong(),
+            { NetworkCapabilities.transportNameOf(it) }, "|") }.toString()
+
+private fun Number.toCapsString() = StringBuilder().also { sb ->
+    BitUtils.appendStringRepresentationOfBitMaskToStringBuilder(sb, this.toLong(),
+            { NetworkCapabilities.capabilityNameOf(it) }, "&") }.toString()
+
+private fun Number.toPolicyString() = StringBuilder().also {sb ->
+    BitUtils.appendStringRepresentationOfBitMaskToStringBuilder(sb, this.toLong(),
+            { FullScore.policyNameOf(it) }, "|") }.toString()
+
+private fun Number.exceptCSManaged() = this.toLong() and CONNECTIVITY_MANAGED_CAPABILITIES.inv()
+
+private val NetworkCapabilities.meteredState get() = when {
+    hasCapability(NET_CAPABILITY_TEMPORARILY_NOT_METERED) ->
+        MeteredState.METERED_TEMPORARILY_UNMETERED
+    hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) ->
+        MeteredState.METERED_NO
+    else ->
+        MeteredState.METERED_YES
+}
+
+private val NetworkCapabilities.validatedState get() = when {
+    hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL) -> ValidatedState.VS_PORTAL
+    hasCapability(NET_CAPABILITY_PARTIAL_CONNECTIVITY) -> ValidatedState.VS_PARTIAL
+    hasCapability(NET_CAPABILITY_VALIDATED) -> ValidatedState.VS_VALID
+    else -> ValidatedState.VS_INVALID
+}
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class ConnectivitySampleMetricsTest : CSTest() {
+    @Test
+    fun testSampleConnectivityState() {
+        val wifi1Caps = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_NOT_METERED)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .addCapability(NET_CAPABILITY_NOT_ROAMING)
+                .build()
+        val wifi1Score = NetworkScore.Builder().setExiting(true).build()
+        val agentWifi1 = Agent(nc = wifi1Caps, score = FromS(wifi1Score)).also { it.connect() }
+
+        val wifi2Caps = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_ENTERPRISE)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .addCapability(NET_CAPABILITY_NOT_ROAMING)
+                .addEnterpriseId(NET_ENTERPRISE_ID_3)
+                .build()
+        val wifi2Score = NetworkScore.Builder().setTransportPrimary(true).build()
+        val agentWifi2 = Agent(nc = wifi2Caps, score = FromS(wifi2Score)).also { it.connect() }
+
+        val cellCaps = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_IMS)
+                .addCapability(NET_CAPABILITY_ENTERPRISE)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .addCapability(NET_CAPABILITY_NOT_ROAMING)
+                .addEnterpriseId(NET_ENTERPRISE_ID_1)
+                .build()
+        val cellScore = NetworkScore.Builder().build()
+        val agentCell = Agent(nc = cellCaps, score = FromS(cellScore)).also { it.connect() }
+
+        val stats = csHandler.onHandler { service.sampleConnectivityState() }
+        assertEquals(3, stats.networks.networkDescriptionList.size)
+        val foundCell = stats.networks.networkDescriptionList.find {
+            it.transportTypes == (1 shl TRANSPORT_CELLULAR)
+        } ?: fail("Can't find cell network (searching by transport)")
+        val foundWifi1 = stats.networks.networkDescriptionList.find {
+            it.transportTypes == (1 shl TRANSPORT_WIFI) &&
+                    0L != (it.capabilities and (1L shl NET_CAPABILITY_NOT_METERED))
+        } ?: fail("Can't find wifi1 (searching by WIFI transport and the NOT_METERED capability)")
+        val foundWifi2 = stats.networks.networkDescriptionList.find {
+            it.transportTypes == (1 shl TRANSPORT_WIFI) &&
+                    0L != (it.capabilities and (1L shl NET_CAPABILITY_ENTERPRISE))
+        } ?: fail("Can't find wifi2 (searching by WIFI transport and the ENTERPRISE capability)")
+
+        fun checkNetworkDescription(
+                network: String,
+                found: NetworkDescription,
+                expected: NetworkCapabilities
+        ) {
+            assertEquals(expected.transportTypesInternal, found.transportTypes.toLong(),
+                    "Transports differ for network $network, " +
+                            "expected ${expected.transportTypesInternal.toTransportsString()}, " +
+                            "found ${found.transportTypes.toTransportsString()}")
+            val expectedCaps = expected.capabilitiesInternal.exceptCSManaged()
+            val foundCaps = found.capabilities.exceptCSManaged()
+            assertEquals(expectedCaps, foundCaps,
+                    "Capabilities differ for network $network, " +
+                            "expected ${expectedCaps.toCapsString()}, " +
+                            "found ${foundCaps.toCapsString()}")
+            assertEquals(expected.enterpriseIdsInternal, found.enterpriseId,
+                    "Enterprise IDs differ for network $network, " +
+                            "expected ${expected.enterpriseIdsInternal}," +
+                            " found ${found.enterpriseId}")
+            assertEquals(expected.meteredState, found.meteredState,
+                    "Metered states differ for network $network, " +
+                            "expected ${expected.meteredState}, " +
+                            "found ${found.meteredState}")
+            assertEquals(expected.validatedState, found.validatedState,
+                    "Validated states differ for network $network, " +
+                            "expected ${expected.validatedState}, " +
+                            "found ${found.validatedState}")
+        }
+
+        checkNetworkDescription("Cell network", foundCell, cellCaps)
+        checkNetworkDescription("Wifi1", foundWifi1, wifi1Caps)
+        checkNetworkDescription("Wifi2", foundWifi2, wifi2Caps)
+
+        assertEquals(0, foundCell.scorePolicies, "Cell score policies incorrect, expected 0, " +
+                        "found ${foundCell.scorePolicies.toPolicyString()}")
+        val expectedWifi1Policies = flags(POLICY_EXITING, POLICY_IS_UNMETERED)
+        assertEquals(expectedWifi1Policies, foundWifi1.scorePolicies,
+                "Wifi1 score policies incorrect, " +
+                        "expected ${expectedWifi1Policies.toPolicyString()}, " +
+                        "found ${foundWifi1.scorePolicies.toPolicyString()}")
+        val expectedWifi2Policies = flags(POLICY_TRANSPORT_PRIMARY)
+        assertEquals(expectedWifi2Policies, foundWifi2.scorePolicies,
+                "Wifi2 score policies incorrect, " +
+                        "expected ${expectedWifi2Policies.toPolicyString()}, " +
+                        "found ${foundWifi2.scorePolicies.toPolicyString()}")
+    }
+}
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index e5dec56..2fccdcb 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -75,10 +75,7 @@
 import static android.net.ConnectivityManager.PROFILE_NETWORK_PREFERENCE_ENTERPRISE_NO_FALLBACK;
 import static android.net.ConnectivityManager.TYPE_ETHERNET;
 import static android.net.ConnectivityManager.TYPE_MOBILE;
-import static android.net.ConnectivityManager.TYPE_MOBILE_FOTA;
-import static android.net.ConnectivityManager.TYPE_MOBILE_MMS;
 import static android.net.ConnectivityManager.TYPE_MOBILE_SUPL;
-import static android.net.ConnectivityManager.TYPE_PROXY;
 import static android.net.ConnectivityManager.TYPE_VPN;
 import static android.net.ConnectivityManager.TYPE_WIFI;
 import static android.net.ConnectivitySettingsManager.PRIVATE_DNS_MODE_OFF;
@@ -492,6 +489,8 @@
  * Build, install and run with:
  *  runtest frameworks-net -c com.android.server.ConnectivityServiceTest
  */
+// TODO : move methods from this test to smaller tests in the 'connectivityservice' directory
+// to enable faster testing of smaller groups of functionality.
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
@@ -756,6 +755,9 @@
             if (Context.TETHERING_SERVICE.equals(name)) return mTetheringManager;
             if (Context.ACTIVITY_SERVICE.equals(name)) return mActivityManager;
             if (Context.TELEPHONY_SUBSCRIPTION_SERVICE.equals(name)) return mSubscriptionManager;
+            // StatsManager is final and can't be mocked, and uses static methods for mostly
+            // everything. The simplest fix is to return null and not have metrics in tests.
+            if (Context.STATS_MANAGER.equals(name)) return null;
             return super.getSystemService(name);
         }
 
@@ -1016,6 +1018,9 @@
     }
 
     private class TestNetworkAgentWrapper extends NetworkAgentWrapper {
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSAgentWrapper and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
         private static final int VALIDATION_RESULT_INVALID = 0;
 
         private static final long DATA_STALL_TIMESTAMP = 10L;
@@ -1340,6 +1345,9 @@
      * operations have been processed and test for them.
      */
     private static class MockNetworkFactory extends NetworkFactory {
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSTest and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
         private final AtomicBoolean mNetworkStarted = new AtomicBoolean(false);
 
         static class RequestEntry {
@@ -1476,6 +1484,10 @@
     }
 
     private class MockVpn extends Vpn implements TestableNetworkCallback.HasNetwork {
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSTest and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
+
         // Careful ! This is different from mNetworkAgent, because MockNetworkAgent does
         // not inherit from NetworkAgent.
         private TestNetworkAgentWrapper mMockNetworkAgent;
@@ -1852,6 +1864,9 @@
 
         MockitoAnnotations.initMocks(this);
 
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSTest and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
         doReturn(asList(PRIMARY_USER_INFO)).when(mUserManager).getAliveUsers();
         doReturn(asList(PRIMARY_USER_HANDLE)).when(mUserManager).getUserHandles(anyBoolean());
         doReturn(PRIMARY_USER_INFO).when(mUserManager).getUserInfo(PRIMARY_USER);
@@ -1938,6 +1953,9 @@
         setCaptivePortalMode(ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_PROMPT);
         setAlwaysOnNetworks(false);
         setPrivateDnsSettings(PRIVATE_DNS_MODE_OFF, "ignored.example.com");
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSTest and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
     }
 
     private void initMockedResources() {
@@ -1974,6 +1992,9 @@
         final ConnectivityResources mConnRes;
         final ArraySet<Pair<Long, Integer>> mEnabledChangeIds = new ArraySet<>();
 
+        // Note : Please do not add any new instrumentation here. If you need new instrumentation,
+        // please add it in CSTest and use subclasses of CSTest instead of adding more
+        // tools in ConnectivityServiceTest.
         ConnectivityServiceDependencies(final Context mockResContext) {
             mConnRes = new ConnectivityResources(mockResContext);
         }
@@ -2583,23 +2604,6 @@
     }
 
     @Test
-    public void testNetworkTypes() {
-        // Ensure that our mocks for the networkAttributes config variable work as expected. If they
-        // don't, then tests that depend on CONNECTIVITY_ACTION broadcasts for these network types
-        // will fail. Failing here is much easier to debug.
-        assertTrue(mCm.isNetworkSupported(TYPE_WIFI));
-        assertTrue(mCm.isNetworkSupported(TYPE_MOBILE));
-        assertTrue(mCm.isNetworkSupported(TYPE_MOBILE_MMS));
-        assertTrue(mCm.isNetworkSupported(TYPE_MOBILE_FOTA));
-        assertFalse(mCm.isNetworkSupported(TYPE_PROXY));
-
-        // Check that TYPE_ETHERNET is supported. Unlike the asserts above, which only validate our
-        // mocks, this assert exercises the ConnectivityService code path that ensures that
-        // TYPE_ETHERNET is supported if the ethernet service is running.
-        assertTrue(mCm.isNetworkSupported(TYPE_ETHERNET));
-    }
-
-    @Test
     public void testNetworkFeature() throws Exception {
         // Connect the cell agent and wait for the connected broadcast.
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
@@ -18801,4 +18805,7 @@
 
         verifyClatdStop(null /* inOrder */, MOBILE_IFNAME);
     }
+
+    // Note : adding tests is ConnectivityServiceTest is deprecated, as it is too big for
+    // maintenance. Please consider adding new tests in subclasses of CSTest instead.
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt
new file mode 100644
index 0000000..6f8ba6c
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt
@@ -0,0 +1,35 @@
+@file:Suppress("DEPRECATION") // This file tests a bunch of deprecated methods : don't warn about it
+
+package com.android.server
+
+import android.net.ConnectivityManager
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.R)
+class CSBasicMethodsTest : CSTest() {
+    @Test
+    fun testNetworkTypes() {
+        // Ensure that mocks for the networkAttributes config variable work as expected. If they
+        // don't, then tests that depend on CONNECTIVITY_ACTION broadcasts for these network types
+        // will fail. Failing here is much easier to debug.
+        assertTrue(cm.isNetworkSupported(ConnectivityManager.TYPE_WIFI))
+        assertTrue(cm.isNetworkSupported(ConnectivityManager.TYPE_MOBILE))
+        assertTrue(cm.isNetworkSupported(ConnectivityManager.TYPE_MOBILE_MMS))
+        assertTrue(cm.isNetworkSupported(ConnectivityManager.TYPE_MOBILE_FOTA))
+        assertFalse(cm.isNetworkSupported(ConnectivityManager.TYPE_PROXY))
+
+        // Check that TYPE_ETHERNET is supported. Unlike the asserts above, which only validate our
+        // mocks, this assert exercises the ConnectivityService code path that ensures that
+        // TYPE_ETHERNET is supported if the ethernet service is running.
+        assertTrue(cm.isNetworkSupported(ConnectivityManager.TYPE_ETHERNET))
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
new file mode 100644
index 0000000..5ae9232
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -0,0 +1,134 @@
+package com.android.server
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.INetworkMonitor
+import android.net.INetworkMonitorCallbacks
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkAgent
+import android.net.NetworkAgentConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkProvider
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkTestResultParcelable
+import android.net.networkstack.NetworkStackClientBase
+import android.os.HandlerThread
+import com.android.modules.utils.build.SdkLevel
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.TestableNetworkCallback
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.verify
+import org.mockito.stubbing.Answer
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.assertEquals
+import kotlin.test.fail
+
+private inline fun <reified T> ArgumentCaptor() = ArgumentCaptor.forClass(T::class.java)
+
+private val agentCounter = AtomicInteger(1)
+private fun nextAgentId() = agentCounter.getAndIncrement()
+
+/**
+ * A wrapper for network agents, for use with CSTest.
+ *
+ * This class knows how to interact with CSTest and has helpful methods to make fake agents
+ * that can be manipulated directly from a test.
+ */
+class CSAgentWrapper(
+        val context: Context,
+        csHandlerThread: HandlerThread,
+        networkStack: NetworkStackClientBase,
+        nac: NetworkAgentConfig,
+        val nc: NetworkCapabilities,
+        val lp: LinkProperties,
+        val score: FromS<NetworkScore>,
+        val provider: NetworkProvider?
+) : TestableNetworkCallback.HasNetwork {
+    private val TAG = "CSAgent${nextAgentId()}"
+    private val VALIDATION_RESULT_INVALID = 0
+    private val VALIDATION_TIMESTAMP = 1234L
+    private val agent: NetworkAgent
+    private val nmCallbacks: INetworkMonitorCallbacks
+    val networkMonitor = mock<INetworkMonitor>()
+
+    override val network: Network get() = agent.network!!
+
+    init {
+        // Capture network monitor callbacks and simulate network monitor
+        val validateAnswer = Answer {
+            CSTest.CSTestExecutor.execute { onValidationRequested() }
+            null
+        }
+        doAnswer(validateAnswer).`when`(networkMonitor).notifyNetworkConnected(any(), any())
+        doAnswer(validateAnswer).`when`(networkMonitor).notifyNetworkConnectedParcel(any())
+        doAnswer(validateAnswer).`when`(networkMonitor).forceReevaluation(anyInt())
+        val nmNetworkCaptor = ArgumentCaptor<Network>()
+        val nmCbCaptor = ArgumentCaptor<INetworkMonitorCallbacks>()
+        doNothing().`when`(networkStack).makeNetworkMonitor(
+                nmNetworkCaptor.capture(),
+                any() /* name */,
+                nmCbCaptor.capture())
+
+        // Create the actual agent. NetworkAgent is abstract, so make an anonymous subclass.
+        if (SdkLevel.isAtLeastS()) {
+            agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
+                    nc, lp, score.value, nac, provider) {}
+        } else {
+            agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
+                    nc, lp, 50 /* score */, nac, provider) {}
+        }
+        agent.register()
+        assertEquals(agent.network!!.netId, nmNetworkCaptor.value.netId)
+        nmCallbacks = nmCbCaptor.value
+        nmCallbacks.onNetworkMonitorCreated(networkMonitor)
+    }
+
+    private fun onValidationRequested() {
+        if (SdkLevel.isAtLeastT()) {
+            verify(networkMonitor).notifyNetworkConnectedParcel(any())
+        } else {
+            verify(networkMonitor).notifyNetworkConnected(any(), any())
+        }
+        nmCallbacks.notifyProbeStatusChanged(0 /* completed */, 0 /* succeeded */)
+        val p = NetworkTestResultParcelable()
+        p.result = VALIDATION_RESULT_INVALID
+        p.probesAttempted = 0
+        p.probesSucceeded = 0
+        p.redirectUrl = null
+        p.timestampMillis = VALIDATION_TIMESTAMP
+        nmCallbacks.notifyNetworkTestedWithExtras(p)
+    }
+
+    fun connect() {
+        val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+        val request = NetworkRequest.Builder().clearCapabilities()
+                .addTransportType(nc.transportTypes[0])
+                .build()
+        val cb = TestableNetworkCallback()
+        mgr.registerNetworkCallback(request, cb)
+        agent.markConnected()
+        if (null == cb.poll { it is Available && agent.network == it.network }) {
+            if (!nc.hasCapability(NET_CAPABILITY_NOT_SUSPENDED) &&
+                    nc.hasTransport(TRANSPORT_CELLULAR)) {
+                // ConnectivityService adds NOT_SUSPENDED by default to all non-cell agents. An
+                // agent without NOT_SUSPENDED will not connect, instead going into the SUSPENDED
+                // state, so this call will not terminate.
+                // Instead of forcefully adding NOT_SUSPENDED to all agents like older tools did,
+                // it's better to let the developer manage it as they see fit but help them
+                // debug if they forget.
+                fail("Could not connect the agent. Did you forget to add " +
+                        "NET_CAPABILITY_NOT_SUSPENDED ?")
+            }
+            fail("Could not connect the agent. Instrumentation failure ?")
+        }
+        mgr.unregisterNetworkCallback(cb)
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
new file mode 100644
index 0000000..68613a6
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -0,0 +1,239 @@
+package com.android.server
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.content.pm.UserInfo
+import android.content.res.Resources
+import android.net.ConnectivityManager
+import android.net.INetd
+import android.net.InetAddresses
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkAgentConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkPolicyManager
+import android.net.NetworkProvider
+import android.net.NetworkScore
+import android.net.PacProxyManager
+import android.net.RouteInfo
+import android.net.networkstack.NetworkStackClientBase
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.UserHandle
+import android.os.UserManager
+import android.telephony.TelephonyManager
+import android.testing.TestableContext
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.internal.util.test.BroadcastInterceptingContext
+import com.android.modules.utils.build.SdkLevel
+import com.android.networkstack.apishim.common.UnsupportedApiLevelException
+import com.android.server.connectivity.AutomaticOnOffKeepaliveTracker
+import com.android.server.connectivity.CarrierPrivilegeAuthenticator
+import com.android.server.connectivity.ClatCoordinator
+import com.android.server.connectivity.ConnectivityFlags
+import com.android.server.connectivity.MultinetworkPolicyTracker
+import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies
+import com.android.server.connectivity.ProxyTracker
+import com.android.testutils.waitForIdle
+import org.mockito.AdditionalAnswers.delegatesTo
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import java.util.concurrent.Executors
+import kotlin.test.fail
+
+internal const val HANDLER_TIMEOUT_MS = 2_000
+internal const val TEST_PACKAGE_NAME = "com.android.test.package"
+internal const val WIFI_WOL_IFNAME = "test_wlan_wol"
+internal val LOCAL_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.1")
+
+open class FromS<Type>(val value: Type)
+
+/**
+ * Base class for tests testing ConnectivityService and its satellites.
+ *
+ * This class sets up a ConnectivityService running locally in the test.
+ */
+// TODO (b/272685721) : make ConnectivityServiceTest smaller and faster by moving the setup
+// parts into this class and moving the individual tests to multiple separate classes.
+open class CSTest {
+    companion object {
+        val CSTestExecutor = Executors.newSingleThreadExecutor()
+    }
+
+    init {
+        if (!SdkLevel.isAtLeastS()) {
+            throw UnsupportedApiLevelException("CSTest subclasses must be annotated to only " +
+                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)");
+        }
+    }
+
+    val instrumentationContext =
+            TestableContext(InstrumentationRegistry.getInstrumentation().context)
+    val context = CSContext(instrumentationContext)
+
+    // See constructor for default-enabled features. All queried features must be either enabled
+    // or disabled, because the test can't hold READ_DEVICE_CONFIG and device config utils query
+    // permissions using static contexts.
+    val enabledFeatures = HashMap<String, Boolean>().also {
+        it[ConnectivityFlags.NO_REMATCH_ALL_REQUESTS_ON_REGISTER] = true
+        it[ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION] = true
+        it[ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION] = true
+    }
+    fun enableFeature(f: String) = enabledFeatures.set(f, true)
+    fun disableFeature(f: String) = enabledFeatures.set(f, false)
+
+    // When adding new members, consider if it's not better to build the object in CSTestHelpers
+    // to keep this file clean of implementation details. Generally, CSTestHelpers should only
+    // need changes when new details of instrumentation are needed.
+    val contentResolver = makeMockContentResolver(context)
+
+    val PRIMARY_USER = 0
+    val PRIMARY_USER_INFO = UserInfo(PRIMARY_USER, "" /* name */, UserInfo.FLAG_PRIMARY)
+    val PRIMARY_USER_HANDLE = UserHandle(PRIMARY_USER)
+    val userManager = makeMockUserManager(PRIMARY_USER_INFO, PRIMARY_USER_HANDLE)
+    val activityManager = makeActivityManager()
+
+    val networkStack = mock<NetworkStackClientBase>()
+    val csHandlerThread = HandlerThread("CSTestHandler")
+    val sysResources = mock<Resources>().also { initMockedResources(it) }
+    val packageManager = makeMockPackageManager()
+    val connResources = makeMockConnResources(sysResources, packageManager)
+
+    val bpfNetMaps = mock<BpfNetMaps>()
+    val clatCoordinator = mock<ClatCoordinator>()
+    val proxyTracker = ProxyTracker(context, mock<Handler>(), 16 /* EVENT_PROXY_HAS_CHANGED */)
+    val alarmManager = makeMockAlarmManager()
+    val systemConfigManager = makeMockSystemConfigManager()
+    val telephonyManager = mock<TelephonyManager>().also {
+        doReturn(true).`when`(it).isDataCapable()
+    }
+
+    val deps = CSDeps()
+    val service = makeConnectivityService(context, deps).also { it.systemReadyInternal() }
+    val cm = ConnectivityManager(context, service)
+    val csHandler = Handler(csHandlerThread.looper)
+
+    inner class CSDeps : ConnectivityService.Dependencies() {
+        override fun getResources(ctx: Context) = connResources
+        override fun getBpfNetMaps(context: Context, netd: INetd) = this@CSTest.bpfNetMaps
+        override fun getClatCoordinator(netd: INetd?) = this@CSTest.clatCoordinator
+        override fun getNetworkStack() = this@CSTest.networkStack
+
+        override fun makeHandlerThread() = csHandlerThread
+        override fun makeProxyTracker(context: Context, connServiceHandler: Handler) = proxyTracker
+
+        override fun makeCarrierPrivilegeAuthenticator(context: Context, tm: TelephonyManager) =
+                if (SdkLevel.isAtLeastT()) mock<CarrierPrivilegeAuthenticator>() else null
+
+        private inner class AOOKTDeps(c: Context) : AutomaticOnOffKeepaliveTracker.Dependencies(c) {
+            override fun isTetheringFeatureNotChickenedOut(name: String): Boolean {
+                return isFeatureEnabled(context, name)
+            }
+        }
+        override fun makeAutomaticOnOffKeepaliveTracker(c: Context, h: Handler) =
+                AutomaticOnOffKeepaliveTracker(c, h, AOOKTDeps(c))
+
+        override fun makeMultinetworkPolicyTracker(c: Context, h: Handler, r: Runnable) =
+                MultinetworkPolicyTracker(c, h, r,
+                        MultinetworkPolicyTrackerTestDependencies(connResources.get()))
+
+        // All queried features must be mocked, because the test cannot hold the
+        // READ_DEVICE_CONFIG permission and device config utils use static methods for
+        // checking permissions.
+        override fun isFeatureEnabled(context: Context?, name: String?) =
+                enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
+    }
+
+    inner class CSContext(base: Context) : BroadcastInterceptingContext(base) {
+        val pacProxyManager = mock<PacProxyManager>()
+        val networkPolicyManager = mock<NetworkPolicyManager>()
+
+        override fun getPackageManager() = this@CSTest.packageManager
+        override fun getContentResolver() = this@CSTest.contentResolver
+
+        // TODO : buff up the capabilities of this permission scheme to allow checking for
+        // permission rejections
+        override fun checkPermission(permission: String, pid: Int, uid: Int) = PERMISSION_GRANTED
+        override fun checkCallingOrSelfPermission(permission: String) = PERMISSION_GRANTED
+
+        // Necessary for MultinetworkPolicyTracker, which tries to register a receiver for
+        // all users. The test can't do that since it doesn't hold INTERACT_ACROSS_USERS.
+        // TODO : ensure MultinetworkPolicyTracker's BroadcastReceiver is tested ; ideally,
+        // just returning null should not have tests pass
+        override fun registerReceiverForAllUsers(
+                receiver: BroadcastReceiver?,
+                filter: IntentFilter,
+                broadcastPermission: String?,
+                scheduler: Handler?
+        ): Intent? = null
+
+        // Create and cache user managers on the fly as necessary.
+        val userManagers = HashMap<UserHandle, UserManager>()
+        override fun createContextAsUser(user: UserHandle, flags: Int): Context {
+            val asUser = mock(Context::class.java, delegatesTo<Any>(this))
+            doReturn(user).`when`(asUser).getUser()
+            doAnswer { userManagers.computeIfAbsent(user) {
+                mock(UserManager::class.java, delegatesTo<Any>(userManager)) }
+            }.`when`(asUser).getSystemService(Context.USER_SERVICE)
+            return asUser
+        }
+
+        // List of mocked services. Add additional services here or in subclasses.
+        override fun getSystemService(serviceName: String) = when (serviceName) {
+            Context.CONNECTIVITY_SERVICE -> cm
+            Context.PAC_PROXY_SERVICE -> pacProxyManager
+            Context.NETWORK_POLICY_SERVICE -> networkPolicyManager
+            Context.ALARM_SERVICE -> alarmManager
+            Context.USER_SERVICE -> userManager
+            Context.ACTIVITY_SERVICE -> activityManager
+            Context.SYSTEM_CONFIG_SERVICE -> systemConfigManager
+            Context.TELEPHONY_SERVICE -> telephonyManager
+            Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
+            else -> super.getSystemService(serviceName)
+        }
+    }
+
+    // Utility methods for subclasses to use
+    fun waitForIdle() = csHandlerThread.waitForIdle(HANDLER_TIMEOUT_MS)
+
+    private fun emptyAgentConfig() = NetworkAgentConfig.Builder().build()
+    private fun defaultNc() = NetworkCapabilities.Builder()
+            // Add sensible defaults for agents that don't want to care
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_ROAMING)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+    private fun defaultScore() = FromS<NetworkScore>(NetworkScore.Builder().build())
+    private fun defaultLp() = LinkProperties().apply {
+        addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+        addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+    }
+
+    // Network agents. See CSAgentWrapper. This class contains utility methods to simplify
+    // creation.
+    fun Agent(
+            nac: NetworkAgentConfig = emptyAgentConfig(),
+            nc: NetworkCapabilities = defaultNc(),
+            lp: LinkProperties = defaultLp(),
+            score: FromS<NetworkScore> = defaultScore(),
+            provider: NetworkProvider? = null
+    ) = CSAgentWrapper(context, csHandlerThread, networkStack, nac, nc, lp, score, provider)
+
+    fun Agent(vararg transports: Int, lp: LinkProperties = defaultLp()): CSAgentWrapper {
+        val nc = NetworkCapabilities.Builder().apply {
+            transports.forEach {
+                addTransportType(it)
+            }
+        }.addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .build()
+        return Agent(nc = nc, lp = lp)
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
new file mode 100644
index 0000000..b8f2151
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
@@ -0,0 +1,140 @@
+@file:JvmName("CsTestHelpers")
+
+package com.android.server
+
+import android.app.ActivityManager
+import android.app.AlarmManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.FEATURE_BLUETOOTH
+import android.content.pm.PackageManager.FEATURE_ETHERNET
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.content.pm.PackageManager.FEATURE_WIFI_DIRECT
+import android.content.pm.UserInfo
+import android.content.res.Resources
+import android.net.IDnsResolver
+import android.net.INetd
+import android.net.metrics.IpConnectivityLog
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.SystemClock
+import android.os.SystemConfigManager
+import android.os.UserHandle
+import android.os.UserManager
+import android.provider.Settings
+import android.test.mock.MockContentResolver
+import com.android.connectivity.resources.R
+import com.android.internal.util.WakeupMessage
+import com.android.internal.util.test.FakeSettingsProvider
+import com.android.modules.utils.build.SdkLevel
+import com.android.server.ConnectivityService.Dependencies
+import com.android.server.connectivity.ConnectivityResources
+import org.mockito.ArgumentMatchers
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyLong
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.doNothing
+import kotlin.test.fail
+
+internal inline fun <reified T> mock() = Mockito.mock(T::class.java)
+internal inline fun <reified T> any() = any(T::class.java)
+
+internal fun makeMockContentResolver(context: Context) = MockContentResolver(context).apply {
+    addProvider(Settings.AUTHORITY, FakeSettingsProvider())
+}
+
+internal fun makeMockUserManager(info: UserInfo, handle: UserHandle) = mock<UserManager>().also {
+    doReturn(listOf(info)).`when`(it).getAliveUsers()
+    doReturn(listOf(handle)).`when`(it).getUserHandles(ArgumentMatchers.anyBoolean())
+}
+
+internal fun makeActivityManager() = mock<ActivityManager>().also {
+    if (SdkLevel.isAtLeastU()) {
+        doNothing().`when`(it).registerUidFrozenStateChangedCallback(any(), any())
+    }
+}
+
+internal fun makeMockPackageManager() = mock<PackageManager>().also { pm ->
+    val supported = listOf(FEATURE_WIFI, FEATURE_WIFI_DIRECT, FEATURE_BLUETOOTH, FEATURE_ETHERNET)
+    doReturn(true).`when`(pm).hasSystemFeature(argThat { supported.contains(it) })
+}
+
+internal fun makeMockConnResources(resources: Resources, pm: PackageManager) = mock<Context>().let {
+    doReturn(resources).`when`(it).resources
+    doReturn(pm).`when`(it).packageManager
+    ConnectivityResources.setResourcesContextForTest(it)
+    ConnectivityResources(it)
+}
+
+private val UNREASONABLY_LONG_ALARM_WAIT_MS = 1000
+internal fun makeMockAlarmManager() = mock<AlarmManager>().also { am ->
+    val alrmHdlr = HandlerThread("TestAlarmManager").also { it.start() }.threadHandler
+    doAnswer {
+        val (_, date, _, wakeupMsg, handler) = it.arguments
+        wakeupMsg as WakeupMessage
+        handler as Handler
+        val delayMs = ((date as Long) - SystemClock.elapsedRealtime()).coerceAtLeast(0)
+        if (delayMs > UNREASONABLY_LONG_ALARM_WAIT_MS) {
+            fail("Attempting to send msg more than $UNREASONABLY_LONG_ALARM_WAIT_MS" +
+                    "ms into the future : $delayMs")
+        }
+        alrmHdlr.postDelayed({ handler.post(wakeupMsg::onAlarm) }, wakeupMsg, delayMs)
+    }.`when`(am).setExact(eq(AlarmManager.ELAPSED_REALTIME_WAKEUP), anyLong(), anyString(),
+            any<WakeupMessage>(), any())
+    doAnswer {
+        alrmHdlr.removeCallbacksAndMessages(it.getArgument<WakeupMessage>(0))
+    }.`when`(am).cancel(any<WakeupMessage>())
+}
+
+internal fun makeMockSystemConfigManager() = mock<SystemConfigManager>().also {
+    doReturn(intArrayOf(0)).`when`(it).getSystemPermissionUids(anyString())
+}
+
+// Mocking resources used by ConnectivityService. Note these can't be defined to return the
+// value returned by the mocking, because a non-null method would mean the helper would also
+// return non-null and the compiler would check that, but mockito has no qualms returning null
+// from a @NonNull method when stubbing. Hence, mock() = doReturn().getString() would crash
+// at runtime, because getString() returns non-null String, therefore mock returns non-null String,
+// and kotlinc adds an intrinsics check for that, which crashes at runtime when mockito actually
+// returns null.
+private fun Resources.mock(r: Int, v: Boolean) { doReturn(v).`when`(this).getBoolean(r) }
+private fun Resources.mock(r: Int, v: Int) { doReturn(v).`when`(this).getInteger(r) }
+private fun Resources.mock(r: Int, v: String) { doReturn(v).`when`(this).getString(r) }
+private fun Resources.mock(r: Int, v: Array<String?>) { doReturn(v).`when`(this).getStringArray(r) }
+private fun Resources.mock(r: Int, v: IntArray) { doReturn(v).`when`(this).getIntArray(r) }
+
+internal fun initMockedResources(res: Resources) {
+    // Resources accessed through reflection need to return the id
+    doReturn(R.array.config_networkSupportedKeepaliveCount).`when`(res)
+            .getIdentifier(eq("config_networkSupportedKeepaliveCount"), eq("array"), any())
+    doReturn(R.array.network_switch_type_name).`when`(res)
+            .getIdentifier(eq("network_switch_type_name"), eq("array"), any())
+    // Mock the values themselves
+    res.mock(R.integer.config_networkTransitionTimeout, 60_000)
+    res.mock(R.string.config_networkCaptivePortalServerUrl, "")
+    res.mock(R.array.config_wakeonlan_supported_interfaces, arrayOf(WIFI_WOL_IFNAME))
+    res.mock(R.array.config_networkSupportedKeepaliveCount, arrayOf("0,1", "1,3"))
+    res.mock(R.array.config_networkNotifySwitches, arrayOfNulls<String>(size = 0))
+    res.mock(R.array.config_protectedNetworks, intArrayOf(10, 11, 12, 14, 15))
+    res.mock(R.array.network_switch_type_name, arrayOfNulls<String>(size = 0))
+    res.mock(R.integer.config_networkAvoidBadWifi, 1)
+    res.mock(R.integer.config_activelyPreferBadWifi, 0)
+    res.mock(R.bool.config_cellular_radio_timesharing_capable, true)
+}
+
+private val TEST_LINGER_DELAY_MS = 400
+private val TEST_NASCENT_DELAY_MS = 300
+internal fun makeConnectivityService(context: Context, deps: Dependencies) = ConnectivityService(
+        context,
+        mock<IDnsResolver>(),
+        mock<IpConnectivityLog>(),
+        mock<INetd>(),
+        deps).also {
+    it.mLingerDelayMs = TEST_LINGER_DELAY_MS
+    it.mNascentDelayMs = TEST_NASCENT_DELAY_MS
+}