Merge changes I2368f118,I57eb7c6d,I66e4e537,If510519b,Ia441170c into main

* changes:
  Skip testRouterSolicitations on T- devices.
  Remove CtsHostsideNetworkTestAppNext.
  Set all hostside network tests apps' target SDK to 10000.
  Consolidate defaults for hostside network tests apps.
  Stop depending on shims in CtsHostsideNetworkTests.
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index d04660d..70b38a4 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -32,16 +32,19 @@
 
 java_defaults {
     name: "TetheringExternalLibs",
+    defaults: [
+        "TetheringApiLevel",
+    ],
     // Libraries not including Tethering's own framework-tethering (different flavors of that one
     // are needed depending on the build rule)
     libs: [
         "connectivity-internal-api-util",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity.stubs.module_lib",
         "framework-connectivity-t.stubs.module_lib",
         "framework-statsd.stubs.module_lib",
-        "framework-wifi",
-        "framework-bluetooth",
+        "framework-wifi.stubs.module_lib",
+        "framework-bluetooth.stubs.module_lib",
         "unsupportedappusage",
     ],
     defaults_visibility: ["//visibility:private"],
@@ -54,6 +57,7 @@
         "src/**/*.java",
         ":framework-connectivity-shared-srcs",
         ":services-tethering-shared-srcs",
+        ":statslog-connectivity-java-gen",
         ":statslog-tethering-java-gen",
     ],
     static_libs: [
@@ -89,7 +93,6 @@
     defaults: [
         "ConnectivityNextEnableDefaults",
         "TetheringAndroidLibraryDefaults",
-        "TetheringApiLevel",
         "TetheringReleaseTargetSdk",
     ],
     static_libs: [
@@ -105,7 +108,6 @@
     name: "TetheringApiStableLib",
     defaults: [
         "TetheringAndroidLibraryDefaults",
-        "TetheringApiLevel",
         "TetheringReleaseTargetSdk",
     ],
     static_libs: [
@@ -194,7 +196,6 @@
     name: "Tethering",
     defaults: [
         "TetheringAppDefaults",
-        "TetheringApiLevel",
     ],
     static_libs: ["TetheringApiStableLib"],
     certificate: "networkstack",
@@ -208,7 +209,6 @@
     name: "TetheringNext",
     defaults: [
         "TetheringAppDefaults",
-        "TetheringApiLevel",
         "ConnectivityNextEnableDefaults",
     ],
     static_libs: ["TetheringApiCurrentLib"],
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 3b197fc..0c05354 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -98,7 +98,6 @@
     ],
     canned_fs_config: "canned_fs_config",
     bpfs: [
-        "block.o",
         "clatd.o",
         "dscpPolicy.o",
         "netd.o",
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 6e00756..2f3307a 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -29,6 +29,7 @@
         "//packages/modules/Connectivity/framework-t",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service-t",
+        "//packages/modules/Connectivity/staticlibs",
 
         // Using for test only
         "//cts/tests/netlegacy22.api",
@@ -46,6 +47,7 @@
         "//packages/modules/Connectivity/tests:__subpackages__",
         "//packages/modules/Connectivity/thread/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
+        "//packages/modules/NetworkStack",
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
     ],
diff --git a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
index 5c853f4..89e06da 100644
--- a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
+++ b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
@@ -33,10 +33,12 @@
 import static com.android.networkstack.tethering.BpfUtils.DOWNSTREAM;
 import static com.android.networkstack.tethering.BpfUtils.UPSTREAM;
 import static com.android.networkstack.tethering.TetheringConfiguration.DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_ACTIVE_SESSIONS_METRICS;
 import static com.android.networkstack.tethering.UpstreamNetworkState.isVcnInterface;
 import static com.android.networkstack.tethering.util.TetheringUtils.getTetheringJniLibraryName;
 
 import android.app.usage.NetworkStatsManager;
+import android.content.Context;
 import android.net.INetd;
 import android.net.IpPrefix;
 import android.net.LinkProperties;
@@ -65,6 +67,7 @@
 import com.android.net.module.util.BpfDump;
 import com.android.net.module.util.BpfMap;
 import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.IBpfMap;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetworkStackConstants;
@@ -84,6 +87,7 @@
 import com.android.net.module.util.netlink.NetlinkUtils;
 import com.android.networkstack.tethering.apishim.common.BpfCoordinatorShim;
 import com.android.networkstack.tethering.util.TetheringUtils.ForwardedStats;
+import com.android.server.ConnectivityStatsLog;
 
 import java.io.IOException;
 import java.net.Inet4Address;
@@ -148,6 +152,13 @@
 
     @VisibleForTesting
     static final int CONNTRACK_TIMEOUT_UPDATE_INTERVAL_MS = 60_000;
+    // The interval is set to 5 minutes to strike a balance between minimizing
+    // the amount of metrics data uploaded and providing sufficient resolution
+    // to track changes in forwarding rules. This choice considers the minimum
+    // push metrics sampling interval of 5 minutes and the 3-minute timeout
+    // for forwarding rules.
+    @VisibleForTesting
+    static final int CONNTRACK_METRICS_UPDATE_INTERVAL_MS = 300_000;
     @VisibleForTesting
     static final int NF_CONNTRACK_TCP_TIMEOUT_ESTABLISHED = 432_000;
     @VisibleForTesting
@@ -314,12 +325,23 @@
         scheduleConntrackTimeoutUpdate();
     };
 
+    private final boolean mSupportActiveSessionsMetrics;
+
+    // Runnable that used by scheduling next refreshing of conntrack metrics sampling.
+    private final Runnable mScheduledConntrackMetricsSampling = () -> {
+        uploadConntrackMetricsSample();
+        scheduleConntrackMetricsSampling();
+    };
+
     // TODO: add BpfMap<TetherDownstream64Key, TetherDownstream64Value> retrieving function.
     @VisibleForTesting
     public abstract static class Dependencies {
         /** Get handler. */
         @NonNull public abstract Handler getHandler();
 
+        /** Get context. */
+        @NonNull public abstract Context getContext();
+
         /** Get netd. */
         @NonNull public abstract INetd getNetd();
 
@@ -472,6 +494,19 @@
                 return null;
             }
         }
+
+        /** Send a TetheringActiveSessionsReported event. */
+        public void sendTetheringActiveSessionsReported(int lastMaxSessionCount) {
+            ConnectivityStatsLog.write(ConnectivityStatsLog.TETHERING_ACTIVE_SESSIONS_REPORTED,
+                    lastMaxSessionCount);
+        }
+
+        /**
+         * @see DeviceConfigUtils#isTetheringFeatureEnabled
+         */
+        public boolean isFeatureEnabled(Context context, String name) {
+            return DeviceConfigUtils.isTetheringFeatureEnabled(context, name);
+        }
     }
 
     @VisibleForTesting
@@ -508,32 +543,57 @@
         if (!mBpfCoordinatorShim.isInitialized()) {
             mLog.e("Bpf shim not initialized");
         }
+
+        // BPF IPv4 forwarding only supports on S+.
+        mSupportActiveSessionsMetrics = mDeps.isAtLeastS()
+                && mDeps.isFeatureEnabled(mDeps.getContext(), TETHER_ACTIVE_SESSIONS_METRICS);
     }
 
     /**
-     * Start BPF tethering offload stats and conntrack timeout polling.
+     * Start BPF tethering offload stats and conntrack polling.
      * Note that this can be only called on handler thread.
      */
-    private void startStatsAndConntrackTimeoutPolling() {
+    private void startStatsAndConntrackPolling() {
         schedulePollingStats();
         scheduleConntrackTimeoutUpdate();
+        if (mSupportActiveSessionsMetrics) {
+            scheduleConntrackMetricsSampling();
+        }
 
         mLog.i("Polling started.");
     }
 
     /**
-     * Stop BPF tethering offload stats and conntrack timeout polling.
+     * Stop BPF tethering offload stats and conntrack polling.
      * The data limit cleanup and the tether stats maps cleanup are not implemented here.
      * These cleanups rely on all IpServers calling #removeIpv6DownstreamRule. After the
      * last rule is removed from the upstream, #removeIpv6DownstreamRule does the cleanup
      * functionality.
      * Note that this can be only called on handler thread.
      */
-    private void stopStatsAndConntrackTimeoutPolling() {
+    private void stopStatsAndConntrackPolling() {
         // Stop scheduled polling conntrack timeout.
         if (mHandler.hasCallbacks(mScheduledConntrackTimeoutUpdate)) {
             mHandler.removeCallbacks(mScheduledConntrackTimeoutUpdate);
         }
+        // Stop scheduled polling conntrack metrics sampling and
+        // clear counters in case there is any counter unsync problem
+        // previously due to possible bpf failures.
+        // Normally this won't happen because all clients are cleared before
+        // reaching here. See IpServer.BaseServingState#exit().
+        if (mSupportActiveSessionsMetrics) {
+            if (mHandler.hasCallbacks(mScheduledConntrackMetricsSampling)) {
+                mHandler.removeCallbacks(mScheduledConntrackMetricsSampling);
+            }
+            final int currentCount = mBpfConntrackEventConsumer.getCurrentConnectionCount();
+            if (currentCount != 0) {
+                Log.wtf(TAG, "Unexpected CurrentConnectionCount: " + currentCount);
+            }
+            // Avoid sending metrics when tethering is about to close.
+            // This leads to a missing final sample before disconnect
+            // but avoids possibly duplicating the last metric in the upload.
+            mBpfConntrackEventConsumer.clearConnectionCounters();
+        }
         // Stop scheduled polling stats and poll the latest stats from BPF maps.
         if (mHandler.hasCallbacks(mScheduledPollingStats)) {
             mHandler.removeCallbacks(mScheduledPollingStats);
@@ -867,7 +927,7 @@
 
         // Start monitoring and polling when the first IpServer is added.
         if (mServedIpServers.isEmpty()) {
-            startStatsAndConntrackTimeoutPolling();
+            startStatsAndConntrackPolling();
             startConntrackMonitoring();
             mIpNeighborMonitor.start();
             mLog.i("Neighbor monitoring started.");
@@ -890,7 +950,7 @@
 
         // Stop monitoring and polling when the last IpServer is removed.
         if (mServedIpServers.isEmpty()) {
-            stopStatsAndConntrackTimeoutPolling();
+            stopStatsAndConntrackPolling();
             stopConntrackMonitoring();
             mIpNeighborMonitor.stop();
             mLog.i("Neighbor monitoring stopped.");
@@ -1031,6 +1091,10 @@
         for (final Tether4Key k : deleteDownstreamRuleKeys) {
             mBpfCoordinatorShim.tetherOffloadRuleRemove(DOWNSTREAM, k);
         }
+        if (mSupportActiveSessionsMetrics) {
+            mBpfConntrackEventConsumer.decreaseCurrentConnectionCount(
+                    deleteUpstreamRuleKeys.size());
+        }
 
         // Cleanup each upstream interface by a set which avoids duplicated work on the same
         // upstream interface. Cleaning up the same interface twice (or more) here may raise
@@ -1300,6 +1364,13 @@
         pw.increaseIndent();
         dumpCounters(pw);
         pw.decreaseIndent();
+
+        pw.println();
+        pw.println("mSupportActiveSessionsMetrics: " + mSupportActiveSessionsMetrics);
+        pw.println("getLastMaxConnectionCount: "
+                + mBpfConntrackEventConsumer.getLastMaxConnectionCount());
+        pw.println("getCurrentConnectionCount: "
+                + mBpfConntrackEventConsumer.getCurrentConnectionCount());
     }
 
     private void dumpStats(@NonNull IndentingPrintWriter pw) {
@@ -1991,6 +2062,21 @@
     // while TCP status is established.
     @VisibleForTesting
     class BpfConntrackEventConsumer implements ConntrackEventConsumer {
+        /**
+         * Tracks the current number of tethering connections and the maximum
+         * observed since the last metrics collection. Used to provide insights
+         * into the distribution of active tethering sessions for metrics reporting.
+
+         * These variables are accessed on the handler thread, which includes:
+         *  1. ConntrackEvents signaling the addition or removal of an IPv4 rule.
+         *  2. ConntrackEvents indicating the removal of a tethering client,
+         *     triggering the removal of associated rules.
+         *  3. Removal of the last IpServer, which resets counters to handle
+         *     potential synchronization issues.
+         */
+        private int mLastMaxConnectionCount = 0;
+        private int mCurrentConnectionCount = 0;
+
         // The upstream4 and downstream4 rules are built as the following tables. Only raw ip
         // upstream interface is supported. Note that the field "lastUsed" is only updated by
         // BPF program which records the last used time for a given rule.
@@ -2124,6 +2210,10 @@
                     return;
                 }
 
+                if (mSupportActiveSessionsMetrics) {
+                    decreaseCurrentConnectionCount(1);
+                }
+
                 maybeClearLimit(upstreamIndex);
                 return;
             }
@@ -2136,8 +2226,50 @@
 
             maybeAddDevMap(upstreamIndex, tetherClient.downstreamIfindex);
             maybeSetLimit(upstreamIndex);
-            mBpfCoordinatorShim.tetherOffloadRuleAdd(UPSTREAM, upstream4Key, upstream4Value);
-            mBpfCoordinatorShim.tetherOffloadRuleAdd(DOWNSTREAM, downstream4Key, downstream4Value);
+
+            final boolean addedUpstream = mBpfCoordinatorShim.tetherOffloadRuleAdd(
+                    UPSTREAM, upstream4Key, upstream4Value);
+            final boolean addedDownstream = mBpfCoordinatorShim.tetherOffloadRuleAdd(
+                    DOWNSTREAM, downstream4Key, downstream4Value);
+            if (addedUpstream != addedDownstream) {
+                Log.wtf(TAG, "The bidirectional rules should be added concurrently ("
+                        + "upstream: " + addedUpstream
+                        + ", downstream: " + addedDownstream + ")");
+                return;
+            }
+            if (mSupportActiveSessionsMetrics && addedUpstream && addedDownstream) {
+                mCurrentConnectionCount++;
+                mLastMaxConnectionCount = Math.max(mCurrentConnectionCount,
+                        mLastMaxConnectionCount);
+            }
+        }
+
+        public int getLastMaxConnectionAndResetToCurrent() {
+            final int ret = mLastMaxConnectionCount;
+            mLastMaxConnectionCount = mCurrentConnectionCount;
+            return ret;
+        }
+
+        /** For dumping current state only. */
+        public int getLastMaxConnectionCount() {
+            return mLastMaxConnectionCount;
+        }
+
+        public int getCurrentConnectionCount() {
+            return mCurrentConnectionCount;
+        }
+
+        public void decreaseCurrentConnectionCount(int count) {
+            mCurrentConnectionCount -= count;
+            if (mCurrentConnectionCount < 0) {
+                Log.wtf(TAG, "Unexpected mCurrentConnectionCount: "
+                        + mCurrentConnectionCount);
+            }
+        }
+
+        public void clearConnectionCounters() {
+            mCurrentConnectionCount = 0;
+            mLastMaxConnectionCount = 0;
         }
     }
 
@@ -2477,6 +2609,11 @@
         });
     }
 
+    private void uploadConntrackMetricsSample() {
+        mDeps.sendTetheringActiveSessionsReported(
+                mBpfConntrackEventConsumer.getLastMaxConnectionAndResetToCurrent());
+    }
+
     private void schedulePollingStats() {
         if (mHandler.hasCallbacks(mScheduledPollingStats)) {
             mHandler.removeCallbacks(mScheduledPollingStats);
@@ -2494,6 +2631,15 @@
                 CONNTRACK_TIMEOUT_UPDATE_INTERVAL_MS);
     }
 
+    private void scheduleConntrackMetricsSampling() {
+        if (mHandler.hasCallbacks(mScheduledConntrackMetricsSampling)) {
+            mHandler.removeCallbacks(mScheduledConntrackMetricsSampling);
+        }
+
+        mHandler.postDelayed(mScheduledConntrackMetricsSampling,
+                CONNTRACK_METRICS_UPDATE_INTERVAL_MS);
+    }
+
     // Return IPv6 downstream forwarding rule map. This is used for testing only.
     // Note that this can be only called on handler thread.
     @NonNull
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 13f4f2a..1938a08 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -374,6 +374,11 @@
                     }
 
                     @NonNull
+                    public Context getContext() {
+                        return mContext;
+                    }
+
+                    @NonNull
                     public INetd getNetd() {
                         return mNetd;
                     }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index 298940e..c9817c9 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -144,6 +144,12 @@
     /** A flag for using synchronous or asynchronous state machine. */
     public static boolean USE_SYNC_SM = false;
 
+    /**
+     * A feature flag to control whether the active sessions metrics should be enabled.
+     * Disabled by default.
+     */
+    public static final String TETHER_ACTIVE_SESSIONS_METRICS = "tether_active_sessions_metrics";
+
     public final String[] tetherableUsbRegexs;
     public final String[] tetherableWifiRegexs;
     public final String[] tetherableWigigRegexs;
diff --git a/Tethering/tests/integration/Android.bp b/Tethering/tests/integration/Android.bp
index 337d408..2211546 100644
--- a/Tethering/tests/integration/Android.bp
+++ b/Tethering/tests/integration/Android.bp
@@ -38,9 +38,9 @@
         "connectivity-net-module-utils-bpf",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
-        "android.test.mock",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
+        "android.test.mock.stubs",
     ],
 }
 
