diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index 8e219a687b2ff98e02ee503dd31fef2d05888caf..abda1fa71f1f180544534c3456b48df33544398a 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -259,6 +259,19 @@ public final class NetworkCapabilities implements Parcelable {
      */
     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.
      *
@@ -741,8 +754,10 @@ public final class NetworkCapabilities implements Parcelable {
 
     /**
      * 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,
@@ -858,6 +873,19 @@ public final class NetworkCapabilities implements Parcelable {
         return this;
     }
 
+    /**
+     * 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.
      *
diff --git a/service/src/com/android/metrics/ConnectivitySampleMetricsHelper.java b/service/src/com/android/metrics/ConnectivitySampleMetricsHelper.java
new file mode 100644
index 0000000000000000000000000000000000000000..93d1d5def1c7b773a3214474b17aff3f17c5108f
--- /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 6770a8fe8c0464e459633e6739c7261e6fba166c..85507f6d16bf9244b6fa9d6764adbda2587c76c2 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_NOT_VPN;
 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.checkAnyPermissionOf;
 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;
 
@@ -235,6 +237,9 @@ import android.os.SystemProperties;
 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;
@@ -247,6 +252,7 @@ import android.util.Pair;
 import android.util.Range;
 import android.util.SparseArray;
 import android.util.SparseIntArray;
+import android.util.StatsEvent;
 
 import androidx.annotation.RequiresApi;
 
@@ -255,6 +261,16 @@ import com.android.internal.annotations.GuardedBy;
 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;
@@ -337,6 +353,7 @@ import java.util.Set;
 import java.util.SortedSet;
 import java.util.StringJoiner;
 import java.util.TreeSet;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -2340,6 +2357,134 @@ public class ConnectivityService extends IConnectivityManager.Stub
         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();
@@ -3453,6 +3598,8 @@ public class ConnectivityService extends IConnectivityManager.Stub
         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 87ae0c9e79e90769d1e4d5ead90ab0fbad9bf301..648f3bf250822ab48044d6f836d748daf1947975 100644
--- a/service/src/com/android/server/connectivity/FullScore.java
+++ b/service/src/com/android/server/connectivity/FullScore.java
@@ -124,7 +124,7 @@ public class FullScore {
             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
@@ -303,6 +303,18 @@ public class FullScore {
         return new FullScore(mPolicies | (1L << POLICY_IS_VALIDATED), mKeepConnectedReason);
     }
 
+    /**
+     * 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.
      */
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 845c04cec3eda0d4647427126ba7324a0ea7016b..bdd841f9ed6ba0fe36589c349ce04b141a2dcb42 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -1105,6 +1105,11 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable {
      *         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 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable {
      * 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 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable {
      * 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 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable {
      * 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/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3043d50cfadc2e5694839ce41f9b1815419eb764
--- /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 3243033d672345a871c68d3d1d4523652b0341ab..2fccdcb9a4254f5ea268ae1b5dd9ff2580d84924 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -755,6 +755,9 @@ public class ConnectivityServiceTest {
             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);
         }