diff --git a/Tethering/tests/mts/Android.bp b/Tethering/tests/mts/Android.bp
index c4d5636..1f1929c 100644
--- a/Tethering/tests/mts/Android.bp
+++ b/Tethering/tests/mts/Android.bp
@@ -26,7 +26,7 @@
     target_sdk_version: "33",
 
     libs: [
-        "android.test.base",
+        "android.test.base.stubs",
     ],
 
     srcs: [
diff --git a/Tethering/tests/unit/Android.bp b/Tethering/tests/unit/Android.bp
index 24407ca..d0d23ac 100644
--- a/Tethering/tests/unit/Android.bp
+++ b/Tethering/tests/unit/Android.bp
@@ -62,9 +62,9 @@
     // remove framework-minus-apex, ext, and framework-res
     sdk_version: "core_platform",
     libs: [
-        "android.test.runner",
-        "android.test.base",
-        "android.test.mock",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
+        "android.test.mock.stubs",
         "ext",
         "framework-minus-apex",
         "framework-res",
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
index e54a7e0..5d22977 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
@@ -48,6 +48,7 @@
 import static com.android.net.module.util.netlink.StructNdMsg.NUD_FAILED;
 import static com.android.net.module.util.netlink.StructNdMsg.NUD_REACHABLE;
 import static com.android.net.module.util.netlink.StructNdMsg.NUD_STALE;
+import static com.android.networkstack.tethering.BpfCoordinator.CONNTRACK_METRICS_UPDATE_INTERVAL_MS;
 import static com.android.networkstack.tethering.BpfCoordinator.CONNTRACK_TIMEOUT_UPDATE_INTERVAL_MS;
 import static com.android.networkstack.tethering.BpfCoordinator.INVALID_MTU;
 import static com.android.networkstack.tethering.BpfCoordinator.NF_CONNTRACK_TCP_TIMEOUT_ESTABLISHED;
@@ -60,6 +61,7 @@
 import static com.android.networkstack.tethering.BpfUtils.DOWNSTREAM;
 import static com.android.networkstack.tethering.BpfUtils.UPSTREAM;
 import static com.android.networkstack.tethering.TetheringConfiguration.DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_ACTIVE_SESSIONS_METRICS;
 import static com.android.testutils.MiscAsserts.assertSameElements;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -87,6 +89,7 @@
 import static org.mockito.Mockito.when;
 
 import android.app.usage.NetworkStatsManager;
+import android.content.Context;
 import android.net.INetd;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
@@ -140,6 +143,8 @@
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.TestBpfMap;
 import com.android.testutils.TestableNetworkStatsProviderCbBinder;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule.FeatureFlag;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -171,6 +176,16 @@
     @Rule
     public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
 
+    final HashMap<String, Boolean> mFeatureFlags = new HashMap<>();
+    // This will set feature flags from @FeatureFlag annotations
+    // into the map before setUp() runs.
+    @Rule
+    public final SetFeatureFlagsRule mSetFeatureFlagsRule =
+            new SetFeatureFlagsRule((name, enabled) -> {
+                mFeatureFlags.put(name, enabled);
+                return null;
+            }, (name) -> mFeatureFlags.getOrDefault(name, false));
+
     private static final boolean IPV4 = true;
     private static final boolean IPV6 = false;
 
@@ -406,6 +421,11 @@
                 return this;
             }
 
+            public Builder setPrivateAddress(Inet4Address privateAddr) {
+                mPrivateAddr = privateAddr;
+                return this;
+            }
+
             public Builder setRemotePort(int remotePort) {
                 mRemotePort = (short) remotePort;
                 return this;
@@ -429,6 +449,7 @@
 
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private INetd mNetd;
+    @Mock private Context mMockContext;
     @Mock private IpServer mIpServer;
     @Mock private IpServer mIpServer2;
     @Mock private TetheringConfiguration mTetherConfig;
@@ -475,6 +496,11 @@
                     }
 
                     @NonNull
+                    public Context getContext() {
+                        return mMockContext;
+                    }
+
+                    @NonNull
                     public INetd getNetd() {
                         return mNetd;
                     }
@@ -546,6 +572,16 @@
                     public IBpfMap<S32, S32> getBpfErrorMap() {
                         return mBpfErrorMap;
                     }
+
+                    @Override
+                    public void sendTetheringActiveSessionsReported(int lastMaxSessionCount) {
+                        // No-op.
+                    }
+
+                    @Override
+                    public boolean isFeatureEnabled(Context context, String name) {
+                        return mFeatureFlags.getOrDefault(name, false);
+                    }
             });
 
     @Before public void setUp() {
@@ -1977,6 +2013,229 @@
         verify(mBpfDevMap, never()).updateEntry(any(), any());
     }
 
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
+    // BPF IPv4 forwarding only supports on S+.
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    @Test
+    public void testMaxConnectionCount_metricsEnabled() throws Exception {
+        doTestMaxConnectionCount(true);
+    }
+
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS, enabled = false)
+    @Test
+    public void testMaxConnectionCount_metricsDisabled() throws Exception {
+        doTestMaxConnectionCount(false);
+    }
+
+    private void doTestMaxConnectionCount(final boolean supportActiveSessionsMetrics)
+            throws Exception {
+        final BpfCoordinator coordinator = makeBpfCoordinator();
+        initBpfCoordinatorForRule4(coordinator);
+        resetNetdAndBpfMaps();
+        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+
+        // Prepare add/delete rule events.
+        final ArrayList<ConntrackEvent> addRuleEvents = new ArrayList<>();
+        final ArrayList<ConntrackEvent> delRuleEvents = new ArrayList<>();
+        for (int i = 0; i < 5; i++) {
+            final ConntrackEvent addEvent = new TestConntrackEvent.Builder().setMsgType(
+                    IPCTNL_MSG_CT_NEW).setProto(IPPROTO_TCP).setRemotePort(i).build();
+            addRuleEvents.add(addEvent);
+            final ConntrackEvent delEvent = new TestConntrackEvent.Builder().setMsgType(
+                    IPCTNL_MSG_CT_DELETE).setProto(IPPROTO_TCP).setRemotePort(i).build();
+            delRuleEvents.add(delEvent);
+        }
+
+        // Add rules, verify counter increases.
+        for (int i = 0; i < 5; i++) {
+            mConsumer.accept(addRuleEvents.get(i));
+            assertConsumerCountersEquals(supportActiveSessionsMetrics ? i + 1 : 0);
+        }
+
+        // Add the same events again should not increase the counter because
+        // all events are already exist.
+        for (final ConntrackEvent event : addRuleEvents) {
+            mConsumer.accept(event);
+            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+        }
+
+        // Verify removing non-existent items won't change the counters.
+        for (int i = 5; i < 8; i++) {
+            mConsumer.accept(new TestConntrackEvent.Builder().setMsgType(
+                    IPCTNL_MSG_CT_DELETE).setProto(IPPROTO_TCP).setRemotePort(i).build());
+            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+        }
+
+        // Verify remove the rules decrease the counter.
+        // Note the max counter returns the max, so it returns the count before deleting.
+        for (int i = 0; i < 5; i++) {
+            mConsumer.accept(delRuleEvents.get(i));
+            assertEquals(supportActiveSessionsMetrics ? 4 - i : 0,
+                    mConsumer.getCurrentConnectionCount());
+            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
+                    mConsumer.getLastMaxConnectionCount());
+            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
+                    mConsumer.getLastMaxConnectionAndResetToCurrent());
+        }
+
+        // Verify remove these rules again doesn't decrease the counter.
+        for (int i = 0; i < 5; i++) {
+            mConsumer.accept(delRuleEvents.get(i));
+            assertConsumerCountersEquals(0);
+        }
+    }
+
+    // Helper method to assert all counter values inside consumer.
+    private void assertConsumerCountersEquals(int expectedCount) {
+        assertEquals(expectedCount, mConsumer.getCurrentConnectionCount());
+        assertEquals(expectedCount, mConsumer.getLastMaxConnectionCount());
+        assertEquals(expectedCount, mConsumer.getLastMaxConnectionAndResetToCurrent());
+    }
+
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
+    // BPF IPv4 forwarding only supports on S+.
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    @Test
+    public void doTestMaxConnectionCount_removeClient_metricsEnabled() throws Exception {
+        doTestMaxConnectionCount_removeClient(true);
+    }
+
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS, enabled = false)
+    @Test
+    public void doTestMaxConnectionCount_removeClient_metricsDisabled() throws Exception {
+        doTestMaxConnectionCount_removeClient(false);
+    }
+
+    private void doTestMaxConnectionCount_removeClient(final boolean supportActiveSessionsMetrics)
+            throws Exception {
+        final BpfCoordinator coordinator = makeBpfCoordinator();
+        initBpfCoordinatorForRule4(coordinator);
+        resetNetdAndBpfMaps();
+
+        // Add client information A and B on on the same downstream.
+        final ClientInfo clientA = new ClientInfo(DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC,
+                PRIVATE_ADDR, MAC_A);
+        final ClientInfo clientB = new ClientInfo(DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC,
+                PRIVATE_ADDR2, MAC_B);
+        coordinator.tetherOffloadClientAdd(mIpServer, clientA);
+        coordinator.tetherOffloadClientAdd(mIpServer, clientB);
+        assertClientInfoExists(mIpServer, clientA);
+        assertClientInfoExists(mIpServer, clientB);
+        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+
+        // Add some rules for both clients.
+        final int addr1RuleCount = 5;
+        final int addr2RuleCount = 3;
+
+        for (int i = 0; i < addr1RuleCount; i++) {
+            mConsumer.accept(new TestConntrackEvent.Builder()
+                    .setMsgType(IPCTNL_MSG_CT_NEW)
+                    .setProto(IPPROTO_TCP)
+                    .setRemotePort(i)
+                    .setPrivateAddress(PRIVATE_ADDR)
+                    .build());
+        }
+
+        for (int i = addr1RuleCount; i < addr1RuleCount + addr2RuleCount; i++) {
+            mConsumer.accept(new TestConntrackEvent.Builder()
+                    .setMsgType(IPCTNL_MSG_CT_NEW)
+                    .setProto(IPPROTO_TCP)
+                    .setRemotePort(i)
+                    .setPrivateAddress(PRIVATE_ADDR2)
+                    .build());
+        }
+
+        assertConsumerCountersEquals(
+                supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0);
+
+        // Remove 1 client. Since the 1st poll will return the LastMaxCounter and
+        // update it to the current, the max counter will be kept at 1st poll, while
+        // the current counter reflect the rule decreasing.
+        coordinator.tetherOffloadClientRemove(mIpServer, clientA);
+        assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
+                mConsumer.getCurrentConnectionCount());
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                mConsumer.getLastMaxConnectionCount());
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                mConsumer.getLastMaxConnectionAndResetToCurrent());
+        // And all counters be updated at 2nd poll.
+        assertConsumerCountersEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0);
+
+        // Remove other client.
+        coordinator.tetherOffloadClientRemove(mIpServer, clientB);
+        assertEquals(0, mConsumer.getCurrentConnectionCount());
+        assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
+                mConsumer.getLastMaxConnectionCount());
+        assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
+                mConsumer.getLastMaxConnectionAndResetToCurrent());
+        // All counters reach zero at 2nd poll.
+        assertConsumerCountersEquals(0);
+    }
+
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
+    // BPF IPv4 forwarding only supports on S+.
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    @Test
+    public void testSendActiveSessionsReported_metricsEnabled() throws Exception {
+        doTestSendActiveSessionsReported(true);
+    }
+
+    @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS, enabled = false)
+    @Test
+    public void testSendActiveSessionsReported_metricsDisabled() throws Exception {
+        doTestSendActiveSessionsReported(false);
+    }
+
+    private void doTestSendActiveSessionsReported(final boolean supportActiveSessionsMetrics)
+            throws Exception {
+        final BpfCoordinator coordinator = makeBpfCoordinator();
+        initBpfCoordinatorForRule4(coordinator);
+        resetNetdAndBpfMaps();
+        assertConsumerCountersEquals(0);
+
+        // Prepare the counter value.
+        for (int i = 0; i < 5; i++) {
+            mConsumer.accept(new TestConntrackEvent.Builder().setMsgType(
+                    IPCTNL_MSG_CT_NEW).setProto(IPPROTO_TCP).setRemotePort(i).build());
+        }
+
+        // Then delete some 3 rules, 2 rules remaining.
+        // The max count is 5 while current rules count is 2.
+        for (int i = 0; i < 3; i++) {
+            mConsumer.accept(new TestConntrackEvent.Builder().setMsgType(
+                    IPCTNL_MSG_CT_DELETE).setProto(IPPROTO_TCP).setRemotePort(i).build());
+        }
+
+        // Verify the method is not invoked when timer is not expired.
+        waitForIdle();
+        verify(mDeps, never()).sendTetheringActiveSessionsReported(anyInt());
+
+        // Verify metrics will be sent upon timer expiry.
+        mTestLooper.moveTimeForward(CONNTRACK_METRICS_UPDATE_INTERVAL_MS);
+        waitForIdle();
+        if (supportActiveSessionsMetrics) {
+            verify(mDeps).sendTetheringActiveSessionsReported(5);
+        } else {
+            verify(mDeps, never()).sendTetheringActiveSessionsReported(anyInt());
+        }
+
+        // Verify next uploaded metrics will reflect the decreased rules count.
+        mTestLooper.moveTimeForward(CONNTRACK_METRICS_UPDATE_INTERVAL_MS);
+        waitForIdle();
+        if (supportActiveSessionsMetrics) {
+            verify(mDeps).sendTetheringActiveSessionsReported(2);
+        } else {
+            verify(mDeps, never()).sendTetheringActiveSessionsReported(anyInt());
+        }
+
+        // Verify no metrics uploaded if polling stopped.
+        clearInvocations(mDeps);
+        coordinator.removeIpServer(mIpServer);
+        mTestLooper.moveTimeForward(CONNTRACK_TIMEOUT_UPDATE_INTERVAL_MS);
+        waitForIdle();
+        verify(mDeps, never()).sendTetheringActiveSessionsReported(anyInt());
+    }
+
     private void setElapsedRealtimeNanos(long nanoSec) {
         mElapsedRealtimeNanos = nanoSec;
     }
diff --git a/bpf/headers/Android.bp b/bpf/headers/Android.bp
index d55584a..aaf8d8d 100644
--- a/bpf/headers/Android.bp
+++ b/bpf/headers/Android.bp
@@ -48,11 +48,10 @@
         "BpfMapTest.cpp",
         "BpfRingbufTest.cpp",
     ],
-    defaults: ["bpf_defaults"],
+    defaults: ["bpf_cc_defaults"],
     cflags: [
-        "-Wall",
-        "-Werror",
-        "-Wno-error=unused-variable",
+        "-Wno-unused-variable",
+        "-Wno-sign-compare",
     ],
     header_libs: ["bpf_headers"],
     static_libs: ["libgmock"],
diff --git a/bpf/loader/Android.bp b/bpf/loader/Android.bp
index b8c0ce7..b08913a 100644
--- a/bpf/loader/Android.bp
+++ b/bpf/loader/Android.bp
@@ -33,12 +33,7 @@
 cc_binary {
     name: "netbpfload",
 
-    defaults: ["bpf_defaults"],
-    cflags: [
-        "-Wall",
-        "-Werror",
-        "-Wthread-safety",
-    ],
+    defaults: ["bpf_cc_defaults"],
     sanitize: {
         integer_overflow: true,
     },
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 69f1cb5..f369458 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -215,7 +215,7 @@
  * is the name of the program, and tracepoint is the type.
  *
  * However, be aware that you should not be directly using the SECTION() macro.
- * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE/CRITICAL macros.
+ * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE macros.
  *
  * Programs shipped inside the tethering apex should be limited to networking stuff,
  * as KPROBE, PERF_EVENT, TRACEPOINT are dangerous to use from mainline updatable code,
@@ -1105,30 +1105,22 @@
     return 0;
 }
 
-int loadProg(const char* const elfPath, bool* const isCritical, const unsigned int bpfloader_ver,
+int loadProg(const char* const elfPath, const unsigned int bpfloader_ver,
              const char* const prefix) {
     vector<char> license;
-    vector<char> critical;
     vector<codeSection> cs;
     vector<unique_fd> mapFds;
     int ret;
 
-    if (!isCritical) return -1;
-    *isCritical = false;
-
     ifstream elfFile(elfPath, ios::in | ios::binary);
     if (!elfFile.is_open()) return -1;
 
-    ret = readSectionByName("critical", elfFile, critical);
-    *isCritical = !ret;
-
     ret = readSectionByName("license", elfFile, license);
     if (ret) {
         ALOGE("Couldn't find license in %s", elfPath);
         return ret;
     } else {
-        ALOGD("Loading %s%s ELF object %s with license %s",
-              *isCritical ? "critical for " : "optional", *isCritical ? (char*)critical.data() : "",
+        ALOGD("Loading ELF object %s with license %s",
               elfPath, (char*)license.data());
     }
 
@@ -1230,10 +1222,9 @@
             string progPath(location.dir);
             progPath += s;
 
-            bool critical;
-            int ret = loadProg(progPath.c_str(), &critical, bpfloader_ver, location.prefix);
+            int ret = loadProg(progPath.c_str(), bpfloader_ver, location.prefix);
             if (ret) {
-                if (critical) retVal = ret;
+                retVal = ret;
                 ALOGE("Failed to load object: %s, ret: %s", progPath.c_str(), std::strerror(-ret));
             } else {
                 ALOGD("Loaded object: %s", progPath.c_str());
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 9682545..5dea851 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -114,6 +114,11 @@
                                     cg_fd, BPF_CGROUP_INET_SOCK_CREATE));
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
+                                    cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
                                     cg_fd, BPF_CGROUP_INET4_CONNECT));
@@ -134,19 +139,12 @@
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_SETSOCKOPT_PROG_PATH,
                                         cg_fd, BPF_CGROUP_SETSOCKOPT));
         }
-
-        if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
-                                        cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
-        }
     }
 
     if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind4_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND4_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET4_BIND));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind6_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND6_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET6_BIND));
 
         // This should trivially pass, since we just attached up above,
@@ -158,6 +156,10 @@
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
@@ -170,10 +172,6 @@
             if (bpf::queryProgram(cg_fd, BPF_CGROUP_GETSOCKOPT) <= 0) abort();
             if (bpf::queryProgram(cg_fd, BPF_CGROUP_SETSOCKOPT) <= 0) abort();
         }
-
-        if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
-        }
     }
 
     return netdutils::status::ok;
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index dc1f56d..52eb1b3 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -64,12 +64,6 @@
 // bpf kernel programs
 //
 bpf {
-    name: "block.o",
-    srcs: ["block.c"],
-    sub_dir: "net_shared",
-}
-
-bpf {
     name: "dscpPolicy.o",
     srcs: ["dscpPolicy.c"],
     sub_dir: "net_shared",
diff --git a/bpf/progs/block.c b/bpf/progs/block.c
deleted file mode 100644
index 0e2dba9..0000000
--- a/bpf/progs/block.c
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright (C) 2022 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.
- */
-
-// The resulting .o needs to load on Android T+
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-
-#include "bpf_net_helpers.h"
-
-DEFINE_BPF_MAP_GRW(blocked_ports_map, ARRAY, int, uint64_t,
-        1024 /* 64K ports -> 1024 u64s */, AID_SYSTEM)
-
-static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
-    if (!ctx->user_port) return BPF_ALLOW;
-
-    switch (ctx->protocol) {
-        case IPPROTO_TCP:
-        case IPPROTO_MPTCP:
-        case IPPROTO_UDP:
-        case IPPROTO_UDPLITE:
-        case IPPROTO_DCCP:
-        case IPPROTO_SCTP:
-            break;
-        default:
-            return BPF_ALLOW; // unknown protocols are allowed
-    }
-
-    int key = ctx->user_port >> 6;
-    int shift = ctx->user_port & 63;
-
-    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
-    // Lookup should never fail in reality, but if it does return here to keep the
-    // BPF verifier happy.
-    if (!val) return BPF_ALLOW;
-
-    if ((*val >> shift) & 1) return BPF_DISALLOW;
-    return BPF_ALLOW;
-}
-
-// the program need to be accessible/loadable by netd (from netd updatable plugin)
-#define DEFINE_NETD_RO_BPF_PROG(SECTION_NAME, the_prog, min_kver) \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog, min_kver, KVER_INF,  \
-                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
-                        "", "netd_readonly/", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
-
-DEFINE_NETD_RO_BPF_PROG("bind4/block_port", bind4_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-DEFINE_NETD_RO_BPF_PROG("bind6/block_port", bind6_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-LICENSE("Apache 2.0");
-CRITICAL("ConnectivityNative");
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index 4248a46..cbe856d 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -69,6 +69,8 @@
 // TODO: consider whether we can merge some of these maps
 // for example it might be possible to merge 2 or 3 of:
 //   uid_counterset_map + uid_owner_map + uid_permission_map
+DEFINE_BPF_MAP_NO_NETD(blocked_ports_map, ARRAY, int, uint64_t,
+                       1024 /* 64K ports -> 1024 u64s */)
 DEFINE_BPF_MAP_RW_NETD(cookie_tag_map, HASH, uint64_t, UidTagValue, COOKIE_UID_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(uid_counterset_map, HASH, uint32_t, uint8_t, UID_COUNTERSET_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(app_uid_stats_map, HASH, uint32_t, StatsValue, APP_STATS_MAP_SIZE)
@@ -643,8 +645,8 @@
     return (get_app_permissions() & BPF_PERMISSION_INTERNET) ? BPF_ALLOW : BPF_DISALLOW;
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
-                            inet_socket_release, KVER_5_10)
+DEFINE_NETD_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
+                          inet_socket_release, KVER_5_10)
 (struct bpf_sock* sk) {
     uint64_t cookie = bpf_get_sk_cookie(sk);
     if (cookie) bpf_cookie_tag_map_delete_elem(&cookie);
@@ -670,6 +672,43 @@
     return BPF_ALLOW;
 }
 
+static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
+    if (!ctx->user_port) return BPF_ALLOW;
+
+    switch (ctx->protocol) {
+        case IPPROTO_TCP:
+        case IPPROTO_MPTCP:
+        case IPPROTO_UDP:
+        case IPPROTO_UDPLITE:
+        case IPPROTO_DCCP:
+        case IPPROTO_SCTP:
+            break;
+        default:
+            return BPF_ALLOW; // unknown protocols are allowed
+    }
+
+    int key = ctx->user_port >> 6;
+    int shift = ctx->user_port & 63;
+
+    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
+    // Lookup should never fail in reality, but if it does return here to keep the
+    // BPF verifier happy.
+    if (!val) return BPF_ALLOW;
+
+    if ((*val >> shift) & 1) return BPF_DISALLOW;
+    return BPF_ALLOW;
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind4/inet4_bind", AID_ROOT, AID_ROOT, inet4_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind6/inet6_bind", AID_ROOT, AID_ROOT, inet6_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
 DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_14)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
diff --git a/bpf/progs/netd.h b/bpf/progs/netd.h
index 4877a4b..be7c311 100644
--- a/bpf/progs/netd.h
+++ b/bpf/progs/netd.h
@@ -157,6 +157,8 @@
 
 #define CGROUP_INET_CREATE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsock_inet_create"
 #define CGROUP_INET_RELEASE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsockrelease_inet_release"
+#define CGROUP_BIND4_PROG_PATH BPF_NETD_PATH "prog_netd_bind4_inet4_bind"
+#define CGROUP_BIND6_PROG_PATH BPF_NETD_PATH "prog_netd_bind6_inet6_bind"
 #define CGROUP_CONNECT4_PROG_PATH BPF_NETD_PATH "prog_netd_connect4_inet4_connect"
 #define CGROUP_CONNECT6_PROG_PATH BPF_NETD_PATH "prog_netd_connect6_inet6_connect"
 #define CGROUP_UDP4_RECVMSG_PROG_PATH BPF_NETD_PATH "prog_netd_recvmsg4_udp4_recvmsg"
diff --git a/bpf/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
index f3c6907..0b5b7be 100644
--- a/bpf/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -82,13 +82,13 @@
 
 // Provided by *current* mainline module for T+ devices
 static const set<string> MAINLINE_FOR_T_PLUS = {
-    SHARED "map_block_blocked_ports_map",
     SHARED "map_clatd_clat_egress4_map",
     SHARED "map_clatd_clat_ingress6_map",
     SHARED "map_dscpPolicy_ipv4_dscp_policies_map",
     SHARED "map_dscpPolicy_ipv6_dscp_policies_map",
     SHARED "map_dscpPolicy_socket_policy_cache_map",
     NETD "map_netd_app_uid_stats_map",
+    NETD "map_netd_blocked_ports_map",
     NETD "map_netd_configuration_map",
     NETD "map_netd_cookie_tag_map",
     NETD "map_netd_data_saver_enabled_map",
@@ -119,8 +119,13 @@
 
 // Provided by *current* mainline module for T+ devices with 5.4+ kernels
 static const set<string> MAINLINE_FOR_T_4_19_PLUS = {
-    NETD_RO "prog_block_bind4_block_port",
-    NETD_RO "prog_block_bind6_block_port",
+    NETD "prog_netd_bind4_inet4_bind",
+    NETD "prog_netd_bind6_inet6_bind",
+};
+
+// Provided by *current* mainline module for T+ devices with 5.10+ kernels
+static const set<string> MAINLINE_FOR_T_5_10_PLUS = {
+    NETD "prog_netd_cgroupsockrelease_inet_release",
 };
 
 // Provided by *current* mainline module for T+ devices with 5.15+ kernels
@@ -154,11 +159,6 @@
     NETD "prog_netd_setsockopt_prog",
 };
 
-// Provided by *current* mainline module for V+ devices with 5.10+ kernels
-static const set<string> MAINLINE_FOR_V_5_10_PLUS = {
-    NETD "prog_netd_cgroupsockrelease_inet_release",
-};
-
 static void addAll(set<string>& a, const set<string>& b) {
     a.insert(b.begin(), b.end());
 }
@@ -196,6 +196,7 @@
     DO_EXPECT(IsAtLeastT(), MAINLINE_FOR_T_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
+    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_T_5_10_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
 
     // U requires Linux Kernel 4.14+, but nothing (as yet) added or removed in U.
@@ -207,7 +208,6 @@
     if (IsAtLeastV()) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
     DO_EXPECT(IsAtLeastV(), MAINLINE_FOR_V_PLUS);
     DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_V_5_4_PLUS);
-    DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_V_5_10_PLUS);
 
     for (const auto& file : mustExist) {
         EXPECT_EQ(0, access(file.c_str(), R_OK)) << file << " does not exist";
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index a05a529..7551b92 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -68,8 +68,8 @@
     impl_only_libs: [
         // The build system will use framework-bluetooth module_current stubs, because
         // of sdk_version: "module_current" above.
-        "framework-bluetooth",
-        "framework-wifi",
+        "framework-bluetooth.stubs.module_lib",
+        "framework-wifi.stubs.module_lib",
         // Compile against the entire implementation of framework-connectivity,
         // including hidden methods. This is safe because if framework-connectivity-t is
         // on the bootclasspath (i.e., T), then framework-connectivity is also on the
@@ -103,8 +103,8 @@
     name: "framework-connectivity-t-pre-jarjar",
     defaults: ["framework-connectivity-t-defaults"],
     libs: [
-        "framework-bluetooth",
-        "framework-wifi",
+        "framework-bluetooth.stubs.module_lib",
+        "framework-wifi.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "framework-location.stubs.module_lib",
     ],
diff --git a/framework-t/src/android/net/INetworkStatsService.aidl b/framework-t/src/android/net/INetworkStatsService.aidl
index 7f0c1fe..01ac106 100644
--- a/framework-t/src/android/net/INetworkStatsService.aidl
+++ b/framework-t/src/android/net/INetworkStatsService.aidl
@@ -78,13 +78,16 @@
     void unregisterUsageRequest(in DataUsageRequest request);
 
     /** Get the uid stats information since boot */
-    long getUidStats(int uid, int type);
+    NetworkStats getTypelessUidStats(int uid);
 
     /** Get the iface stats information since boot */
-    long getIfaceStats(String iface, int type);
+    NetworkStats getTypelessIfaceStats(String iface);
 
     /** Get the total network stats information since boot */
-    long getTotalStats(int type);
+    NetworkStats getTypelessTotalStats();
+
+    /** Get the uid stats information (with specified type) since boot */
+    long getUidStats(int uid, int type);
 
     /** Registers a network stats provider */
     INetworkStatsProviderCallback registerNetworkStatsProvider(String tag,
diff --git a/framework-t/src/android/net/TrafficStats.java b/framework-t/src/android/net/TrafficStats.java
index 77c8001..3b6a69b 100644
--- a/framework-t/src/android/net/TrafficStats.java
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -40,6 +40,9 @@
 import java.net.DatagramSocket;
 import java.net.Socket;
 import java.net.SocketException;
+import java.util.Iterator;
+import java.util.Objects;
+
 
 /**
  * Class that provides network traffic statistics. These statistics include
@@ -730,11 +733,7 @@
      * @return The number of transmitted packets.
      */
     public static long getTxPackets(@NonNull String iface) {
-        try {
-            return getStatsService().getIfaceStats(iface, TYPE_TX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(iface, TYPE_TX_PACKETS);
     }
 
     /**
@@ -753,11 +752,7 @@
      * @return The number of received packets.
      */
     public static long getRxPackets(@NonNull String iface) {
-        try {
-            return getStatsService().getIfaceStats(iface, TYPE_RX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(iface, TYPE_RX_PACKETS);
     }
 
     /**
@@ -776,11 +771,7 @@
      * @return The number of transmitted bytes.
      */
     public static long getTxBytes(@NonNull String iface) {
-        try {
-            return getStatsService().getIfaceStats(iface, TYPE_TX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(iface, TYPE_TX_BYTES);
     }
 
     /**
@@ -799,51 +790,31 @@
      * @return The number of received bytes.
      */
     public static long getRxBytes(@NonNull String iface) {
-        try {
-            return getStatsService().getIfaceStats(iface, TYPE_RX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(iface, TYPE_RX_BYTES);
     }
 
     /** {@hide} */
     @TestApi
     public static long getLoopbackTxPackets() {
-        try {
-            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_TX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(LOOPBACK_IFACE, TYPE_TX_PACKETS);
     }
 
     /** {@hide} */
     @TestApi
     public static long getLoopbackRxPackets() {
-        try {
-            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_RX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(LOOPBACK_IFACE, TYPE_RX_PACKETS);
     }
 
     /** {@hide} */
     @TestApi
     public static long getLoopbackTxBytes() {
-        try {
-            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_TX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(LOOPBACK_IFACE, TYPE_TX_BYTES);
     }
 
     /** {@hide} */
     @TestApi
     public static long getLoopbackRxBytes() {
-        try {
-            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_RX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getIfaceStats(LOOPBACK_IFACE, TYPE_RX_BYTES);
     }
 
     /**
@@ -856,11 +827,7 @@
      * return {@link #UNSUPPORTED} on devices where statistics aren't available.
      */
     public static long getTotalTxPackets() {
-        try {
-            return getStatsService().getTotalStats(TYPE_TX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getTotalStats(TYPE_TX_PACKETS);
     }
 
     /**
@@ -873,11 +840,7 @@
      * return {@link #UNSUPPORTED} on devices where statistics aren't available.
      */
     public static long getTotalRxPackets() {
-        try {
-            return getStatsService().getTotalStats(TYPE_RX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getTotalStats(TYPE_RX_PACKETS);
     }
 
     /**
@@ -890,11 +853,7 @@
      * return {@link #UNSUPPORTED} on devices where statistics aren't available.
      */
     public static long getTotalTxBytes() {
-        try {
-            return getStatsService().getTotalStats(TYPE_TX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getTotalStats(TYPE_TX_BYTES);
     }
 
     /**
@@ -907,11 +866,7 @@
      * return {@link #UNSUPPORTED} on devices where statistics aren't available.
      */
     public static long getTotalRxBytes() {
-        try {
-            return getStatsService().getTotalStats(TYPE_RX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getTotalStats(TYPE_RX_BYTES);
     }
 
     /**
@@ -933,11 +888,7 @@
      * @see android.content.pm.ApplicationInfo#uid
      */
     public static long getUidTxBytes(int uid) {
-        try {
-            return getStatsService().getUidStats(uid, TYPE_TX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getUidStats(uid, TYPE_TX_BYTES);
     }
 
     /**
@@ -959,11 +910,7 @@
      * @see android.content.pm.ApplicationInfo#uid
      */
     public static long getUidRxBytes(int uid) {
-        try {
-            return getStatsService().getUidStats(uid, TYPE_RX_BYTES);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getUidStats(uid, TYPE_RX_BYTES);
     }
 
     /**
@@ -985,11 +932,7 @@
      * @see android.content.pm.ApplicationInfo#uid
      */
     public static long getUidTxPackets(int uid) {
-        try {
-            return getStatsService().getUidStats(uid, TYPE_TX_PACKETS);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
+        return getUidStats(uid, TYPE_TX_PACKETS);
     }
 
     /**
@@ -1011,11 +954,50 @@
      * @see android.content.pm.ApplicationInfo#uid
      */
     public static long getUidRxPackets(int uid) {
+        return getUidStats(uid, TYPE_RX_PACKETS);
+    }
+
+    /** @hide */
+    public static long getUidStats(int uid, int type) {
+        if (!isEntryValueTypeValid(type)
+                || android.os.Process.myUid() != uid) {
+            return UNSUPPORTED;
+        }
+        final NetworkStats stats;
         try {
-            return getStatsService().getUidStats(uid, TYPE_RX_PACKETS);
+            stats = getStatsService().getTypelessUidStats(uid);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
+        return getValueForTypeFromFirstEntry(stats, type);
+    }
+
+    /** @hide */
+    public static long getTotalStats(int type) {
+        if (!isEntryValueTypeValid(type)) {
+            return UNSUPPORTED;
+        }
+        final NetworkStats stats;
+        try {
+            stats = getStatsService().getTypelessTotalStats();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+        return getValueForTypeFromFirstEntry(stats, type);
+    }
+
+    /** @hide */
+    public static long getIfaceStats(String iface, int type) {
+        if (!isEntryValueTypeValid(type)) {
+            return UNSUPPORTED;
+        }
+        final NetworkStats stats;
+        try {
+            stats = getStatsService().getTypelessIfaceStats(iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+        return getValueForTypeFromFirstEntry(stats, type);
     }
 
     /**
@@ -1143,4 +1125,45 @@
     public static final int TYPE_TX_BYTES = 2;
     /** {@hide} */
     public static final int TYPE_TX_PACKETS = 3;
+
+    /** @hide */
+    private static long getEntryValueForType(@NonNull NetworkStats.Entry entry, int type) {
+        Objects.requireNonNull(entry);
+        if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
+        switch (type) {
+            case TYPE_RX_BYTES:
+                return entry.getRxBytes();
+            case TYPE_RX_PACKETS:
+                return entry.getRxPackets();
+            case TYPE_TX_BYTES:
+                return entry.getTxBytes();
+            case TYPE_TX_PACKETS:
+                return entry.getTxPackets();
+            default:
+                throw new IllegalStateException("Bug: Invalid type: "
+                        + type + " should not reach here.");
+        }
+    }
+
+    /** @hide */
+    private static boolean isEntryValueTypeValid(int type) {
+        switch (type) {
+            case TYPE_RX_BYTES:
+            case TYPE_RX_PACKETS:
+            case TYPE_TX_BYTES:
+            case TYPE_TX_PACKETS:
+                return true;
+            default :
+                return false;
+        }
+    }
+
+    /** @hide */
+    public static long getValueForTypeFromFirstEntry(@NonNull NetworkStats stats, int type) {
+        Objects.requireNonNull(stats);
+        Iterator<NetworkStats.Entry> iter = stats.iterator();
+        if (!iter.hasNext()) return UNSUPPORTED;
+        return getEntryValueForType(iter.next(), type);
+    }
 }
+
diff --git a/framework/Android.bp b/framework/Android.bp
index 4c4f792..0334e11 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -178,8 +178,10 @@
         // In preparation for future move
         "//packages/modules/Connectivity/apex",
         "//packages/modules/Connectivity/framework-t",
+        "//packages/modules/Connectivity/remoteauth/service",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service-t",
+        "//packages/modules/Connectivity/staticlibs",
         "//frameworks/base",
 
         // Tests using hidden APIs
@@ -201,6 +203,7 @@
         "//packages/modules/Connectivity/tests:__subpackages__",
         "//packages/modules/Connectivity/thread/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
+        "//packages/modules/NetworkStack",
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
     ],
diff --git a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
index 6e87ed3..ba39ca0 100644
--- a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
@@ -24,8 +24,8 @@
 import androidx.annotation.RequiresApi;
 
 /**
- * Utility providing limited access to module-internal APIs which are only available on Android T+,
- * as this class is only in the bootclasspath on T+ as part of framework-connectivity.
+ * Utility providing limited access to module-internal APIs which are only available on Android S+,
+ * as this class is only in the bootclasspath on S+ as part of framework-connectivity.
  *
  * R+ module components like Tethering cannot depend on all hidden symbols from
  * framework-connectivity. They only have access to stable API stubs where newer APIs can be
diff --git a/nearby/framework/Android.bp b/nearby/framework/Android.bp
index f84ddcf..6bfa54d 100644
--- a/nearby/framework/Android.bp
+++ b/nearby/framework/Android.bp
@@ -49,7 +49,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
-        "framework-bluetooth",
+        "framework-bluetooth.stubs.module_lib",
         "framework-location.stubs.module_lib",
     ],
     static_libs: [
diff --git a/nearby/service/Android.bp b/nearby/service/Android.bp
index 749113d..c9c7b44 100644
--- a/nearby/service/Android.bp
+++ b/nearby/service/Android.bp
@@ -35,11 +35,11 @@
     ],
     libs: [
         "androidx.annotation_annotation",
-        "framework-bluetooth",
+        "framework-bluetooth.stubs.module_lib",
         "error_prone_annotations",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-t.impl",
-        "framework-statsd",
+        "framework-statsd.stubs.module_lib",
     ],
     static_libs: [
         "androidx.core_core",
diff --git a/nearby/tests/cts/fastpair/Android.bp b/nearby/tests/cts/fastpair/Android.bp
index 8009303..9d42dd1 100644
--- a/nearby/tests/cts/fastpair/Android.bp
+++ b/nearby/tests/cts/fastpair/Android.bp
@@ -30,9 +30,9 @@
         "truth",
     ],
     libs: [
-        "android.test.base",
+        "android.test.base.stubs.system",
         "framework-bluetooth.stubs.module_lib",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-t.impl",
         "framework-location.stubs.module_lib",
     ],
diff --git a/nearby/tests/unit/Android.bp b/nearby/tests/unit/Android.bp
index 2950568..4d2d1d5 100644
--- a/nearby/tests/unit/Android.bp
+++ b/nearby/tests/unit/Android.bp
@@ -27,9 +27,9 @@
     srcs: ["src/**/*.java"],
 
     libs: [
-        "android.test.base",
-        "android.test.mock",
-        "android.test.runner",
+        "android.test.base.stubs.test",
+        "android.test.mock.stubs.test",
+        "android.test.runner.stubs.test",
     ],
     compile_multilib: "both",
 
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index e33abd5..52667ae 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -27,7 +27,7 @@
     ],
 
     libs: [
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "service-connectivity-pre-jarjar",
     ],
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
index f35b163..b2ef345 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -15,39 +15,68 @@
  */
 package com.android.server.net.ct;
 
+import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.net.Uri;
+import android.os.Build;
 import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
 
 /** Helper class to download certificate transparency log files. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 class CertificateTransparencyDownloader extends BroadcastReceiver {
 
     private static final String TAG = "CertificateTransparencyDownloader";
 
+    // TODO: move key to a DeviceConfig flag.
+    private static final byte[] PUBLIC_KEY_BYTES =
+            Base64.getDecoder()
+                    .decode(
+                            "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsu0BHGnQ++W2CTdyZyxv"
+                                + "HHRALOZPlnu/VMVgo2m+JZ8MNbAOH2cgXb8mvOj8flsX/qPMuKIaauO+PwROMjiq"
+                                + "fUpcFm80Kl7i97ZQyBDYKm3MkEYYpGN+skAR2OebX9G2DfDqFY8+jUpOOWtBNr3L"
+                                + "rmVcwx+FcFdMjGDlrZ5JRmoJ/SeGKiORkbbu9eY1Wd0uVhz/xI5bQb0OgII7hEj+"
+                                + "i/IPbJqOHgB8xQ5zWAJJ0DmG+FM6o7gk403v6W3S8qRYiR84c50KppGwe4YqSMkF"
+                                + "bLDleGQWLoaDSpEWtESisb4JiLaY4H+Kk0EyAhPSb+49JfUozYl+lf7iFN3qRq/S"
+                                + "IXXTh6z0S7Qa8EYDhKGCrpI03/+qprwy+my6fpWHi6aUIk4holUCmWvFxZDfixox"
+                                + "K0RlqbFDl2JXMBquwlQpm8u5wrsic1ksIv9z8x9zh4PJqNpCah0ciemI3YGRQqSe"
+                                + "/mRRXBiSn9YQBUPcaeqCYan+snGADFwHuXCd9xIAdFBolw9R9HTedHGUfVXPJDiF"
+                                + "4VusfX6BRR/qaadB+bqEArF/TzuDUr6FvOR4o8lUUxgLuZ/7HO+bHnaPFKYHHSm+"
+                                + "+z1lVDhhYuSZ8ax3T0C3FZpb7HMjZtpEorSV5ElKJEJwrhrBCMOD8L01EoSPrGlS"
+                                + "1w22i9uGHMn/uGQKo28u7AsCAwEAAQ==");
+
     private final Context mContext;
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
     private final CertificateTransparencyInstaller mInstaller;
+    private final byte[] mPublicKey;
 
     @VisibleForTesting
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
-            CertificateTransparencyInstaller installer) {
+            CertificateTransparencyInstaller installer,
+            byte[] publicKey) {
         mContext = context;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
         mInstaller = installer;
+        mPublicKey = publicKey;
     }
 
     CertificateTransparencyDownloader(Context context, DataStore dataStore) {
@@ -55,13 +84,14 @@
                 context,
                 dataStore,
                 new DownloadHelper(context),
-                new CertificateTransparencyInstaller());
+                new CertificateTransparencyInstaller(),
+                PUBLIC_KEY_BYTES);
     }
 
     void registerReceiver() {
         IntentFilter intentFilter = new IntentFilter();
         intentFilter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
-        mContext.registerReceiver(this, intentFilter);
+        mContext.registerReceiver(this, intentFilter, Context.RECEIVER_EXPORTED);
 
         if (Config.DEBUG) {
             Log.d(TAG, "CertificateTransparencyDownloader initialized successfully");
@@ -139,12 +169,22 @@
             return;
         }
 
-        // TODO: 1. verify file signature, 2. validate file content.
+        boolean success = false;
+        try {
+            success = verify(contentUri, metadataUri);
+        } catch (IOException | GeneralSecurityException e) {
+            Log.e(TAG, "Could not verify new log list", e);
+        }
+        if (!success) {
+            Log.w(TAG, "Log list did not pass verification");
+            return;
+        }
+
+        // TODO: validate file content.
 
         String version = mDataStore.getProperty(Config.VERSION_PENDING);
         String contentUrl = mDataStore.getProperty(Config.CONTENT_URL_PENDING);
         String metadataUrl = mDataStore.getProperty(Config.METADATA_URL_PENDING);
-        boolean success = false;
         try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
             success = mInstaller.install(inputStream, version);
         } catch (IOException e) {
@@ -161,6 +201,19 @@
         }
     }
 
+    private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
+        Signature verifier = Signature.getInstance("SHA256withRSA");
+        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+        verifier.initVerify(keyFactory.generatePublic(new X509EncodedKeySpec(mPublicKey)));
+        ContentResolver contentResolver = mContext.getContentResolver();
+
+        try (InputStream fileStream = contentResolver.openInputStream(file);
+                InputStream signatureStream = contentResolver.openInputStream(signature)) {
+            verifier.update(fileStream.readAllBytes());
+            return verifier.verify(signatureStream.readAllBytes());
+        }
+    }
+
     private long download(String url) {
         try {
             return mDownloadHelper.startDownload(url);
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index fdac434..a263546 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -15,19 +15,18 @@
  */
 package com.android.server.net.ct;
 
-import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
-
+import android.annotation.RequiresApi;
 import android.content.Context;
+import android.os.Build;
 import android.provider.DeviceConfig;
 import android.provider.DeviceConfig.Properties;
 import android.text.TextUtils;
 import android.util.Log;
 
-import com.android.modules.utils.build.SdkLevel;
-
 import java.util.concurrent.Executors;
 
 /** Listener class for the Certificate Transparency Phenotype flags. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 class CertificateTransparencyFlagsListener implements DeviceConfig.OnPropertiesChangedListener {
 
     private static final String TAG = "CertificateTransparencyFlagsListener";
@@ -45,7 +44,7 @@
         mDataStore.load();
         mCertificateTransparencyDownloader.registerReceiver();
         DeviceConfig.addOnPropertiesChangedListener(
-                NAMESPACE_TETHERING, Executors.newSingleThreadExecutor(), this);
+                Config.NAMESPACE_NETWORK_SECURITY, Executors.newSingleThreadExecutor(), this);
         if (Config.DEBUG) {
             Log.d(TAG, "CertificateTransparencyFlagsListener initialized successfully");
         }
@@ -54,14 +53,18 @@
 
     @Override
     public void onPropertiesChanged(Properties properties) {
-        if (!SdkLevel.isAtLeastV() || !NAMESPACE_TETHERING.equals(properties.getNamespace())) {
+        if (!Config.NAMESPACE_NETWORK_SECURITY.equals(properties.getNamespace())) {
             return;
         }
 
-        String newVersion = DeviceConfig.getString(NAMESPACE_TETHERING, Config.VERSION, "");
-        String newContentUrl = DeviceConfig.getString(NAMESPACE_TETHERING, Config.CONTENT_URL, "");
+        String newVersion =
+                DeviceConfig.getString(Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_VERSION, "");
+        String newContentUrl =
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_CONTENT_URL, "");
         String newMetadataUrl =
-                DeviceConfig.getString(NAMESPACE_TETHERING, Config.METADATA_URL, "");
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_METADATA_URL, "");
         if (TextUtils.isEmpty(newVersion)
                 || TextUtils.isEmpty(newContentUrl)
                 || TextUtils.isEmpty(newMetadataUrl)) {
@@ -85,6 +88,8 @@
             return;
         }
 
+        // TODO: handle the case where there is already a pending download.
+
         mDataStore.setProperty(Config.VERSION_PENDING, newVersion);
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, newContentUrl);
         mDataStore.setProperty(Config.METADATA_URL_PENDING, newMetadataUrl);
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index 52478c0..edf7c56 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -19,27 +19,23 @@
 import android.content.Context;
 import android.net.ct.ICertificateTransparencyManager;
 import android.os.Build;
+import android.provider.DeviceConfig;
 
 import com.android.net.ct.flags.Flags;
-import com.android.net.module.util.DeviceConfigUtils;
 import com.android.server.SystemService;
 
 /** Implementation of the Certificate Transparency service. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
 
-    private static final String CERTIFICATE_TRANSPARENCY_ENABLED =
-            "certificate_transparency_service_enabled";
-
     private final CertificateTransparencyFlagsListener mFlagsListener;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
      */
     public static boolean enabled(Context context) {
-        // TODO: replace isTetheringFeatureEnabled with CT namespace flag.
-        return DeviceConfigUtils.isTetheringFeatureEnabled(
-                        context, CERTIFICATE_TRANSPARENCY_ENABLED)
+        return DeviceConfig.getBoolean(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_SERVICE_ENABLED, false)
                 && Flags.certificateTransparencyService();
     }
 
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index 04b7dac..2a6b8e2 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,7 +33,15 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
-    // flags and properties names
+    // Phenotype flags
+    static final String NAMESPACE_NETWORK_SECURITY = "network_security";
+    private static final String FLAGS_PREFIX = "CertificateTransparencyLogList__";
+    static final String FLAG_SERVICE_ENABLED = FLAGS_PREFIX + "service_enabled";
+    static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
+    static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
+    static final String FLAG_VERSION = FLAGS_PREFIX + "version";
+
+    // properties
     static final String VERSION_PENDING = "version_pending";
     static final String VERSION = "version";
     static final String CONTENT_URL_PENDING = "content_url_pending";
diff --git a/networksecurity/tests/unit/Android.bp b/networksecurity/tests/unit/Android.bp
index 639f644..11263cf 100644
--- a/networksecurity/tests/unit/Android.bp
+++ b/networksecurity/tests/unit/Android.bp
@@ -27,9 +27,9 @@
     srcs: ["src/**/*.java"],
 
     libs: [
-        "android.test.base",
-        "android.test.mock",
-        "android.test.runner",
+        "android.test.base.stubs.test",
+        "android.test.mock.stubs.test",
+        "android.test.runner.stubs.test",
     ],
     static_libs: [
         "androidx.test.ext.junit",
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index 5131a71..a056c35 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -40,7 +40,17 @@
 import org.mockito.MockitoAnnotations;
 
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Signature;
 
 /** Tests for the {@link CertificateTransparencyDownloader}. */
 @RunWith(JUnit4.class)
@@ -49,15 +59,20 @@
     @Mock private DownloadHelper mDownloadHelper;
     @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
 
+    private PrivateKey mPrivateKey;
     private Context mContext;
     private File mTempFile;
     private DataStore mDataStore;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
     @Before
-    public void setUp() throws IOException {
+    public void setUp() throws IOException, NoSuchAlgorithmException {
         MockitoAnnotations.initMocks(this);
 
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        KeyPair keyPair = instance.generateKeyPair();
+        mPrivateKey = keyPair.getPrivate();
+
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mTempFile = File.createTempFile("datastore-test", ".properties");
         mDataStore = new DataStore(mTempFile);
@@ -65,7 +80,11 @@
 
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
-                        mContext, mDataStore, mDownloadHelper, mCertificateTransparencyInstaller);
+                        mContext,
+                        mDataStore,
+                        mDownloadHelper,
+                        mCertificateTransparencyInstaller,
+                        keyPair.getPublic().getEncoded());
     }
 
     @After
@@ -128,23 +147,16 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallSuccessful() throws IOException {
+    public void testDownloader_handleContentCompleteInstallSuccessful() throws Exception {
         String version = "666";
-        mDataStore.setProperty(Config.VERSION_PENDING, version);
-
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-metadata", "txt"));
-        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
-        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
-
         long contentId = 666;
-        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
-        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+        File logListFile = File.createTempFile("log_list", "json");
+        Uri contentUri = Uri.fromFile(logListFile);
+        long metadataId = 123;
+        File metadataFile = sign(logListFile);
+        Uri metadataUri = Uri.fromFile(metadataFile);
 
+        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
 
         assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
@@ -161,23 +173,16 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallFails() throws IOException {
+    public void testDownloader_handleContentCompleteInstallFails() throws Exception {
         String version = "666";
-        mDataStore.setProperty(Config.VERSION_PENDING, version);
-
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-metadata", "txt"));
-        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
-        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
-
         long contentId = 666;
-        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
-        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+        File logListFile = File.createTempFile("log_list", "json");
+        Uri contentUri = Uri.fromFile(logListFile);
+        long metadataId = 123;
+        File metadataFile = sign(logListFile);
+        Uri metadataUri = Uri.fromFile(metadataFile);
 
+        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(false);
 
         mCertificateTransparencyDownloader.onReceive(
@@ -188,8 +193,56 @@
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
     }
 
+    @Test
+    public void testDownloader_handleContentCompleteVerificationFails() throws IOException {
+        String version = "666";
+        long contentId = 666;
+        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
+        long metadataId = 123;
+        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
+
+        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        verify(mCertificateTransparencyInstaller, never()).install(any(), eq(version));
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
     private Intent makeDownloadCompleteIntent(long downloadId) {
         return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
+
+    private void setUpDownloadComplete(
+            String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
+            throws IOException {
+        mDataStore.setProperty(Config.VERSION_PENDING, version);
+
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
+        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
+
+        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
+        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+    }
+
+    private File sign(File file) throws IOException, GeneralSecurityException {
+        File signatureFile = File.createTempFile("log_list-metadata", "sig");
+        Signature signer = Signature.getInstance("SHA256withRSA");
+        signer.initSign(mPrivateKey);
+
+        try (InputStream fileStream = new FileInputStream(file);
+                OutputStream outputStream = new FileOutputStream(signatureFile)) {
+            signer.update(fileStream.readAllBytes());
+            outputStream.write(signer.sign());
+        }
+
+        return signatureFile;
+    }
 }
diff --git a/remoteauth/framework/Android.bp b/remoteauth/framework/Android.bp
index 2f1737f..33de139 100644
--- a/remoteauth/framework/Android.bp
+++ b/remoteauth/framework/Android.bp
@@ -47,7 +47,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
-        "framework-bluetooth",
+        "framework-bluetooth.stubs.module_lib",
     ],
     static_libs: [
         "modules-utils-preconditions",
diff --git a/remoteauth/service/Android.bp b/remoteauth/service/Android.bp
index 32ae54f..52f301a 100644
--- a/remoteauth/service/Android.bp
+++ b/remoteauth/service/Android.bp
@@ -33,13 +33,13 @@
     ],
     libs: [
         "androidx.annotation_annotation",
-        "framework-bluetooth",
-        "framework-connectivity",
+        "framework-bluetooth.stubs.module_lib",
+        "framework-connectivity.impl",
         "error_prone_annotations",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
-        "framework-statsd",
+        "framework-statsd.stubs.module_lib",
     ],
     static_libs: [
         "modules-utils-build",
diff --git a/remoteauth/service/jni/Android.bp b/remoteauth/service/jni/Android.bp
index fc91e0c..57e3ec9 100644
--- a/remoteauth/service/jni/Android.bp
+++ b/remoteauth/service/jni/Android.bp
@@ -13,7 +13,6 @@
     rustlibs: [
         "libbinder_rs",
         "libjni_legacy",
-        "liblazy_static",
         "liblog_rust",
         "liblogger",
         "libnum_traits",
diff --git a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
index 421fe7e..9add6df 100644
--- a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
+++ b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
@@ -21,12 +21,11 @@
 use jni::signature::TypeSignature;
 use jni::sys::{jbyteArray, jint, jlong, jvalue};
 use jni::{JNIEnv, JavaVM};
-use lazy_static::lazy_static;
 use log::{debug, error, info};
 use std::collections::HashMap;
 use std::sync::{
     atomic::{AtomicI64, Ordering},
-    Arc, Mutex,
+    Arc, LazyLock, Mutex,
 };
 
 /// Macro capturing the name of the function calling this macro.
@@ -51,11 +50,9 @@
     }};
 }
 
-lazy_static! {
-    static ref HANDLE_MAPPING: Mutex<HashMap<i64, Arc<Mutex<JavaPlatform>>>> =
-        Mutex::new(HashMap::new());
-    static ref HANDLE_RN: AtomicI64 = AtomicI64::new(0);
-}
+static HANDLE_MAPPING: LazyLock<Mutex<HashMap<i64, Arc<Mutex<JavaPlatform>>>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
+static HANDLE_RN: AtomicI64 = AtomicI64::new(0);
 
 fn generate_platform_handle() -> i64 {
     HANDLE_RN.fetch_add(1, Ordering::SeqCst)
diff --git a/remoteauth/tests/unit/Android.bp b/remoteauth/tests/unit/Android.bp
index 47b9e31..f784b8e 100644
--- a/remoteauth/tests/unit/Android.bp
+++ b/remoteauth/tests/unit/Android.bp
@@ -30,9 +30,9 @@
     srcs: [],
 
     libs: [
-        "android.test.base",
-        "android.test.mock",
-        "android.test.runner",
+        "android.test.base.stubs.test",
+        "android.test.mock.stubs.test",
+        "android.test.runner.stubs.test",
         "framework-annotations-lib",
     ],
     compile_multilib: "both",
diff --git a/service-t/Android.bp b/service-t/Android.bp
index 32dbcaa..787e94e 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -51,12 +51,12 @@
     ],
     libs: [
         "framework-annotations-lib",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
         // TODO: use framework-tethering-pre-jarjar when it is separated from framework-tethering
         "framework-tethering.impl",
-        "framework-wifi",
+        "framework-wifi.stubs.module_lib",
         "service-connectivity-pre-jarjar",
         "service-nearby-pre-jarjar",
         "service-networksecurity-pre-jarjar",
diff --git a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
index 241d5fa..9cca078 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
@@ -41,10 +41,7 @@
   // The task runner is sequential so these can't run on top of each other.
   runner->PostDelayedTask([=, this]() { PollAndSchedule(runner, poll_ms); }, poll_ms);
 
-  if (mMutex.try_lock()) {
-    ConsumeAllLocked();
-    mMutex.unlock();
-  }
+  ConsumeAll();
 }
 
 bool NetworkTracePoller::Start(uint32_t pollMs) {
@@ -76,7 +73,10 @@
     return false;
   }
 
-  mRingBuffer = std::move(*rb);
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer = std::move(*rb);
+  }
 
   auto res = mConfigurationMap.writeValue(0, true, BPF_ANY);
   if (!res.ok()) {
@@ -114,10 +114,14 @@
   // Drain remaining events from the ring buffer now that tracing is disabled.
   // This prevents the next trace from seeing stale events and allows writing
   // the last batch of events to Perfetto.
-  ConsumeAllLocked();
+  ConsumeAll();
 
   mTaskRunner.reset();
-  mRingBuffer.reset();
+
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer.reset();
+  }
 
   return res.ok();
 }
@@ -145,22 +149,20 @@
 }
 
 bool NetworkTracePoller::ConsumeAll() {
-  std::scoped_lock<std::mutex> lock(mMutex);
-  return ConsumeAllLocked();
-}
-
-bool NetworkTracePoller::ConsumeAllLocked() {
-  if (mRingBuffer == nullptr) {
-    ALOGW("Tracing is not active");
-    return false;
-  }
-
   std::vector<PacketTrace> packets;
-  base::Result<int> ret = mRingBuffer->ConsumeAll(
-      [&](const PacketTrace& pkt) { packets.push_back(pkt); });
-  if (!ret.ok()) {
-    ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
-    return false;
+  {
+    std::scoped_lock<std::mutex> lock(mBufferMutex);
+    if (mRingBuffer == nullptr) {
+      ALOGW("Tracing is not active");
+      return false;
+    }
+
+    base::Result<int> ret = mRingBuffer->ConsumeAll(
+        [&](const PacketTrace& pkt) { packets.push_back(pkt); });
+    if (!ret.ok()) {
+      ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
+      return false;
+    }
   }
 
   ATRACE_INT("NetworkTracePackets", packets.size());
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
index 092ab64..72fa66e 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
@@ -50,7 +50,7 @@
   bool Stop() EXCLUDES(mMutex);
 
   // Consumes all available events from the ringbuffer.
-  bool ConsumeAll() EXCLUDES(mMutex);
+  bool ConsumeAll() EXCLUDES(mBufferMutex);
 
  private:
   // Poll the ring buffer for new data and schedule another run of ourselves
@@ -59,15 +59,19 @@
   // and thus a deadlock while resetting the TaskRunner. The runner pointer is
   // always valid within tasks run by that runner.
   void PollAndSchedule(perfetto::base::TaskRunner* runner, uint32_t poll_ms);
-  bool ConsumeAllLocked() REQUIRES(mMutex);
 
   // Record sparse iface stats via atrace. This queries the per-iface stats maps
   // for any iface present in the vector of packets. This is inexact, but should
   // have sufficient coverage given these are cumulative counters.
-  void TraceIfaces(const std::vector<PacketTrace>& packets) REQUIRES(mMutex);
+  static void TraceIfaces(const std::vector<PacketTrace>& packets);
 
   std::mutex mMutex;
 
+  // The mBufferMutex protects the ring buffer. This allows separate protected
+  // access of mTaskRunner in Stop (to terminate) and mRingBuffer in ConsumeAll.
+  // Without this separation, Stop() can deadlock.
+  std::mutex mBufferMutex;
+
   // Records the number of successfully started active sessions so that only the
   // first active session attempts setup and only the last cleans up. Note that
   // the session count will remain zero if Start fails. It is expected that Stop
@@ -78,10 +82,10 @@
   uint32_t mPollMs GUARDED_BY(mMutex);
 
   // The function to process PacketTrace, typically a Perfetto sink.
-  EventSink mCallback GUARDED_BY(mMutex);
+  const EventSink mCallback;
 
   // The BPF ring buffer handle.
-  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mMutex);
+  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mBufferMutex);
 
   // The packet tracing config map (really a 1-element array).
   BpfMap<uint32_t, bool> mConfigurationMap GUARDED_BY(mMutex);
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 8e4ec2f..0adb290 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -1938,6 +1938,11 @@
                         mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setAvoidAdvertisingEmptyTxtRecords(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS))
+                .setIsCachedServicesRemovalEnabled(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.NSD_CACHED_SERVICES_REMOVAL))
+                .setCachedServicesRetentionTime(mDeps.getDeviceConfigPropertyInt(
+                        MdnsFeatureFlags.NSD_CACHED_SERVICES_RETENTION_TIME,
+                        MdnsFeatureFlags.DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS))
                 .setOverrideProvider(new MdnsFeatureFlags.FlagOverrideProvider() {
                     @Override
                     public boolean isForceEnabledForTest(@NonNull String flag) {
@@ -1947,10 +1952,9 @@
                     }
 
                     @Override
-                    public int getIntValueForTest(@NonNull String flag) {
+                    public int getIntValueForTest(@NonNull String flag, int defaultValue) {
                         return mDeps.getDeviceConfigPropertyInt(
-                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag,
-                                -1 /* defaultValue */);
+                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag, defaultValue);
                     }
                 })
                 .build();
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
index a74bdf7..b16d8bd 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -301,6 +301,17 @@
                         serviceTypeClient.notifySocketDestroyed();
                         executorProvider.shutdownExecutorService(serviceTypeClient.getExecutor());
                         perSocketServiceTypeClients.remove(serviceTypeClient);
+                        // The cached services may not be reliable after the socket is disconnected,
+                        // the service type client won't receive any updates for them. Therefore,
+                        // remove these cached services after exceeding the retention time
+                        // (currently 10s) if no service type client requires them.
+                        if (mdnsFeatureFlags.isCachedServicesRemovalEnabled()) {
+                            final MdnsServiceCache.CacheKey cacheKey =
+                                    serviceTypeClient.getCacheKey();
+                            discoveryExecutor.executeDelayed(
+                                    () -> handleRemoveCachedServices(cacheKey),
+                                    mdnsFeatureFlags.getCachedServicesRetentionTime());
+                        }
                     }
                 });
     }
@@ -337,6 +348,42 @@
                 // of the service type clients.
                 executorProvider.shutdownExecutorService(serviceTypeClient.getExecutor());
                 perSocketServiceTypeClients.remove(serviceTypeClient);
+                // The cached services may not be reliable after the socket is disconnected, the
+                // service type client won't receive any updates for them. Therefore, remove these
+                // cached services after exceeding the retention time (currently 10s) if no service
+                // type client requires them.
+                // Note: This removal is only called if the requested socket is still active for
+                // other requests. If the requested socket is no longer needed after the listener
+                // is unregistered, SocketCreationCallback#onSocketDestroyed callback will remove
+                // both the service type client and cached services there.
+                //
+                // List some multiple listener cases for the cached service removal flow.
+                //
+                // Case 1 - Same service type, different network requests
+                //  - Register Listener A (service type X, requesting all networks: Y and Z)
+                //  - Create service type clients X-Y and X-Z
+                //  - Register Listener B (service type X, requesting network Y)
+                //  - Reuse service type client X-Y
+                //  - Unregister Listener A
+                //  - Socket destroyed on network Z; remove the X-Z client. Unregister the listener
+                //    from the X-Y client and keep it, as it's still being used by Listener B.
+                //  - Remove cached services associated with the X-Z client after 10 seconds.
+                //
+                // Case 2 - Different service types, same network request
+                //  - Register Listener A (service type X, requesting network Y)
+                //  - Create service type client X-Y
+                //  - Register Listener B (service type Z, requesting network Y)
+                //  - Create service type client Z-Y
+                //  - Unregister Listener A
+                //  - No socket is destroyed because network Y is still being used by Listener B.
+                //  - Unregister the listener from the X-Y client, then remove it.
+                //  - Remove cached services associated with the X-Y client after 10 seconds.
+                if (mdnsFeatureFlags.isCachedServicesRemovalEnabled()) {
+                    final MdnsServiceCache.CacheKey cacheKey = serviceTypeClient.getCacheKey();
+                    discoveryExecutor.executeDelayed(
+                            () -> handleRemoveCachedServices(cacheKey),
+                            mdnsFeatureFlags.getCachedServicesRetentionTime());
+                }
             }
         }
         if (perSocketServiceTypeClients.isEmpty()) {
@@ -381,6 +428,26 @@
         }
     }
 
+    private void handleRemoveCachedServices(@NonNull MdnsServiceCache.CacheKey cacheKey) {
+        // Check if there is an active service type client that requires the cached services. If so,
+        // do not remove associated services from cache.
+        for (MdnsServiceTypeClient client : getMdnsServiceTypeClient(cacheKey.mSocketKey)) {
+            if (client.getCacheKey().equals(cacheKey)) {
+                // Found a client that has same CacheKey.
+                return;
+            }
+        }
+        sharedLog.log("Remove cached services for " + cacheKey);
+        // No client has same CacheKey. Remove associated services.
+        getServiceCache().removeServices(cacheKey);
+    }
+
+    @VisibleForTesting
+    @NonNull
+    MdnsServiceCache getServiceCache() {
+        return serviceCache;
+    }
+
     @VisibleForTesting
     MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
             @NonNull SocketKey socketKey) {
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index b2be6ce..4e27fef 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -73,6 +73,22 @@
     public static final String NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS =
             "nsd_avoid_advertising_empty_txt_records";
 
+    /**
+     * A feature flag to control whether the cached services removal should be enabled.
+     * The removal will be triggered if the retention time has elapsed after all listeners have been
+     * unregistered from the service type client or the interface has been destroyed.
+     */
+    public static final String NSD_CACHED_SERVICES_REMOVAL = "nsd_cached_services_removal";
+
+    /**
+     * A feature flag to control the retention time for cached services.
+     *
+     * <p> Making the retention time configurable allows for testing and future adjustments.
+     */
+    public static final String NSD_CACHED_SERVICES_RETENTION_TIME =
+            "nsd_cached_services_retention_time";
+    public static final int DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS = 10000;
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
@@ -100,6 +116,12 @@
     // Flag for avoiding advertising empty TXT records
     public final boolean mAvoidAdvertisingEmptyTxtRecords;
 
+    // Flag for cached services removal
+    public final boolean mIsCachedServicesRemovalEnabled;
+
+    // Retention Time for cached services
+    public final long mCachedServicesRetentionTime;
+
     @Nullable
     private final FlagOverrideProvider mOverrideProvider;
 
@@ -116,7 +138,7 @@
         /**
          * Get the int value of the flag for testing purposes.
          */
-        int getIntValueForTest(@NonNull String flag);
+        int getIntValueForTest(@NonNull String flag, int defaultValue);
     }
 
     /**
@@ -129,13 +151,14 @@
     /**
      * Get the int value of the flag for testing purposes.
      *
-     * @return the test int value, or -1 if it is unset or the OverrideProvider doesn't exist.
+     * @return the test int value, or given default value if it is unset or the OverrideProvider
+     * doesn't exist.
      */
-    private int getIntValueForTest(@NonNull String flag) {
+    private int getIntValueForTest(@NonNull String flag, int defaultValue) {
         if (mOverrideProvider == null) {
-            return -1;
+            return defaultValue;
         }
-        return mOverrideProvider.getIntValueForTest(flag);
+        return mOverrideProvider.getIntValueForTest(flag, defaultValue);
     }
 
     /**
@@ -178,6 +201,23 @@
     }
 
     /**
+     * Indicates whether {@link #NSD_CACHED_SERVICES_REMOVAL} is enabled, including for testing.
+     */
+    public boolean isCachedServicesRemovalEnabled() {
+        return mIsCachedServicesRemovalEnabled
+                || isForceEnabledForTest(NSD_CACHED_SERVICES_REMOVAL);
+    }
+
+    /**
+     * Get the value which is set to {@link #NSD_CACHED_SERVICES_RETENTION_TIME}, including for
+     * testing.
+     */
+    public long getCachedServicesRetentionTime() {
+        return getIntValueForTest(
+                NSD_CACHED_SERVICES_RETENTION_TIME, (int) mCachedServicesRetentionTime);
+    }
+
+    /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
     public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
@@ -189,6 +229,8 @@
             boolean isAggressiveQueryModeEnabled,
             boolean isQueryWithKnownAnswerEnabled,
             boolean avoidAdvertisingEmptyTxtRecords,
+            boolean isCachedServicesRemovalEnabled,
+            long cachedServicesRetentionTime,
             @Nullable FlagOverrideProvider overrideProvider) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
         mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
@@ -199,6 +241,8 @@
         mIsAggressiveQueryModeEnabled = isAggressiveQueryModeEnabled;
         mIsQueryWithKnownAnswerEnabled = isQueryWithKnownAnswerEnabled;
         mAvoidAdvertisingEmptyTxtRecords = avoidAdvertisingEmptyTxtRecords;
+        mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
+        mCachedServicesRetentionTime = cachedServicesRetentionTime;
         mOverrideProvider = overrideProvider;
     }
 
@@ -220,6 +264,8 @@
         private boolean mIsAggressiveQueryModeEnabled;
         private boolean mIsQueryWithKnownAnswerEnabled;
         private boolean mAvoidAdvertisingEmptyTxtRecords;
+        private boolean mIsCachedServicesRemovalEnabled;
+        private long mCachedServicesRetentionTime;
         private FlagOverrideProvider mOverrideProvider;
 
         /**
@@ -235,6 +281,8 @@
             mIsAggressiveQueryModeEnabled = false;
             mIsQueryWithKnownAnswerEnabled = false;
             mAvoidAdvertisingEmptyTxtRecords = true; // Default enabled.
+            mIsCachedServicesRemovalEnabled = false;
+            mCachedServicesRetentionTime = DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS;
             mOverrideProvider = null;
         }
 
@@ -341,6 +389,26 @@
         }
 
         /**
+         * Set whether the cached services removal is enabled.
+         *
+         * @see #NSD_CACHED_SERVICES_REMOVAL
+         */
+        public Builder setIsCachedServicesRemovalEnabled(boolean isCachedServicesRemovalEnabled) {
+            mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
+            return this;
+        }
+
+        /**
+         * Set cached services retention time.
+         *
+         * @see #NSD_CACHED_SERVICES_RETENTION_TIME
+         */
+        public Builder setCachedServicesRetentionTime(long cachedServicesRetentionTime) {
+            mCachedServicesRetentionTime = cachedServicesRetentionTime;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsFeatureFlags} with the arguments supplied to this builder.
          */
         public MdnsFeatureFlags build() {
@@ -353,6 +421,8 @@
                     mIsAggressiveQueryModeEnabled,
                     mIsQueryWithKnownAnswerEnabled,
                     mAvoidAdvertisingEmptyTxtRecords,
+                    mIsCachedServicesRemovalEnabled,
+                    mCachedServicesRetentionTime,
                     mOverrideProvider);
         }
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index 591ed8b..22f7a03 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -49,7 +49,7 @@
  *  to their default value (0, false or null).
  */
 public class MdnsServiceCache {
-    static class CacheKey {
+    public static class CacheKey {
         @NonNull final String mUpperCaseServiceType;
         @NonNull final SocketKey mSocketKey;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 4b55ea9..a5dd536 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -456,6 +456,14 @@
         return executor;
     }
 
+    /**
+     * Get the cache key for this service type client.
+     */
+    @NonNull
+    public MdnsServiceCache.CacheKey getCacheKey() {
+        return cacheKey;
+    }
+
     private void removeScheduledTask() {
         dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
         sharedLog.log("Remove EVENT_START_QUERYTASK"
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 9b7af49..294a85a 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -51,12 +51,8 @@
 import static android.net.NetworkTemplate.MATCH_WIFI;
 import static android.net.TrafficStats.KB_IN_BYTES;
 import static android.net.TrafficStats.MB_IN_BYTES;
-import static android.net.TrafficStats.TYPE_RX_BYTES;
-import static android.net.TrafficStats.TYPE_RX_PACKETS;
-import static android.net.TrafficStats.TYPE_TX_BYTES;
-import static android.net.TrafficStats.TYPE_TX_PACKETS;
 import static android.net.TrafficStats.UID_TETHERING;
-import static android.net.TrafficStats.UNSUPPORTED;
+import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -308,9 +304,10 @@
 
     static final String TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME =
             "trafficstats_cache_expiry_duration_ms";
-    static final String TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME = "trafficstats_cache_max_entries";
+    static final String TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME =
+            "trafficstats_cache_max_entries";
     static final int DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS = 1000;
-    static final int DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES = 400;
+    static final int DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES = 400;
     /**
      * The delay time between to network stats update intents.
      * Added to fix intent spams (b/343844995)
@@ -491,13 +488,13 @@
     private final TrafficStatsRateLimitCache mTrafficStatsTotalCache;
     private final TrafficStatsRateLimitCache mTrafficStatsIfaceCache;
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
-    static final String TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG =
+    static final String TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG =
             "trafficstats_rate_limit_cache_enabled_flag";
     static final String BROADCAST_NETWORK_STATS_UPDATED_RATE_LIMIT_ENABLED_FLAG =
             "broadcast_network_stats_updated_rate_limit_enabled_flag";
-    private final boolean mAlwaysUseTrafficStatsRateLimitCache;
+    private final boolean mAlwaysUseTrafficStatsServiceRateLimitCache;
     private final int mTrafficStatsRateLimitCacheExpiryDuration;
-    private final int mTrafficStatsRateLimitCacheMaxEntries;
+    private final int mTrafficStatsServiceRateLimitCacheMaxEntries;
     private final boolean mBroadcastNetworkStatsUpdatedRateLimitEnabled;
 
 
@@ -691,20 +688,23 @@
             mEventLogger = null;
         }
 
-        mAlwaysUseTrafficStatsRateLimitCache =
-                mDeps.alwaysUseTrafficStatsRateLimitCache(mContext);
+        mAlwaysUseTrafficStatsServiceRateLimitCache =
+                mDeps.alwaysUseTrafficStatsServiceRateLimitCache(mContext);
         mBroadcastNetworkStatsUpdatedRateLimitEnabled =
                 mDeps.enabledBroadcastNetworkStatsUpdatedRateLimiting(mContext);
         mTrafficStatsRateLimitCacheExpiryDuration =
                 mDeps.getTrafficStatsRateLimitCacheExpiryDuration();
-        mTrafficStatsRateLimitCacheMaxEntries =
-                mDeps.getTrafficStatsRateLimitCacheMaxEntries();
+        mTrafficStatsServiceRateLimitCacheMaxEntries =
+                mDeps.getTrafficStatsServiceRateLimitCacheMaxEntries();
         mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
+                mTrafficStatsRateLimitCacheExpiryDuration,
+                mTrafficStatsServiceRateLimitCacheMaxEntries);
         mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
+                mTrafficStatsRateLimitCacheExpiryDuration,
+                mTrafficStatsServiceRateLimitCacheMaxEntries);
         mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
+                mTrafficStatsRateLimitCacheExpiryDuration,
+                mTrafficStatsServiceRateLimitCacheMaxEntries);
 
         // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
         // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
@@ -964,14 +964,14 @@
         }
 
         /**
-         * Get whether TrafficStats rate-limit cache is always applied.
+         * Get whether TrafficStats service side rate-limit cache is always applied.
          *
          * This method should only be called once in the constructor,
          * to ensure that the code does not need to deal with flag values changing at runtime.
          */
-        public boolean alwaysUseTrafficStatsRateLimitCache(@NonNull Context ctx) {
+        public boolean alwaysUseTrafficStatsServiceRateLimitCache(@NonNull Context ctx) {
             return SdkLevel.isAtLeastV() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
-                    ctx, TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG);
+                    ctx, TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG);
         }
 
         /**
@@ -987,15 +987,15 @@
         }
 
         /**
-         * Get TrafficStats rate-limit cache max entries.
+         * Get TrafficStats service side rate-limit cache max entries.
          *
          * This method should only be called once in the constructor,
          * to ensure that the code does not need to deal with flag values changing at runtime.
          */
-        public int getTrafficStatsRateLimitCacheMaxEntries() {
+        public int getTrafficStatsServiceRateLimitCacheMaxEntries() {
             return getDeviceConfigPropertyInt(
-                    NAMESPACE_TETHERING, TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME,
-                    DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES);
+                    NAMESPACE_TETHERING, TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                    DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES);
         }
 
         /**
@@ -2135,20 +2135,28 @@
 
     @Override
     public long getUidStats(int uid, int type) {
+        return getValueForTypeFromFirstEntry(getTypelessUidStats(uid), type);
+    }
+
+    @NonNull
+    @Override
+    public NetworkStats getTypelessUidStats(int uid) {
+        final NetworkStats stats = new NetworkStats(0, 0);
         final int callingUid = Binder.getCallingUid();
         if (callingUid != android.os.Process.SYSTEM_UID && callingUid != uid) {
-            return UNSUPPORTED;
+            return stats;
         }
-        if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
-
-        if (mAlwaysUseTrafficStatsRateLimitCache
+        final NetworkStats.Entry entry;
+        if (mAlwaysUseTrafficStatsServiceRateLimitCache
                 || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid)) {
-            final NetworkStats.Entry entry = mTrafficStatsUidCache.getOrCompute(IFACE_ALL, uid,
+            entry = mTrafficStatsUidCache.getOrCompute(IFACE_ALL, uid,
                     () -> mDeps.nativeGetUidStat(uid));
-            return getEntryValueForType(entry, type);
-        }
+        } else entry = mDeps.nativeGetUidStat(uid);
 
-        return getEntryValueForType(mDeps.nativeGetUidStat(uid), type);
+        if (entry != null) {
+            stats.insertEntry(entry);
+        }
+        return stats;
     }
 
     @Nullable
@@ -2165,50 +2173,24 @@
         return entry;
     }
 
+    @NonNull
     @Override
-    public long getIfaceStats(@NonNull String iface, int type) {
+    public NetworkStats getTypelessIfaceStats(@NonNull String iface) {
         Objects.requireNonNull(iface);
-        if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
 
-        if (mAlwaysUseTrafficStatsRateLimitCache
+        final NetworkStats.Entry entry;
+        if (mAlwaysUseTrafficStatsServiceRateLimitCache
                 || mDeps.isChangeEnabled(
                         ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
-            final NetworkStats.Entry entry = mTrafficStatsIfaceCache.getOrCompute(iface, UID_ALL,
+            entry = mTrafficStatsIfaceCache.getOrCompute(iface, UID_ALL,
                     () -> getIfaceStatsInternal(iface));
-            return getEntryValueForType(entry, type);
-        }
+        } else entry = getIfaceStatsInternal(iface);
 
-        return getEntryValueForType(getIfaceStatsInternal(iface), type);
-    }
-
-    private long getEntryValueForType(@Nullable NetworkStats.Entry entry, int type) {
-        if (entry == null) return UNSUPPORTED;
-        if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
-        switch (type) {
-            case TYPE_RX_BYTES:
-                return entry.rxBytes;
-            case TYPE_RX_PACKETS:
-                return entry.rxPackets;
-            case TYPE_TX_BYTES:
-                return entry.txBytes;
-            case TYPE_TX_PACKETS:
-                return entry.txPackets;
-            default:
-                throw new IllegalStateException("Bug: Invalid type: "
-                        + type + " should not reach here.");
+        NetworkStats stats = new NetworkStats(0, 0);
+        if (entry != null) {
+            stats.insertEntry(entry);
         }
-    }
-
-    private boolean isEntryValueTypeValid(int type) {
-        switch (type) {
-            case TYPE_RX_BYTES:
-            case TYPE_RX_PACKETS:
-            case TYPE_TX_BYTES:
-            case TYPE_TX_PACKETS:
-                return true;
-            default :
-                return false;
-        }
+        return stats;
     }
 
     @Nullable
@@ -2221,18 +2203,22 @@
         return entry;
     }
 
+    @NonNull
     @Override
-    public long getTotalStats(int type) {
-        if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
-        if (mAlwaysUseTrafficStatsRateLimitCache
+    public NetworkStats getTypelessTotalStats() {
+        final NetworkStats.Entry entry;
+        if (mAlwaysUseTrafficStatsServiceRateLimitCache
                 || mDeps.isChangeEnabled(
                         ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
-            final NetworkStats.Entry entry = mTrafficStatsTotalCache.getOrCompute(
+            entry = mTrafficStatsTotalCache.getOrCompute(
                     IFACE_ALL, UID_ALL, () -> getTotalStatsInternal());
-            return getEntryValueForType(entry, type);
-        }
+        } else entry = getTotalStatsInternal();
 
-        return getEntryValueForType(getTotalStatsInternal(), type);
+        final NetworkStats stats = new NetworkStats(0, 0);
+        if (entry != null) {
+            stats.insertEntry(entry);
+        }
+        return stats;
     }
 
     @Override
@@ -3010,12 +2996,14 @@
             } catch (IOException e) {
                 pw.println("(failed to dump FastDataInput counters)");
             }
-            pw.print("trafficstats.cache.alwaysuse", mAlwaysUseTrafficStatsRateLimitCache);
+            pw.print("trafficstats.service.cache.alwaysuse",
+                    mAlwaysUseTrafficStatsServiceRateLimitCache);
             pw.println();
             pw.print(TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
                     mTrafficStatsRateLimitCacheExpiryDuration);
             pw.println();
-            pw.print(TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME, mTrafficStatsRateLimitCacheMaxEntries);
+            pw.print(TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
             pw.println();
 
             pw.decreaseIndent();
diff --git a/service/Android.bp b/service/Android.bp
index c68f0b8..94061a4 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -161,7 +161,7 @@
     ],
     libs: [
         "framework-annotations-lib",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         // The framework-connectivity-t library is only available on T+ platforms
         // so any calls to it must be protected with a check to ensure that it is
@@ -175,12 +175,12 @@
         // TODO: figure out why just using "framework-tethering" uses the stubs, even though both
         // service-connectivity and framework-tethering are in the same APEX.
         "framework-tethering.impl",
-        "framework-wifi",
+        "framework-wifi.stubs.module_lib",
         "unsupportedappusage",
         "ServiceConnectivityResources",
-        "framework-statsd",
-        "framework-permission",
-        "framework-permission-s",
+        "framework-statsd.stubs.module_lib",
+        "framework-permission.stubs.module_lib",
+        "framework-permission-s.stubs.module_lib",
     ],
     static_libs: [
         // Do not add libs here if they are already included
@@ -264,10 +264,10 @@
         "framework-connectivity.impl",
         "framework-connectivity-t.impl",
         "framework-tethering.impl",
-        "framework-wifi",
+        "framework-wifi.stubs.module_lib",
         "libprotobuf-java-nano",
-        "framework-permission",
-        "framework-permission-s",
+        "framework-permission.stubs.module_lib",
+        "framework-permission-s.stubs.module_lib",
     ],
     jarjar_rules: ":connectivity-jarjar-rules",
     apex_available: [
diff --git a/service/ServiceConnectivityResources/res/values-eu/strings.xml b/service/ServiceConnectivityResources/res/values-eu/strings.xml
index 81d8ddb..5a0a9d4 100644
--- a/service/ServiceConnectivityResources/res/values-eu/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-eu/strings.xml
@@ -18,7 +18,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="connectivityResourcesAppLabel" msgid="2476261877900882974">"Sistemaren konexio-baliabideak"</string>
-    <string name="wifi_available_sign_in" msgid="8041178343789805553">"Hasi saioa Wi-Fi sarean"</string>
+    <string name="wifi_available_sign_in" msgid="8041178343789805553">"Hasi saioa wifi-sarean"</string>
     <string name="network_available_sign_in" msgid="2622520134876355561">"Hasi saioa sarean"</string>
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
     <skip />
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index 917ad4d..7a008c6 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -43,7 +43,7 @@
     private static final String TAG = ConnectivityNativeService.class.getSimpleName();
 
     private static final String BLOCKED_PORTS_MAP_PATH =
-            "/sys/fs/bpf/net_shared/map_block_blocked_ports_map";
+            "/sys/fs/bpf/netd_shared/map_netd_blocked_ports_map";
 
     private final Context mContext;
 
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index f47a23a..85258f8 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -70,7 +70,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity.stubs.module_lib",
     ],
     lint: {
@@ -264,7 +264,7 @@
     ],
     libs: [
         "framework-annotations-lib",
-        "framework-connectivity",
+        "framework-connectivity.stubs.module_lib",
     ],
     static_libs: [
         "net-utils-device-common",
@@ -342,7 +342,7 @@
     min_sdk_version: "30",
     libs: [
         "framework-annotations-lib",
-        "framework-connectivity",
+        "framework-connectivity.stubs.module_lib",
         "modules-utils-build_system",
     ],
     // TODO: remove "apex_available:platform".
@@ -468,7 +468,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
-        "framework-configinfrastructure",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity.stubs.module_lib",
     ],
     lint: {
@@ -484,12 +484,11 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
-        "framework-configinfrastructure",
-        "framework-connectivity",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity.stubs.module_lib",
         "framework-connectivity-t.stubs.module_lib",
         "framework-location.stubs.module_lib",
-        "framework-tethering",
+        "framework-tethering.stubs.module_lib",
         "unsupportedappusage",
     ],
     static_libs: [
@@ -522,6 +521,8 @@
     ],
     libs: [
         "net-utils-framework-connectivity",
+        "framework-connectivity.impl",
+        "framework-tethering.impl",
     ],
     defaults: ["net-utils-non-bootclasspath-defaults"],
     jarjar_rules: "jarjar-rules-shared.txt",
diff --git a/staticlibs/client-libs/tests/unit/Android.bp b/staticlibs/client-libs/tests/unit/Android.bp
index 7aafd69..79234f5 100644
--- a/staticlibs/client-libs/tests/unit/Android.bp
+++ b/staticlibs/client-libs/tests/unit/Android.bp
@@ -17,8 +17,8 @@
         "netd-client",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
+        "android.test.runner.stubs.system",
+        "android.test.base.stubs.system",
     ],
     visibility: [
         // Visible for Tethering and NetworkStack integration test and link NetdStaticLibTestsLib
diff --git a/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
new file mode 100644
index 0000000..7591d5c
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+
+/**
+ * This class wraps the static methods of {@link android.system.Os} for mocking and testing.
+ */
+public class OsAccess {
+    /**
+     * Constant indicating that the {@code if_nametoindex()} function could not find the network
+     * interface index corresponding to the given interface name.
+     */
+    public static int INVALID_INTERFACE_INDEX = 0;
+
+    /** Wraps {@link Os#if_nametoindex(String)}. */
+    public int if_nametoindex(@NonNull String name) {
+        return Os.if_nametoindex(name);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
index 0c49edc..27869ef 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
@@ -16,6 +16,13 @@
 
 package com.android.net.module.util.netlink;
 
+import static android.system.OsConstants.AF_UNSPEC;
+
+import static com.android.net.module.util.NetworkStackConstants.ETHER_ADDR_LEN;
+import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
+import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
+import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST_ACK;
+
 import android.net.MacAddress;
 import android.system.OsConstants;
 
@@ -24,6 +31,7 @@
 import androidx.annotation.VisibleForTesting;
 
 import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 
 /**
  * A NetlinkMessage subclass for rtnetlink link messages.
@@ -46,29 +54,55 @@
 
     public static final short IN6_ADDR_GEN_MODE_NONE = 1;
 
-    private int mMtu;
-    @NonNull
-    private StructIfinfoMsg mIfinfomsg;
-    @Nullable
-    private MacAddress mHardwareAddress;
-    @Nullable
-    private String mInterfaceName;
+    // The maximum buffer size to hold an interface name including the null-terminator '\0'.
+    private static final int IFNAMSIZ = 16;
+    // The default value of MTU, which means the MTU is unspecified.
+    private static final int DEFAULT_MTU = 0;
 
-    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr header) {
-        super(header);
-        mIfinfomsg = null;
-        mMtu = 0;
-        mHardwareAddress = null;
-        mInterfaceName = null;
+    @NonNull
+    private final StructIfinfoMsg mIfinfomsg;
+    private final int mMtu;
+    @Nullable
+    private final MacAddress mHardwareAddress;
+    @Nullable
+    private final String mInterfaceName;
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance.
+     *
+     * <p>This method validates the arguments and returns {@code null} if any of them are invalid.
+     * nlmsghdr's nlmsg_len will be updated to the correct length before creation.
+     *
+     * @param nlmsghdr The Netlink message header. Must not be {@code null}.
+     * @param ifinfomsg The interface information message. Must not be {@code null}.
+     * @param mtu The Maximum Transmission Unit (MTU) value for the link.
+     * @param hardwareAddress The hardware address (MAC address) of the link. May be {@code null}.
+     * @param interfaceName The name of the interface. May be {@code null}.
+     * @return A new {@link RtNetlinkLinkMessage} instance, or {@code null} if the input arguments
+     *         are invalid.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage build(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
+        if (mtu < 0) {
+            return null;
+        }
+        if (interfaceName != null
+                && (interfaceName.isEmpty() || interfaceName.length() + 1 > IFNAMSIZ)) {
+            return null;
+        }
+
+        nlmsghdr.nlmsg_len = calculateMessageLength(mtu, hardwareAddress, interfaceName);
+        return new RtNetlinkLinkMessage(nlmsghdr, ifinfomsg, mtu, hardwareAddress, interfaceName);
     }
 
-    @VisibleForTesting
-    public RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
-            int mtu, @NonNull StructIfinfoMsg ifinfomsg, @NonNull MacAddress hardwareAddress,
-            @NonNull String interfaceName) {
+    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
         super(nlmsghdr);
-        mMtu = mtu;
         mIfinfomsg = ifinfomsg;
+        mMtu = mtu;
         mHardwareAddress = hardwareAddress;
         mInterfaceName = interfaceName;
     }
@@ -102,33 +136,46 @@
     @Nullable
     public static RtNetlinkLinkMessage parse(@NonNull final StructNlMsgHdr header,
             @NonNull final ByteBuffer byteBuffer) {
-        final RtNetlinkLinkMessage linkMsg = new RtNetlinkLinkMessage(header);
-
-        linkMsg.mIfinfomsg = StructIfinfoMsg.parse(byteBuffer);
-        if (linkMsg.mIfinfomsg == null) return null;
+        final StructIfinfoMsg ifinfoMsg = StructIfinfoMsg.parse(byteBuffer);
+        if (ifinfoMsg == null) {
+            return null;
+        }
 
         // IFLA_MTU
+        int mtu = DEFAULT_MTU;
         final int baseOffset = byteBuffer.position();
         StructNlAttr nlAttr = StructNlAttr.findNextAttrOfType(IFLA_MTU, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mMtu = nlAttr.getValueAsInt(0 /* default value */);
+            mtu = nlAttr.getValueAsInt(DEFAULT_MTU);
         }
 
         // IFLA_ADDRESS
+        MacAddress hardwareAddress = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_ADDRESS, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mHardwareAddress = nlAttr.getValueAsMacAddress();
+            hardwareAddress = nlAttr.getValueAsMacAddress();
         }
 
         // IFLA_IFNAME
+        String interfaceName = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_IFNAME, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mInterfaceName = nlAttr.getValueAsString();
+            interfaceName = nlAttr.getValueAsString();
         }
 
-        return linkMsg;
+        return new RtNetlinkLinkMessage(header, ifinfoMsg, mtu, hardwareAddress, interfaceName);
+    }
+
+    /**
+     *  Write a rtnetlink link message to {@link byte} array.
+     */
+    public byte[] pack(ByteOrder order) {
+        byte[] bytes = new byte[mHeader.nlmsg_len];
+        ByteBuffer buffer = ByteBuffer.wrap(bytes).order(order);
+        pack(buffer);
+        return bytes;
     }
 
     /**
@@ -136,10 +183,10 @@
      */
     @VisibleForTesting
     protected void pack(ByteBuffer byteBuffer) {
-        getHeader().pack(byteBuffer);
+        mHeader.pack(byteBuffer);
         mIfinfomsg.pack(byteBuffer);
 
-        if (mMtu != 0) {
+        if (mMtu != DEFAULT_MTU) {
             final StructNlAttr mtu = new StructNlAttr(IFLA_MTU, mMtu);
             mtu.pack(byteBuffer);
         }
@@ -153,11 +200,92 @@
         }
     }
 
+    /**
+     *  Calculate the byte length of the packed buffer.
+     */
+    private static int calculateMessageLength(int mtu, MacAddress hardwareAddress,
+            String interfaceName) {
+        int length = StructNlMsgHdr.STRUCT_SIZE + StructIfinfoMsg.STRUCT_SIZE;
+
+        if (mtu != DEFAULT_MTU) {
+            length += NetlinkConstants.alignedLengthOf(StructNlAttr.NLA_HEADERLEN + Integer.BYTES);
+        }
+        if (hardwareAddress != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    StructNlAttr.NLA_HEADERLEN + ETHER_ADDR_LEN);
+        }
+        if (interfaceName != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    // The string should be end with '\0', so the length should plus 1.
+                    StructNlAttr.NLA_HEADERLEN + interfaceName.length() + 1);
+        }
+
+        return length;
+    }
+
+    /**
+     * Create a link message to set the operational state (up or down) of a network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param isUp           {@code true} to set the interface up, {@code false} to set it down.
+     * @return A `RtNetlinkLinkMessage` instance configured to set the link state.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp) {
+        return createSetLinkStateMessage(interfaceName, sequenceNumber, isUp, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex,
+                                    isUp ? IFF_UP : 0, IFF_UP), DEFAULT_MTU, null, null);
+    }
+
+    /**
+     * Create a link message to rename the network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param newName        The new name of the network interface.
+     * @return A `RtNetlinkLinkMessage` instance configured to rename the network interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName) {
+        return createSetLinkNameMessage(interfaceName, sequenceNumber, newName, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
+                DEFAULT_MTU, null, newName);
+    }
+
     @Override
     public String toString() {
         return "RtNetlinkLinkMessage{ "
                 + "nlmsghdr{" + mHeader.toString(OsConstants.NETLINK_ROUTE) + "}, "
-                + "Ifinfomsg{" + mIfinfomsg.toString() + "}, "
+                + "Ifinfomsg{" + mIfinfomsg + "}, "
                 + "Hardware Address{" + mHardwareAddress + "}, "
                 + "MTU{" + mMtu + "}, "
                 + "Ifname{" + mInterfaceName + "} "
diff --git a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
index 5272366..7cc95de 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
@@ -32,10 +32,11 @@
     // Already aligned.
     public static final int STRUCT_SIZE = 16;
 
-    public static final short NLM_F_REQUEST = 0x0001;
-    public static final short NLM_F_MULTI   = 0x0002;
-    public static final short NLM_F_ACK     = 0x0004;
-    public static final short NLM_F_ECHO    = 0x0008;
+    public static final short NLM_F_REQUEST     = 0x0001;
+    public static final short NLM_F_MULTI       = 0x0002;
+    public static final short NLM_F_ACK         = 0x0004;
+    public static final short NLM_F_ECHO        = 0x0008;
+    public static final short NLM_F_REQUEST_ACK = NLM_F_REQUEST | NLM_F_ACK;
     // Flags for a GET request.
     public static final short NLM_F_ROOT    = 0x0100;
     public static final short NLM_F_MATCH   = 0x0200;
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 61f41f7..8c54e6a 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -30,8 +30,8 @@
         "net-utils-service-connectivity",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
     ],
     visibility: [
         "//frameworks/base/packages/Tethering/tests/integration",
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
index 9db63db..ee74468 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
@@ -24,24 +24,29 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
 
+import android.annotation.SuppressLint;
 import android.net.MacAddress;
 import android.system.OsConstants;
 
 import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.net.module.util.HexDump;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 
-@RunWith(AndroidJUnit4.class)
+@RunWith(MockitoJUnitRunner.class)
 @SmallTest
 public class RtNetlinkLinkMessageTest {
+    @Mock
+    private OsAccess mOsAccess;
 
     // An example of the full RTM_NEWLINK message.
     private static final String RTM_NEWLINK_HEX =
@@ -124,14 +129,14 @@
     }
 
     private static final String RTM_NEWLINK_PACK_HEX =
-            "34000000100000000000000000000000"   // struct nlmsghr
+            "40000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
             + "08000400DC050000"                 // IFLA_MTU
             + "0A00010092C3E3C9374E0000"         // IFLA_ADDRESS
             + "0A000300776C616E30000000";        // IFLA_IFNAME(wlan0)
 
     @Test
-    public void testPackRtmNewLink() {
+    public void testParseAndPackRtmNewLink() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_PACK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
         final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
@@ -145,6 +150,21 @@
         assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBuffer.array()));
     }
 
+    @Test
+    public void testPackRtmNewLink() {
+        final RtNetlinkLinkMessage linkMsg = RtNetlinkLinkMessage.build(
+                // nlmsg_len will be updated inside create() method, so it's ok to set 0 here.
+                new StructNlMsgHdr(0 /*nlmsg_len*/, (short) 0x10, (short) 0, 0),
+                new StructIfinfoMsg((byte) 0, (short) 1, 0x1e, 0x1002, 0),
+                1500,
+                MacAddress.fromString("92:c3:e3:c9:37:4e"),
+                "wlan0");
+        assertNotNull(linkMsg);
+
+        final byte[] packBytes = linkMsg.pack(ByteOrder.LITTLE_ENDIAN);
+        assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBytes));
+    }
+
     private static final String RTM_NEWLINK_TRUNCATED_HEX =
             "54000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
@@ -171,6 +191,104 @@
     }
 
     @Test
+    public void testCreateSetLinkUpMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                + "00000000080000000100000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = true;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkDownMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                        + "00000000080000000000000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkStateMessage_InvalidInterface() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNull(msg);
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage() {
+        final String expectedHexBytes =
+                "2C000000100005006824000000000000"   // struct nlmsghdr
+                + "00000000080000000000000000000000" // struct ifinfomsg
+                + "0A000300776C616E31000000";        // IFLA_IFNAME(wlan1)
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InterfaceNotFound() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InvalidNewName() {
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final String[] invalidNames = {"", "interface_name_longer_than_limit"};
+        for (String invalidName : invalidNames) {
+            assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                    interfaceName, sequenceNumber, invalidName, mOsAccess));
+        }
+    }
+
+    @Test
     public void testToString() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
index 84fb47b..341d55f 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
@@ -29,7 +29,6 @@
 import android.os.Binder
 import android.os.Build
 import androidx.annotation.RequiresApi
-import com.android.modules.utils.build.SdkLevel.isAtLeastR
 import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.TimeUnit
@@ -137,7 +136,6 @@
 
         network = try {
             if (lp != null) {
-                assertTrue(isAtLeastR(), "Cannot specify TestNetwork LinkProperties before R")
                 tnm.setupTestNetwork(lp, true /* isMetered */, binder)
             } else {
                 tnm.setupTestNetwork(iface.interfaceName, binder)
diff --git a/staticlibs/testutils/host/python/apf_test_base.py b/staticlibs/testutils/host/python/apf_test_base.py
index 7203265..9a30978 100644
--- a/staticlibs/testutils/host/python/apf_test_base.py
+++ b/staticlibs/testutils/host/python/apf_test_base.py
@@ -23,6 +23,11 @@
     super().setup_class()
 
     # Check test preconditions.
+    asserts.abort_class_if(
+        not self.client.isAtLeastV(),
+        "Do not enforce the test until V+ since chipset potential bugs are"
+        " expected to be fixed on V+ releases.",
+    )
     tether_utils.assume_hotspot_test_preconditions(
         self.serverDevice, self.clientDevice, UpstreamType.NONE
     )
@@ -34,13 +39,12 @@
     )
 
     # Fetch device properties and storing them locally for later use.
-    client = self.clientDevice.connectivity_multi_devices_snippet
     self.server_iface_name, client_network = (
         tether_utils.setup_hotspot_and_client_for_upstream_type(
             self.serverDevice, self.clientDevice, UpstreamType.NONE
         )
     )
-    self.client_iface_name = client.getInterfaceNameFromNetworkHandle(
+    self.client_iface_name = self.client.getInterfaceNameFromNetworkHandle(
         client_network
     )
     self.server_mac_address = apf_utils.get_hardware_address(
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index a3ec6e9..c3330d2 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -236,7 +236,7 @@
     ad: android_device.AndroidDevice, iface_name: str, expected_version: int
 ) -> None:
   caps = get_apf_capabilities(ad, iface_name)
-  asserts.skip_if(
+  asserts.abort_class_if(
       caps.apf_version_supported < expected_version,
       f"Supported apf version {caps.apf_version_supported} < expected version"
       f" {expected_version}",
diff --git a/staticlibs/testutils/host/python/multi_devices_test_base.py b/staticlibs/testutils/host/python/multi_devices_test_base.py
index f8a92f3..677329a 100644
--- a/staticlibs/testutils/host/python/multi_devices_test_base.py
+++ b/staticlibs/testutils/host/python/multi_devices_test_base.py
@@ -52,3 +52,4 @@
         max_workers=2,
         raise_on_exception=True,
     )
+    self.client = self.clientDevice.connectivity_multi_devices_snippet
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 3868905..7fff1c2 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -37,8 +37,8 @@
         "modules-utils-build",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
     ],
     srcs: [
         "src/**/*.java",
diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp
index 5f062f1..40aa1e4 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -26,6 +26,7 @@
         "run_tests.py",
     ],
     libs: [
+        "absl-py",
         "mobly",
         "net-tests-utils-host-python-common",
     ],
diff --git a/tests/cts/multidevices/apfv4_test.py b/tests/cts/multidevices/apfv4_test.py
index 4633d37..7795be5 100644
--- a/tests/cts/multidevices/apfv4_test.py
+++ b/tests/cts/multidevices/apfv4_test.py
@@ -12,23 +12,52 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-from net_tests_utils.host.python import apf_test_base
+from absl.testing import parameterized
+from mobly import asserts
+from net_tests_utils.host.python import apf_test_base, apf_utils
 
 # Constants.
 COUNTER_DROPPED_ETHERTYPE_NOT_ALLOWED = "DROPPED_ETHERTYPE_NOT_ALLOWED"
 ETHER_BROADCAST_ADDR = "FFFFFFFFFFFF"
-ETH_P_ETHERCAT = "88A4"
 
 
-class ApfV4Test(apf_test_base.ApfTestBase):
+class ApfV4Test(apf_test_base.ApfTestBase, parameterized.TestCase):
+  def setup_class(self):
+    super().setup_class()
+    # Check apf version preconditions.
+    caps = apf_utils.get_apf_capabilities(
+        self.clientDevice, self.client_iface_name
+    )
+    if self.client.getVsrApiLevel() >= 34:
+      # Enforce APFv4 support for Android 14+ VSR.
+      asserts.assert_true(
+          caps.apf_version_supported >= 4,
+          "APFv4 became mandatory in Android 14 VSR.",
+      )
+    else:
+      # Skip tests for APF version < 4 before Android 14 VSR.
+      apf_utils.assume_apf_version_support_at_least(
+          self.clientDevice, self.client_iface_name, 4
+      )
 
-  def test_apf_drop_ethercat(self):
+  # APF L2 packet filtering on V+ Android allows only specific
+  # types: IPv4, ARP, IPv6, EAPOL, WAPI.
+  # Tests can use any disallowed packet type. Currently,
+  # several ethertypes from the legacy ApfFilter denylist are used.
+  @parameterized.parameters(
+      "88a2",  # ATA over Ethernet
+      "88a4",  # EtherCAT
+      "88b8",  # GOOSE (Generic Object Oriented Substation event)
+      "88cd",  # SERCOS III
+      "88e3",  # Media Redundancy Protocol (IEC62439-2)
+  )  # Declare inputs for state_str and expected_result.
+  def test_apf_drop_ethertype_not_allowed(self, blocked_ether_type):
     # Ethernet header (14 bytes).
     packet = ETHER_BROADCAST_ADDR  # Destination MAC (broadcast)
     packet += self.server_mac_address.replace(":", "")  # Source MAC
-    packet += ETH_P_ETHERCAT  # EtherType (EtherCAT)
+    packet += blocked_ether_type
 
-    # EtherCAT header (2 bytes) + 44 bytes of zero padding.
+    # Pad with zeroes to minimum ethernet frame length.
     packet += "00" * 46
     self.send_packet_and_expect_counter_increased(
         packet, COUNTER_DROPPED_ETHERTYPE_NOT_ALLOWED
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 7368669..49688cc 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -36,6 +36,7 @@
 import android.net.wifi.WifiNetworkSpecifier
 import android.net.wifi.WifiSsid
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.PropertyUtil
 import com.android.modules.utils.build.SdkLevel
 import com.android.testutils.AutoReleaseNetworkCallbackRule
 import com.android.testutils.ConnectUtil
@@ -75,6 +76,12 @@
     @Rpc(description = "Check whether the device SDK is as least T")
     fun isAtLeastT() = SdkLevel.isAtLeastT()
 
+    @Rpc(description = "Return whether the Sdk level is at least V.")
+    fun isAtLeastV() = SdkLevel.isAtLeastV()
+
+    @Rpc(description = "Return the API level that the VSR requirement must be fulfilled.")
+    fun getVsrApiLevel() = PropertyUtil.getVsrApiLevel()
+
     @Rpc(description = "Request cellular connection and ensure it is the default network.")
     fun requestCellularAndEnsureDefault() {
         ctsNetUtils.disableWifi()
diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp
index 1cd8327..a5ad7f2 100644
--- a/tests/cts/net/Android.bp
+++ b/tests/cts/net/Android.bp
@@ -29,7 +29,7 @@
 
     libs: [
         "voip-common",
-        "android.test.base",
+        "android.test.base.stubs",
     ],
 
     jni_libs: [
diff --git a/tests/cts/net/api23Test/Android.bp b/tests/cts/net/api23Test/Android.bp
index 587d5a5..7d93c3a 100644
--- a/tests/cts/net/api23Test/Android.bp
+++ b/tests/cts/net/api23Test/Android.bp
@@ -25,7 +25,7 @@
     compile_multilib: "both",
 
     libs: [
-        "android.test.base",
+        "android.test.base.stubs.test",
     ],
 
     srcs: [
diff --git a/tests/cts/net/api23Test/AndroidTest.xml b/tests/cts/net/api23Test/AndroidTest.xml
index 8042d50..fcc73f3 100644
--- a/tests/cts/net/api23Test/AndroidTest.xml
+++ b/tests/cts/net/api23Test/AndroidTest.xml
@@ -18,6 +18,7 @@
     <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
     <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
     <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user_on_secondary_display" />
     <option name="not-shardable" value="true" />
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="cleanup-apks" value="true" />
diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
index 07e2024..1389be7 100644
--- a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
+++ b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
@@ -44,7 +44,6 @@
 import android.util.Log
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import androidx.test.runner.AndroidJUnit4
-import com.android.modules.utils.build.SdkLevel.isAtLeastR
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTP_URL
 import com.android.testutils.AutoReleaseNetworkCallbackRule
@@ -201,10 +200,7 @@
                     "access."
             assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
 
-            val startPortalAppPermission =
-                    if (isAtLeastR()) NETWORK_SETTINGS
-                    else CONNECTIVITY_INTERNAL
-            runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) }
+            runAsShell(NETWORK_SETTINGS) { cm.startCaptivePortalApp(network) }
 
             // Expect the portal content to be fetched at some point after detecting the portal.
             // Some implementations may fetch the URL before startCaptivePortalApp is called.
diff --git a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
index f6a025a..cb55bd5 100644
--- a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
+++ b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
@@ -184,6 +184,8 @@
 
     // Static state to reduce setup/teardown
     private static final Context sContext = InstrumentationRegistry.getContext();
+    private static boolean sIsWatch =
+                sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH);
     private static final ConnectivityManager sCM =
             (ConnectivityManager) sContext.getSystemService(Context.CONNECTIVITY_SERVICE);
     private static final VpnManager sVpnMgr =
@@ -205,12 +207,15 @@
 
     @Before
     public void setUp() {
-        assumeFalse("Skipping test because watches don't support VPN",
-            sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH));
+        assumeFalse("Skipping test because watches don't support VPN", sIsWatch);
     }
 
     @After
     public void tearDown() {
+        if (sIsWatch) {
+            return; // Tests are skipped for watches.
+        }
+
         for (TestableNetworkCallback callback : mCallbacksToUnregister) {
             sCM.unregisterNetworkCallback(callback);
         }
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 1023173..1165018 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -22,7 +22,7 @@
     defaults: ["cts_defaults"],
 
     libs: [
-        "android.test.base",
+        "android.test.base.stubs.system",
     ],
 
     srcs: [
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
index 349529dd..6c3b7a0 100644
--- a/tests/integration/Android.bp
+++ b/tests/integration/Android.bp
@@ -33,7 +33,7 @@
         "src/**/*.aidl",
     ],
     libs: [
-        "android.test.mock",
+        "android.test.mock.stubs",
         "ServiceConnectivityResources",
     ],
     static_libs: [
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index ef3ebb0..00f9d05 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -104,9 +104,9 @@
     ],
     libs: [
         "android.net.ipsec.ike.stubs.module_lib",
-        "android.test.runner",
-        "android.test.base",
-        "android.test.mock",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
+        "android.test.mock.stubs",
         "ServiceConnectivityResources",
     ],
     exclude_kotlinc_generated_files: false,
diff --git a/tests/unit/java/android/net/TrafficStatsTest.kt b/tests/unit/java/android/net/TrafficStatsTest.kt
new file mode 100644
index 0000000..c61541e
--- /dev/null
+++ b/tests/unit/java/android/net/TrafficStatsTest.kt
@@ -0,0 +1,46 @@
+/*
+* Copyright (C) 2024 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+package android.net
+
+import android.net.TrafficStats.getValueForTypeFromFirstEntry
+import android.net.TrafficStats.TYPE_RX_BYTES
+import android.net.TrafficStats.UNSUPPORTED
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+
+private const val TEST_IFACE1 = "test_iface1"
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class TrafficStatsTest {
+
+    @Test
+    fun testGetValueForTypeFromFirstEntry() {
+        var stats: NetworkStats = NetworkStats(0, 0)
+        // empty stats
+        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), UNSUPPORTED.toLong())
+        // invalid type
+        stats.insertEntry(TEST_IFACE1, 1, 2, 3, 4)
+        assertEquals(getValueForTypeFromFirstEntry(stats, 1000), UNSUPPORTED.toLong())
+        // valid type
+        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), 1)
+    }
+}
\ No newline at end of file
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
index ec47618..d801fba 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -102,6 +102,7 @@
     @Mock MdnsServiceBrowserListener mockListenerOne;
     @Mock MdnsServiceBrowserListener mockListenerTwo;
     @Mock SharedLog sharedLog;
+    @Mock MdnsServiceCache mockServiceCache;
     private MdnsDiscoveryManager discoveryManager;
     private HandlerThread thread;
     private Handler handler;
@@ -145,7 +146,9 @@
                         return null;
                     }
                 };
+        discoveryManager = makeDiscoveryManager(MdnsFeatureFlags.newBuilder().build());
         doReturn(mockExecutorService).when(mockServiceTypeClientType1NullNetwork).getExecutor();
+        doReturn(mockExecutorService).when(mockServiceTypeClientType1Network1).getExecutor();
     }
 
     @After
@@ -156,6 +159,40 @@
         }
     }
 
+    private MdnsDiscoveryManager makeDiscoveryManager(@NonNull MdnsFeatureFlags featureFlags) {
+        return new MdnsDiscoveryManager(executorProvider, socketClient, sharedLog, featureFlags) {
+            @Override
+            MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
+                    @NonNull SocketKey socketKey) {
+                createdServiceTypeClientCount++;
+                final Pair<String, SocketKey> perSocketServiceType =
+                        Pair.create(serviceType, socketKey);
+                if (perSocketServiceType.equals(PER_SOCKET_SERVICE_TYPE_1_NULL_NETWORK)) {
+                    return mockServiceTypeClientType1NullNetwork;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_1_NETWORK_1)) {
+                    return mockServiceTypeClientType1Network1;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NULL_NETWORK)) {
+                    return mockServiceTypeClientType2NullNetwork;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NETWORK_1)) {
+                    return mockServiceTypeClientType2Network1;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NETWORK_2)) {
+                    return mockServiceTypeClientType2Network2;
+                }
+                fail("Unexpected perSocketServiceType: " + perSocketServiceType);
+                return null;
+            }
+
+            @Override
+            MdnsServiceCache getServiceCache() {
+                return mockServiceCache;
+            }
+        };
+    }
+
     private void runOnHandler(Runnable r) {
         handler.post(r);
         HandlerUtils.waitForIdle(handler, DEFAULT_TIMEOUT);
@@ -438,6 +475,57 @@
         }
     }
 
+    @Test
+    public void testRemoveServicesAfterAllListenersUnregistered() throws IOException {
+        final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
+                .setIsCachedServicesRemovalEnabled(true)
+                .setCachedServicesRetentionTime(0L)
+                .build();
+        discoveryManager = makeDiscoveryManager(mdnsFeatureFlags);
+
+        final MdnsSearchOptions options =
+                MdnsSearchOptions.newBuilder().setNetwork(NETWORK_1).build();
+        final SocketCreationCallback callback = expectSocketCreationCallback(
+                SERVICE_TYPE_1, mockListenerOne, options);
+        runOnHandler(() -> callback.onSocketCreated(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).startSendAndReceive(mockListenerOne, options);
+
+        final MdnsServiceCache.CacheKey cacheKey =
+                new MdnsServiceCache.CacheKey(SERVICE_TYPE_1, SOCKET_KEY_NETWORK_1);
+        doReturn(cacheKey).when(mockServiceTypeClientType1Network1).getCacheKey();
+        doReturn(true).when(mockServiceTypeClientType1Network1)
+                .stopSendAndReceive(mockListenerOne);
+        runOnHandler(() -> discoveryManager.unregisterListener(SERVICE_TYPE_1, mockListenerOne));
+        verify(executorProvider).shutdownExecutorService(mockExecutorService);
+        verify(mockServiceTypeClientType1Network1).stopSendAndReceive(mockListenerOne);
+        verify(socketClient).stopDiscovery();
+        verify(mockServiceCache).removeServices(cacheKey);
+    }
+
+    @Test
+    public void testRemoveServicesAfterSocketDestroyed() throws IOException {
+        final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
+                .setIsCachedServicesRemovalEnabled(true)
+                .setCachedServicesRetentionTime(0L)
+                .build();
+        discoveryManager = makeDiscoveryManager(mdnsFeatureFlags);
+
+        final MdnsSearchOptions options =
+                MdnsSearchOptions.newBuilder().setNetwork(NETWORK_1).build();
+        final SocketCreationCallback callback = expectSocketCreationCallback(
+                SERVICE_TYPE_1, mockListenerOne, options);
+        runOnHandler(() -> callback.onSocketCreated(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).startSendAndReceive(mockListenerOne, options);
+
+        final MdnsServiceCache.CacheKey cacheKey =
+                new MdnsServiceCache.CacheKey(SERVICE_TYPE_1, SOCKET_KEY_NETWORK_1);
+        doReturn(cacheKey).when(mockServiceTypeClientType1Network1).getCacheKey();
+        runOnHandler(() -> callback.onSocketDestroyed(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).notifySocketDestroyed();
+        verify(executorProvider).shutdownExecutorService(mockExecutorService);
+        verify(mockServiceCache).removeServices(cacheKey);
+    }
+
     private MdnsPacket createMdnsPacket(String serviceType) {
         final String[] type = TextUtils.split(serviceType, "\\.");
         final ArrayList<String> name = new ArrayList<>(type.length + 1);
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 3d2f389..ef4c44d 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -56,6 +56,7 @@
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.net.TrafficStats.UID_REMOVED;
 import static android.net.TrafficStats.UID_TETHERING;
+import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -72,13 +73,13 @@
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_UPDATED;
 import static com.android.server.net.NetworkStatsService.BROADCAST_NETWORK_STATS_UPDATED_RATE_LIMIT_ENABLED_FLAG;
 import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS;
-import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES;
+import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES;
 import static com.android.server.net.NetworkStatsService.NETSTATS_FASTDATAINPUT_FALLBACKS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_FASTDATAINPUT_SUCCESSES_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_SUCCESSES_COUNTER_NAME;
-import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG;
+import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertEquals;
@@ -620,8 +621,9 @@
         }
 
         @Override
-        public boolean alwaysUseTrafficStatsRateLimitCache(Context ctx) {
-            return mFeatureFlags.getOrDefault(TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
+        public boolean alwaysUseTrafficStatsServiceRateLimitCache(Context ctx) {
+            return mFeatureFlags.getOrDefault(
+                    TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
         }
 
         @Override
@@ -636,8 +638,8 @@
         }
 
         @Override
-        public int getTrafficStatsRateLimitCacheMaxEntries() {
-            return DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES;
+        public int getTrafficStatsServiceRateLimitCacheMaxEntries() {
+            return DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES;
         }
 
         @Override
@@ -2451,28 +2453,28 @@
         assertUidTotal(sTemplateWifi, UID_GREEN, 64L, 3L, 1024L, 8L, 0);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
     @Test
     public void testTrafficStatsRateLimitCache_disabledWithCompatChangeEnabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
         doTestTrafficStatsRateLimitCache(true /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
     @Test
     public void testTrafficStatsRateLimitCache_enabledWithCompatChangeEnabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
         doTestTrafficStatsRateLimitCache(true /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
     @Test
     public void testTrafficStatsRateLimitCache_disabledWithCompatChangeDisabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
         doTestTrafficStatsRateLimitCache(false /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
     @Test
     public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
@@ -2514,11 +2516,13 @@
     private void assertTrafficStatsValues(String iface, int uid, long rxBytes, long rxPackets,
             long txBytes, long txPackets) {
         assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> mService.getTotalStats(type));
+                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessTotalStats(), type));
         assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> mService.getIfaceStats(iface, type));
+                (type) -> getValueForTypeFromFirstEntry(
+                        mService.getTypelessIfaceStats(iface), type)
+        );
         assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> mService.getUidStats(uid, type));
+                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessUidStats(uid), type));
     }
 
     private void assertTrafficStatsValuesThat(long rxBytes, long rxPackets, long txBytes,
diff --git a/thread/demoapp/Android.bp b/thread/demoapp/Android.bp
index 117b4f9..a786639 100644
--- a/thread/demoapp/Android.bp
+++ b/thread/demoapp/Android.bp
@@ -32,7 +32,7 @@
         "guava",
     ],
     libs: [
-        "framework-connectivity-t",
+        "framework-connectivity-t.stubs.module_lib",
     ],
     required: [
         "privapp-permissions-com.android.threadnetwork.demoapp",
diff --git a/thread/docs/build-an-android-border-router.md b/thread/docs/build-an-android-border-router.md
index 257999b..f90a23b 100644
--- a/thread/docs/build-an-android-border-router.md
+++ b/thread/docs/build-an-android-border-router.md
@@ -169,7 +169,7 @@
     user thread_network
 ```
 
-For real RCP devices, it supports both SPI and UART interace and you can
+For real RCP devices, it supports both SPI and UART interfaces and you can
 specify the device with the schema `spinel+spi://`, `spinel+hdlc+uart://` and
 `spinel+socket://` respectively.
 
diff --git a/thread/framework/java/android/net/thread/IOutputReceiver.aidl b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
new file mode 100644
index 0000000..b6b4375
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
@@ -0,0 +1,24 @@
+/*
+ * Copyright 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 android.net.thread;
+
+/** Receives the output of a Thread network operation. @hide */
+oneway interface IOutputReceiver {
+    void onOutput(in String output);
+    void onComplete();
+    void onError(int errorCode, String errorMessage);
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index ecaefd0..cb4e8de 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -706,9 +706,9 @@
     /**
      * Sets max power of each channel.
      *
-     * <p>This method sets the max power for the given channel. The platform sets the actual
-     * output power to be less than or equal to the {@code channelMaxPowers} and as close as
-     * possible to the {@code channelMaxPowers}.
+     * <p>This method sets the max power for the given channel. The platform sets the actual output
+     * power to be less than or equal to the {@code channelMaxPowers} and as close as possible to
+     * the {@code channelMaxPowers}.
      *
      * <p>If not set, the default max power is set by the Thread HAL service or the Thread radio
      * chip firmware.
@@ -726,13 +726,13 @@
      *     and corresponding max power. Valid channel values should be between {@link
      *     ActiveOperationalDataset#CHANNEL_MIN_24_GHZ} and {@link
      *     ActiveOperationalDataset#CHANNEL_MAX_24_GHZ}. The unit of the max power is 0.01dBm. For
-     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of
-     *     {@code channelMaxPowers} is lower than the minimum output power supported by the
-     *     platform, the output power will be set to the minimum output power supported by the
-     *     platform. If the power value of {@code channelMaxPowers} is higher than the maximum
-     *     output power supported by the platform, the output power will be set to the maximum
-     *     output power supported by the platform. If the power value of {@code channelMaxPowers}
-     *     is set to {@link #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
+     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of {@code
+     *     channelMaxPowers} is lower than the minimum output power supported by the platform, the
+     *     output power will be set to the minimum output power supported by the platform. If the
+     *     power value of {@code channelMaxPowers} is higher than the maximum output power supported
+     *     by the platform, the output power will be set to the maximum output power supported by
+     *     the platform. If the power value of {@code channelMaxPowers} is set to {@link
+     *     #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
      * @param executor the executor to execute {@code receiver}.
      * @param receiver the receiver to receive the result of this operation.
      * @throws IllegalArgumentException if the size of {@code channelMaxPowers} is smaller than 1,
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index bca8b6e..b863bc2 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -81,6 +81,19 @@
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
     /**
+     * Permission allows accessing Thread network state and performing certain testing-related
+     * operations.
+     *
+     * <p>This is the same value as android.Manifest.permission.THREAD_NETWORK_TESTING. That symbol
+     * is not available on U while this feature needs to support Android U TV devices, so here is
+     * making a copy of android.Manifest.permission.THREAD_NETWORK_TESTING.
+     *
+     * @hide
+     */
+    public static final String PERMISSION_THREAD_NETWORK_TESTING =
+            "android.permission.THREAD_NETWORK_TESTING";
+
+    /**
      * This user restriction specifies if Thread network is disallowed on the device. If Thread
      * network is disallowed it cannot be turned on via Settings.
      *
diff --git a/thread/service/Android.bp b/thread/service/Android.bp
index a82a499..1f4e601 100644
--- a/thread/service/Android.bp
+++ b/thread/service/Android.bp
@@ -37,7 +37,7 @@
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
         "framework-location.stubs.module_lib",
-        "framework-wifi",
+        "framework-wifi.stubs.module_lib",
         "service-connectivity-pre-jarjar",
         "ServiceConnectivityResources",
     ],
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index 8d89e13..9697c02 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -23,6 +23,7 @@
 import android.content.Context;
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -30,6 +31,7 @@
 import android.os.CancellationSignal;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.system.Os;
 import android.text.TextUtils;
 import android.util.SparseArray;
 
@@ -66,6 +68,7 @@
 
     // TODO: b/321883491 - specify network for mDNS operations
     @Nullable private Network mNetwork;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final NsdManager mNsdManager;
     private final DnsResolver mDnsResolver;
     private final Handler mHandler;
@@ -76,17 +79,28 @@
     private final SparseArray<HostInfoListener> mHostInfoListeners = new SparseArray<>(0);
 
     @VisibleForTesting
-    public NsdPublisher(NsdManager nsdManager, DnsResolver dnsResolver, Handler handler) {
+    public NsdPublisher(
+            NsdManager nsdManager,
+            DnsResolver dnsResolver,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mNetwork = null;
         mNsdManager = nsdManager;
         mDnsResolver = dnsResolver;
         mHandler = handler;
         mExecutor = runnable -> mHandler.post(runnable);
+        mNetworkToLinkProperties = networkToLinkProperties;
     }
 
-    public static NsdPublisher newInstance(Context context, Handler handler) {
+    public static NsdPublisher newInstance(
+            Context context,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         return new NsdPublisher(
-                context.getSystemService(NsdManager.class), DnsResolver.getInstance(), handler);
+                context.getSystemService(NsdManager.class),
+                DnsResolver.getInstance(),
+                handler,
+                networkToLinkProperties);
     }
 
     // TODO: b/321883491 - NsdPublisher should be disabled when mNetwork is null
@@ -586,6 +600,14 @@
                             + ", serviceInfo: "
                             + serviceInfo);
             List<String> addresses = new ArrayList<>();
+            int interfaceIndex = 0;
+            if (mNetworkToLinkProperties.containsKey(serviceInfo.getNetwork())) {
+                interfaceIndex =
+                        Os.if_nametoindex(
+                                mNetworkToLinkProperties
+                                        .get(serviceInfo.getNetwork())
+                                        .getInterfaceName());
+            }
             for (InetAddress address : serviceInfo.getHostAddresses()) {
                 if (address instanceof Inet6Address) {
                     addresses.add(address.getHostAddress());
@@ -602,6 +624,7 @@
             try {
                 mResolveServiceCallback.onServiceResolved(
                         serviceInfo.getHostname(),
+                        interfaceIndex,
                         serviceInfo.getServiceName(),
                         serviceInfo.getServiceType(),
                         serviceInfo.getPort(),
diff --git a/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
new file mode 100644
index 0000000..aa9a05d
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
@@ -0,0 +1,120 @@
+/*
+ * 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.server.thread;
+
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
+
+import android.net.thread.IOutputReceiver;
+import android.net.thread.ThreadNetworkException;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** A {@link IOutputReceiver} wrapper which makes it easier to invoke the callbacks. */
+final class OutputReceiverWrapper {
+    private final IOutputReceiver mReceiver;
+    private final boolean mExpectOtDaemonDied;
+
+    private static final Object sPendingReceiversLock = new Object();
+
+    @GuardedBy("sPendingReceiversLock")
+    private static final Set<OutputReceiverWrapper> sPendingReceivers = new HashSet<>();
+
+    public OutputReceiverWrapper(IOutputReceiver receiver) {
+        this(receiver, false /* expectOtDaemonDied */);
+    }
+
+    /**
+     * Creates a new {@link OutputReceiverWrapper}.
+     *
+     * <p>If {@code expectOtDaemonDied} is {@code true}, it's expected that ot-daemon becomes dead
+     * before {@code receiver} is completed with {@code onComplete} and {@code onError} and {@code
+     * receiver#onComplete} will be invoked in this case.
+     */
+    public OutputReceiverWrapper(IOutputReceiver receiver, boolean expectOtDaemonDied) {
+        mReceiver = receiver;
+        mExpectOtDaemonDied = expectOtDaemonDied;
+
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.add(this);
+        }
+    }
+
+    public static void onOtDaemonDied() {
+        synchronized (sPendingReceiversLock) {
+            for (OutputReceiverWrapper receiver : sPendingReceivers) {
+                try {
+                    if (receiver.mExpectOtDaemonDied) {
+                        receiver.mReceiver.onComplete();
+                    } else {
+                        receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                    }
+                } catch (RemoteException e) {
+                    // The client is dead, do nothing
+                }
+            }
+            sPendingReceivers.clear();
+        }
+    }
+
+    public void onOutput(String output) {
+        try {
+            mReceiver.onOutput(output);
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onComplete() {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onComplete();
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onError(Throwable e) {
+        if (e instanceof ThreadNetworkException) {
+            ThreadNetworkException threadException = (ThreadNetworkException) e;
+            onError(threadException.getErrorCode(), threadException.getMessage());
+        } else if (e instanceof RemoteException) {
+            onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } else {
+            throw new AssertionError(e);
+        }
+    }
+
+    public void onError(int errorCode, String errorMessage, Object... messageArgs) {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onError(errorCode, String.format(errorMessage, messageArgs));
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 6edaae9..57fea34 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -43,6 +43,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
@@ -94,6 +95,7 @@
 import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOperationalDatasetCallback;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.IStateCallback;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.OperationalDatasetTimestamp;
@@ -124,6 +126,7 @@
 import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
 import com.android.server.thread.openthread.IOtDaemonCallback;
+import com.android.server.thread.openthread.IOtOutputReceiver;
 import com.android.server.thread.openthread.IOtStatusReceiver;
 import com.android.server.thread.openthread.InfraLinkState;
 import com.android.server.thread.openthread.Ipv6AddressInfo;
@@ -209,7 +212,7 @@
     private NetworkRequest mUpstreamNetworkRequest;
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
-    private final HashMap<Network, String> mNetworkToInterface;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
     private boolean mUserRestricted;
@@ -231,7 +234,8 @@
             NsdPublisher nsdPublisher,
             UserManager userManager,
             ConnectivityResources resources,
-            Supplier<String> countryCodeSupplier) {
+            Supplier<String> countryCodeSupplier,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mContext = context;
         mHandler = handler;
         mNetworkProvider = networkProvider;
@@ -240,7 +244,9 @@
         mTunIfController = tunIfController;
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
-        mNetworkToInterface = new HashMap<Network, String>();
+        // TODO: networkToLinkProperties should be shared with NsdPublisher, add a test/assert to
+        // verify they are the same.
+        mNetworkToLinkProperties = networkToLinkProperties;
         mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
         mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
@@ -259,6 +265,7 @@
         Handler handler = new Handler(handlerThread.getLooper());
         NetworkProvider networkProvider =
                 new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider");
+        Map<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
 
         return new ThreadNetworkControllerService(
                 context,
@@ -269,10 +276,11 @@
                 new TunInterfaceController(TUN_IF_NAME),
                 new InfraInterfaceController(),
                 persistentSettings,
-                NsdPublisher.newInstance(context, handler),
+                NsdPublisher.newInstance(context, handler, networkToLinkProperties),
                 context.getSystemService(UserManager.class),
                 new ConnectivityResources(context),
-                countryCodeSupplier);
+                countryCodeSupplier,
+                networkToLinkProperties);
     }
 
     private NetworkRequest newUpstreamNetworkRequest() {
@@ -426,6 +434,7 @@
         LOG.w("OT daemon is dead, clean up...");
 
         OperationReceiverWrapper.onOtDaemonDied();
+        OutputReceiverWrapper.onOtDaemonDied();
         mOtDaemonCallbackProxy.onOtDaemonDied();
         mTunIfController.onOtDaemonDied();
         mNsdPublisher.onOtDaemonDied();
@@ -447,7 +456,7 @@
                     }
                     mConnectivityManager.registerNetworkProvider(mNetworkProvider);
                     requestUpstreamNetwork();
-                    requestThreadNetwork();
+                    registerThreadNetworkCallback();
                     mUserRestricted = isThreadUserRestricted();
                     registerUserRestrictionsReceiver();
                     maybeInitializeOtDaemon();
@@ -690,7 +699,7 @@
         if (mUpstreamNetworkCallback == null) {
             throw new AssertionError("The upstream network request null.");
         }
-        mNetworkToInterface.clear();
+        mNetworkToLinkProperties.clear();
         mConnectivityManager.unregisterNetworkCallback(mUpstreamNetworkCallback);
         mUpstreamNetworkCallback = null;
     }
@@ -712,20 +721,19 @@
 
         @Override
         public void onLinkPropertiesChanged(
-                @NonNull Network network, @NonNull LinkProperties linkProperties) {
+                @NonNull Network network, @NonNull LinkProperties newLinkProperties) {
             checkOnHandlerThread();
 
-            String existingIfName = mNetworkToInterface.get(network);
-            String newIfName = linkProperties.getInterfaceName();
-            if (Objects.equals(existingIfName, newIfName)) {
+            LinkProperties oldLinkProperties = mNetworkToLinkProperties.get(network);
+            if (Objects.equals(oldLinkProperties, newLinkProperties)) {
                 return;
             }
-            LOG.i("Upstream network changed: " + existingIfName + " -> " + newIfName);
-            mNetworkToInterface.put(network, newIfName);
+            LOG.i("Upstream network changed: " + oldLinkProperties + " -> " + newLinkProperties);
+            mNetworkToLinkProperties.put(network, newLinkProperties);
 
             // TODO: disable border routing if netIfName is null
             if (network.equals(mUpstreamNetwork)) {
-                enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
+                setInfraLinkState(newInfraLinkStateBuilder(newLinkProperties).build());
             }
         }
     }
@@ -741,7 +749,7 @@
         public void onLost(@NonNull Network network) {
             checkOnHandlerThread();
             LOG.i("Thread network is lost: " + network);
-            disableBorderRouting();
+            setInfraLinkState(newInfraLinkStateBuilder().build());
         }
 
         @Override
@@ -755,20 +763,22 @@
                             + localNetworkInfo
                             + "}");
             if (localNetworkInfo.getUpstreamNetwork() == null) {
-                disableBorderRouting();
+                setInfraLinkState(newInfraLinkStateBuilder().build());
                 return;
             }
             if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
                 mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
-                if (mNetworkToInterface.containsKey(mUpstreamNetwork)) {
-                    enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
+                if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
+                    setInfraLinkState(
+                            newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
+                                    .build());
                 }
                 mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
             }
         }
     }
 
-    private void requestThreadNetwork() {
+    private void registerThreadNetworkCallback() {
         mConnectivityManager.registerNetworkCallback(
                 new NetworkRequest.Builder()
                         // clearCapabilities() is needed to remove forbidden capabilities and UID
@@ -1042,6 +1052,25 @@
         };
     }
 
+    private IOtOutputReceiver newOtOutputReceiver(OutputReceiverWrapper receiver) {
+        return new IOtOutputReceiver.Stub() {
+            @Override
+            public void onOutput(String output) {
+                receiver.onOutput(output);
+            }
+
+            @Override
+            public void onComplete() {
+                receiver.onComplete();
+            }
+
+            @Override
+            public void onError(int otError, String message) {
+                receiver.onError(otErrorToAndroidError(otError), message);
+            }
+        };
+    }
+
     @ErrorCode
     private static int otErrorToAndroidError(int otError) {
         // See external/openthread/include/openthread/error.h for OT error definition
@@ -1228,45 +1257,51 @@
         }
     }
 
-    private void setInfraLinkState(InfraLinkState infraLinkState) {
-        if (mInfraLinkState.equals(infraLinkState)) {
+    private void setInfraLinkState(InfraLinkState newInfraLinkState) {
+        if (mInfraLinkState.equals(newInfraLinkState)) {
             return;
         }
-        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + infraLinkState);
-        mInfraLinkState = infraLinkState;
+        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
+
+        setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
+        setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
+        mInfraLinkState = newInfraLinkState;
+    }
+
+    private void setInfraLinkInterfaceName(String newInfraLinkInterfaceName) {
+        if (Objects.equals(mInfraLinkState.interfaceName, newInfraLinkInterfaceName)) {
+            return;
+        }
         ParcelFileDescriptor infraIcmp6Socket = null;
-        if (mInfraLinkState.interfaceName != null) {
+        if (newInfraLinkInterfaceName != null) {
             try {
-                infraIcmp6Socket =
-                        mInfraIfController.createIcmp6Socket(mInfraLinkState.interfaceName);
+                infraIcmp6Socket = mInfraIfController.createIcmp6Socket(newInfraLinkInterfaceName);
             } catch (IOException e) {
                 LOG.e("Failed to create ICMPv6 socket on infra network interface", e);
             }
         }
         try {
             getOtDaemon()
-                    .setInfraLinkState(
-                            mInfraLinkState,
+                    .setInfraLinkInterfaceName(
+                            newInfraLinkInterfaceName,
                             infraIcmp6Socket,
-                            new LoggingOtStatusReceiver("setInfraLinkState"));
+                            new LoggingOtStatusReceiver("setInfraLinkInterfaceName"));
         } catch (RemoteException | ThreadNetworkException e) {
-            LOG.e("Failed to configure border router " + mOtDaemonConfig, e);
+            LOG.e("Failed to set infra link interface name " + newInfraLinkInterfaceName, e);
         }
     }
 
-    private void enableBorderRouting(String infraIfName) {
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(infraIfName).build();
-        LOG.i("Enable border routing on AIL: " + infraIfName);
-        setInfraLinkState(infraLinkState);
-    }
-
-    private void disableBorderRouting() {
-        mUpstreamNetwork = null;
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(null).build();
-        LOG.i("Disabling border routing");
-        setInfraLinkState(infraLinkState);
+    private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
+        if (Objects.equals(mInfraLinkState.nat64Prefix, newNat64Prefix)) {
+            return;
+        }
+        try {
+            getOtDaemon()
+                    .setInfraLinkNat64Prefix(
+                            newNat64Prefix, new LoggingOtStatusReceiver("setInfraLinkNat64Prefix"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("Failed to set infra link NAT64 prefix " + newNat64Prefix, e);
+        }
     }
 
     private void handleThreadInterfaceStateChanged(boolean isUp) {
@@ -1318,6 +1353,31 @@
         }
     }
 
+    @RequiresPermission(
+            allOf = {PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING})
+    public void runOtCtlCommand(
+            @NonNull String command, boolean isInteractive, @NonNull IOutputReceiver receiver) {
+        enforceAllPermissionsGranted(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING);
+
+        mHandler.post(
+                () ->
+                        runOtCtlCommandInternal(
+                                command, isInteractive, new OutputReceiverWrapper(receiver)));
+    }
+
+    private void runOtCtlCommandInternal(
+            String command, boolean isInteractive, @NonNull OutputReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().runOtCtlCommand(command, isInteractive, newOtOutputReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.runOtCtlCommand failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
     private void sendLocalNetworkConfig() {
         if (mNetworkAgent == null) {
             return;
@@ -1372,8 +1432,22 @@
         return new OtDaemonConfiguration.Builder();
     }
 
-    private static InfraLinkState.Builder newInfraLinkStateBuilder(InfraLinkState infraLinkState) {
-        return new InfraLinkState.Builder().setInterfaceName(infraLinkState.interfaceName);
+    private static InfraLinkState.Builder newInfraLinkStateBuilder() {
+        return new InfraLinkState.Builder().setInterfaceName("");
+    }
+
+    private static InfraLinkState.Builder newInfraLinkStateBuilder(
+            @Nullable LinkProperties linkProperties) {
+        if (linkProperties == null) {
+            return newInfraLinkStateBuilder();
+        }
+        String nat64Prefix = null;
+        if (linkProperties.getNat64Prefix() != null) {
+            nat64Prefix = linkProperties.getNat64Prefix().toString();
+        }
+        return new InfraLinkState.Builder()
+                .setInterfaceName(linkProperties.getInterfaceName())
+                .setNat64Prefix(nat64Prefix);
     }
 
     private static final class CallbackMetadata {
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 54155ee..1eddebf 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -20,9 +20,12 @@
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
 import android.net.thread.ThreadNetworkException;
+import android.os.Binder;
+import android.os.Process;
 import android.text.TextUtils;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -52,6 +55,7 @@
     private static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
+    private static final Duration OT_CTL_COMMAND_TIMEOUT = Duration.ofSeconds(5);
     private static final String PERMISSION_THREAD_NETWORK_TESTING =
             "android.permission.THREAD_NETWORK_TESTING";
 
@@ -62,7 +66,8 @@
     @Nullable private PrintWriter mOutputWriter;
     @Nullable private PrintWriter mErrorWriter;
 
-    public ThreadNetworkShellCommand(
+    @VisibleForTesting
+    ThreadNetworkShellCommand(
             Context context,
             ThreadNetworkControllerService controllerService,
             ThreadNetworkCountryCode countryCode) {
@@ -77,6 +82,10 @@
         mErrorWriter = errorWriter;
     }
 
+    private static boolean isRootProcess() {
+        return Binder.getCallingUid() == Process.ROOT_UID;
+    }
+
     private PrintWriter getOutputWriter() {
         return (mOutputWriter != null) ? mOutputWriter : getOutPrintWriter();
     }
@@ -107,6 +116,8 @@
         pw.println("    Gets country code as a two-letter string");
         pw.println("  force-country-code enabled <two-letter code> | disabled ");
         pw.println("    Sets country code to <two-letter code> or left for normal value");
+        pw.println("  ot-ctl <subcommand>");
+        pw.println("    Runs ot-ctl command");
     }
 
     @Override
@@ -133,6 +144,8 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
+            case "ot-ctl":
+                return handleOtCtlCommand();
             default:
                 return handleDefaultCommands(cmd);
         }
@@ -248,6 +261,50 @@
         return 0;
     }
 
+    private static final class OutputReceiver extends IOutputReceiver.Stub {
+        private final CompletableFuture<Void> future;
+        private final PrintWriter outputWriter;
+
+        public OutputReceiver(CompletableFuture<Void> future, PrintWriter outputWriter) {
+            this.future = future;
+            this.outputWriter = outputWriter;
+        }
+
+        @Override
+        public void onOutput(String output) {
+            outputWriter.print(output);
+            outputWriter.flush();
+        }
+
+        @Override
+        public void onComplete() {
+            future.complete(null);
+        }
+
+        @Override
+        public void onError(int errorCode, String errorMessage) {
+            future.completeExceptionally(new ThreadNetworkException(errorCode, errorMessage));
+        }
+    }
+
+    private int handleOtCtlCommand() {
+        ensureTestingPermission();
+
+        if (!isRootProcess()) {
+            getErrorWriter().println("No access to ot-ctl command");
+            return -1;
+        }
+
+        final String subCommand = String.join(" ", peekRemainingArgs());
+
+        CompletableFuture<Void> completeFuture = new CompletableFuture<>();
+        mControllerService.runOtCtlCommand(
+                subCommand,
+                false /* isInteractive */,
+                new OutputReceiver(completeFuture, getOutputWriter()));
+        return waitForFuture(completeFuture, OT_CTL_COMMAND_TIMEOUT, getErrorWriter());
+    }
+
     private static IOperationReceiver newOperationReceiver(CompletableFuture<Void> future) {
         return new IOperationReceiver.Stub() {
             @Override
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 6572755..2630d21 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -49,8 +49,8 @@
         "truth",
     ],
     libs: [
-        "android.test.base",
-        "android.test.runner",
+        "android.test.base.stubs",
+        "android.test.runner.stubs",
         "framework-connectivity-module-api-stubs-including-flagged",
     ],
     // Test coverage system runs on different devices. Need to
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 59e8e19..8f082a4 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -37,9 +37,9 @@
         "ot-daemon-aidl-java",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
-        "android.test.mock",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
+        "android.test.mock.stubs",
     ],
 }
 
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 103282a..4a8462d8 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -46,8 +46,11 @@
 import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
+import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.RouteInfo;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
 import android.net.thread.utils.IntegrationTestUtils;
@@ -99,6 +102,11 @@
     private static final Inet4Address IPV4_SERVER_ADDR =
             (Inet4Address) parseNumericAddress("8.8.8.8");
     private static final String NAT64_CIDR = "192.168.255.0/24";
+    private static final IpPrefix DHCP6_PD_PREFIX = new IpPrefix("2001:db8::/64");
+    private static final IpPrefix AIL_NAT64_PREFIX = new IpPrefix("2001:db8:1234::/96");
+    private static final Inet6Address AIL_NAT64_SYNTHESIZED_SERVER_ADDR =
+            (Inet6Address) parseNumericAddress("2001:db8:1234::8.8.8.8");
+    private static final Duration UPDATE_NAT64_PREFIX_TIMEOUT = Duration.ofSeconds(10);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
@@ -623,13 +631,50 @@
         // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
         mOtCtl.setNat64Cidr(NAT64_CIDR);
         mOtCtl.setNat64Enabled(true);
-        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), Duration.ofSeconds(10));
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
         ftd.ping(IPV4_SERVER_ADDR);
 
         assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMP_ECHO, null, IPV4_SERVER_ADDR));
     }
 
+    @Test
+    public void nat64_withAilNat64Prefix_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded()
+            throws Exception {
+        tearDownInfraNetwork();
+        LinkProperties lp = new LinkProperties();
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("0.0.0.0/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("::/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.setNat64Prefix(AIL_NAT64_PREFIX);
+        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController, lp);
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        FullThreadDevice ftd = mFtds.get(0);
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
+        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
+        mOtCtl.setNat64Enabled(true);
+        mOtCtl.addPrefixInNetworkData(DHCP6_PD_PREFIX, "paros", "med");
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
+
+        ftd.ping(IPV4_SERVER_ADDR);
+
+        assertNotNull(
+                pollForIcmpPacketOnInfraNetwork(
+                        ICMPV6_ECHO_REQUEST_TYPE, null, AIL_NAT64_SYNTHESIZED_SERVER_ADDR));
+    }
+
     private void setUpInfraNetwork() throws Exception {
         mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController);
     }
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 8835f40..87219d3 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,14 +19,18 @@
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 
 import android.content.Context;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
@@ -41,6 +45,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.net.Inet6Address;
+import java.time.Duration;
+import java.util.List;
 import java.util.concurrent.ExecutionException;
 
 /** Integration tests for {@link ThreadNetworkShellCommand}. */
@@ -53,14 +60,24 @@
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private final ThreadNetworkControllerWrapper mController =
             ThreadNetworkControllerWrapper.newInstance(mContext);
+    private final OtDaemonController mOtCtl = new OtDaemonController();
+    private FullThreadDevice mFtd;
 
     @Before
-    public void setUp() {
+    public void setUp() throws Exception {
+        // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
+        // results in timeout error.
+        // A future fix will provide proper support for factoryreset, allowing us to replace the
+        // legacy "ot-ctl".
+        mOtCtl.factoryReset();
+
+        mFtd = new FullThreadDevice(10 /* nodeId */);
         ensureThreadEnabled();
     }
 
     @After
-    public void tearDown() {
+    public void tearDown() throws Exception {
+        mFtd.destroy();
         ensureThreadEnabled();
     }
 
@@ -69,6 +86,13 @@
         runThreadCommand("enable");
     }
 
+    private static void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+    }
+
     @Test
     public void enable_threadStateIsEnabled() throws Exception {
         runThreadCommand("enable");
@@ -123,6 +147,38 @@
         assertThat(result).contains("Thread country code = CN");
     }
 
+    @Test
+    public void handleOtCtlCommand_enableIfconfig_getIfconfigReturnsUP() {
+        runThreadCommand("ot-ctl ifconfig up");
+
+        final String result = runThreadCommand("ot-ctl ifconfig");
+
+        assertThat(result).isEqualTo("up\r\nDone\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_disableIfconfig_startThreadFailsWithInvalidState() {
+        runThreadCommand("ot-ctl ifconfig down");
+
+        final String result = runThreadCommand("ot-ctl thread start");
+
+        assertThat(result).isEqualTo("Error 13: InvalidState\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        startFtdChild(mFtd, DEFAULT_DATASET);
+        final Inet6Address ftdMlEid = mFtd.getMlEid();
+        assertNotNull(ftdMlEid);
+
+        final String result = runThreadCommand("ot-ctl ping " + ftdMlEid.getHostAddress());
+
+        assertThat(result).contains("1 packets transmitted, 1 packets received");
+        assertThat(result).contains("Packet loss = 0.0%");
+        assertThat(result).endsWith("Done\r\n");
+    }
+
     private static String runThreadCommand(String cmd) {
         return runShellCommandOrThrow("cmd thread_network " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index fa9855e..3df74b0 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -551,6 +551,22 @@
         )
     }
 
+    private fun defaultLinkProperties(): LinkProperties {
+        val lp = LinkProperties()
+        // TODO: use a fake DNS server
+        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+            RouteInfo(
+                IpPrefix("0.0.0.0/0") /* destination */,
+                null /* gateway */,
+                null /* iface */,
+                RouteInfo.RTN_UNICAST, 1500 /* mtu */
+            )
+        )
+        return lp
+    }
+
     @JvmStatic
     @JvmOverloads
     fun startInfraDeviceAndWaitForOnLinkAddr(
@@ -564,23 +580,13 @@
     }
 
     @JvmStatic
+    @JvmOverloads
     @Throws(java.lang.Exception::class)
     fun setUpInfraNetwork(
-        context: Context, controller: ThreadNetworkControllerWrapper
+        context: Context,
+        controller: ThreadNetworkControllerWrapper,
+        lp: LinkProperties = defaultLinkProperties()
     ): TestNetworkTracker {
-        val lp = LinkProperties()
-
-        // TODO: use a fake DNS server
-        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
-        // NAT64 feature requires the infra network to have an IPv4 default route.
-        lp.addRoute(
-            RouteInfo(
-                IpPrefix("0.0.0.0/0") /* destination */,
-                null /* gateway */,
-                null /* iface */,
-                RouteInfo.RTN_UNICAST, 1500 /* mtu */
-            )
-        )
         val infraNetworkTracker: TestNetworkTracker =
             runAsShell(
                 MANAGE_TEST_NETWORKS,
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
index 15a3f5c..046d9bf 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
@@ -128,6 +128,12 @@
         return false;
     }
 
+    /** Adds a prefix in the Network Data. */
+    public void addPrefixInNetworkData(IpPrefix ipPrefix, String flags, String preference) {
+        executeCommand("prefix add " + ipPrefix + " " + flags + " " + preference);
+        executeCommand("netdata register");
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index 9404d1b..c6a24ea 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -50,10 +50,10 @@
         "service-thread-pre-jarjar",
     ],
     libs: [
-        "android.test.base",
-        "android.test.runner",
+        "android.test.base.stubs.system",
+        "android.test.runner.stubs.system",
         "ServiceConnectivityResources",
-        "framework-wifi",
+        "framework-wifi.stubs.module_lib",
     ],
     jni_libs: [
         "libservice-thread-jni",
diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
index b32986d..d52191a 100644
--- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
@@ -34,6 +34,7 @@
 
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -61,6 +62,7 @@
 import java.net.InetAddress;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Executor;
@@ -584,6 +586,7 @@
         verify(mResolveServiceCallback, times(1))
                 .onServiceResolved(
                         eq("test-host"),
+                        eq(0),
                         eq("test"),
                         eq("_test._tcp"),
                         eq(12345),
@@ -811,7 +814,10 @@
     private void prepareTest() {
         mTestLooper = new TestLooper();
         Handler handler = new Handler(mTestLooper.getLooper());
-        mNsdPublisher = new NsdPublisher(mMockNsdManager, mMockDnsResolver, handler);
+        HashMap<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
+        mNsdPublisher =
+                new NsdPublisher(
+                        mMockNsdManager, mMockDnsResolver, handler, networkToLinkProperties);
         mNsdPublisher.setNetworkForHostResolution(mNetwork);
     }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index be32764..d8cdbc4 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -30,6 +30,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
@@ -63,12 +64,15 @@
 import android.content.Intent;
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
 import android.net.NetworkAgent;
 import android.net.NetworkProvider;
 import android.net.NetworkRequest;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
 import android.os.Handler;
@@ -110,6 +114,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicReference;
@@ -170,6 +175,7 @@
     @Mock private IBinder mIBinder;
     @Mock Resources mResources;
     @Mock ConnectivityResources mConnectivityResources;
+    @Mock Map<Network, LinkProperties> mMockNetworkToLinkProperties;
 
     private Context mContext;
     private TestLooper mTestLooper;
@@ -192,6 +198,9 @@
                         eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), anyString());
         doNothing()
                 .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), anyString());
+        doNothing()
+                .when(mContext)
                 .enforceCallingOrSelfPermission(eq(NETWORK_SETTINGS), anyString());
 
         mTestLooper = new TestLooper();
@@ -232,7 +241,8 @@
                         mMockNsdPublisher,
                         mMockUserManager,
                         mConnectivityResources,
-                        () -> DEFAULT_COUNTRY_CODE);
+                        () -> DEFAULT_COUNTRY_CODE,
+                        mMockNetworkToLinkProperties);
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }
 
@@ -801,4 +811,31 @@
         assertThat(networkRequest2.getNetworkSpecifier()).isNull();
         assertThat(networkRequest2.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
     }
+
+    @Test
+    public void runOtCtlCommand_noPermission_throwsSecurityException() {
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), any());
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), any());
+
+        assertThrows(
+                SecurityException.class,
+                () -> mService.runOtCtlCommand("", false, new IOutputReceiver.Default()));
+    }
+
+    @Test
+    public void runOtCtlCommand_otDaemonRemoteFailure_receiverOnErrorIsCalled() throws Exception {
+        mService.initialize();
+        final IOutputReceiver mockReceiver = mock(IOutputReceiver.class);
+        mFakeOtDaemon.setRunOtCtlCommandException(
+                new RemoteException("ot-daemon runOtCtlCommand() throws"));
+
+        mService.runOtCtlCommand("ot-ctl state", false, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index dfb3129..af5c9aa 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -20,12 +20,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
@@ -35,8 +38,10 @@
 
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.PendingOperationalDataset;
 import android.os.Binder;
+import android.os.Process;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
@@ -47,6 +52,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -95,6 +101,9 @@
 
         mShellCommand = new ThreadNetworkShellCommand(mContext, mControllerService, mCountryCode);
         mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+
+        // by default emulate shell uid.
+        BinderUtil.setUid(Process.SHELL_UID);
     }
 
     @After
@@ -102,16 +111,20 @@
         validateMockitoUsage();
     }
 
-    @Test
-    public void getCountryCode_testingPermissionIsChecked() {
-        when(mCountryCode.getCountryCode()).thenReturn("US");
-
+    private void runShellCommand(String... args) {
         mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
-                new String[] {"get-country-code"});
+                args);
+    }
+
+    @Test
+    public void getCountryCode_testingPermissionIsChecked() {
+        when(mCountryCode.getCountryCode()).thenReturn("US");
+
+        runShellCommand("get-country-code");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -122,24 +135,14 @@
     public void getCountryCode_currentCountryCodePrinted() {
         when(mCountryCode.getCountryCode()).thenReturn("US");
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"get-country-code"});
+        runShellCommand("get-country-code");
 
         verify(mOutputWriter).println(contains("US"));
     }
 
     @Test
     public void forceSetCountryCodeEnabled_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -148,36 +151,21 @@
 
     @Test
     public void forceSetCountryCodeEnabled_countryCodeIsOverridden() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mCountryCode).setOverrideCountryCode(eq("US"));
     }
 
     @Test
     public void forceSetCountryCodeDisabled_overriddenCountryCodeIsCleared() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "disabled"});
+        runShellCommand("force-country-code", "disabled");
 
         verify(mCountryCode).clearOverrideCountryCode();
     }
 
     @Test
     public void forceStopOtDaemon_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -190,12 +178,7 @@
                 .when(mControllerService)
                 .forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mOutputWriter, never()).println();
@@ -205,12 +188,7 @@
     public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() {
         doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
@@ -221,12 +199,7 @@
     public void join_controllerServiceJoinIsCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", DEFAULT_ACTIVE_DATASET_TLVS});
+        runShellCommand("join", DEFAULT_ACTIVE_DATASET_TLVS);
 
         var activeDataset =
                 ActiveOperationalDataset.fromThreadTlvs(
@@ -239,12 +212,7 @@
     public void join_invalidDataset_controllerServiceJoinIsNotCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", "000102"});
+        runShellCommand("join", "000102");
 
         verify(mControllerService, never()).join(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -254,12 +222,7 @@
     public void migrate_controllerServiceMigrateIsCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300"});
+        runShellCommand("migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300");
 
         ArgumentCaptor<PendingOperationalDataset> captor =
                 ArgumentCaptor.forClass(PendingOperationalDataset.class);
@@ -276,12 +239,7 @@
     public void migrate_invalidDataset_controllerServiceMigrateIsNotCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", "000102", "300"});
+        runShellCommand("migrate", "000102", "300");
 
         verify(mControllerService, never()).scheduleMigration(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -291,14 +249,75 @@
     public void leave_controllerServiceLeaveIsCalled() {
         doNothing().when(mControllerService).leave(any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"leave"});
+        runShellCommand("leave");
 
         verify(mControllerService, times(1)).leave(any());
         verify(mErrorWriter, never()).println();
     }
+
+    @Test
+    public void handleOtCtlCommand_testingPermissionIsChecked() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(1);
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(anyString(), anyBoolean(), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void handleOtCtlCommand_failsWithNonRootProcess() {
+        runShellCommand("ot-ctl", "state");
+
+        verify(mErrorWriter, times(1)).println(contains("No access to ot-ctl command"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_serviceTimeout_failsWithTimeoutError() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doNothing().when(mControllerService).runOtCtlCommand(anyString(), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mControllerService, times(1)).runOtCtlCommand(anyString(), eq(false), any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_state_outputIsPrinted() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(2);
+
+                            receiver.onOutput("leader");
+                            receiver.onOutput("\r\n");
+                            receiver.onOutput("Done");
+                            receiver.onOutput("\r\n");
+
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(eq("state"), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        InOrder inOrder = inOrder(mOutputWriter);
+        inOrder.verify(mOutputWriter).print("leader");
+        inOrder.verify(mOutputWriter).print("\r\n");
+        inOrder.verify(mOutputWriter).print("Done");
+        inOrder.verify(mOutputWriter).print("\r\n");
+    }
 }